org.texai.torrent.Tracker.java Source code

Java tutorial

Introduction

Here is the source code for org.texai.torrent.Tracker.java

Source

/*
 * Tracker - Keeps track of clients sharing a particular torrent MetaInfo.
 * Copyright (C) 2003 Mark J. Wielaard
 *
 * This file is part of Snark.
 *
 * 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 2, or (at your option) 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.
 *
 * Revised by Stephen L. Reed, Dec 22, 2009.
 * Reformatted, fixed Checkstyle, Findbugs and PMD violations, and substituted Log4J logger
 * for consistency with the Texai project.  Converted to a private torrent protocol that
 * uses a single SSL socket.
 */
package org.texai.torrent;

import java.io.UnsupportedEncodingException;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.UnknownHostException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import org.apache.commons.codec.DecoderException;
import org.apache.commons.codec.net.URLCodec;
import org.apache.log4j.Level;
import org.apache.log4j.Logger;
import org.jboss.netty.channel.Channel;
import org.jboss.netty.handler.codec.http.HttpRequest;
import org.jboss.netty.handler.codec.http.QueryStringDecoder;
import org.jboss.netty.handler.codec.http.websocketx.TextWebSocketFrame;
import org.texai.network.netty.handler.HTTPRequestHandler;
import org.texai.network.netty.handler.TexaiHTTPRequestHandler;
import org.texai.network.netty.utils.NettyHTTPUtils;
import org.texai.torrent.bencode.BEncoder;
import org.texai.torrent.domainEntity.MetaInfo;
import org.texai.util.HTTPUtils;
import org.texai.util.NetworkUtils;
import org.texai.util.TexaiException;

/** Keeps track of clients sharing a particular torrent MetaInfo.
 * The tracker shares a multiplexed port with the local peer. */
public final class Tracker implements TexaiHTTPRequestHandler {

    /** the logger */
    private static final Logger LOGGER = Logger.getLogger(Tracker.class);
    /** no event */
    public static final String NO_EVENT = "";
    /** started event */
    public static final String STARTED_EVENT = "started";
    /** completed event */
    public static final String COMPLETED_EVENT = "completed";
    /** stopped event */
    public static final String STOPPED_EVENT = "stopped";
    /** the 15 minute interval between tracker requests from the peer */
    private static final int REQUEST_INTERVAL_SECONDS = 15 * 60;
    /** the peer expiration cushion factor */
    private static final double PEER_EXPIRATION_CUSHION_FACTOR = 1.5d;
    /** the metainfo dictionary, URL-encoded torrent hash --> metainfo */
    private final Map<String, MetaInfo> metaInfoDictionary = new HashMap<>();
    /** the peers dictionary, URL-encoded torrent hash --> (map of tracked peer info --> tracked peer status) */
    private final Map<String, Map<TrackedPeerInfo, TrackedPeerStatus>> trackedPeerInfosDictionary = new HashMap<>();
    /** the completions dictionary, URL-encoded torrent hash --> number of download completions */
    private final Map<String, Integer> completionsDictionary = new HashMap<>();

    /** Creates a new Tracker instance. */
    public Tracker() {
        HTTPRequestHandler.getInstance().register(this);
    }

    /** Handles the HTTP request.
     *
     * @param httpRequest the HTTP request
     * @param channel the channel
     * @return the indicator whether the HTTP request was handled
     */
    @Override
    public boolean httpRequestReceived(final HttpRequest httpRequest, final Channel channel) {
        //Preconditions
        assert httpRequest != null : "httpRequest must not be null";
        assert channel != null : "channel must not be null";

        final String uriString = httpRequest.getUri();
        LOGGER.info("received HTTP request: " + uriString);
        if (uriString.endsWith("/torrent-tracker/statistics")) {
            handleStatisticsRequest(httpRequest, channel);
            return true;
        } else if (uriString.endsWith("/torrent-tracker/scrape")) {
            handleScrapeRequest(httpRequest, channel);
            return true;
        } else if (uriString.contains("/torrent-tracker/announce")) {
            handleAnnounceRequest(httpRequest, channel);
            return true;
        } else {
            return false;
        }
    }

