chromium/components/gcm_driver/android/java/src/org/chromium/components/gcm_driver/GCMMessage.java

// Copyright 2017 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

package org.chromium.components.gcm_driver;

import android.os.Bundle;
import android.os.PersistableBundle;

import androidx.annotation.IntDef;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;

import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;

import org.chromium.base.Log;

import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.nio.charset.Charset;
import java.util.ArrayList;
import java.util.List;

/**
 * Represents the contents of a GCM Message that is to be handled by the GCM Driver. Can be created
 * based on data received from GCM, or serialized and deserialized to and from a Bundle.
 */
public class GCMMessage {
    @VisibleForTesting static final String VERSION = "v1";
    private static final String TAG = "GCMMessage";
    private static final String SERIALIZATION_CHARSET = "ISO-8859-1";

    /** Keys used to store information for serialization purposes. */
    private static final String KEY_VERSION = "version";

    private static final String KEY_APP_ID = "appId";
    private static final String KEY_COLLAPSE_KEY = "collapseKey";
    private static final String KEY_DATA = "data";
    private static final String KEY_RAW_DATA = "rawData";
    private static final String KEY_SENDER_ID = "senderId";
    private static final String KEY_ORIGINAL_PRIORITY = "originalPriority";
    private static final String KEY_MESSAGE_ID = "messageId";

    private final String mSenderId;
    private final String mAppId;

    @Nullable private final String mMessageId;

    @Nullable private final String mCollapseKey;
    @Nullable private final byte[] mRawData;

    /** A list of possible priority values the GCMMessage can have. */
    @IntDef({Priority.NONE, Priority.NORMAL, Priority.HIGH})
    @Retention(RetentionPolicy.SOURCE)
    public @interface Priority {
        int NONE = 0;
        int NORMAL = 1;
        int HIGH = 2;
        int NUM_ENTRIES = 3;
    }

    /** The priority at which this GCMMessage was originally sent. */
    @Nullable private final String mOriginalPriority;

    /** Array that contains pairs of entries in the format of {key, value}. */
    private final String[] mDataKeysAndValuesArray;

    /** Creates a GCMMessage object based on data received from GCM. The extras will be filtered. */
    public GCMMessage(String senderId, Bundle extras) {
        String bundleCollapseKey = "collapse_key";
        String bundleGcmplex = "com.google.ipc.invalidation.gcmmplex.";
        String bundleRawData = "rawData";
        String bundleSenderId = "from";
        String bundleSubtype = "subtype";
        String bundleOriginalPriority = "google.original_priority";
        String bundleMessageId = "google.message_id";

        if (!extras.containsKey(bundleSubtype)) {
            throw new IllegalArgumentException("Received push message with no subtype");
        }

        mSenderId = senderId;
        mAppId = extras.getString(bundleSubtype);

        mCollapseKey = extras.getString(bundleCollapseKey); // May be null.
        mRawData = extras.getByteArray(bundleRawData); // May be null.
        mOriginalPriority = extras.getString(bundleOriginalPriority); // May be null.
        mMessageId = extras.getString(bundleMessageId); // May be null.

        List<String> dataKeysAndValues = new ArrayList<String>();
        for (String key : extras.keySet()) {
            if (key.equals(bundleSubtype)
                    || key.equals(bundleSenderId)
                    || key.equals(bundleCollapseKey)
                    || key.equals(bundleRawData)
                    || key.equals(bundleOriginalPriority)
                    || key.startsWith(bundleGcmplex)
                    || key.equals(bundleMessageId)) {
                continue;
            }

            Object value = extras.get(key);
            if (!(value instanceof String)) {
                continue;
            }

            dataKeysAndValues.add(key);
            dataKeysAndValues.add((String) value);
        }

        mDataKeysAndValuesArray = dataKeysAndValues.toArray(new String[dataKeysAndValues.size()]);
    }

    /**
     * Creates a GCMMessage object based on the given bundle. Assumes that the bundle has previously
     * been created through {@link #toBundle}.
     */
    @Nullable
    public static GCMMessage createFromBundle(Bundle bundle) {
        return create(bundle, new BundleReader());
    }

    /**
     * Creates a GCMMessage object based on the given bundle. Assumes that the bundle has previously
     * been created through {@link #toPersistableBundle}.
     */
    @Nullable
    public static GCMMessage createFromPersistableBundle(PersistableBundle bundle) {
        return create(bundle, new PersistableBundleReader());
    }

    /**
     * Creates a GCMMessage object based on the given JSONObject. Assumes that the JSONObject has
     * previously been created through {@link #toJSON}.
     */
    @Nullable
    public static GCMMessage createFromJSON(JSONObject messageJSON) {
        return create(messageJSON, new JSONReader());
    }

