package org.limewire.promotion.containers;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.StringTokenizer;
import org.limewire.io.BadGGEPBlockException;
import org.limewire.io.BadGGEPPropertyException;
import org.limewire.io.GGEP;
import org.limewire.promotion.LatitudeLongitude;
import org.limewire.promotion.exceptions.PromotionException;
import org.limewire.util.ByteUtils;
import org.limewire.util.StringUtils;
/**
* Instances of this class are messages that contain the keywords, target URL
* and restrictions that define a promotion.
*/
public class PromotionMessageContainer implements MessageContainer, Serializable {
/**
* Since internal dates are stored in 4-bytes, seconds since Unix epoch,
* this is the maximum value that can be encoded, which works out to be
* January 18, 2038. Hello Y2K38.
*/
public static final long MAX_DATE_IN_SECONDS = 2147483647L;
private static final String KEY_HEADER = "H";
private static final String KEY_TERRITORIES = "T";
private static final String KEY_DESCRIPTION = "D";
private static final String KEY_TITLE = "t";
private static final String KEY_DISPLAY_URL = "u";
private static final String KEY_URL = "U";
private static final String KEY_KEYWORDS = "K";
private static final String KEY_GEO_RESTRICT = "G";
private static final String KEY_DATE_RANGE = "d";
private static final String KEY_PROPERTIES = "P";
private static final String KEY_IMPRESS_ONLY = "I";
private GGEP payload = new GGEP();
public byte[] getType() {
return StringUtils.toUTF8Bytes("P");
}
/* Throws a RTE if we're missing any required fields. */
public byte[] encode() {
payload.put(TYPE_KEY, getType());
if (!payload.hasKey(KEY_HEADER))
throw new RuntimeException("Missing header");
if (!payload.hasKey(KEY_TERRITORIES))
throw new RuntimeException("Missing territories");
if (!payload.hasKey(KEY_DESCRIPTION))
throw new RuntimeException("Missing description");
if (!payload.hasKey(KEY_URL))
throw new RuntimeException("Missing URL");
if (!payload.hasKey(KEY_KEYWORDS))
throw new RuntimeException("Missing keywords");
return payload.toByteArray();
}
/**
* A long that specifies a globally unique ID for this promo.
*/
public void setUniqueID(long id) {
byte[] header = getHeader();
ByteUtils.long2beb(id, header, 0);
setHeader(header);
}
public long getUniqueID() {
byte[] header = getHeader();
return ByteUtils.beb2long(header, 0, 8);
}
/**
* A goodness ratio, between 0 and 1. If greater than 1, sets to 1. If less
* than 0, sets to 0. This is an approximation with only 8 bits of
* resolution.
*/
public void setProbability(float probability) {
if (probability < 0)
probability = 0;
if (probability > 1)
probability = 1;
byte probByte = (byte) ((probability * 255) - 128);
byte[] header = getHeader();
header[9] = probByte;
setHeader(header);
}
public float getProbability() {
byte[] header = getHeader();
byte probByte = header[9];
return (probByte + 128) / 255.0F;
}
public PromotionMediaType getMediaType() {
byte[] header = getHeader();
byte typeByte = header[8];
return PromotionMediaType.getInstance(typeByte);
}
public void setMediaType(PromotionMediaType type) {
byte[] header = getHeader();
header[8] = type.getValue();
setHeader(header);
}
public boolean isImpressionOnly() {
return payload.hasKey(KEY_IMPRESS_ONLY);
}
public void setImpressionOnly(boolean impressOnly) {
if(impressOnly)
payload.put(KEY_IMPRESS_ONLY);
else
payload.getHeaders().remove(KEY_IMPRESS_ONLY);
}
public void setOptions(PromotionOptions options) {
byte[] header = getHeader();
// How many bytes we have for the bitmask.
int bitmaskLength = header.length - 10;
byte[] mask = new byte[bitmaskLength];
if (mask.length > 0) {
// We have a byte to fill
if (options.isMatchAllWords())
mask[0] = (byte) (mask[0] | 1);
if (options.isOpenInNewTab())
mask[0] = (byte) (mask[0] | 2);
if (options.isOpenInHomeTab())
mask[0] = (byte) (mask[0] | 4);
if (options.isOpenInStoreTab())
mask[0] = (byte) (mask[0] | 8);
if (options.isOpenInUnknownTab())
mask[0] = (byte) (mask[0] | 16);
}
// Now we have the mask. Copy it back to the header.
System.arraycopy(mask, 0, header, 10, bitmaskLength);
setHeader(header);
}
public PromotionOptions getOptions() {
byte[] header = getHeader();
PromotionOptions options = new PromotionOptions();
if (header.length > 10) {
byte mask = header[10];
options.setMatchAllWords((mask & 1) > 0);
options.setOpenInNewTab((mask & 2) > 0);
options.setOpenInHomeTab((mask & 4) > 0);
options.setOpenInStoreTab((mask & 8) > 0);
options.setOpenInUnknownTab((mask & 16) > 0);
}
return options;
}
/** Sets the payload with the given header. Should be 11 bytes or bigger. */
private void setHeader(byte[] header) {
if (header == null || header.length < 11)
throw new IllegalArgumentException("header must be at least 11 bytes long.");
payload.put(KEY_HEADER, header);
}
/**
* Gets the header bytes from the payload, or creates them freshly with
* defaults set if there is a problem parsing them.
*/
private byte[] getHeader() {
byte[] header = null;
try {
if (payload.hasKey(KEY_HEADER))
header = payload.getBytes(KEY_HEADER);
} catch (BadGGEPPropertyException ignored) {
}
if (header == null || header.length < 11)
header = new byte[11];
return header;
}
/**
* Set the territory list. Takes the #getCountry() value from each locale,
* discarding any other info.
*/
public void setTerritories(Locale... locales) {
StringBuilder countries = new StringBuilder();
for (Locale locale : locales)
countries.append(locale.getCountry());
payload.put(KEY_TERRITORIES, StringUtils.toUTF8Bytes(countries.toString()));
}
/**
* @return an array of 0 or more {@link Locale} instances with their country
* property set to a two-character ISO country code
*/
public Locale[] getTerritories() {
List<Locale> territoryList = new ArrayList<Locale>();
String territories;
try {
territories = StringUtils.toUTF8String(payload.getBytes(KEY_TERRITORIES));
} catch (BadGGEPPropertyException ex) {
throw new RuntimeException("GGEP exception parsing territories.", ex);
}
for (int i = 0; i < territories.length() - 1; i += 2)
territoryList.add(new Locale("", territories.substring(i, i + 2)));
return territoryList.toArray(new Locale[territoryList.size()]);
}
/**
* The description to display to the user. Setting this to null sets it to
* "".
*/
public void setDescription(String description) {
set(KEY_DESCRIPTION, description);
}
public String getDescription() {
return get(KEY_DESCRIPTION);
}
public void setTitle(String title) {
set(KEY_TITLE, title);
}
public String getTitle() {
return get(KEY_TITLE);
}
public void setDisplayUrl(String displayUrl) {
set(KEY_DISPLAY_URL, displayUrl);
}
public String getDisplayUrl() {
return get(KEY_DISPLAY_URL);
}
/**
* The keywords to search by, in encoded format (See the keyword parser
* elsewhere for format). Setting this to null sets it to "".
*/
public void setKeywords(String keywords) {
set(KEY_KEYWORDS, keywords);
}
public String getKeywords() {
return get(KEY_KEYWORDS);
}
/**
* The url to direct the user to . Setting this to null sets it to "".
*/
public void setURL(String url) {
set(KEY_URL, url);
}
public String getURL() {
return get(KEY_URL);
}
/**
* Sets the properties for this promotion. OPTIONAL. Keys must obviously not
* be null. Null values are ignored and not encoded into the final message.
* Keys with trailing "." characters are trimmed to remove them.
*/
public void setProperties(Map<String, String> properties) {
StringBuilder builder = new StringBuilder();
for (String key : properties.keySet()) {
String value = properties.get(key);
if (value == null)
continue;
builder.append(encodePropertyKey(key)).append('=').append(value).append('\t');
}
if (builder.length() == 0) {
payload.put(KEY_PROPERTIES, new byte[0]);
return;
}
// Put the string into the ggep, but strip the last character (which is
// always a tab)
byte[] encodedProperties = StringUtils.toUTF8Bytes(builder.substring(0,
builder.length() - 1));
payload.put(KEY_PROPERTIES, encodedProperties);
}
/**
* If the key is recognized as an encodable key, returns a shortened
* version. Otherwise returns the original key. Package visible for testing.
*/
String encodePropertyKey(String key) {
if (key != null && key.indexOf(".") != -1 && !key.equals("."))
return encodeDottedPropertyKey(key);
for (int i = 0; i < PROPERTY_ENCODING_ARRAY.length; i++)
if (PROPERTY_ENCODING_ARRAY[i].equals(key))
return new String(((char) (i + 128)) + "");
return key;
}
/**
* Takes a key like "xxx.yyy.zzz" splits it and encodes each token
* individually, then rejoins them.
*/
private String encodeDottedPropertyKey(String key) {
StringTokenizer tokens = new StringTokenizer(key, ".", true);
StringBuilder builder = new StringBuilder();
boolean lastTokenWasCompressed = false;
while (tokens.hasMoreTokens()) {
String token = encodePropertyKey(tokens.nextToken());
if (!token.equals(".") || !lastTokenWasCompressed)
builder.append(token);
lastTokenWasCompressed = (token.length() == 1 && token.charAt(0) >= 128);
}
return trimTrailingPeriods(builder);
}
private String trimTrailingPeriods(StringBuilder builder) {
if (builder.charAt(builder.length() - 1) == '.') {
builder.deleteCharAt(builder.length() - 1);
return trimTrailingPeriods(builder);
}
return builder.toString();
}
/** ONLY ADD TO THIS LIST, AND ADD AT THE END. ORDERING IS SUPER-IMPORTANT. */
private static final String[] PROPERTY_ENCODING_ARRAY = new String[] { "artist", "album",
"url", "genre", "license", "size", "creation_time", "vendor", "name", "audio", "video",
"document" };
/**
* If the key is decodable, returns the lengthened version, otherwise
* returns the original key. Package visible for testing.
*/
String decodePropertyKey(String key) {
if (key == null || key.length() == 0)
return key;
StringBuilder builder = new StringBuilder();
for (int i = 0; i < key.length(); i++) {
int index = key.charAt(i);
if (index >= 128) {
index -= 128;
if (index < PROPERTY_ENCODING_ARRAY.length) {
builder.append(PROPERTY_ENCODING_ARRAY[index]);
builder.append(".");
}
} else {
builder.append((char) index);
}
}
if (builder.charAt(builder.length() - 1) == '.' && !key.endsWith("."))
return builder.substring(0, builder.length() - 1);
else
return builder.toString();
}
/**
* @return a map of properties, and an empty map if no properties have been
* set or the field has an encoding error. Never null.
*/
public Map<String, String> getProperties() {
Map<String, String> properties = new HashMap<String, String>();
try {
String encoded = StringUtils.toUTF8String(payload.getBytes(KEY_PROPERTIES));
StringTokenizer tokens = new StringTokenizer(encoded, "\t");
while (tokens.hasMoreTokens()) {
String token = tokens.nextToken();
if (token.indexOf('=') > 0) {
String key = token.substring(0, token.indexOf('='));
String value = token.substring(key.length() + 1);
properties.put(decodePropertyKey(key), value);
}
}
} catch (BadGGEPPropertyException ignored) {
}
return properties;
}
/** Sets the {@link GeoRestriction} list for this message. Optional. */
public void setGeoRestrictions(List<GeoRestriction> restrictions) {
ByteArrayOutputStream out = new ByteArrayOutputStream();
for (GeoRestriction restriction : restrictions)
try {
out.write(restriction.toBytes());
} catch (IOException ignored) {
// This stream won't throw this
}
payload.put(KEY_GEO_RESTRICT, out.toByteArray());
}
/**
* Gets a list of {@link GeoRestriction} entries for this message, or an
* empty list if there are none.
*/
public List<GeoRestriction> getGeoRestrictions() {
List<GeoRestriction> list = new ArrayList<GeoRestriction>();
if (payload.hasValueFor(KEY_GEO_RESTRICT)) {
byte[] encoded = payload.get(KEY_GEO_RESTRICT);
for (int i = 0; i < encoded.length - 6; i += 7) {
byte[] geoBytes = new byte[7];
System.arraycopy(encoded, i, geoBytes, 0, 7);
try {
list.add(new GeoRestriction(geoBytes));
} catch (PromotionException ex) {
// Only happens if we miscalculated the array size
throw new RuntimeException(
"PromotionException while parsing geo restrictions.", ex);
}
}
}
return list;
}
/**
* Sets an individual validity range for this promotion, OPTIONAL (if not
* set, promotion inherits validity range from its parent container).
*
* @param start when this promo becomes valid. Cannot be past the parent
* container's end date, or will never be valid. If null, defaults to
* now.
*/
public void setValidStart(Date start) {
setValidRange(start, getValidEnd());
}
/**
* Sets an individual validity range for this promotion, OPTIONAL (if not
* set, promotion inherits validity range from its parent container).
*
* @param end when this promo expires. If null, the promo will expire at the
* end of the parent container's validity period.
*/
public void setValidEnd(Date end) {
setValidRange(getValidStart(), end);
}
/**
* @param start when this promo becomes valid. Cannot be past the parent
* container's end date, or will never be valid. If null, defaults to
* now.
* @param end when this promo expires. If null, the promo will expire at the
* end of the parent container's validity period.
*/
private void setValidRange(Date start, Date end) {
if (start == null)
start = new Date();
if (end == null)
end = new Date(MAX_DATE_IN_SECONDS * 1000);
byte[] range = new byte[8];
byte[] startBytes = ByteUtils.long2bytes(start.getTime() / 1000, 4);
byte[] endBytes = ByteUtils.long2bytes(end.getTime() / 1000, 4);
System.arraycopy(startBytes, 0, range, 0, 4);
System.arraycopy(endBytes, 0, range, 4, 4);
payload.put(KEY_DATE_RANGE, range);
}
/**
* If not set or there is trouble parsing the field, returns the earliest
* possible date, which will be overridden by the parent container's start date.
*/
public Date getValidStart() {
// Date is stored as a 4 byte long, seconds since UNIX epoch.
try {
byte range[] = payload.getBytes(KEY_DATE_RANGE);
byte start[] = new byte[4];
System.arraycopy(range, 0, start, 0, 4);
long startLong = ByteUtils.beb2long(start, 0, 4);
return new Date(startLong * 1000);
} catch (BadGGEPPropertyException ex) {
return new Date(0);
}
}
/**
* If not set or there is trouble parsing the field, returns a date far in
* the future which will be overwritten by the parent container's expiration
* date.
*/
public Date getValidEnd() {
// Date is stored as a 4 byte long, seconds since UNIX epoch.
try {
byte range[] = payload.getBytes(KEY_DATE_RANGE);
byte end[] = new byte[4];
System.arraycopy(range, 4, end, 0, 4);
long endLong = ByteUtils.beb2long(end, 0, 4);
return new Date(endLong * 1000);
} catch (BadGGEPPropertyException ex) {
return new Date(MAX_DATE_IN_SECONDS * 1000L);
}
}
/** Parses out the given key, or returns "" if the key is not present. */
private String get(String key) {
try {
if (!payload.hasValueFor(key)) {
return "";
} else {
return StringUtils.toUTF8String(payload.getBytes(key));
}
} catch (BadGGEPPropertyException ex) {
throw new RuntimeException("GGEP exception parsing value.", ex);
}
}
/**
* Set the given key to the given value encoded to UTF-8. Setting to null
* sets the value to "".
*/
private void set(String key, String value) {
payload.put(key, StringUtils.toUTF8Bytes(value));
}
public void decode(GGEP rawGGEP) throws BadGGEPBlockException {
if (!Arrays.equals(getType(), rawGGEP.get(TYPE_KEY)))
throw new BadGGEPBlockException("Incorrect type.");
if (!rawGGEP.hasKey(KEY_HEADER))
throw new BadGGEPBlockException("Missing header");
if (!rawGGEP.hasKey(KEY_TERRITORIES))
throw new BadGGEPBlockException("Missing territories");
if (!rawGGEP.hasKey(KEY_DESCRIPTION))
throw new BadGGEPBlockException("Missing description");
if (!rawGGEP.hasKey(KEY_URL))
throw new BadGGEPBlockException("Missing URL");
if (!rawGGEP.hasKey(KEY_KEYWORDS))
throw new BadGGEPBlockException("Missing keywords");
this.payload = rawGGEP;
}
/**
* Checks the UID, validity dates, keywords and URL to decide equality.
*/
@Override
public boolean equals(Object obj) {
if (!(obj instanceof PromotionMessageContainer))
return false;
PromotionMessageContainer compare = (PromotionMessageContainer) obj;
if (getUniqueID() != compare.getUniqueID())
return false;
if (!getKeywords().equals(compare.getKeywords()))
return false;
if (!getURL().equals(compare.getURL()))
return false;
return true;
}
@Override
public int hashCode() {
return (int) getUniqueID();
}
public static enum PromotionMediaType {
/** audio content. */
AUDIO(1),
/** video content. */
VIDEO(2),
/** LimeWire Store content. */
STORE(3),
/** LimeSpot content. */
SPOT(4),
/** A browser link of no special type. */
GENERIC_LINK(5),
/**
* UNKNOWN is the default for if A) the type is not known for B) the
* promotion specifies a media type that this client does not know how
* to parse.
*/
UNKNOWN(0);
private byte value;
/** The value this enum encodes to in the message. */
public byte getValue() {
return value;
}
private PromotionMediaType(int value) {
this.value = (byte) value;
}
/**
* An instance of this enum, or UNKNOWN if the value cannot be mapped to
* an enum.
*/
static PromotionMediaType getInstance(byte value) {
for (PromotionMediaType type : PromotionMediaType.values()) {
if (type.value == value)
return type;
}
return UNKNOWN;
}
}
public static class GeoRestriction {
private LatitudeLongitude center;
private int radiusInMeters;
/**
* Constructs an instance with the given center point and radius.
*/
public GeoRestriction(LatitudeLongitude center, int radiusInMeters) {
this.center = center;
this.radiusInMeters = radiusInMeters;
}
/**
* Decodes a 7-byte array, first 3 bytes are latitude, second 3 are
* longitude, and final byte encodes radius using the rules discussed in
* {@link #getEncodedRadius()}. Package visible for unit testing, but
* should only be called during promo message parsing.
*
* @throws PromotionException if the array is not exactly 7 bytes long.
*/
GeoRestriction(byte[] bytes) throws PromotionException {
if (bytes == null || bytes.length != 7)
throw new PromotionException("expected exactly 7 bytes for construction.");
byte[] lat = new byte[3];
byte[] lon = new byte[3];
System.arraycopy(bytes, 0, lat, 0, 3);
System.arraycopy(bytes, 3, lon, 0, 3);
this.center = new LatitudeLongitude(lat, lon);
this.radiusInMeters = decodeRadius(bytes[6]);
}
/**
* @return true if point is within this restriction
*/
public boolean contains(LatitudeLongitude point) {
return center.distanceFrom(point) <= (radiusInMeters / 1000.0);
}
/**
* @return a 7-byte array, the first 3 bytes encode latitude, next 3
* encode longitude, and final byte encodes radius using the
* following formula (b is byte, 1-256): (b*13)^2 meters
*/
public byte[] toBytes() {
byte[] bytes = new byte[7];
System.arraycopy(center.toBytes(), 0, bytes, 0, 6);
bytes[6] = getEncodedRadius();
return bytes;
}
/**
* Encodes the radius to a single byte. The byte can be inflated by
* multiplying it by 13 and then squaring the result. Package visible
* for unit testing.
*/
byte getEncodedRadius() {
long value = (long) (Math.sqrt(radiusInMeters) / 13);
return ByteUtils.long2bytes(value - 1, 1)[0];
}
/**
* @return the radius in meters that the byte represents, as defined by
* {@link #getEncodedRadius()}.
*/
static int decodeRadius(byte radiusByte) {
long radius = ByteUtils.beb2long(new byte[] { radiusByte }, 0, 1) + 1;
return (int) Math.pow(13 * radius, 2);
}
}
/** Bean that represents the options bit mask. */
public static class PromotionOptions {
private boolean matchAllWords = false;
private boolean openInNewTab = false;
private boolean openInHomeTab = false;
private boolean openInStoreTab = false;
private boolean openInUnknownTab = false;
/**
* @return if true, query should match all words, otherwise any words.
*/
public boolean isMatchAllWords() {
return matchAllWords;
}
public void setMatchAllWords(boolean matchAllWords) {
this.matchAllWords = matchAllWords;
}
/**
* Not a settable property, goes to true if all the other browser
* options are false.
*
* @return if true, open the link in a new (external to LW) browser
* window.
*/
public boolean isOpenInNewWindow() {
return !(openInStoreTab || openInNewTab || openInHomeTab || openInUnknownTab);
}
/**
* @return if true and LW supports tabs, open a new tab for this
* browser.
*/
public boolean isOpenInNewTab() {
return openInNewTab;
}
public void setOpenInNewTab(boolean openInNewTab) {
this.openInNewTab = openInNewTab;
}
/**
* @return if true and LW supports a "Store" tab, open this into that
* tab, creating it if it's not already open
*/
public boolean isOpenInStoreTab() {
return openInStoreTab;
}
public void setOpenInStoreTab(boolean openInStoreTab) {
this.openInStoreTab = openInStoreTab;
}
/**
* @return if true and LimeWire supports a "Spot" tab, open this into that
* tab, creating it if it's not already open
*/
public boolean isOpenInUnknownTab() {
return openInUnknownTab;
}
public void setOpenInUnknownTab(boolean openInUnknownTab) {
this.openInUnknownTab = openInUnknownTab;
}
/**
* @return if true and LW supports a "Client" (browser) tab, open this
* into that tab, creating it if it's not already open
*/
public boolean isOpenInHomeTab() {
return openInHomeTab;
}
public void setOpenInHomeTab(boolean openInHomeTab) {
this.openInHomeTab = openInHomeTab;
}
}
}