com.httrack.android.HTTrackActivity.java Source code

Java tutorial

Introduction

Here is the source code for com.httrack.android.HTTrackActivity.java

Source

/*
HTTrack Android Java Interface.
    
HTTrack Website Copier, Offline Browser for Windows and Unix
Copyright (C) Xavier Roche and other contributors
    
This program is free software; you can redistribute it and/or
modify it under the terms of the GNU General Public License
as published by the Free Software Foundation; either version 3
of the License, or any later version.
    
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
GNU General Public License for more details.
    
You should have received a copy of the GNU General Public License
along with this program; if not, write to the Free Software
Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA  02111-1307, USA.
 */

package com.httrack.android;

import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.FileReader;
import java.io.FileWriter;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.PrintWriter;
import java.net.Inet6Address;
import java.net.InetAddress;
import java.net.NetworkInterface;
import java.net.SocketException;
import java.nio.channels.FileLock;
import java.util.ArrayList;
import java.util.Enumeration;
import java.util.HashSet;
import java.util.List;
import java.util.regex.Pattern;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;

import android.app.Activity;
import android.app.AlertDialog;
import android.app.Notification;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.content.pm.ApplicationInfo;
import android.content.pm.PackageInfo;
import android.content.pm.PackageManager.NameNotFoundException;
import android.content.res.Configuration;
import android.net.Uri;
import android.os.AsyncTask;
import android.os.Bundle;
import android.os.Environment;
import android.os.Handler;
import android.os.Parcelable;
import android.support.v4.app.Fragment;
import android.support.v4.app.FragmentActivity;
import android.support.v4.app.FragmentManager;
import android.support.v4.app.NotificationCompat;
import android.text.Editable;
import android.text.Html;
import android.text.TextUtils;
import android.text.TextWatcher;
import android.util.Log;
import android.util.SparseArray;
import android.view.Menu;
import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup.LayoutParams;
import android.view.animation.Animation;
import android.view.animation.AnimationUtils;
import android.widget.ArrayAdapter;
import android.widget.AutoCompleteTextView;
import android.widget.EditText;
import android.widget.LinearLayout;
import android.widget.ProgressBar;
import android.widget.RadioGroup;
import android.widget.TextView;
import android.widget.Toast;

import com.httrack.android.jni.HTTrackCallbacks;
import com.httrack.android.jni.HTTrackLib;
import com.httrack.android.jni.HTTrackStats;
import com.httrack.android.jni.HTTrackStats.Element;

public class HTTrackActivity extends FragmentActivity {
    // Page layouts
    protected static final int layouts[] = { R.layout.activity_startup, R.layout.activity_proj_name,
            R.layout.activity_proj_setup, R.layout.activity_mirror_progress, R.layout.activity_mirror_finished };

    // Special layout positions
    protected static final int LAYOUT_START = 0;
    protected static final int LAYOUT_PROJECT_NAME = 1;
    protected static final int LAYOUT_PROJECT_SETUP = 2;
    protected static final int LAYOUT_MIRROR_PROGRESS = 3;
    protected static final int LAYOUT_FINISHED = 4;

    // Preferences
    protected static final String PREFS_NAME = "HTTrackPreferences";
    protected static final String BASE_NAME = "BasePath";

    // <br /> Pattern
    protected static final Pattern brHtmlPattern = Pattern.compile(Pattern.quote("<br />"));

    // ".nomedia" ; prevents media scanner from reading media files
    public static final String NOMEDIA_FILE = ".nomedia";

    // Running instances of HTTrack (based on winprofile.ini path)
    protected static final HashSet<String> runningInstances = new HashSet<String>();

    /*
     * Build identifiers. See
     * <http://developer.android.com/reference/android/os/Build
     * .VERSION_CODES.html>
     */
    protected static class VERSION_CODES {
        public static final int FROYO = 0x00000008;
        public static final int HONEYCOMB = 0x0000000b;
        public static final int KITKAT = 0x00000013;
    };

    // Fields to restore/save state (Note: might be read-only fields)
    protected static final int fields[][] = { {},
            { R.id.fieldProjectName, R.id.fieldProjectCategory, R.id.fieldBasePath },
            { R.id.fieldWebsiteURLs, R.id.radioAction }, {}, { R.id.fieldDisplay } };

    // Activity identifier when using startActivityForResult()
    protected static final int ACTIVITY_OPTIONS = 0;
    protected static final int ACTIVITY_FILE_CHOOSER = 1;
    protected static final int ACTIVITY_PROJECT_NAME_CHOOSER = 2;
    protected static final int ACTIVITY_CLEANUP = 3;

    // Process unique session ID for the fragment identifier
    protected String sessionID = "runner_task" + "_" + Long.toString(System.nanoTime());

    // Project path
    protected File projectPath;
    protected File rscPath;

    // Options mapper, containing all options
    protected final OptionsMapper mapper = new OptionsMapper();

    // Engine
    protected RunnerFragment runner = null;

    // Current pane id and context
    protected int pane_id = -1;

    // Is the application "started" ? (ie. visible to user)
    protected volatile boolean started;

    // Is the application paused (pending visible state) ?
    protected volatile boolean paused;

    // "Project name" pane is dirty
    protected boolean dirtyNamePane;

    // Handler to execute code in UI thread
    private final Handler handlerUI = new Handler();

    // Interrupt was requested
    protected boolean interruptRequested;

    // Warn spaces in project name
    protected boolean warnPreHoneycombSpaceIssue;
    protected boolean switchEmptyProjectName;

    // Widget data exchange helper
    private final WidgetDataExchange widgetDataExchange = new WidgetDataExchange(this);

    // Project settings
    protected String version;
    protected String versionFeatures;
    protected int versionCode;

    /*
     * Mark this profile as in use.
     */
    protected static synchronized void markRunningInstance(final File profile) throws IOException {
        if (runningInstances.contains(profile.getAbsolutePath())) {
            throw new IOException("This project is already in progress");
        }
        runningInstances.add(profile.getAbsolutePath());
    }

    /*
     * Mark this profile as not in use.
     */
    protected static synchronized void clearRunningInstance(final File profile) {
        synchronized (runningInstances) {
            runningInstances.remove(profile.getAbsolutePath());
        }
    }