    @Nullable
    private static <T> GCMMessage create(T in, Reader<T> reader) {
        // validate() checks that the fields are present, which is different to the checks below
        // that check if required fields are not null.
        if (!validate(in, reader)) {
            return null;
        }
        if (reader.readString(in, KEY_APP_ID) == null
                || reader.readString(in, KEY_SENDER_ID) == null) {
            return null;
        }
        return new GCMMessage(in, reader);
    }

    /** Validates that all required fields have been set in the given object. */
    private static <T> boolean validate(T in, Reader<T> reader) {
        return reader.hasKey(in, KEY_APP_ID)
                && reader.hasKey(in, KEY_COLLAPSE_KEY)
                && reader.hasKey(in, KEY_DATA)
                && reader.hasKey(in, KEY_RAW_DATA)
                && reader.hasKey(in, KEY_SENDER_ID)
                && reader.hasKey(in, KEY_ORIGINAL_PRIORITY)
                && reader.hasKey(in, KEY_MESSAGE_ID);
    }

    private <T> GCMMessage(T source, Reader<T> reader) {
        mSenderId = reader.readString(source, KEY_SENDER_ID);
        mAppId = reader.readString(source, KEY_APP_ID);
        mCollapseKey = reader.readString(source, KEY_COLLAPSE_KEY);
        mOriginalPriority = reader.readString(source, KEY_ORIGINAL_PRIORITY);
        mMessageId = reader.readString(source, KEY_MESSAGE_ID);
        // The rawData field needs to distinguish between {not set, set but empty, set with data}.
        String rawDataString = reader.readString(source, KEY_RAW_DATA);
        if (rawDataString != null) {
            if (rawDataString.length() > 0) {
                mRawData = rawDataString.getBytes(Charset.forName(SERIALIZATION_CHARSET));
            } else {
                mRawData = new byte[0];
            }
        } else {
            mRawData = null;
        }

        mDataKeysAndValuesArray = reader.readStringArray(source, KEY_DATA);
    }

    public String getSenderId() {
        return mSenderId;
    }

    public String getAppId() {
        return mAppId;
    }

    @Nullable
    public String getMessageId() {
        return mMessageId;
    }

    @Nullable
    public String getCollapseKey() {
        return mCollapseKey;
    }

    /** Callers are expected to not modify values in the returned byte array. */
    @Nullable
    public byte[] getRawData() {
        return mRawData;
    }

    public @Priority int getOriginalPriority() {
        if (mOriginalPriority == null) {
            return Priority.NONE;
        }

        switch (mOriginalPriority) {
            case "normal":
                return Priority.NORMAL;
            case "high":
                return Priority.HIGH;
            default:
                return Priority.NONE;
        }
    }

    /** Callers are expected to not modify values in the returned byte array. */
    public String[] getDataKeysAndValuesArray() {
        return mDataKeysAndValuesArray;
    }

    /**
     * Returns the collapse key of GCMMessage encoded as a JSONObject. Returns null if non exists.
     * Assumes that the JSONObject has previously been created through
     * {@link #toJSON}.
     * @param jsonObject The JSONObject encoding the GCMMessage
     * @return The collapse key. Null if non-exists.
     */
    public static String peekCollapseKey(JSONObject jsonObject) {
        return jsonObject.optString(KEY_COLLAPSE_KEY, null);
    }

    /**
     * Returns the sender id of GCMMessage encoded as a JSONObject. Returns null if non exists.
     * Assumes that the JSONObject has previously been created through
     * {@link #toJSON}.
     * @param jsonObject The JSONObject encoding the GCMMessage
     * @return The collapse key. Null if non-exists.
     */
    public static String peekSenderId(JSONObject jsonObject) {
        return jsonObject.optString(KEY_SENDER_ID, null);
    }

    /**
     * Serializes the contents of this GCM Message to a new bundle that can be stored, for example
     * for purposes of scheduling a job. Only methods available in BaseBundle may be used here,
     * as it may have to be converted to a PersistableBundle.
     */
    public Bundle toBundle() {
        return serialize(new BundleWriter());
    }

    /**
     * Serializes the contents of this GCM Message to a new bundle that can be stored, for example
     * for purposes of scheduling a job.
     */
    public PersistableBundle toPersistableBundle() {
        return serialize(new PersistableBundleWriter());
    }

    /**
     * Serializes the contents of this GCM Message to a JSONObject such that it
     * could be stored as a String.
     */
    public JSONObject toJSON() {
        return serialize(new JSONWriter());
    }