    /** Gets the torrent metainfo given the hash.
     *
     * @param urlEncodedInfoHash the URL-encoded info hash
     * @return the torrent metainfo given the hash
     */
    public MetaInfo getMetaInfo(final String urlEncodedInfoHash) {
        //Preconditions
        assert urlEncodedInfoHash != null : "urlEncodedInfoHash must not be null";
        assert !urlEncodedInfoHash.isEmpty() : "urlEncodedInfoHash must not be empty";

        return metaInfoDictionary.get(urlEncodedInfoHash);
    }

    /** Adds a new info hash to the tracker.
     *
     * @param urlEncodedInfoHash the new URL-encoded info hash
     */
    public void addInfoHash(final String urlEncodedInfoHash) {
        //Preconditions
        if (urlEncodedInfoHash == null || urlEncodedInfoHash.isEmpty()) {
            throw new IllegalArgumentException("invalid hash");
        }

        synchronized (trackedPeerInfosDictionary) {
            trackedPeerInfosDictionary.put(urlEncodedInfoHash, new HashMap<>());
        }
    }

    /** Adds a new metainfo to the tracker.
     *
     * @param metaInfo the new metainfo
     */
    public void addMetainfo(final MetaInfo metaInfo) {
        //Preconditions
        if (metaInfo == null) {
            throw new IllegalArgumentException("invalid metaInfo");
        }

        final String urlEncodedInfoHash = metaInfo.getURLEncodedInfoHash();
        addInfoHash(urlEncodedInfoHash);
        metaInfoDictionary.put(urlEncodedInfoHash, metaInfo);
    }

    /** Handles the statistics request, which is not part of the BitTorrent protocol.  It returns an
     * HTML statistics page.
     *
     * @param httpRequest the HTTP request
     * @param channel the channel
     */
    public void handleStatisticsRequest(final HttpRequest httpRequest, final Channel channel) {
        assert httpRequest != null : "httpRequest must not be null";
        assert channel != null : "channel must not be null";

        final StringBuilder responseContent = new StringBuilder();
        responseContent.append("<?xml version=\"1.0\" encoding=\"iso-8859-1\"?>\n");
        responseContent.append(
                "<!DOCTYPE html PUBLIC \"-//W3C//DTD XHTML 1.0 Transitional//EN\" \"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd\">\n");
        responseContent.append("<html lang=\"en-US\" xml:lang=\"en-US\" xmlns=\"http://www.w3.org/1999/xhtml\">\n");
        responseContent.append("  <head>\n");
        responseContent.append("    <title>Snark</title>\n");
        responseContent.append("  </head>\n");
        responseContent.append("  <body>\n");
        responseContent.append("    <h2>Snark BitTorrent Tracker</h2>\n");
        responseContent.append("    <table border=\"1\">\n");
        responseContent.append("      <tr>\n");
        responseContent.append("        <th>Torrent Hash</th>\n");
        responseContent.append("        <th>Finished</th>\n");
        responseContent.append("        <th>Seeders</th>\n");
        responseContent.append("        <th>Leechers</th>\n");
        responseContent.append("      </tr>\n");
        Map<byte[], Map<String, Object>> filesDictionary = null;

        final QueryStringDecoder queryStringDecoder = new QueryStringDecoder(httpRequest.getUri());
        final Map<String, List<String>> parameterDictionary = queryStringDecoder.getParameters();
        try {
            filesDictionary = gatherFilesDictionary(parameterDictionary);
        } catch (DecoderException ex) {
            throw new TexaiException(ex);
        }
        for (final Entry<byte[], Map<String, Object>> filesDictionaryEntry : filesDictionary.entrySet()) {
            responseContent.append("      <tr>\n");
            responseContent.append("        <td>");
            responseContent.append(new String((new URLCodec()).encode(filesDictionaryEntry.getKey())));
            responseContent.append("</td>\n");
            final Map<String, Object> peersDictionary = filesDictionaryEntry.getValue();
            responseContent.append("        <td>");
            responseContent.append(peersDictionary.get("downloaded"));
            responseContent.append("</td>\n");
            responseContent.append("        <td>");
            responseContent.append(peersDictionary.get("complete"));
            responseContent.append("</td>\n");
            responseContent.append("        <td>");
            responseContent.append(peersDictionary.get("incomplete"));
            responseContent.append("</td>\n");
            responseContent.append("      </tr>\n");
        }
        responseContent.append("    </table>\n");
        responseContent.append("  </body>\n");
        responseContent.append("</html>\n");

        NettyHTTPUtils.writeHTMLResponse(httpRequest, responseContent, channel);
    }