    /* Get the root storage. */
    private File getExternalStorage() {
        final String state = Environment.getExternalStorageState();
        if (Environment.MEDIA_MOUNTED.equals(state)) {
            if (android.os.Build.VERSION.SDK_INT >= VERSION_CODES.FROYO) {
                return Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS);
            } else {
                return Environment.getExternalStorageDirectory();
            }
        } else {
            // Fallback.
            return getFilesDir();
        }
    }

    /* Get the default root external storage. */
    private File getDefaultHTTrackPath() {
        final File rootPath = getExternalStorage();

        // Naming
        final File httrackBasePath = new File(rootPath, "HTTrack");
        final File httrackPath = new File(httrackBasePath, "Websites");

        // Take newest form unless old one exists.
        return httrackPath;
    }

    /*
     * (Re)Compute (external) storage pathes for downloaded websites.
     */
    private void computeStorageTarget() {
        final SharedPreferences settings = getSharedPreferences(PREFS_NAME, 0);
        final String base = settings.getString(BASE_NAME, null);
        if (base != null) {
            final File path = new File(base);
            projectPath = path;
        } else if (projectPath == null || !projectPath.exists()) {
            final File path = getDefaultHTTrackPath();
            projectPath = path;
        }

        // Set root path for logs
        HTTrackLib.initRootPath(projectPath.getAbsolutePath());

        // Change ?
        final View view = findViewById(R.id.fieldBasePath);
        if (view != null) {
            TextView.class.cast(view).setText(projectPath.getAbsolutePath());
        }
    }

    /*
     * Set the base path.
     */
    private void setBasePath(final String path) {
        final SharedPreferences settings = getSharedPreferences(PREFS_NAME, 0);
        final SharedPreferences.Editor editor = settings.edit();
        editor.putString(BASE_NAME, path);
        editor.commit();
        // Load it
        computeStorageTarget();
        refreshprojectNameSuggests();
    }

    /*
     * Refresh project name suggest.
     */
    private void refreshprojectNameSuggests() {
        final AutoCompleteTextView name = AutoCompleteTextView.class.cast(this.findViewById(R.id.fieldProjectName));
        if (name != null) {
            /* Setup name selection adapter. */
            final String[] names = getProjectNames();
            if (names != null) {
                Log.v(getClass().getSimpleName(), "project names: " + printArray(names));
                name.setAdapter(new ArrayAdapter<String>(this, android.R.layout.simple_dropdown_item_1line, names));
            }
        }
    }

    /**
     * Return the project category of a given project.
     * 
     * @param projectName
     * @return the project category, or @c null if none (or there is no such
     *         project)
     */
    private String getProjectCategory(final String projectName) {
        final SparseArray<String> projectMap = new SparseArray<String>();
        final File target = new File(getProjectRootFile(), projectName);
        try {
            OptionsMapper.unserialize(HTTrackActivity.getProfileFile(target), projectMap);
            final String description = projectMap.get(R.id.fieldProjectCategory);
            return description;
        } catch (final IOException e) {
            return null;
        }
    }

    /*
     * Check if the external storage is available, and if not, display a warning
     * message.
     */
    private void warnIfExternalStorageUnsuitable() {
        String message;
        final String state = Environment.getExternalStorageState();
        if (Environment.MEDIA_MOUNTED.equals(state)) {
            final File root = getProjectRootFile();
            if (root == null) {
                message = getString(R.string.could_not_get_external_directory);
            } else if (!root.exists()) {
                message = getString(R.string.could_not_write_to) + " " + root.getAbsolutePath();
            } else {
                return;
            }
        } else {
            if (Environment.MEDIA_MOUNTED_READ_ONLY.equals(state)) {
                message = getString(R.string.read_only_storage_media);
            } else {
                message = getString(R.string.no_storage_media);
            }
        }
        showNotification(message + "\n" + getString(R.string.may_not_download_until_problem_fixed), true);
    }

    /*
     * Create a .nomedia empty file
     * 
     * @param directory The target directory
     */
    protected static boolean createNoMediaFile(final File directory) throws IOException {
        final File nomedia = new File(directory, HTTrackActivity.NOMEDIA_FILE);
        if (!nomedia.exists()) {
            final FileWriter emptyFile = new FileWriter(nomedia);
            emptyFile.close();
            return true;
        } else {
            return false;
        }
    }

    /*
     * Ensure the "Websites" directory exists.
     * 
     * Note: calls computeStorageTarget().
     */
    protected boolean ensureExternalStorage() {
        Log.d("httrack", "called ensureExternalStorage");

        computeStorageTarget();

        final File root = getProjectRootFile();
        if (root != null && mkdirsRetry(root)) {
            Log.d(getClass().getSimpleName(), "validated " + root.getAbsolutePath());

            /*
             * Android: "Include an empty file named .nomedia in your external files
             * directory (note the dot prefix in the filename). This prevents media
             * scanner from reading your media files"
             */
            try {
                if (createNoMediaFile(root)) {
                    Log.d(getClass().getSimpleName(),
                            "successfully created " + HTTrackActivity.NOMEDIA_FILE + " file");
                }
            } catch (final IOException io) {
                Log.w(getClass().getSimpleName(), "could not create " + HTTrackActivity.NOMEDIA_FILE + " file", io);
            }

            return true;
        } else {
            Log.w(getClass().getSimpleName(),
                    "could not create " + (root != null ? root.getAbsolutePath() : "<NULL ROOTPATH!>"));
            return false;
        }
    }

    @Override
    protected void onCreate(final Bundle savedInstanceState) {
        Log.d(getClass().getSimpleName(), "onCreate");
        super.onCreate(savedInstanceState);

        // Attempt to load the native library.
        // Fetch httrack engine version
        version = HTTrackLib.getVersion();
        versionFeatures = HTTrackLib.getFeatures();

        // Android package version code
        try {
            final PackageInfo info = getPackageManager().getPackageInfo(getPackageName(), 0);
            versionCode = info.versionCode;
        } catch (final NameNotFoundException e) {
            throw new RuntimeException(e);
        }

        // Compute target directory on external storage
        ensureExternalStorage();

        // Check if an external storage is available
        warnIfExternalStorageUnsuitable();

        // Extract resources if necessary
        rscPath = buildResourceFile();

        // Ensure users can see us
        HTTrackActivity.setFileReadWrite(getProjectRootFile());

        // Clear map (useful to get dynamic fields)
        mapper.setContext(this);
        mapper.resetMap();

        // Go to first pane now
        setPane(0);

        // Load intent ? (example: shutdown phone, and power-it on during setup)
        final Bundle extras = getIntent().getExtras();
        if (extras != null) {
            restoreInstanceState(extras);
        }
    }

    /* Install/Update time. */
    private long installOrUpdateTime() {
        final ApplicationInfo appInfo = getApplicationInfo();
        final String appFile = appInfo.sourceDir;
        final long installed = new File(appFile).lastModified();
        return installed;
    }

    /**
     * Get the resource directory. Create it if necessary. Resources are created
     * in the dedicated cache, so that the files can be uninstalled upon
     * application removal.
     **/
    private File buildResourceFile() {
        final File cache = android.os.Build.VERSION.SDK_INT >= VERSION_CODES.FROYO ? getExternalCacheDir()
                : getCacheDir();
        final File rscPath = new File(cache, "resources");
        final File stampFile = new File(rscPath, "resources.stamp");
        final long stamp = installOrUpdateTime();

        // Check timestamp of resources. If the applicate has been updated,
        // recreated cached resources.
        if (rscPath.exists()) {
            long diskStamp = 0;
            try {
                if (stampFile.exists()) {
                    final FileReader reader = new FileReader(stampFile);
                    final BufferedReader lreader = new BufferedReader(reader);
                    try {
                        diskStamp = Long.parseLong(lreader.readLine());
                    } catch (final NumberFormatException nfe) {
                        diskStamp = 0;
                    }
                    lreader.close();
                    reader.close();
                }
            } catch (final IOException io) {
                diskStamp = 0;
            }
            // Different one: wipe and recreate
            if (stamp != diskStamp) {
                Log.i(getClass().getSimpleName(), "deleting old resources " + rscPath.getAbsolutePath()
                        + " (app_stamp=" + stamp + " != disk_stamp=" + diskStamp + ")");
                CleanupActivity.deleteRecursively(rscPath);
            } else {
                Log.i(getClass().getSimpleName(),
                        "keeping resources " + rscPath.getAbsolutePath() + " (app_stamp=disk_stamp=" + stamp + ")");
            }
        }

        // Recreate resources ?
        if (!rscPath.exists()) {
            Log.i(getClass().getSimpleName(),
                    "creating resources " + rscPath.getAbsolutePath() + " with stamp " + stamp);
            if (HTTrackActivity.mkdirs(rscPath)) {
                long totalSize = 0;
                int totalFiles = 0;
                try {
                    final InputStream zipStream = getResources().openRawResource(R.raw.resources);
                    final ZipInputStream file = new ZipInputStream(zipStream);
                    ZipEntry entry;
                    while ((entry = file.getNextEntry()) != null) {
                        final File dest = new File(rscPath.getAbsoluteFile() + "/" + entry.getName());
                        if (entry.getName().endsWith("/")) {
                            dest.mkdirs();
                        } else {
                            final FileOutputStream writer = new FileOutputStream(dest);
                            final byte[] bytes = new byte[1024];
                            int length;
                            while ((length = file.read(bytes)) >= 0) {
                                writer.write(bytes, 0, length);
                                totalSize += length;
                            }
                            writer.close();
                            totalFiles++;
                            dest.setLastModified(entry.getTime());
                        }
                    }
                    file.close();
                    zipStream.close();
                    Log.i(getClass().getSimpleName(), "created resources " + rscPath.getAbsolutePath() + " ("
                            + totalFiles + " files, " + totalSize + " bytes)");

                    // Write stamp
                    final FileWriter writer = new FileWriter(stampFile);
                    final BufferedWriter lwriter = new BufferedWriter(writer);
                    lwriter.write(Long.toString(stamp));
                    lwriter.close();
                    writer.close();

                    // Little info
                    showNotification(getString(R.string.info_recreated_resources));
                } catch (final IOException io) {
                    Log.w(getClass().getSimpleName(), "could not create resources", io);
                    CleanupActivity.deleteRecursively(rscPath);
                    showNotification(getString(R.string.info_could_not_create_resources), true);
                }
            }
        }

        // Return resources path
        return rscPath;
    }

    /** Return a text resource as a string. **/
    private String getTextResource(final int id) {
        try {
            final InputStream is = getResources().openRawResource(id);
            final byte[] b = new byte[is.available()];
            is.read(b);
            return new String(b, "UTF-8");
        } catch (final IOException io) {
            return "resource not found";
        }
    }

    /**
     * Get the resource directory.
     * 
     * @return the resource directory
     */
    private File getResourceFile() {
        return rscPath;
    }

    /**
     * Get a map value.
     * 
     * @param key
     *          The key
     * @return The value
     */
    public String getMap(final int key) {
        return mapper.getMap(key);
    }

    /**
     * Set a map value.
     * 
     * @param key
     *          The key
     * @param value
     *          The value
     */
    public void setMap(final int key, final String value) {
        mapper.setMap(key, value);
    }

    /**
     * Trunk to mapper's buildCommandline()
     * 
     * @return
     */
    public synchronized List<String> buildCommandline() {
        return mapper.buildCommandline();
    }

    /**
     * Are there any projects yet ?
     * 
     * @return true if projects have been detected
     */
    protected boolean hasProjectNames() {
        final String[] names = getProjectNames();
        return names != null && names.length != 0;
    }

    /**
     * Get the root directory.
     * 
     * @return The root directory.
     */
    protected File getProjectRootFile() {
        return projectPath;
    }

    /**
     * Get the root index.html file.
     * 
     * @return The root index.html file.
     */
    protected File getProjectRootIndexFile() {
        final File target = getProjectRootFile();
        return new File(target, "index.html");
    }

    /**
     * A top index is present.
     * 
     * @return true if a top index.html is present
     */
    private boolean hasProjectRootIndexFile() {
        return getProjectRootIndexFile().exists();
    }

    /**
     * Return the already downloaded project names.
     * 
     * @return The list of project names.
     */
    protected String[] getProjectNames() {
        final File dir = getProjectRootFile();
        if (dir != null && dir.exists() && dir.isDirectory()) {
            final List<String> list = new ArrayList<String>();
            final File[] listFiles = dir.listFiles();
            if (listFiles != null) {
                for (final File item : listFiles) {
                    if (item.isDirectory()) {
                        final File profile = new File(new File(item, "hts-cache"), "winprofile.ini");
                        if (profile.exists() && profile.isFile()) {
                            list.add(item.getName());
                        }
                    }
                }
            }
            return list.toArray(new String[] {});
        }
        return null;
    }

    /**
     * Get the destination directory for the current project.
     * 
     * @return The destination directory.
     */
    protected File getTargetFile() {
        final String name = mapper.getProjectName();
        if (name != null && name.length() != 0) {
            return new File(getProjectRootFile(), name);
        } else {
            return null;
        }
    }

    /**
     * Get the index.htm for the current project.
     * 
     * @return The destination directory.
     */
    protected File getTargetIndexFile() {
        final File target = getTargetFile();
        if (target != null) {
            return new File(target, "index.html");
        } else {
            return null;
        }
    }

    /**
     * Check whether an index.html file is present for the current project.
     * 
     * @return true if an index.html file is present for the current project.
     */
    protected boolean hasTargetIndexFile() {
        final File index = getTargetIndexFile();
        return index != null && index.exists();
    }

    /**
     * Get the hts-log.txt for the current project.
     * 
     * @return The destination directory.
     */
    protected File getTargetLogFile() {
        final File target = getTargetFile();
        if (target != null && target.exists()) {
            final File log = new File(target, "hts-log.txt");
            return log;
        } else {
            return null;
        }
    }

    /**
     * Check whether an hts-log.txt file is present for the current project.
     * 
     * @return true if an hts-log.txt file is present for the current project.
     */
    protected boolean hasTargetLogFile() {
        final File index = getTargetLogFile();
        return index != null && index.exists();
    }

    /**
     * Pretty-print a string array.
     * 
     * @param array
     *          The string array
     * @return The pretty-printed value
     */
    private static String printArray(final String[] array) {
        final StringBuilder builder = new StringBuilder();
        for (final String s : array) {
            if (builder.length() != 0) {
                builder.append(' ');
            }
            builder.append(s);
        }
        return builder.toString();
    }

    /**
     * Return the IPv6 address.
     * 
     * @return The ipv6 address, or @c null if no IPv6 connectivity is available.
     */
    protected static InetAddress getIPv6Address() {
        try {
            for (final Enumeration<NetworkInterface> interfaces = NetworkInterface
                    .getNetworkInterfaces(); interfaces.hasMoreElements();) {
                final NetworkInterface iface = interfaces.nextElement();
                for (final Enumeration<InetAddress> addresses = iface.getInetAddresses(); addresses
                        .hasMoreElements();) {
                    final InetAddress address = addresses.nextElement();
                    Log.d(HTTrackActivity.class.getSimpleName(), "seen interface: " + address.toString());
                    if (address instanceof Inet6Address) {
                        if (!address.isLoopbackAddress() && !address.isLinkLocalAddress()
                                && !address.isSiteLocalAddress() && !address.isMulticastAddress()) {
                            return address;
                        }
                    }
                }
            }
        } catch (final SocketException se) {
            Log.w(HTTrackActivity.class.getSimpleName(), "could not enumerate interfaces", se);
        }
        return null;
    }

    /**
     * is IPv6 available on this phone ?
     * 
     * @return true if IPv6 is available on this phone
     */
    protected static boolean isIPv6Enabled() {
        return getIPv6Address() != null;
    }

    /**
     * Emergency dump.
     */
    protected static void emergencyDump(final Throwable e) {
        try {
            final File dumpFile = new File(new File(Environment.getExternalStorageState(), "HTTrack"), "error.txt");
            final FileWriter writer = new FileWriter(dumpFile);
            final PrintWriter print = new PrintWriter(writer);
            e.printStackTrace(print);
            writer.close();
            HTTrackActivity.setFileReadWrite(dumpFile);
        } catch (final IOException io) {
        }
    }

    /**
     * Build the top index.
     * 
     * @return 1 upon success
     */
    protected synchronized int buildTopIndex() {
        // Build top index
        final File rsc = getResourceFile();
        if (rsc != null) {
            return HTTrackLib.buildTopIndex(getProjectRootFile(), rsc);
        } else {
            return 0;
        }
    }

    /**
     * The runner fragment class.<br />
     * Thanks to Alex Lockwood for the useful hints regarding fragments!
     */
    protected static class RunnerFragment extends Fragment {
        protected Runner runner;

        public RunnerFragment() {
        }

        @Override
        public void onCreate(final Bundle savedInstanceState) {
            super.onCreate(savedInstanceState);

            // Retain this fragment across configuration changes.
            setRetainInstance(true);
        }

        @Override
        public void onDestroy() {
            // Ensure the running job is killed when the fragment is disposed
            if (runner != null) {
                if (!runner.isEnded()) {
                    if (runner.stopMirror(true)) {
                        runner.sendAbortNotification();
                    }
                }
            }
            super.onDestroy();
        }

        public void setParent(final HTTrackActivity parent) {
            if (runner != null) {
                throw new RuntimeException("can not attach twice");
            }
            // Create and execute the background task.
            runner = new Runner(parent);
            runner.execute();
        }

        // Attach activity
        @Override
        public void onAttach(final Activity activity) {
            super.onAttach(activity);
            final HTTrackActivity parent = HTTrackActivity.class.cast(activity);
            // We expect the runner not to be null at this point.
            if (runner != null) {
                runner.setParent(parent);
            } else {
                // We have to create the runner. Should not happen ?
                Throwable trace;
                try {
                    throw new RuntimeException();
                } catch (final RuntimeException re) {
                    trace = re;
                }
                Log.w(this.getClass().getSimpleName(),
                        "empty fragment attached to an activity, creating the associated runner", trace);
                setParent(parent);
            }
        }

        // Detach activity
        @Override
        public void onDetach() {
            super.onDetach();
            runner.detach();
        }

        public boolean stopMirror(final boolean force) {
            return runner.stopMirror(force);
        }

        public HTTrackStats getLastStats() {
            return runner.getLastStats();
        }
    }

    /**
     * Engine thread runner.
     */
    protected static class Runner extends AsyncTask<Void, Integer, Void> implements HTTrackCallbacks {
        private final HTTrackLib engine = new HTTrackLib(this);
        final private StringBuilder str = new StringBuilder();
        private HTTrackActivity parent;
        private final List<Runnable> pendingParentActions = new ArrayList<Runnable>();
        private boolean mirrorRefresh;
        protected HTTrackStats lastStats;
        private volatile boolean ended;
        private volatile boolean interrupted;
        private volatile boolean interruptedHard;

        // Copy of parent strings.
        private String string_bytes_saved;
        private String string_links_scanned;
        private String string_time;
        private String string_files_written;
        private String string_transfer_rate;
        private String string_files_updated;
        private String string_active_connections;
        private String string_errors;
        private String string_connect;
        private String string_ready;
        private String string_creating_project;
        private String string_starting_mirror;
        private String string_mirror_finished;

        /**
         * Constructor.
         * 
         * @param parent
         *          the parent activity.
         */
        public Runner(final HTTrackActivity parent) {
            setParent(parent);
        }

        /* Get a string from parent. */
        private String getParentString(final int id) {
            if (parent == null) {
                throw new NullPointerException("parent is null");
            }
            final String s = parent.getString(id);
            if (s == null) {
                throw new NullPointerException("null string #" + id);
            }
            return s;
        }

        /**
         * Set the parent activity.
         * 
         * @param parent
         *          the parent activity
         */
        public synchronized void setParent(final HTTrackActivity parent) {
            this.parent = parent;

            // Cache localized strings
            string_bytes_saved = getParentString(R.string.bytes_saved);
            string_links_scanned = getParentString(R.string.links_scanned);
            string_time = getParentString(R.string.time);
            string_files_written = getParentString(R.string.files_written);
            string_transfer_rate = getParentString(R.string.transfer_rate);
            string_files_updated = getParentString(R.string.files_updated);
            string_active_connections = getParentString(R.string.active_connections);
            string_errors = getParentString(R.string.errors);
            string_connect = getParentString(R.string.connect);
            string_ready = getParentString(R.string.ready);
            string_creating_project = getParentString(R.string.creating_project);
            string_starting_mirror = getParentString(R.string.starting_mirror);
            string_mirror_finished = getParentString(R.string.mirror_finished);

            // Execute pending actions now we are attached
            if (pendingParentActions.size() != 0) {
                for (final Runnable run : pendingParentActions) {
                    run.run();
                }
                pendingParentActions.clear();
            }
        }

        /**
         * Detach a parent.
         */
        public synchronized void detach() {
            this.parent = null;
        }

        /** Trunk to parent's sendAbortNotification(). **/
        public synchronized void sendAbortNotification() {
            if (parent != null) {
                parent.sendAbortNotification();
            }
        }

        /* Background task. */
        @Override
        protected Void doInBackground(final Void... arg0) {
            try {
                runInternal();
            } catch (final RuntimeException e) {
                HTTrackActivity.emergencyDump(e);
                throw e;
            } finally {
                ended = true;
            }
            return null;
        }

        protected void runInternal() {
            // Rock'in!
            String message = null;
            FileOutputStream outLock = null;
            FileLock lock = null;
            File profile = null;
            try {
                // Sanity checks
                if (parent == null) {
                    throw new IOException("no parent!");
                }
                final File target = parent.getTargetFile();
                if (target == null) {
                    throw new IOException("no project name defined!");
                }

                // Progress info for slow phones
                setProgressLines(new String[] { string_creating_project });

                // Validate path
                if (!HTTrackActivity.mkdirs(target)) {
                    throw new IOException("Unable to create " + target.getAbsolutePath());
                }
                HTTrackActivity.setFileReadWrite(target);

                // Inter-thread locking
                profile = parent.createProfileDirectory();
                markRunningInstance(profile);

                // Lock winprofile.ini by opening it in append mode, and requesting an
                // exclusive lock
                outLock = new FileOutputStream(profile, true);
                lock = outLock.getChannel().tryLock();
                if (lock == null) {
                    throw new IOException("This project is already in progress");
                }

                // Args list
                final List<String> args = new ArrayList<String>();

                // Program name
                args.add("httrack");

                // If IPv6 is not available, do not use it.
                if (!isIPv6Enabled()) {
                    args.add("-@i4");
                }

                // Target
                args.add("-O");
                args.add(target.getAbsolutePath());

                // Get args from mapper
                for (final String cmd : parent.buildCommandline()) {
                    args.add(cmd);
                }

                // Final args array
                final String[] cargs = args.toArray(new String[] {});
                Log.v(getClass().getSimpleName(), "starting engine: " + HTTrackActivity.printArray(cargs));

                // Serialize settings
                parent.serialize(outLock);

                // Progress info for slow phones
                setProgressLines(new String[] { string_starting_mirror });

                // Run engine
                final int code = engine.main(cargs);

                // Result
                if (code == 0) {
                    if (interrupted) {
                        message = "<b>Interrupted</b>! (" + lastStats.errorsCount + " errors)";
                    } else if (lastStats.errorsCount == 0) {
                        message = "<b>Success</b>!";
                    } else if (lastStats.filesWritten != 0) {
                        message = "<b>Success</b>! (" + lastStats.errorsCount + " errors)";
                    } else {
                        message = "<b>Failed</b>! (" + lastStats.errorsCount + " errors, no files written)";
                    }
                    message += "<br /><br />Mirror copied in <i>" + target.getAbsolutePath() + "</i>";
                } else {
                    message = "<b>Error</b> (<i>code " + code + "</i>)";
                }

                // Build top index
                buildTopIndex();
            } catch (final IOException io) {
                message = io.getMessage();
            } finally {
                // Release inter-thread lock
                if (profile != null) {
                    clearRunningInstance(profile);
                }
                // Release lock
                if (lock != null) {
                    try {
                        lock.release();
                        outLock.close();
                    } catch (IOException io) {
                    }
                }
            }

            // Ensure we switch to the final pane
            final String displayMessage = string_mirror_finished + ": " + message;
            final long errorsCount = lastStats != null ? lastStats.errorsCount : 0;
            displayFinishedPanel(displayMessage, errorsCount);
        }

        /*
         * Trunk to parent.buildTopIndex().
         */
        private synchronized void buildTopIndex() {
            if (parent != null) {
                parent.buildTopIndex();
            } else {
                pendingParentActions.add(new Runnable() {
                    @Override
                    public void run() {
                        parent.buildTopIndex();
                    }
                });
            }
        }

        /*
         * Trunk to parent.displayFinishedPanel().
         */
        private synchronized void displayFinishedPanel(final String displayMessage, final long errorsCount) {
            if (parent != null) {
                parent.displayFinishedPanel(displayMessage, errorsCount);
            } else {
                pendingParentActions.add(new Runnable() {
                    @Override
                    public void run() {
                        parent.displayFinishedPanel(displayMessage, errorsCount);
                    }
                });
            }
        }

        /*
         * Trunk to parent.setInterruptedProfile().
         */
        private synchronized void setInterruptedProfile(final boolean interrupted) throws IOException {
            if (parent != null) {
                if (interrupted) {
                    parent.setInterruptedProfile(true);
                } else {
                    parent.setInterruptedProfile(false);
                }
            } else {
                throw new IOException("parent has been detached");
            }
        }

        /*
         * Trunk to parent.setProgressLines().
         */
        private synchronized void setProgressLines(final String[] lines) {
            if (parent != null) {
                parent.setProgressLines(lines);
            }
        }

        /**
         * Stop the mirror.
         */
        public boolean stopMirror(final boolean force) {
            // Set interrupted flags
            interrupted = true;
            if (force) {
                interruptedHard = true;
            }
            // Stop engine
            final boolean stopSent = engine.stop(force);
            // If not yet stopped, mark as dirty
            // ("Continue an interrupted mirror ...")
            try {
                setInterruptedProfile(stopSent);
            } catch (final IOException io) {
                Log.w(getClass().getSimpleName(), "could not lock file", io);
            }
            return stopSent;
        }

        /**
         * Has the mirror been interrupted ?
         */
        public boolean isInterrupted() {
            return interrupted;
        }

        /**
         * Has the mirror stopped ?
         */
        public boolean isEnded() {
            return ended;
        }

        @Override
        public void onRefresh(HTTrackStats stats) {
            // fake first refresh for cosmetic reasons.
            if (stats == null) {
                if (mirrorRefresh) {
                    return;
                }
                mirrorRefresh = true;
                stats = new HTTrackStats();
            } else {
                synchronized (this) {
                    lastStats = stats;
                }
            }

            // Do not refresh GUI if stopped
            if (interruptedHard) {
                return;
            }

            synchronized (this) {
                if (parent == null) {
                    return;
                }
            }

            // build stats infos
            final String sep = "  ";
            str.setLength(0);
            str.append("<b>");
            str.append(string_bytes_saved);
            str.append("</b>: ");
            str.append(stats.bytesWritten);
            str.append(sep);
            str.append("<b>");
            str.append(string_links_scanned);
            str.append("</b>: ");
            str.append(stats.linksScanned);
            str.append("/");
            str.append(stats.linksTotal);
            str.append(" (+");
            str.append(stats.linksBackground);
            str.append(")<br />");
            /* */
            str.append("<b>");
            str.append(string_time);
            str.append("</b>: ");
            str.append(stats.elapsedTime);
            str.append(sep);
            str.append("<b>");
            str.append(string_files_written);
            str.append("</b>: ");
            str.append(stats.filesWritten);
            str.append(" (+");
            str.append(stats.filesWrittenBackground);
            str.append(")<br />");
            /* */
            str.append("<b>");
            str.append(string_transfer_rate);
            str.append("</b>: ");
            str.append(stats.transferRate);
            str.append(" (");
            str.append(stats.totalTransferRate);
            str.append(")");
            str.append(sep);
            str.append("<b>");
            str.append(string_files_updated);
            str.append("</b>: ");
            str.append(stats.filesUpdated);
            str.append("<br />");
            /* */
            str.append("<b>");
            str.append(string_active_connections);
            str.append("</b>: ");
            str.append(stats.socketsCount);
            str.append(sep);
            str.append("<b>");
            str.append(string_errors);
            str.append("</b>:");
            str.append(stats.errorsCount);
            /* */
            if (stats.elements != null && stats.elements.length != 0) {
                str.append("<br />");
                str.append("<br />");

                int maxElts = 32; // limit the number of displayed items
                for (final Element element : stats.elements) {
                    if (element == null || element.address == null || element.filename == null) {
                        continue;
                    }
                    if (--maxElts == 0) {
                        break;
                    }

                    // URL
                    str.append("<i>");
                    str.append(element.address);
                    str.append(element.path);
                    str.append("</i>");
                    str.append("  ");

                    // state
                    switch (element.state) {
                    case Element.STATE_CONNECTING:
                        str.append(string_connect);
                        break;
                    case Element.STATE_DNS:
                        str.append("dns");
                        break;
                    case Element.STATE_FTP:
                        str.append("ftp");
                        break;
                    case Element.STATE_READY:
                        str.append("<b>");
                        str.append(string_ready);
                        str.append("</b>");
                        break;
                    case Element.STATE_RECEIVE:
                        if (element.totalSize > 0) {
                            final long completion = (100 * element.size + element.totalSize / 2)
                                    / element.totalSize;
                            str.append(completion);
                            str.append("%");
                        } else {
                            str.append(element.size);
                            str.append("B");
                        }
                        break;
                    default:
                        str.append("???");
                        break;
                    }

                    // Next line
                    str.append("<br />");
                }
            }

            // Final string
            final String message = str.toString();
            final String[] lines = brHtmlPattern.split(message);

            // Post refresh.
            setProgressLines(lines);
        }

        /**
         * Get last statistics
         * 
         * @return last statistics (or @c null if none)
         */
        public synchronized HTTrackStats getLastStats() {
            return lastStats;
        }
    }

    /**
     * Get the profile target file for the current project.
     * 
     * @return The profile target file.
     */
    protected File getProfileFile() {
        final File target = getTargetFile();
        if (target != null) {
            return getProfileFile(target);
        } else {
            return null;
        }
    }

    /**
     * Get the profile cache file for the current project.
     * 
     * @return The profile cache file.
     */
    protected File getCacheFile() {
        final File target = getTargetFile();
        if (target != null) {
            return getCacheFile(target);
        } else {
            return null;
        }
    }

    /**
     * Is there an existing profile yet ?
     * 
     * @return true if there is a winprofile.ini file
     */
    protected boolean hasProfileFile() {
        final File profile = getProfileFile();
        return profile != null && profile.exists();
    }

    /**
     * Is there an existing cache yet ?
     * 
     * @return true if there is a new.zip cache file
     */
    protected boolean hasCacheFile() {
        final File profile = getCacheFile();
        return profile != null && profile.exists();
    }

    /**
     * Interrupted profile ?
     * 
     * @return true if the mirror was interrupted
     */
    protected boolean isInterruptedProfile() {
        final File target = getTargetFile();
        if (target != null) {
            final File cache = new File(target, "hts-cache");
            return new File(target, "hts-in_progress.lock").exists()
                    || new File(cache, "interrupted.lock").exists();
        } else {
            return false;
        }
    }

    /**
     * Set the "interrupted" flag.
     * 
     * @param interrupted
     *          Interrupted mirror ?
     * @throws IOException
     *           Upon I/O error.
     */
    protected synchronized void setInterruptedProfile(final boolean interrupted) throws IOException {
        final File target = getTargetFile();
        final File cache = new File(target, "hts-cache");
        final File lock = new File(cache, "interrupted.lock");
        if (interrupted) {
            final FileWriter wr = new FileWriter(lock);
            wr.close();
        } else {
            lock.delete();
        }
    }

    /**
     * Get the profile target file for a given project.
     * 
     * @return The profile target file.
     * @param target
     *          The project directory
     * @return The profile file
     */
    protected static File getProfileFile(final File target) {
        if (target != null) {
            return new File(new File(target, "hts-cache"), "winprofile.ini");
        } else {
            return null;
        }
    }

    /**
     * Get the cache file for a given project.
     * 
     * @return The profile target file.
     * @param target
     *          The project directory
     * @return The cache file
     */
    protected static File getCacheFile(final File target) {
        if (target != null) {
            return new File(new File(target, "hts-cache"), "new.zip");
        } else {
            return null;
        }
    }

    /** Make directorie(s) if necessary. **/
    private static boolean mkdirs(final File target) {
        return target.mkdirs() || target.isDirectory();
    }

    /** Make directorie(s) if necessary. **/
    private static boolean mkdirsRetry(final File target) {
        if (mkdirs(target)) {
            return true;
        } else {
            Log.d("httrack", "creation of " + target + " failed, retrying ...");

            final File parent = target.getParentFile();
            if (parent != null && !parent.exists()) {
                if (!mkdirs(parent)) {
                    Log.d("httrack", "creation of " + parent + " failed");
                }
            }
            if (target.mkdir()) {
                Log.d("httrack", "creation of " + target + " succeeded");
                return true;
            } else {
                Log.d("httrack", "creation of " + target + " failed");
                return false;
            }
        }
    }

    /** make the given file readable/writabe. **/
    private static boolean setFileReadWrite(final File target) {
        // return target.setReadable(true) && target.setWritable(true);
        return true;
    }

    /**
     * Create the profile directory.
     * 
     * @return The profile file to be created
     * @throws IOException
     *           Upon error
     */
    protected synchronized File createProfileDirectory() throws IOException {
        final File target = getTargetFile();
        final File profile = getProfileFile();
        if (target == null || profile == null) {
            throw new IOException("No project defined yet");
        }
        final File cache = profile.getParentFile();

        // Validate path
        if (!HTTrackActivity.mkdirs(target)) {
            throw new IOException("Unable to create " + target.getAbsolutePath());
        }
        // Create cache for winprofile.ini
        else if (!HTTrackActivity.mkdirs(cache)) {
            throw new IOException("Unable to create " + cache);
        }
        HTTrackActivity.setFileReadWrite(target);
        HTTrackActivity.setFileReadWrite(cache);

        return profile;
    }

    /**
     * Serialize current profile to disk.
     * 
     * @throws IOException
     *           Upon I/O error.
     */
    protected synchronized void serialize() throws IOException {
        // Write settings
        final File profile = createProfileDirectory();
        mapper.serialize(profile);
        HTTrackActivity.setFileReadWrite(profile);
    }

    /**
     * Serialize current profile to an existing OutputStream.
     * 
     * @throws IOException
     *           Upon I/O error.
     */
    protected synchronized void serialize(final OutputStream os) throws IOException {
        mapper.serialize(os);
        os.flush();
    }

    /**
     * Unserialize profile from disk.
     * 
     * @throws IOException
     *           Upon I/O error.
     */
    protected void unserialize() throws IOException {
        final File profile = getProfileFile();
        mapper.unserialize(profile);
    }

    /**
     * Start the runner
     */
    protected synchronized void startRunner() {
        // First attempt to reclaim a running one (orientation change)
        if (runner == null) {
            final FragmentManager fm = getSupportFragmentManager();
            runner = (RunnerFragment) fm.findFragmentByTag(sessionID);
            // onAttach() should be called.
        }
        // Then, create one if necessary
        if (runner == null) {
            final FragmentManager fm = getSupportFragmentManager();
            runner = new RunnerFragment();
            runner.setParent(this);
            fm.beginTransaction().add(runner, sessionID).commit();
        }
    }

    /**
     * We just entered in a new pane.
     */
    protected void onEnterNewPane() {
        switch (layouts[pane_id]) {
        case R.layout.activity_startup:
            final TextView text = TextView.class.cast(this.findViewById(R.id.fieldDisplay));
            final TextView textDebug = TextView.class.cast(this.findViewById(R.id.fieldDebug));

            // Welcome message.
            final String html = getString(R.string.welcome_message).replace("\n-", "\n").replace("\n", "<br />")
                    .replace("HTTrack Website Copier", "<b>HTTrack Website Copier</b>");
            text.setText(Html.fromHtml(html));

            // Debugging and information.
            final StringBuilder str = new StringBuilder();
            str.append("<i>");
            if (version != null) {
                str.append("<br />Version: ");
                // Library version
                str.append(version);
                str.append(versionFeatures);
                // Android version
                str.append(" (Android version ");
                str.append(versionCode);
                str.append(")");
            }
            // str.append("  Path: ");
            // str.append(getProjectRootFile().getAbsolutePath());
            str.append("  IPv6: ");
            final InetAddress addrV6 = getIPv6Address();
            str.append(addrV6 != null ? ("YES (" + addrV6.getHostAddress() + ")") : "NO");
            str.append("</i>");
            textDebug.setText(Html.fromHtml(str.toString()));

            // Enable or disable browse & cleanup button depending on existing
            // project(s)
            View.class.cast(this.findViewById(R.id.buttonClear)).setEnabled(hasProjectNames());
            View.class.cast(this.findViewById(R.id.buttonBrowseAll)).setEnabled(hasProjectRootIndexFile());

            break;
        case R.layout.activity_proj_name:
            // Refresh suggest
            refreshprojectNameSuggests();

            /* Base path */
            TextView.class.cast(findViewById(R.id.fieldBasePath)).setText(getProjectRootFile().getAbsolutePath());

            // "Next" button is disabled if no project name is defined
            switchEmptyProjectName = !OptionsMapper.isStringNonEmpty(mapper.getProjectName());
            View.class.cast(findViewById(R.id.buttonNext)).setEnabled(!switchEmptyProjectName);

            /*
             * Prior to Honeycomb (TODO FIXME: check that), the android browser is
             * unable to browse local file:// pages embedding spaces (%20 or +)
             * Therefore, warn the user.
             */
            warnPreHoneycombSpaceIssue = android.os.Build.VERSION.SDK_INT < VERSION_CODES.HONEYCOMB;

            /* Add text watcher for the "Next" button. */
            final AutoCompleteTextView name = AutoCompleteTextView.class
                    .cast(this.findViewById(R.id.fieldProjectName));
            name.addTextChangedListener(new TextWatcher() {
                @Override
                public void onTextChanged(final CharSequence s, final int start, final int before,
                        final int count) {
                    // Warn when seeing space
                    if (warnPreHoneycombSpaceIssue) {
                        for (int i = start; i < start + count; i++) {
                            if (s.charAt(i) == ' ') {
                                showNotification(getString(R.string.warning_space_in_filename), true);
                                warnPreHoneycombSpaceIssue = false;
                                break;
                            }
                        }
                    }
                }

                @Override
                public void afterTextChanged(final Editable s) {
                    // Enable/disable next button
                    boolean empty = true;
                    for (int i = 0; i < s.length(); i++) {
                        if (Character.isLetterOrDigit(s.charAt(i))) {
                            empty = false;
                            break;
                        }
                    }
                    if (empty != switchEmptyProjectName) {
                        switchEmptyProjectName = empty;
                        View.class.cast(findViewById(R.id.buttonNext)).setEnabled(!empty);
                    }

                    // (re) Set category
                    final String category = getProjectCategory(s.toString());
                    TextView.class.cast(findViewById(R.id.fieldProjectCategory))
                            .setText(category != null ? category : "");
                }

                // NOOP
                @Override
                public void beforeTextChanged(final CharSequence s, final int start, final int count,
                        final int after) {
                }
            });
            break;
        case R.layout.activity_proj_setup:
            // Existing cache ?
            final boolean hasProfile = hasCacheFile();
            View.class.cast(this.findViewById(R.id.radioAction)).setEnabled(hasProfile);
            View.class.cast(this.findViewById(R.id.radioAction))
                    .setVisibility(hasProfile ? View.VISIBLE : View.GONE);
            if (hasProfile) {
                // Update is the default unless something was broken
                RadioGroup.class.cast(this.findViewById(R.id.radioAction))
                        .check(isInterruptedProfile() ? R.id.radioItemContinue : R.id.radioItemUpdate);
            } else {
                // Reset
                RadioGroup.class.cast(this.findViewById(R.id.radioAction)).check(-1);
            }
            break;
        case R.layout.activity_mirror_progress:
            setProgressLinesInternal(new String[] { getString(R.string.starting_worker_thread) });
            startRunner();
            if (runner != null) {
                ProgressBar.class.cast(findViewById(R.id.progressMirror)).setVisibility(View.VISIBLE);
            }
            break;
        case R.layout.activity_mirror_finished:
            // Ensure the engine has stopped running
            if (runner != null) {
                runner.stopMirror(true);
            }

            // Enable browse button if index.html exists
            final boolean hasIndex = hasTargetIndexFile();
            View.class.cast(this.findViewById(R.id.buttonBrowse)).setEnabled(hasIndex);
            if (!hasIndex) {
                final File index = getTargetIndexFile();
                Log.d(getClass().getSimpleName(),
                        "no index found (" + (index != null ? index.getAbsolutePath() : "unknown location") + ")");
                final String template = getString(R.string.no_index_html_in_xx);
                if (template == null) {
                    throw new RuntimeException("R.string.no_index_html_in_xx is null");
                }
                final File target = getTargetFile();
                if (target != null) {
                    final String warning = template.replace("%s", target.getPath());
                    showNotification(warning);
                }
            }

            // Enable logs if present
            final boolean hasLog = hasTargetLogFile();
            View.class.cast(this.findViewById(R.id.buttonLogs)).setEnabled(hasLog);
            if (!hasLog) {
                final File log = getTargetLogFile();
                Log.d(getClass().getSimpleName(),
                        "no log found (" + (log != null ? log.getAbsolutePath() : "unknown location") + ")");
            }

            // Final stats
            if (runner != null) {
                final HTTrackStats lastStats = runner.getLastStats();
                if (lastStats != null && lastStats.errorsCount != 0) {
                    // TODO
                }
            }
            break;
        }
    }

    /**
     * Exit button.
     */
    protected void onFinish() {
        // FIXME TODO CRASHES!
        // runOnUiThread(new Runnable() {
        // public void run() {
        // finish();
        // }
        // });
    }

    /**
     * Get a specific field text.
     * 
     * @param id
     *          The field ID.
     * @return the associated text
     */
    private String getFieldText(final int id) {
        return widgetDataExchange.getFieldText(id);
    }

    /**
     * Set a specific field text value.
     * 
     * @param id
     *          The field ID.
     * @param value
     *          the associated text
     */
    private void setFieldText(final int id, final String value) {
        widgetDataExchange.setFieldText(id, value);
    }

    /*
     * Add an empty TextView to "in progress" window.
     */
    private TextView addEmptyLineToLayout(final LinearLayout layout) {
        final TextView text = new TextView(this);

        // Span to the width
        text.setWidth(LayoutParams.FILL_PARENT);
        text.setHeight(LayoutParams.WRAP_CONTENT);

        // Single line, with optional ellipsis (...) on the middle
        text.setSingleLine();
        text.setEllipsize(TextUtils.TruncateAt.MIDDLE);
        layout.addView(text);

        // Return new widget
        return text;
    }

    /*
     * Remove lines from the "in progress" window.
     */
    private void removeLinesFromLayout(final LinearLayout layout, final int fromPosition) {
        if (fromPosition < layout.getChildCount()) {
            layout.removeViews(fromPosition, layout.getChildCount() - fromPosition);
        }
    }

    /*
     * Set the "progress" layout lines. To be run in any thread.
     */
    protected void setProgressLines(final String[] lines) {
        handlerUI.post(new Runnable() {
            @Override
            public void run() {
                setProgressLinesInternal(lines);
            }
        });
    }

    /*
     * Set the "progress" layout lines. Must be run in the GUI thread.
     */
    private void setProgressLinesInternal(final String[] lines) {
        // Get line divider position of the bottom coordinate
        final View lineDivider = findViewById(R.id.buttonStop);
        if (lineDivider == null) {
            return;
        }
        final int[] textPosition = new int[2];
        final int[] linePosition = new int[2];
        lineDivider.getLocationInWindow(linePosition);
        final int lineYTopPosition = linePosition[1];

        // Explode lines
        final LinearLayout layout = LinearLayout.class.cast(findViewById(R.id.layout));

        // Remove any additional lines
        removeLinesFromLayout(layout, lines.length);

        // Add lines while we can
        final int currSize = layout.getChildCount();
        for (int i = 0; i < lines.length; i++) {
            // Fetch or create next layout line.
            final TextView text = i < currSize ? TextView.class.cast(layout.getChildAt(i))
                    : addEmptyLineToLayout(layout);

            // Stop adding lines if overlapping to the end of the screen
            text.getLocationInWindow(textPosition);
            final int height = text.getHeight();
            final int textYBottomPosition = textPosition[1] + height;
            if (textYBottomPosition >= lineYTopPosition) {
                // Then cut everything remaining
                removeLinesFromLayout(layout, i);

                // Stop here
                break;
            }

            // Set text (html-formatted) for this line
            final String line = lines[i].trim();
            if (line.length() != 0) {
                text.setText(Html.fromHtml(line));
            } else {
                // Otherwise Android 3.0 does not insert any blank line
                text.setText(Html.fromHtml("&nbsp;"));
            }
        }
    }

    /*
     * Display the final "finished" panel.
     */
    protected void displayFinishedPanel(final String displayMessage, final long errorsCount) {
        handlerUI.post(new Runnable() {
            @Override
            public void run() {
                // Final pane
                setPane(LAYOUT_FINISHED);

                // Fancy result message
                if (displayMessage != null) {
                    final View view = findViewById(R.id.fieldDisplay);
                    if (view != null) {
                        TextView.class.cast(view).setText(Html.fromHtml(displayMessage));
                    }
                }
                if (errorsCount != 0) {
                    final View view = findViewById(R.id.buttonLogs);
                    if (view != null) {
                        final Animation shake = AnimationUtils.loadAnimation(HTTrackActivity.this, R.anim.scale);
                        view.startAnimation(shake);
                    }
                }
                /* If not visible, send a notification. */
                if (!started) {
                    final String name = mapper.getProjectName();
                    final String finished = getString(R.string.mirror_finished);
                    /* FIXME TODO check intent */
                    final Intent current = getCurrentIntentReference();
                    sendSystemNotification(current, finished + ": " + name, Html.fromHtml(displayMessage));
                }
            }
        });
    }

    /**
     * Validate the current pane
     * 
     * @return true if the current pane is valid
     */
    protected boolean validatePane() {
        switch (pane_id) {
        case LAYOUT_START:
            // Warn if no sdcard
            ensureExternalStorage();
            warnIfExternalStorageUnsuitable();
            break;
        case LAYOUT_PROJECT_NAME:
            // Recompute storage target if necessary
            if (!ensureExternalStorage()) {
                warnIfExternalStorageUnsuitable();
                return false;
            }
            // Continue if we could have a place to write to (even if we warned)

            // Check project name
            final String name = getFieldText(R.id.fieldProjectName);
            if (OptionsMapper.isStringNonEmpty(name)) {
                // Changed name ?
                final String prevName = getMap(R.id.fieldProjectName);
                if (prevName == null || !prevName.equals(name) || dirtyNamePane) {
                    // We need to put immediately the name in the map to be able to
                    // unserialize.
                    try {
                        mapper.resetMap();
                        setMap(R.id.fieldProjectName, name);
                        unserialize();
                    } catch (final IOException e) {
                        // Ignore (if not found)
                    } finally {
                        dirtyNamePane = false;
                    }
                }
                return true;
            }
            return false;
        case LAYOUT_PROJECT_SETUP:
            return OptionsMapper.isStringNonEmpty(getFieldText(R.id.fieldWebsiteURLs));
        }
        return true;
    }

    /**
     * Validate the current pane with visual effects on error. Thanks to Sushant
     * for the idea.
     * 
     * @return true if the current pane is valid
     */
    protected boolean validatePaneWithEffects(final boolean next) {
        final boolean validated = validatePane();
        if (!validated) {
            final Animation shake = AnimationUtils.loadAnimation(this, R.anim.shake);
            findViewById(next ? R.id.buttonNext : R.id.buttonPrevious).startAnimation(shake);
        }
        return validated;
    }

    /* Save current pane fieds into map. */
    private void savePaneFields() {
        if (pane_id != -1) {
            for (final int id : fields[pane_id]) {
                final String value = getFieldText(id);
                setMap(id, value);
            }
        }
    }

    /* Load pane fields from map. */
    private void loadPaneFields() {
        // Entering a new pane: restore data
        for (final int id : fields[pane_id]) {
            final String value = getMap(id);
            setFieldText(id, value);
        }
    }

    private void setPane(final int position) {
        if (pane_id != position) {
            // Leaving a pane: save data
            savePaneFields();

            // Switch pane
            pane_id = position;
            setContentView(layouts[pane_id]);

            // Entering a new pane: restore data
            loadPaneFields();

            // Post-actions
            onEnterNewPane();
        }
    }

    @Override
    public boolean onCreateOptionsMenu(final Menu menu) {
        // Inflate the menu; this adds items to the action bar if it is present.
        if (pane_id != -1) {
            getMenuInflater().inflate(R.menu.menu, menu);
        }
        return true;
    }

    /**
     * "Next"
     */
    public void onClickNext(final View view) {
        if (pane_id < layouts.length) {
            if (validatePaneWithEffects(true)) {
                setPane(pane_id + 1);
            }
        } else {
            onFinish();
        }
    }

    /**
     * "Previous"
     */
    public void onClickPrevious(final View view) {
        if (pane_id > 0) {
            setPane(pane_id - 1);
        }
    }

    /**
     * Change base path
     */
    public void onClickBasePath(final View view) {
        // Start new activity
        final Intent intent = new Intent(this, FileChooserActivity.class);
        fillExtra(intent);
        intent.putExtra("com.httrack.android.defaultHTTrackPath", getDefaultHTTrackPath());
        startActivityForResult(intent, ACTIVITY_FILE_CHOOSER);
    }

    /**
     * "Interrupt" or "Stop"
     */
    public void onClickStop(final View view) {
        if (runner != null) {
            // Soft interrupt
            if (!interruptRequested) {
                runner.stopMirror(false);
                interruptRequested = true;
                final TextView text = (TextView) this.findViewById(R.id.buttonStop);
                // Change text to "Stop"
                text.setText(getString(R.string.cancel));
                // Notice
                showNotification(getString(R.string.finishing_pending_transfers), true);
            }
            // Hard interrupt
            else {
                runner.stopMirror(true);
            }
        }
    }

    /**
     * "Options"
     */
    public void onClickOptions(final View view) {
        // First save current pane settings
        savePaneFields();

        // Then start new activity
        final Intent intent = new Intent(this, OptionsActivity.class);
        fillExtra(intent);
        intent.putExtra("com.httrack.android.map", mapper.serialize());
        Log.d(getClass().getSimpleName(), "map size: " + mapper.size());
        startActivityForResult(intent, ACTIVITY_OPTIONS);
    }

    /**
     * Project name "..." button.
     */
    public void onClickMoreProjectName(final View view) {
        final String[] names = getProjectNames();
        if (names != null && names.length != 0) {
            final Intent intent = new Intent(this, CleanupActivity.class);
            fillExtra(intent);
            intent.putExtra("com.httrack.android.projectNames", names);
            intent.putExtra("com.httrack.android.action", CleanupActivity.ACTION_SELECT);
            startActivityForResult(intent, ACTIVITY_PROJECT_NAME_CHOOSER);
        }
    }

    /** Restore a previously saved map context. **/
    private void loadParcelable(final Parcelable data) {
        // Load modified map
        mapper.unserialize(data);
        Log.d(getClass().getSimpleName(), "received map size: " + mapper.size());

        // Load possibly modified field(s)
        loadPaneFields();
    }

    @Override
    protected void onActivityResult(final int requestCode, final int resultCode, final Intent data) {
        Log.d(getClass().getSimpleName(), "onActivityResult");
        super.onActivityResult(requestCode, resultCode, data);
        switch (requestCode) {
        case ACTIVITY_OPTIONS:
            if (resultCode == Activity.RESULT_OK) {
                // Load modified map
                loadParcelable(data.getParcelableExtra("com.httrack.android.map"));
            }
            break;
        case ACTIVITY_FILE_CHOOSER:
            if (resultCode == Activity.RESULT_OK) {
                // Load modified map
                final String path = data.getStringExtra("com.httrack.android.rootFile");
                setBasePath(path);
            }
            break;
        case ACTIVITY_PROJECT_NAME_CHOOSER:
            if (resultCode == Activity.RESULT_OK) {
                final String projectName = data.getStringExtra("com.httrack.android.projectName");
                final EditText name = EditText.class.cast(this.findViewById(R.id.fieldProjectName));
                name.setText(projectName);
                name.setSelection(projectName.length());
                name.requestFocus();
            }
            break;
        case ACTIVITY_CLEANUP:
            if (resultCode == Activity.RESULT_OK) {
                final boolean rootWasDeleted = data.getBooleanExtra("com.httrack.android.rootPathWasDeleted",
                        false);
                // Exit because otherwise we can't recreate the directory (!)
                if (rootWasDeleted && android.os.Build.VERSION.SDK_INT >= VERSION_CODES.KITKAT) {
                    Log.w("httrack", "exiting because root path was deleted (Android >= Kitkat issue)");
                    // restartActivity();
                    finish();
                    // darn, this is not clean, but otherwise restarting the activity
                    // won't be enough. I suspect FUSE-related issue here
                    System.exit(0);
                }
            }
            break;
        }
    }

    /**
     * "Show Logs"
     */
    public void onShowLogs(final View view) {
        final File log = getTargetLogFile();
        if (log.exists()) {
            FileInputStream rd;
            try {
                rd = new FileInputStream(log);
                final byte[] data = new byte[(int) log.length()];
                rd.read(data);
                rd.close();
                final String logs = new String(data, "UTF-8");
                new AlertDialog.Builder(this).setTitle("Logs").setMessage(logs).show();
            } catch (final IOException e) {
            }
        }
    }

    /** Browser a specific index. **/
    public static Intent getBrowseIntent(final File index) {
        if (index != null && index.exists()) {
            final Intent intent = new Intent();
            intent.setAction(android.content.Intent.ACTION_VIEW);
            // Note: won't work on certain Android releases if the project name has
            // spaces :(
            final Uri uri = Uri.fromFile(index);
            // Without the MIME, Android tend to crash with a NPE (!)
            intent.setDataAndType(uri, "text/html");
            return intent;
        } else {
            return null;
        }
    }

    /** Browser a specific web page. **/
    private void browse(final Uri uri) {
        final Intent intent = new Intent();
        intent.setAction(android.content.Intent.ACTION_VIEW);
        intent.setData(uri);
        startActivity(intent);
    }

    /** Browser a specific index. **/
    private void browse(final File index) {
        final Intent intent = getBrowseIntent(index);
        if (intent != null) {
            startActivity(intent);
        }
    }

    /**
     * "Browse Website"
     */
    public void onBrowse(final View view) {
        browse(getTargetIndexFile());
    }

    /**
     * "Browse All Websites"
     */
    public void onBrowseAll(final View view) {
        browse(getProjectRootIndexFile());
    }

    /*
     * Restart activity. (exit current app)
     */
    protected void restartActivity() {
        final Intent intent = getIntent();
        finish();
        startActivity(intent);
    }

    /**
     * "New project"
     */
    public void onNewProject(final View view) {
        restartActivity();
    }

    /**
     * "Exit"
     */
    public void onExit(final View view) {
        finish();
    }

    /**
     * Fill intent with common settings (project path, etc.).
     * 
     * @param intent
     *          The intent object
     */
    protected void fillExtra(final Intent intent) {
        intent.putExtra("com.httrack.android.rootFile", getProjectRootFile());
        intent.putExtra("com.httrack.android.resourceFile", getResourceFile());
    }

    /**
     * "Cleanup"
     */
    public void onCleanup(final View view) {
        final String[] names = getProjectNames();
        if (names != null && names.length != 0) {
            final Intent intent = new Intent(this, CleanupActivity.class);
            fillExtra(intent);
            intent.putExtra("com.httrack.android.projectNames", names);
            intent.putExtra("com.httrack.android.action", CleanupActivity.ACTION_CLEANUP);
            startActivityForResult(intent, ACTIVITY_CLEANUP);
        }
    }

    @Override
    public void onConfigurationChanged(final Configuration newConfig) {
        Log.d(getClass().getSimpleName(), "onConfigurationChanged");
        // TODO: handle orientation change ?
    }

    @Override
    public boolean onOptionsItemSelected(final MenuItem item) {
        // Handle item selection
        switch (item.getItemId()) {
        case R.id.action_about:
            final String about = getString(R.string.about_credits);
            final String about0 = getString(R.string.app_name);
            final String aboutLegal = getTextResource(R.raw.legal);
            new AlertDialog.Builder(this).setTitle("About").setMessage(about0 + "\n" + about + "\n\n" + aboutLegal)
                    .show();
            break;
        case R.id.action_license:
            browse(new File(new File(getResourceFile(), "license"), "gpl-3.0-standalone.html"));
            break;
        case R.id.action_forum:
            browse(Uri.parse("http://forum.httrack.com/"));
            break;
        case R.id.action_website:
            browse(Uri.parse("http://www.httrack.com/"));
            break;
        case R.id.action_help:
            browse(new File(new File(getResourceFile(), "html"), "index.html"));
            break;
        default:
            return super.onOptionsItemSelected(item);
        }
        return true;
    }

    /** Get current focus identifier. **/
    protected int[] getCurrentFocusId() {
        final View view = getCurrentFocus();
        if (view != null) {
            final int id = view.getId();
            if (view instanceof EditText) {
                final EditText edit = EditText.class.cast(view);
                final int start = edit.getSelectionStart();
                final int end = edit.getSelectionEnd();
                return new int[] { id, start, end };
            }
            return new int[] { id };
        } else {
            return new int[] {};
        }
    }

    /** Set current focus identifier. **/
    private void setCurrentFocusId(final int ids[]) {
        if (ids != null && ids.length != 0) {
            final int id = ids[0];
            final View view = findViewById(id);
            if (view != null) {
                view.requestFocus();
                if (view instanceof EditText) {
                    final EditText edit = EditText.class.cast(view);
                    if (ids.length >= 3) {
                        final int start = ids[1];
                        final int end = ids[2];
                        edit.setSelection(start, end);
                    }
                }
            }
        }
    }

    /** Save instance state. **/
    protected void saveInstanceState(final Bundle outState) {
        // Save current state. There is no guarantee than the application is going
        // to be killed/restored, however.

        // Save current pane
        savePaneFields();

        // Save settings to bundle

        // Serialized session ID, used to reclain the fragment runner if any
        outState.putString("com.httrack.android.sessionID", sessionID);

        // Version ID
        outState.putInt("com.httrack.android.version", versionCode);

        // Map keys
        outState.putParcelable("com.httrack.android.map", mapper.serialize());

        // Current pane
        outState.putInt("com.httrack.android.pane_id", pane_id);

        // Current focus id
        outState.putIntArray("com.httrack.android.focus_id", getCurrentFocusId());
    }

    @Override
    protected void onSaveInstanceState(final Bundle outState) {
        Log.d(getClass().getSimpleName(), "onSaveInstanceState");
        super.onSaveInstanceState(outState);
        saveInstanceState(outState);
    }

    /**
     * Display a notification ("toast")
     * 
     * @param message
     *          The message to be shown.
     * @param longDuration
     *          Should the notification duration be long ?
     */
    protected void showNotification(final String message, final boolean longDuration) {
        final Context context = getApplicationContext();
        final int duration = longDuration ? Toast.LENGTH_LONG : Toast.LENGTH_SHORT;
        final Toast toast = Toast.makeText(context, message, duration);
        toast.show();
    }

    /**
     * Display a short notification ("toast")
     * 
     * @param message
     *          The message to be shown.
     */
    protected void showNotification(final String message) {
        showNotification(message, false);
    }

    /** Get a pointer to the current activity. **/
    protected Intent getCurrentIntentReference() {
        Intent intent = new Intent(getApplicationContext(), this.getClass());
        intent.setAction("android.intent.action.MAIN");
        intent.addCategory("android.intent.category.LAUNCHER");
        return intent;
    }

    /** Send a notification. **/
    protected void sendAbortNotification() {
        final String title = getString(R.string.mirror_xxx_stopped).replace("%s", mapper.getProjectName());
        final String text = getString(R.string.click_on_notification_to_restart);

        // Continue an interrupted mirror
        setMap(R.id.radioAction, "0");

        // Build notification
        final Intent intent = new Intent(this, HTTrackActivity.class);
        final Bundle extras = new Bundle();
        saveInstanceState(extras);

        intent.putExtras(extras);
        sendSystemNotification(intent, title, text);
    }

    /** Send a notification with a specific Intent. **/
    protected void sendSystemNotification(final Intent intent, final CharSequence title, final CharSequence text) {

        // Create notification
        final long when = System.currentTimeMillis();
        final PendingIntent pintent = PendingIntent.getActivity(this, 0, intent, 0);
        final Notification notification = new NotificationCompat.Builder(this).setContentTitle(title)
                .setContentText(text).setTicker(title).setSmallIcon(R.drawable.ic_launcher).setWhen(when)
                .setContentInfo(getString(R.string.start)).setContentIntent(pintent).setAutoCancel(true).build();

        // Send
        final NotificationManager manager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);
        manager.notify((int) System.currentTimeMillis(), notification);
    }

    /** Send a notification with a blank Intent. **/
    protected void sendSystemNotification(final CharSequence title, final CharSequence text) {
        sendSystemNotification(new Intent(), title, text);
    }

    /** Restore a saved instance state. **/
    protected void restoreInstanceState(final Bundle savedInstanceState) {
        // Check version ID
        final int version = savedInstanceState.getInt("com.httrack.android.version");
        if (version != versionCode) {
            Log.d(getClass().getSimpleName(), "refused bundle version " + version);
            return;
        }

        // Serialized session ID
        sessionID = savedInstanceState.getString("com.httrack.android.sessionID");

        // Switch pane id
        final int id = savedInstanceState.getInt("com.httrack.android.pane_id");

        // Current focus
        final int[] focus_ids = savedInstanceState.getIntArray("com.httrack.android.focus_id");

        // Load map
        final Parcelable data = savedInstanceState.getParcelable("com.httrack.android.map");

        // Load map
        if (data != null) {
            // Load map settings
            loadParcelable(data);

            // Switch pane id (0 by default)
            setPane(id);

            // Set focus
            setCurrentFocusId(focus_ids);

            // Special case: we're on the project name pane - load it if no settings
            // are defined.
            if (id == LAYOUT_PROJECT_NAME) {
                dirtyNamePane = true;
            }
        }
    }

    @Override
    protected void onRestoreInstanceState(final Bundle savedInstanceState) {
        Log.d(getClass().getSimpleName(), "onRestoreInstanceState");
        super.onRestoreInstanceState(savedInstanceState);

        // Security
        if (runner != null) {
            showNotification("restore called while running ignored! please report this warning to the developers",
                    true);
            return;
        }

        restoreInstanceState(savedInstanceState);
    }

    @Override
    protected void onPause() {
        Log.d(getClass().getSimpleName(), "onPause");
        super.onPause();
        paused = true;
    }

    @Override
    protected void onResume() {
        Log.d(getClass().getSimpleName(), "onResume");
        super.onResume();
        paused = false;
    }

    @Override
    protected void onStart() {
        Log.d(getClass().getSimpleName(), "onStart");
        super.onStart();
        started = true;
    }

    @Override
    protected void onStop() {
        Log.d(getClass().getSimpleName(), "onStop");
        super.onStop();
        started = false;
    }

    @Override
    public void onDestroy() {
        Log.d(getClass().getSimpleName(), "onDestroy");
        // We are being destroyed... save profile ?
        savePaneFields();
        final String name = mapper.getProjectName();
        if (mapper.isDirty() && OptionsMapper.isStringNonEmpty(name)
                && OptionsMapper.isStringNonEmpty(mapper.getProjectUrl())) {
            try {
                serialize();
                Log.d(getClass().getSimpleName(), "saved profile for " + mapper.getProjectName());
            } catch (final IOException e) {
                Log.d(getClass().getSimpleName(), "could not save profile", e);
                final String title = getString(R.string.could_not_save_profile_xxx).replace("%s", name);
                sendSystemNotification(title, e.getMessage());
            }
        }
        super.onDestroy();
    }

    /** Navigate back to home, without killing us. **/
    private void goToHome() {
        final Intent intent = new Intent(Intent.ACTION_MAIN);
        intent.addCategory(Intent.CATEGORY_HOME);
        startActivity(intent);
    }

    @Override
    public void onBackPressed() {
        // Downloading data
        if (pane_id == LAYOUT_MIRROR_PROGRESS) {
            // Do not go home, or our fragment will die.
            goToHome();
        } else {
            super.onBackPressed();
        }
    }
}