| Index: adblockplussbrowser/src/main/java/org/adblockplus/sbrowser/contentblocker/engine/Subscription.java |
| =================================================================== |
| new file mode 100644 |
| --- /dev/null |
| +++ b/adblockplussbrowser/src/main/java/org/adblockplus/sbrowser/contentblocker/engine/Subscription.java |
| @@ -0,0 +1,609 @@ |
| +/* |
| + * This file is part of Adblock Plus <https://adblockplus.org/>, |
| + * Copyright (C) 2006-present eyeo GmbH |
| + * |
| + * Adblock Plus is free software: you can redistribute it and/or modify |
| + * it under the terms of the GNU General Public License version 3 as |
| + * published by the Free Software Foundation. |
| + * |
| + * Adblock Plus 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 Adblock Plus. If not, see <http://www.gnu.org/licenses/>. |
| + */ |
| + |
| +package org.adblockplus.sbrowser.contentblocker.engine; |
| + |
| +import java.io.BufferedInputStream; |
| +import java.io.BufferedOutputStream; |
| +import java.io.BufferedReader; |
| +import java.io.DataInputStream; |
| +import java.io.DataOutputStream; |
| +import java.io.File; |
| +import java.io.FileInputStream; |
| +import java.io.FileOutputStream; |
| +import java.io.IOException; |
| +import java.io.StringReader; |
| +import java.net.URL; |
| +import java.nio.charset.StandardCharsets; |
| +import java.security.MessageDigest; |
| +import java.security.NoSuchAlgorithmException; |
| +import java.util.ArrayList; |
| +import java.util.Arrays; |
| +import java.util.Collection; |
| +import java.util.Collections; |
| +import java.util.HashMap; |
| +import java.util.HashSet; |
| +import java.util.List; |
| +import java.util.Locale; |
| +import java.util.Map; |
| +import java.util.Map.Entry; |
| +import java.util.zip.GZIPInputStream; |
| +import java.util.zip.GZIPOutputStream; |
| + |
| +import android.text.TextUtils; |
| +import android.text.format.DateUtils; |
| +import android.util.Log; |
| + |
| +/** |
| + * Simple subscription representation. |
| + */ |
| +final class Subscription |
| +{ |
| + private static final String TAG = Subscription.class.getSimpleName(); |
| + public static final String KEY_TITLE = "title"; |
| + public static final String KEY_VERSION = "version"; |
| + public static final String KEY_HTTP_ETAG = "_etag"; |
| + public static final String KEY_HTTP_LAST_MODIFIED = "_last_modified"; |
| + public static final String KEY_UPDATE_TIMESTAMP = "_update_timestamp"; |
| + public static final String KEY_TRIED_UPDATE_TIMESTAMP = "_tried_update_timestamp"; |
| + public static final String KEY_DOWNLOAD_COUNT = "_download_count"; |
| + public static final String KEY_ENABLED = "_enabled"; |
| + public static final String KEY_META_HASH = "_meta_hash"; |
| + |
| + private static final long MINIMAL_DOWNLOAD_INTERVAL = DateUtils.HOUR_IN_MILLIS / 4; |
| + private static final long DOWNLOAD_RETRY_INTERVAL = DateUtils.HOUR_IN_MILLIS; |
| + |
| + /** |
| + * List of meta keys that are allowed to import from a downloaded |
| + * subscription. |
| + */ |
| + private static final String[] ALLOWED_META_KEYS_ARRAY = |
| + { |
| + "checksum", KEY_VERSION, KEY_TITLE, "last modified", "expires", "homepage", "licence" |
| + }; |
| + private static final HashSet<String> ALLOWED_META_KEYS = |
| + new HashSet<>(Arrays.asList(ALLOWED_META_KEYS_ARRAY)); |
| + |
| + private static final Locale LOCALE_EN = Locale.ENGLISH; |
| + |
| + private final long updateInterval = DateUtils.DAY_IN_MILLIS |
| + + (long) (DateUtils.HOUR_IN_MILLIS * 8. * Math.random()); |
| + |
| + private final URL url; |
| + private final Type type; |
| + private final HashMap<String, String> meta = new HashMap<>(); |
| + private final HashSet<String> filters = new HashSet<>(); |
| + |
| + private boolean metaDataValid = true; |
| + private boolean filtersValid = true; |
| + |
| + /** |
| + * Subscription type. |
| + * |
| + * @author René Jeschke <rene@adblockplus.org> |
| + */ |
| + public enum Type |
| + { |
| + /** |
| + * Initiated from an URL, can be automatically downloaded. |
| + */ |
| + DOWNLOADABLE, |
| + /** |
| + * User defined filters or exceptions. |
| + */ |
| + USER |
| + } |
| + |
| + /** |
| + * Creates a subscription. The type gets determined by {@code url} being |
| + * {@code null} or not. |
| + * |
| + * @param url |
| + * @see Subscription.Type |
| + */ |
| + private Subscription(final URL url) |
| + { |
| + this.url = url; |
| + this.type = url != null ? Type.DOWNLOADABLE : Type.USER; |
| + } |
| + |
| + /** |
| + * Creates a {@code USER} subscription. |
| + * |
| + * @see Subscription.Type |
| + */ |
| + private Subscription() |
| + { |
| + this.url = null; |
| + this.type = Type.USER; |
| + } |
| + |
| + public boolean isMetaDataValid() |
| + { |
| + return this.metaDataValid; |
| + } |
| + |
| + public boolean isFiltersValid() |
| + { |
| + return this.filtersValid; |
| + } |
| + |
| + static long parseLong(final String number) |
| + { |
| + try |
| + { |
| + return Long.parseLong(number); |
| + } |
| + catch (final NumberFormatException nfe) |
| + { |
| + return 0; |
| + } |
| + } |
| + |
| + public long getVersion() |
| + { |
| + return parseLong(this.getMeta(KEY_VERSION, "0")); |
| + } |
| + |
| + public long getDownloadCount() |
| + { |
| + return parseLong(this.getMeta(KEY_DOWNLOAD_COUNT, "0")); |
| + } |
| + |
| + public long getLastUpdateTimestamp() |
| + { |
| + return parseLong(this.getMeta(KEY_UPDATE_TIMESTAMP, "0")); |
| + } |
| + |
| + public long getLastTriedUpdateTimestamp() |
| + { |
| + return parseLong(this.getMeta(KEY_TRIED_UPDATE_TIMESTAMP, "0")); |
| + } |
| + |
| + public boolean shouldUpdate(final boolean forced) |
| + { |
| + final long now = System.currentTimeMillis(); |
| + final long lastUpdate = this.getLastUpdateTimestamp(); |
| + final long lastTry = this.getLastTriedUpdateTimestamp(); |
| + |
| + if (forced) |
| + { |
| + return now - Math.max(lastUpdate, lastTry) > MINIMAL_DOWNLOAD_INTERVAL; |
| + } |
| + |
| + if (lastTry > lastUpdate) |
| + { |
| + return now - lastTry > DOWNLOAD_RETRY_INTERVAL; |
| + } |
| + else |
| + { |
| + return now - lastUpdate > this.updateInterval; |
| + } |
| + } |
| + |
| + /** |
| + * @return the download URL, null for a {@code USER} subscription. |
| + */ |
| + public URL getURL() |
| + { |
| + return this.url; |
| + } |
| + |
| + /** |
| + * @return the type of this subscription |
| + * @see Subscription.Type |
| + */ |
| + public Type getType() |
| + { |
| + return this.type; |
| + } |
| + |
| + /** |
| + * Retrieves a meta data entry |
| + * |
| + * @param key |
| + * gets converted to all lower case |
| + * @param defaultValue |
| + * @return the meta data or {@code defaultValue} if not defined |
| + */ |
| + public String getMeta(final String key, final String defaultValue) |
| + { |
| + final String value = this.meta.get(key.toLowerCase(LOCALE_EN)); |
| + return value != null ? value : defaultValue; |
| + } |
| + |
| + /** |
| + * Retrieves a meta data entry |
| + * |
| + * @param key |
| + * gets converted to all lower case |
| + * @return the meta data or {@code null} if not defined |
| + */ |
| + public String getMeta(final String key) |
| + { |
| + return this.meta.get(key.toLowerCase(LOCALE_EN)); |
| + } |
| + |
| + public String putMeta(final String key, final String value) |
| + { |
| + return this.meta.put(key, value); |
| + } |
| + |
| + public String getTitle() |
| + { |
| + final String title = this.getMeta(KEY_TITLE); |
| + return title != null ? title : this.url.toString(); |
| + } |
| + |
| + /** |
| + * Creates a {@code DOWNLOADABLE} subscription. |
| + * |
| + * @param url |
| + * the update URL |
| + * @param lines |
| + * @return the subscription |
| + * @see Subscription.Type |
| + */ |
| + public static Subscription create(final URL url, final List<String> lines) |
| + { |
| + final Subscription sub = new Subscription(url); |
| + sub.parseLines(lines); |
| + return sub; |
| + } |
| + |
| + /** |
| + * Creates a {@code DOWNLOADABLE} subscription. |
| + * |
| + * @param url |
| + * the update URL |
| + * @return the subscription |
| + * @see Subscription.Type |
| + */ |
| + public static Subscription create(final URL url) |
| + { |
| + return new Subscription(url); |
| + } |
| + |
| + public static Subscription create(final String urlString) throws IOException |
| + { |
| + try |
| + { |
| + return new Subscription(new URL(urlString)); |
| + } |
| + catch (final IOException e) |
| + { |
| + Log.d(TAG, "Creation failed for: '" + urlString + "'"); |
| + throw e; |
| + } |
| + } |
| + |
| + /** |
| + * Creates a {@code USER} subscription. |
| + * |
| + * @param title |
| + * @return the subscription |
| + * @see Subscription.Type |
| + */ |
| + public static Subscription createUserSubscription(final String title) |
| + { |
| + final Subscription sub = new Subscription(); |
| + sub.meta.put(KEY_TITLE, title); |
| + return sub; |
| + } |
| + |
| + public boolean isEnabled() |
| + { |
| + return "true".equals(this.getMeta(KEY_ENABLED)); |
| + } |
| + |
| + public void setEnabled(boolean enable) |
| + { |
| + this.putMeta(KEY_ENABLED, Boolean.toString(enable)); |
| + } |
| + |
| + public void copyFilters(Collection<String> filters) |
| + { |
| + if (filters != null) |
| + { |
| + filters.addAll(this.filters); |
| + } |
| + } |
| + |
| + public void clearFilters() |
| + { |
| + this.filters.clear(); |
| + } |
| + |
| + /** |
| + * @return an internal management ID |
| + */ |
| + public String getId() |
| + { |
| + return getId(this); |
| + } |
| + |
| + public static String getId(final Subscription subscription) |
| + { |
| + switch (subscription.type) |
| + { |
| + case DOWNLOADABLE: |
| + return "url:" + subscription.url.toString(); |
| + case USER: |
| + return "user:" + subscription.getMeta(KEY_TITLE); |
| + } |
| + return ""; |
| + } |
| + |
| + private static String byteArrayToHexString(final byte[] array) |
| + { |
| + final StringBuilder sb = new StringBuilder(array.length * 2); |
| + for (final byte b : array) |
| + { |
| + final int value = b & 255; |
| + if (value < 16) |
| + { |
| + sb.append('0'); |
| + } |
| + sb.append(Integer.toHexString(value)); |
| + } |
| + return sb.toString(); |
| + } |
| + |
| + private static String createMetaDataHash(final HashMap<String, String> meta) throws IOException |
| + { |
| + final ArrayList<String> keyValues = new ArrayList<>(); |
| + for (final Entry<String, String> e : meta.entrySet()) |
| + { |
| + if (!KEY_META_HASH.equals(e.getKey())) |
| + { |
| + keyValues.add(e.getKey() + ":" + e.getValue()); |
| + } |
| + } |
| + return createFilterHash(keyValues); |
| + } |
| + |
| + private static String createFilterHash(List<String> filters) throws IOException |
| + { |
| + try |
| + { |
| + final MessageDigest md5 = MessageDigest.getInstance("MD5"); |
| + Collections.sort(filters); |
| + for (final String filter : filters) |
| + { |
| + md5.update(filter.getBytes(StandardCharsets.UTF_8)); |
| + } |
| + return byteArrayToHexString(md5.digest()); |
| + } |
| + catch (final NoSuchAlgorithmException e) |
| + { |
| + throw new IOException("MD5 is unavailable: " + e.getMessage(), e); |
| + } |
| + } |
| + |
| + public void serializeMetaData(final File metaFile) throws IOException |
| + { |
| + this.putMeta(KEY_META_HASH, createMetaDataHash(this.meta)); |
| + try (final DataOutputStream metaOut = new DataOutputStream(new BufferedOutputStream( |
| + new GZIPOutputStream(new FileOutputStream(metaFile))))) |
| + { |
| + metaOut.writeUTF(this.url != null ? this.url.toString() : ""); |
| + metaOut.writeInt(this.meta.size()); |
| + for (final Entry<String, String> e : this.meta.entrySet()) |
| + { |
| + metaOut.writeUTF(e.getKey()); |
| + metaOut.writeUTF(e.getValue()); |
| + } |
| + } |
| + } |
| + |
| + public void serializeFilters(final File filtersFile) throws IOException |
| + { |
| + try (final DataOutputStream filtersOut = new DataOutputStream(new BufferedOutputStream( |
| + new GZIPOutputStream(new FileOutputStream(filtersFile))))) |
| + { |
| + filtersOut.writeInt(this.filters.size()); |
| + filtersOut.writeUTF(createFilterHash(new ArrayList<>(this.filters))); |
| + for (final String s : this.filters) |
| + { |
| + final byte[] b = s.getBytes(StandardCharsets.UTF_8); |
| + filtersOut.writeInt(b.length); |
| + filtersOut.write(b); |
| + } |
| + } |
| + } |
| + |
| + public void serializeSubscription(final File metaFile, final File filtersFile) throws IOException |
| + { |
| + this.serializeMetaData(metaFile); |
| + this.serializeFilters(filtersFile); |
| + } |
| + |
| + public static Subscription deserializeSubscription(final File metaFile) |
| + { |
| + Subscription sub = null; |
| + try (final DataInputStream in = new DataInputStream(new BufferedInputStream(new GZIPInputStream( |
| + new FileInputStream(metaFile))))) |
| + { |
| + final String urlString = in.readUTF(); |
| + sub = new Subscription(!TextUtils.isEmpty(urlString) ? new URL(urlString) : null); |
| + sub.metaDataValid = false; |
| + final int numMetaEntries = in.readInt(); |
| + for (int i = 0; i < numMetaEntries; i++) |
| + { |
| + final String key = in.readUTF(); |
| + final String value = in.readUTF(); |
| + sub.meta.put(key, value); |
| + } |
| + sub.metaDataValid = createMetaDataHash(sub.meta).equals(sub.getMeta(KEY_META_HASH)); |
| + } |
| + catch (Throwable t) |
| + { |
| + // We catch Throwable here in order to return whatever we could retrieve from the meta file |
| + } |
| + return sub; |
| + } |
| + |
| + public void deserializeFilters(final File filtersFile) |
| + { |
| + this.clearFilters(); |
| + this.filtersValid = false; |
| + try (final DataInputStream in = new DataInputStream(new BufferedInputStream(new GZIPInputStream( |
| + new FileInputStream(filtersFile))))) |
| + { |
| + final int numFilters = in.readInt(); |
| + final String filtersHash = in.readUTF(); |
| + for (int i = 0; i < numFilters; i++) |
| + { |
| + final int length = in.readInt(); |
| + final byte[] b = new byte[length]; |
| + in.readFully(b); |
| + this.filters.add(new String(b, StandardCharsets.UTF_8)); |
| + } |
| + this.filtersValid = createFilterHash(new ArrayList<>(this.filters)).equals( |
| + filtersHash); |
| + Log.d(TAG, "Filters valid: " + this.filtersValid); |
| + } |
| + catch (Throwable t) |
| + { |
| + // We catch Throwable here in order to load whatever we could retrieve from the filters file |
| + } |
| + } |
| + |
| + /** |
| + * Adds the given string, which should be a single filter to this |
| + * subscription. |
| + * |
| + * @param input |
| + */ |
| + public Subscription parseLine(String input) |
| + { |
| + final String line = input.trim(); |
| + if (!line.isEmpty()) |
| + { |
| + if (line.startsWith("!")) |
| + { |
| + // Meta data |
| + final int colon = line.indexOf(':'); |
| + if (colon > 2) |
| + { |
| + final String key = line.substring(1, colon).trim().toLowerCase(LOCALE_EN); |
| + final String value = line.substring(colon + 1).trim(); |
| + if (!key.isEmpty() && !value.isEmpty() && ALLOWED_META_KEYS.contains(key)) |
| + { |
| + this.meta.put(key, value); |
| + } |
| + } |
| + } |
| + else if (line.startsWith("[")) |
| + { |
| + // currently ignored |
| + } |
| + else |
| + { |
| + this.filters.add(line); |
| + } |
| + } |
| + return this; |
| + } |
| + |
| + public Subscription parseLines(final List<String> lines) |
| + { |
| + for (String line : lines) |
| + { |
| + this.parseLine(line); |
| + } |
| + return this; |
| + } |
| + |
| + public Subscription parseText(final String string) |
| + { |
| + try (final BufferedReader r = new BufferedReader(new StringReader(string))) |
| + { |
| + for (String line = r.readLine(); line != null; line = r.readLine()) |
| + { |
| + this.parseLine(line); |
| + } |
| + } |
| + catch (final IOException e) |
| + { |
| + // ignored ... we're reading from a String |
| + } |
| + return this; |
| + } |
| + |
| + boolean updateSubscription(final int responseCode, final String text, |
| + final Map<String, String> httpHeaders, final File metaFile, final File filtersFile) |
| + throws IOException |
| + { |
| + boolean filtersChanged = false; |
| + if (responseCode == 304) |
| + { |
| + // Not changed, update update timestamp only |
| + this.meta.put(KEY_UPDATE_TIMESTAMP, Long.toString(System.currentTimeMillis())); |
| + } |
| + else |
| + { |
| + if (responseCode != 200 || text == null) |
| + { |
| + // We tried, but we failed |
| + this.meta.put(KEY_TRIED_UPDATE_TIMESTAMP, Long.toString(System.currentTimeMillis())); |
| + } |
| + else |
| + { |
| + // Update succeeded, update filters |
| + filtersChanged = true; |
| + this.meta.put(KEY_UPDATE_TIMESTAMP, Long.toString(System.currentTimeMillis())); |
| + if (httpHeaders != null) |
| + { |
| + final String etag = httpHeaders.get("etag"); |
| + final String lastModified = httpHeaders.get("last-modified"); |
| + |
| + if (etag != null) |
| + { |
| + this.meta.put(KEY_HTTP_ETAG, etag); |
| + } |
| + else |
| + { |
| + this.meta.remove(KEY_HTTP_ETAG); |
| + } |
| + |
| + if (lastModified != null) |
| + { |
| + this.meta.put(KEY_HTTP_LAST_MODIFIED, lastModified); |
| + } |
| + else |
| + { |
| + this.meta.remove(KEY_HTTP_LAST_MODIFIED); |
| + } |
| + this.meta.put(KEY_DOWNLOAD_COUNT, Long.toString(this.getDownloadCount() + 1)); |
| + |
| + this.clearFilters(); |
| + this.parseText(text); |
| + } |
| + } |
| + } |
| + |
| + this.serializeMetaData(metaFile); |
| + if (filtersChanged) |
| + { |
| + this.serializeFilters(filtersFile); |
| + this.clearFilters(); |
| + } |
| + |
| + return filtersChanged; |
| + } |
| +} |