    /** Handles the scrape request.
     *
     * @param httpRequest the HTTP request
     * @param channel the channel
     */
    public void handleScrapeRequest(final HttpRequest httpRequest, final Channel channel) {
        assert httpRequest != null : "httpRequest must not be null";
        assert channel != null : "channel must not be null";

        final QueryStringDecoder queryStringDecoder = new QueryStringDecoder(httpRequest.getUri());
        final Map<String, List<String>> parameterDictionary = queryStringDecoder.getParameters();
        final InetSocketAddress remoteAddress = (InetSocketAddress) channel.getRemoteAddress();
        LOGGER.warn("tracker scrape request: " + remoteAddress + " -> " + parameterDictionary);
        try {
            NettyHTTPUtils.writeBinaryResponse(httpRequest,
                    BEncoder.bencode(gatherFilesDictionary(parameterDictionary)), channel, null); // sessionCookie
        } catch (DecoderException ex) {
            throw new TexaiException(ex);
        }
    }

    /** Handles the scrape request.
     *
     * @param parameterDictionary the parameter dictionary
     * @return the bencoded response
     * @throws DecoderException when a hash cannot be URL-decoded
     */
    @SuppressWarnings("unchecked")
    public Map<byte[], Map<String, Object>> gatherFilesDictionary(
            final Map<String, List<String>> parameterDictionary) throws DecoderException {
        //Preconditions
        assert parameterDictionary != null : "parameterDictionary must not be null";

        final Map<byte[], Map<String, Object>> filesDictionary = new HashMap<>();
        final List<String> requestedURLEncodedInfoHashes = new ArrayList<>();
        final Object infoHashValue = parameterDictionary.get("info_hash");
        if (infoHashValue == null) {
            // when the optional parameter is absent, then reply with all tracked info hashes
            requestedURLEncodedInfoHashes.addAll(trackedPeerInfosDictionary.keySet());
        } else if (infoHashValue instanceof List<?>) {
            requestedURLEncodedInfoHashes.addAll((List<String>) infoHashValue);
        } else {
            assert infoHashValue instanceof String;
            requestedURLEncodedInfoHashes.add((String) infoHashValue);
        }
        LOGGER.warn("reviewing tracked peers for statistics");
        for (final String requestedURLEncodedInfoHash : requestedURLEncodedInfoHashes) {
            if (trackedPeerInfosDictionary.containsKey(requestedURLEncodedInfoHash)) {
                LOGGER.warn("  requested info hash: " + requestedURLEncodedInfoHash);
                final byte[] key;
                key = URLCodec.decodeUrl(requestedURLEncodedInfoHash.getBytes());
                final Map<String, Object> peersDictionary = new HashMap<>();

                // total number of times the tracker has registered a completion ("event=complete", i.e. a client finished downloading the torrent)
                final Integer nbrDownloaded = completionsDictionary.get(requestedURLEncodedInfoHash);
                LOGGER.warn("  nbrDownloaded: " + nbrDownloaded);
                if (nbrDownloaded == null) {
                    peersDictionary.put("downloaded", 0);
                } else {
                    peersDictionary.put("downloaded", nbrDownloaded);
                }

                int nbrLeechers = 0;
                int nbrSeeders = 0;
                synchronized (trackedPeerInfosDictionary) {
                    final Map<TrackedPeerInfo, TrackedPeerStatus> trackedPeerInfosMap = trackedPeerInfosDictionary
                            .get(requestedURLEncodedInfoHash);
                    for (final TrackedPeerStatus trackedPeerStatus : trackedPeerInfosMap.values()) {
                        LOGGER.warn("  trackedPeerInfo: " + trackedPeerStatus);
                        switch (trackedPeerStatus.event) {
                        case STARTED_EVENT:
                            nbrLeechers++;
                            break;
                        case COMPLETED_EVENT:
                            nbrSeeders++;
                            break;
                        default:
                            assert false;
                            break;
                        }
                    }
                }
                // number of non-seeder peers, aka "leechers"
                peersDictionary.put("incomplete", nbrLeechers);

                // number of peers with the entire file, i.e. seeders
                peersDictionary.put("complete", nbrSeeders);

                // (optional) the torrent's internal name, as specified by the "name" file in the info section of the metainfo
                final MetaInfo metaInfo = metaInfoDictionary.get(requestedURLEncodedInfoHash);
                if (metaInfo != null) {
                    assert metaInfo.getName() != null;
                    peersDictionary.put("name", metaInfo.getName());
                }
                filesDictionary.put(key, peersDictionary);
            }
        }
        LOGGER.warn("filesDictionary: " + filesDictionary);
        return filesDictionary;
    }