    private <T> T serialize(Writer<T> writer) {
        T out = writer.createOutputObject();
        writer.writeString(out, KEY_VERSION, VERSION);
        writer.writeString(out, KEY_SENDER_ID, mSenderId);
        writer.writeString(out, KEY_APP_ID, mAppId);
        writer.writeString(out, KEY_COLLAPSE_KEY, mCollapseKey);
        writer.writeString(out, KEY_ORIGINAL_PRIORITY, mOriginalPriority);
        writer.writeString(out, KEY_MESSAGE_ID, mMessageId);

        // The rawData field needs to distinguish between {not set, set but empty, set with data}.
        if (mRawData != null) {
            if (mRawData.length > 0) {
                writer.writeString(
                        out,
                        KEY_RAW_DATA,
                        new String(mRawData, Charset.forName(SERIALIZATION_CHARSET)));
            } else {
                writer.writeString(out, KEY_RAW_DATA, "");
            }
        } else {
            writer.writeString(out, KEY_RAW_DATA, null);
        }

        writer.writeStringArray(out, KEY_DATA, mDataKeysAndValuesArray);
        return out;
    }

    private interface Reader<T> {
        public boolean hasKey(T in, String key);

        public String readString(T in, String key);

        @Nullable
        public String[] readStringArray(T in, String key);
    }

    private static class BundleReader implements Reader<Bundle> {
        @Override
        public boolean hasKey(Bundle bundle, String key) {
            return bundle.containsKey(key);
        }

        @Override
        public String readString(Bundle bundle, String key) {
            return bundle.getString(key);
        }

        @Override
        public String[] readStringArray(Bundle bundle, String key) {
            return bundle.getStringArray(key);
        }
    }

    private static class PersistableBundleReader implements Reader<PersistableBundle> {
        @Override
        public boolean hasKey(PersistableBundle bundle, String key) {
            return bundle.containsKey(key);
        }

        @Override
        public String readString(PersistableBundle bundle, String key) {
            return bundle.getString(key);
        }

        @Override
        public String[] readStringArray(PersistableBundle bundle, String key) {
            return bundle.getStringArray(key);
        }
    }

    private static class JSONReader implements Reader<JSONObject> {
        @Override
        public boolean hasKey(JSONObject jsonObj, String key) {
            return jsonObj.has(key);
        }

        @Override
        public String readString(JSONObject jsonObj, String key) {
            if (JSONObject.NULL.equals(jsonObj.opt(key))) {
                return null;
            }
            return jsonObj.optString(key, /* fallback= */ null);
        }

        @Override
        public String[] readStringArray(JSONObject jsonObj, String key) {
            JSONArray jsonArray = jsonObj.optJSONArray(key);
            if (jsonArray == null) {
                return null;
            }
            List<String> strings = new ArrayList<String>(jsonArray.length());
            for (int i = 0; i < jsonArray.length(); i++) {
                strings.add(jsonArray.optString(i));
            }
            return strings.toArray(new String[strings.size()]);
        }
    }

    private interface Writer<T> {
        public T createOutputObject();

        public void writeString(T out, String key, String value);

        public void writeStringArray(T out, String key, String[] value);
    }

    private class PersistableBundleWriter implements Writer<PersistableBundle> {
        @Override
        public PersistableBundle createOutputObject() {
            return new PersistableBundle();
        }

        @Override
        public void writeString(PersistableBundle bundle, String key, String value) {
            bundle.putString(key, value);
        }

        @Override
        public void writeStringArray(PersistableBundle bundle, String key, String[] value) {
            bundle.putStringArray(key, value);
        }
    }

    private class BundleWriter implements Writer<Bundle> {
        @Override
        public Bundle createOutputObject() {
            return new Bundle();
        }

        @Override
        public void writeString(Bundle bundle, String key, String value) {
            bundle.putString(key, value);
        }

        @Override
        public void writeStringArray(Bundle bundle, String key, String[] value) {
            bundle.putStringArray(key, value);
        }
    }

    private class JSONWriter implements Writer<JSONObject> {
        @Override
        public JSONObject createOutputObject() {
            return new JSONObject();
        }

        @Override
        public void writeString(JSONObject jsonObj, String key, String value) {
            try {
                if (value == null) {
                    jsonObj.put(key, JSONObject.NULL);
                    return;
                }
                jsonObj.put(key, value);
            } catch (JSONException e) {
                Log.e(GCMMessage.TAG, "Error when serializing a GCMMessage into a JSONObject.");
            }
        }

        @Override
        public void writeStringArray(JSONObject jsonObj, String key, String[] value) {
            JSONArray jsonArray = new JSONArray();
            try {
                for (String str : value) {
                    jsonArray.put(str);
                }
                jsonObj.put(key, jsonArray);
            } catch (JSONException e) {
                Log.e(GCMMessage.TAG, "Error when serializing a GCMMessage into a JSONObject.");
            }
        }
    }
}