    /** Returns whether this tracker is tracking the given URL-encoded info hash.
     *
     * @param requestedURLEncodedInfoHash the given URL-encoded info hash
     * @return whether this tracker is tracking the given URL-encoded info hash
     */
    public boolean isTracking(final String requestedURLEncodedInfoHash) {
        //Preconditions
        assert requestedURLEncodedInfoHash != null : "requestedURLEncodedInfoHash must not be null";

        synchronized (trackedPeerInfosDictionary) {
            return trackedPeerInfosDictionary.containsKey(requestedURLEncodedInfoHash);
        }
    }

    /** Returns whether this tracker has peers for the given URL-encoded info hash.
     *
     * @param requestedURLEncodedInfoHash the given URL-encoded info hash
     * @return whether this tracker is tracking the given URL-encoded info hash
     */
    public boolean hasPeers(final String requestedURLEncodedInfoHash) {
        //Preconditions
        assert requestedURLEncodedInfoHash != null : "requestedURLEncodedInfoHash must not be null";

        synchronized (trackedPeerInfosDictionary) {
            if (trackedPeerInfosDictionary.containsKey(requestedURLEncodedInfoHash)) {
                return !trackedPeerInfosDictionary.get(requestedURLEncodedInfoHash).isEmpty();
            } else {
                return false;
            }
        }
    }

    /** Handles the announce request.
     *
     * @param httpRequest the HTTP request
     * @param channel the channel
     */
    public void handleAnnounceRequest(final HttpRequest httpRequest, final Channel channel) {
        //Preconditions
        assert httpRequest != null : "httpRequest must not be null";
        assert channel != null : "channel must not be null";

        final URI uri;
        try {
            uri = new URI(httpRequest.getUri());
        } catch (URISyntaxException ex) {
            throw new TexaiException(ex);
        }
        final Map<String, String> parameterDictionary = HTTPUtils.getQueryMap(uri.getRawQuery());
        LOGGER.warn("tracker announce request: " + channel.getRemoteAddress() + " -> " + parameterDictionary);

        // info hash
        final String requestedUrlEncodedInfoHash = parameterDictionary.get("info_hash");
        LOGGER.debug("  requestedUrlEncodedInfoHash: " + requestedUrlEncodedInfoHash);
        if (requestedUrlEncodedInfoHash == null) {
            failure(httpRequest, "No info_hash given", channel);
            return;
        }
        boolean found = false;
        LOGGER.debug("trackedPeerInfosDictionary: " + trackedPeerInfosDictionary);
        synchronized (trackedPeerInfosDictionary) {
            for (String urlEncodedInfoHash : trackedPeerInfosDictionary.keySet()) {
                if (urlEncodedInfoHash.equals(requestedUrlEncodedInfoHash)) {
                    found = true;
                }
            }
        }
        if (!found) {
            failure(httpRequest, "Tracker doesn't handle given info_hash", channel);
            return;
        }

        // peer id
        byte[] peerIdBytes;
        final String peerIdValue = parameterDictionary.get("peer_id");
        if (peerIdValue == null) {
            failure(httpRequest, "No peer_id given", channel);
            return;
        }
        try {
            peerIdBytes = (new URLCodec()).decode(peerIdValue.getBytes());
        } catch (DecoderException ex) {
            failure(httpRequest, "cannot decode peer id value: " + peerIdValue, channel);
            return;
        }
        if (peerIdBytes.length != 20) {
            failure(httpRequest, "peer_id must be 20 bytes long", channel);
            return;
        }

        // port
        @SuppressWarnings("UnusedAssignment")
        int peerPort = 0;
        final String peerPortValue = parameterDictionary.get("port");
        if (peerPortValue == null) {
            failure(httpRequest, "No port given", channel);
            return;
        }
        try {
            peerPort = Integer.parseInt(peerPortValue);
        } catch (NumberFormatException nfe) {
            failure(httpRequest, "port not a number: " + nfe, channel);
            return;
        }

        // ip
        final InetAddress inetAddress = ((InetSocketAddress) channel.getRemoteAddress()).getAddress();
        InetAddress inetAddress1 = null;
        if (NetworkUtils.isPrivateNetworkAddress(inetAddress)) {
            // See http://wiki.theory.org/BitTorrentSpecification#Tracker_Response .
            // Handle the case where the peer and ourselves are both behind the same NAT router.  The ip address from the
            // socket connection will be a private address which indicates the peer is on our private network behind the
            // internet-facing router.  The optional ip parameter contains what the peer found as its local host address.
            final String peerSuppliedIPAddress = parameterDictionary.get("ip");
            if (peerSuppliedIPAddress == null) {
                failure(httpRequest, "No ip address given", channel);
                return;
            } else {
                try {
                    inetAddress1 = InetAddress.getByName(peerSuppliedIPAddress);
                } catch (UnknownHostException ex) {
                    LOGGER.warn("invalid ip parameter supplied by peer: '" + peerSuppliedIPAddress + "'");
                }
            }
            if (inetAddress1 == null) {
                inetAddress1 = inetAddress;
            }
        } else {
            inetAddress1 = inetAddress;
        }
        final TrackedPeerInfo trackedPeerInfo = new TrackedPeerInfo(peerIdBytes, inetAddress1, peerPort);

        // downloaded
        final int downloaded;
        final String downloaded_value = parameterDictionary.get("downloaded");
        if (downloaded_value == null) {
            failure(httpRequest, "No downloaded given", channel);
            return;
        }
        try {
            downloaded = Integer.parseInt(downloaded_value);
        } catch (NumberFormatException nfe) {
            failure(httpRequest, "downloaded not a number: " + nfe, channel);
            return;
        }

        // event
        final Map<String, Object> responseDictionary = new HashMap<>();
        final Map<TrackedPeerInfo, TrackedPeerStatus> trackedPeerInfosMap = trackedPeerInfosDictionary
                .get(requestedUrlEncodedInfoHash);
        final String event = parameterDictionary.get("event");
        if (event == null || event.isEmpty()) {
            synchronized (trackedPeerInfosMap) {
                final TrackedPeerStatus trackedPeerStatus = trackedPeerInfosMap.get(trackedPeerInfo);
                if (trackedPeerStatus == null) {
                    failure(httpRequest, "peer never started", channel);
                    return;
                }
                LOGGER.warn("updating peer " + trackedPeerInfo + " expiration");
                trackedPeerStatus.updatePeerExpirationMilliseconds();
            }
        } else {
            if (event.equals(COMPLETED_EVENT)) {
                final TrackedPeerStatus trackedPeerStatus = trackedPeerInfosMap.get(trackedPeerInfo);
                if (trackedPeerStatus == null) {
                    failure(httpRequest, "peer never started", channel);
                    return;
                }
                trackedPeerStatus.event = COMPLETED_EVENT;
                if (downloaded == 0) {
                    LOGGER.warn("seeding without download: " + trackedPeerInfo);
                } else {
                    LOGGER.warn("seeding after completed download: " + trackedPeerInfo);
                    synchronized (completionsDictionary) {
                        final Integer nbrDownloaded = completionsDictionary.get(requestedUrlEncodedInfoHash);
                        if (nbrDownloaded == null) {
                            completionsDictionary.put(requestedUrlEncodedInfoHash, 1);
                        } else {
                            completionsDictionary.put(requestedUrlEncodedInfoHash, nbrDownloaded + 1);
                        }
                    }
                }
            } else {
                synchronized (trackedPeerInfosMap) {
                    switch (event) {
                    case STOPPED_EVENT:
                        LOGGER.warn("removing stopped peer " + trackedPeerInfo);
                        trackedPeerInfosMap.remove(trackedPeerInfo);
                        break;
                    case STARTED_EVENT:
                        LOGGER.warn("adding new peer " + trackedPeerInfo);
                        trackedPeerInfosMap.put(trackedPeerInfo, new TrackedPeerStatus(trackedPeerInfo, event));
                        break;
                    default:
                        failure(httpRequest, "invalid event", channel);
                        return;
                    }
                }
            }
        }

        // compose tracker response
        responseDictionary.put("interval", REQUEST_INTERVAL_SECONDS);
        final List<Map<String, Object>> peerList = new ArrayList<>();
        final Iterator<TrackedPeerStatus> trackedPeerInfos_iter = trackedPeerInfosMap.values().iterator();
        try {
            LOGGER.warn("client peer id " + new String(peerIdBytes, "US-ASCII"));
        } catch (UnsupportedEncodingException ex) {
            throw new TexaiException(ex);
        }
        LOGGER.warn("tracked peers ...");
        while (trackedPeerInfos_iter.hasNext()) {
            final TrackedPeerStatus trackedPeerStatus = trackedPeerInfos_iter.next();
            final TrackedPeerInfo trackedPeerInfo1 = trackedPeerStatus.trackedPeerInfo;
            LOGGER.warn("");
            LOGGER.warn("    peer id    " + trackedPeerInfo1.toIDString());
            LOGGER.warn("    ip         " + trackedPeerInfo1.getInetAddress().getHostAddress());
            LOGGER.warn("    port       " + trackedPeerInfo1.getPort());
            if (trackedPeerStatus.peerExpirationMilliseconds < System.currentTimeMillis()) {
                LOGGER.warn("expiring peer " + trackedPeerInfo1);
                trackedPeerInfos_iter.remove();
            } else if (trackedPeerInfo1.equals(trackedPeerInfo)) {
                LOGGER.warn("omitting self-peer from the peer list");
            } else {
                final Map<String, Object> map = new HashMap<>();
                map.put("peer id", trackedPeerInfo1.getPeerIdBytes());
                map.put("ip", trackedPeerInfo1.getInetAddress().getHostAddress());
                map.put("port", trackedPeerInfo1.getPort());
                peerList.add(map);
            }
        }
        responseDictionary.put("peers", peerList);

        LOGGER.log(Level.DEBUG, "Tracker response: " + responseDictionary);
        NettyHTTPUtils.writeBinaryResponse(httpRequest, BEncoder.bencode(responseDictionary), channel, null); // sessionCookie
    }

    /** Returns a bencoded failure reason.
     *
     * @param httpRequest the HTTP request
     * @param reason the failure reason
     * @param channel the channel
     */
    private static void failure(final HttpRequest httpRequest, final String reason, final Channel channel) {
        //Preconditions
        assert httpRequest != null : "httpRequest must not be null";
        assert reason != null : "reason must not be null";
        assert !reason.isEmpty() : "reason must not be empty";
        assert channel != null : "channel must not be null";

        final Map<String, String> map = new HashMap<>();
        map.put("failure reason", reason);

        NettyHTTPUtils.writeBinaryResponse(httpRequest, BEncoder.bencode(map), channel, null); // sessionCookie
    }

    /** Handles a received text web socket frame.
     *
     * @param channel the channel handler context
     * @param textWebSocketFrame  the text web socket frame
     * @return the indicator whether the web socket request was handled
     */
    @Override
    public boolean textWebSocketFrameReceived(final Channel channel, final TextWebSocketFrame textWebSocketFrame) {
        throw new UnsupportedOperationException("Not supported yet.");

    }

    /** Contains tracked peer status. */
    private static final class TrackedPeerStatus {

        /** the peer ID */
        private final TrackedPeerInfo trackedPeerInfo;
        /** the peer expiration time in milliseconds */
        private long peerExpirationMilliseconds;
        /** the last tracked event, either started, completed or stopped */
        private String event; // NOPMD

        /** Constructs a new TrackedPeerStatus instance for a started peer.
         *
         * @param trackedPeerInfo the tracked peer information
         * @param event the last tracked event, either started, complete or stopped
         */
        TrackedPeerStatus(final TrackedPeerInfo trackedPeerInfo, final String event) {
            //Preconditions
            assert trackedPeerInfo != null : "trackedPeerInfo must not be null";
            assert event != null : "event must not be null";
            assert event.equals(STARTED_EVENT) || event.equals(COMPLETED_EVENT)
                    || event.equals(STOPPED_EVENT) : "event must be either started, completed or stopped";

            this.trackedPeerInfo = trackedPeerInfo;
            this.event = event;
            updatePeerExpirationMilliseconds();
        }

        /** Updates the peer expiration time in milliseconds. */
        public void updatePeerExpirationMilliseconds() {
            peerExpirationMilliseconds = System.currentTimeMillis()
                    + (long) (PEER_EXPIRATION_CUSHION_FACTOR * (REQUEST_INTERVAL_SECONDS * 1000L));
        }

        /** Returns whether some other TrackedPeerStatus has the same peer ID as this one.
         *
         * @param obj the other object
         * @return whether some other object equals this one
         */
        @Override
        public boolean equals(final Object obj) {
            if (obj instanceof TrackedPeerStatus) {
                final TrackedPeerStatus that = (TrackedPeerStatus) obj;
                return this.trackedPeerInfo.equals(that.trackedPeerInfo);
            } else {
                return false;
            }
        }

        /** Returns a hash code for this object.
         *
         * @return a hash code for this object
         */
        @Override
        public int hashCode() {
            return trackedPeerInfo.hashCode();
        }

        /** Returns a string representation of this object.
         *
         * @return a string representation of this object
         */
        @Override
        public String toString() {
            final StringBuilder stringBuilder = new StringBuilder();
            stringBuilder.append("[TrackedPeerStatus ");
            stringBuilder.append(trackedPeerInfo);
            stringBuilder.append(' ');
            stringBuilder.append(event);
            stringBuilder.append(']');
            return stringBuilder.toString();
        }
    }
}