BundleMarshaller.java

/*
 * Copyright 2018 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package androidx.car.navigation.utils;

import static androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP;

import android.os.Bundle;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.RestrictTo;

import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.function.Supplier;

/**
 * Class responsible for serializing and deserializing data into a {@link Bundle}. It also
 * provides a way to detect what items in the {@link Bundle} have been modified during
 * marshalling.
 * <p>
 * A single {@link BundleMarshaller} can be re-used to serialize or deserialize data multiple times.
 * Similarity, deserialization can be done in-place, updating existing {@link Bundlable}s. This
 * reduces the number of instances being allocated.
 * <p>
 * When serializing, use {@link #resetBundle()} before marshalling and {@link #getBundle()} to
 * obtain an snap-shot of the serialized content. Or use {@link #resetDelta()} and
 * {@link #getDelta()} to obtain a {@link Bundle} representing the patch between the last and
 * the new serialized data.
 * <p>
 * When deserializing, use {@link #setBundle(Bundle)} to deserialize a {@link Bundle} containing an
 * snap-shot, or {@link #applyDelta(Bundle)} to process a patch from the last deserialized data.
 * <p>
 * Keys used in the "get" and "put" methods must be lower camel case alphanumerical identifiers
 * (e.g.: "distanceUnit"). Symbols like "." and "_" are reserved by the system.
 * <p>
 * When deserializing {@link List} objects, this class assumes that they implement random access
 * (e.g. {@link ArrayList}), or they are relatively small (see more details at
 * {@link #trimList(List, int)})
 *
 * @hide
 */
@RestrictTo(LIBRARY_GROUP)
public class BundleMarshaller {
    /**
     * Separator used to concatenate identifiers when marshalling non-primitive types (e.g. lists
     * or {@link Bundlable}s).
     */
    private static final String KEY_SEPARATOR = ".";
    /**
     * Identifier used to record if a given non-primitive field is null or not. This allows
     * serializing null objects without the need of using reflection or static methods.
     */
    private static final String IS_NULL_KEY = "_isNull";
    /**
     * Identifier used to record the length of a collection. This allows serializing changes to the
     * length of a collection without having to remove elements or having to iterate over every
     * possible collection key.
     */
    private static final String SIZE_KEY = "_size";
    /**
     * Special value for {@link #SIZE_KEY} to serialize a null collection.
     */
    private static final int NULL_SIZE = -1;

    private Bundle mBundle = new Bundle();
    private String mKeyPrefix = "";
    private final Bundle mBundleDelta = new Bundle();

    /**
     * Returns data serialized since the last time this instance was constructed, or
     * {@link #resetBundle()} was called.
     */
    public Bundle getBundle() {
        return mBundle;
    }

    /**
     * Resets this {@link BundleMarshaller} causing {@link #getBundle()} to return an empty
     * {@link Bundle} until the next marshalling is executed. This can be used occasionally to
     * remove unused keys in the {@link Bundle}.
     */
    public void resetBundle() {
        mBundle.clear();
    }

    /**
     * Replaces the {@link Bundle} to serialize into or deserialize from.
     */
    public void setBundle(Bundle bundle) {
        mBundle = bundle;
    }

    /**
     * Gets a {@link Bundle} containing only the entries of {@link #getBundle()} that were modified
     * since this instance was constructed, or {@link #resetDelta()} was called.
     */
    public Bundle getDelta() {
        return mBundleDelta;
    }

    /**
     * Merges the provided {@link Bundle} on top of the one stored in this {@link BundleMarshaller}.
     *
     * @param delta a {@link Bundle} containing entries to be updated on one stored in this
     *              {@link BundleMarshaller} instance. Such {@link Bundle} can be produced by
     *              using the {@link #resetDelta()} and {@link #getDelta()} methods during data
     *              serialization.
     */
    public void applyDelta(Bundle delta) {
        mBundle.putAll(delta);
    }

    /**
     * Resets tracking of modified entries, causing {@link #getDelta()} to return an empty
     * {@link Bundle} until the next marshalling is executed. This can be used between
     * serializations make {@link #getDelta()} return only the differences.
     */
    public void resetDelta() {
        mBundleDelta.clear();
    }

    /**
     * Inserts an int value, replacing any existing value for the given key.
     *
     * @param key lower camel case alphanumerical identifier
     * @param value an int
     */
    public void putInt(@NonNull String key, int value) {
        String mangledKey = getMangledKey(key);
        if (!mBundle.containsKey(mangledKey) || mBundle.getInt(mangledKey) != value) {
            mBundleDelta.putInt(mangledKey, value);
            mBundle.putInt(mangledKey, value);
        }
    }

    /**
     * Returns the value associated with the given key, or 0 if no mapping of the desired type
     * exists for the given key.
     *
     * @param key lower camel case alphanumerical identifier
     * @return an int
     */
    public int getInt(@NonNull String key) {
        return mBundle.getInt(getMangledKey(key));
    }

    /**
     * Inserts a float value, replacing any existing value for the given key.
     *
     * @param key lower camel case alphanumerical identifier
     * @param value a float
     */
    public void putFloat(@NonNull String key, float value) {
        String mangledKey = getMangledKey(key);
        if (!mBundle.containsKey(mangledKey)
                || Float.compare(mBundle.getFloat(mangledKey), value) != 0) {
            mBundleDelta.putFloat(mangledKey, value);
            mBundle.putFloat(mangledKey, value);
        }
    }

    /**
     * Returns the value associated with the given key, or 0.0f if no mapping of the desired type
     * exists for the given key.
     *
     * @param key lower camel case alphanumerical identifier
     * @return a float
     */
    public float getFloat(@NonNull String key) {
        return mBundle.getFloat(getMangledKey(key));
    }

    /**
     * Inserts a double value, replacing any existing value for the given key.
     *
     * @param key lower camel case alphanumerical identifier
     * @param value a double
     */
    public void putDouble(@NonNull String key, double value) {
        String mangledKey = getMangledKey(key);
        if (!mBundle.containsKey(mangledKey)
                || Double.compare(mBundle.getDouble(mangledKey), value) != 0) {
            mBundleDelta.putDouble(mangledKey, value);
            mBundle.putDouble(mangledKey, value);
        }
    }

    /**
     * Returns the value associated with the given key, or 0.0 if no mapping of the desired type
     * exists for the given key.
     *
     * @param key lower camel case alphanumerical identifier
     * @return a double
     */
    public double getDouble(@NonNull String key) {
        return mBundle.getDouble(getMangledKey(key));
    }

    /**
     * Inserts a boolean value, replacing any existing value for the given key.
     *
     * @param key lower camel case alphanumerical identifier
     * @param value a boolean
     */
    public void putBoolean(@NonNull String key, boolean value) {
        String mangledKey = getMangledKey(key);
        if (!mBundle.containsKey(mangledKey) || mBundle.getBoolean(mangledKey) != value) {
            mBundleDelta.putBoolean(mangledKey, value);
            mBundle.putBoolean(mangledKey, value);
        }
    }

    /**
     * Returns the value associated with the given key, or false if no mapping of the desired type
     * exists for the given key.
     *
     * @param key lower camel case alphanumerical identifier
     * @return a boolean
     */
    public boolean getBoolean(@NonNull String key) {
        return mBundle.getBoolean(getMangledKey(key));
    }

    /**
     * Inserts a string value, replacing any existing value for the given key.
     *
     * @param key lower camel case alphanumerical identifier
     * @param value a string, or null
     */
    public void putString(@NonNull String key, @Nullable String value) {
        String mangledKey = getMangledKey(key);
        if (!mBundle.containsKey(mangledKey)
                || !Objects.equals(mBundle.getString(mangledKey), value)) {
            mBundleDelta.putString(mangledKey, value);
            mBundle.putString(mangledKey, value);
        }
    }

    /**
     * Returns the value associated with the given key, or null if no mapping of the desired type
     * exists for the given key.
     *
     * @param key lower camel case alphanumerical identifier
     * @return a string, or null
     */
    @Nullable
    public String getString(@NonNull String key) {
        return mBundle.getString(getMangledKey(key));
    }

    /**
     * Returns the value associated with the given key, or the provided default value if no mapping
     * of the desired type exists for the given key.
     *
     * @param key lower camel case alphanumerical identifier
     * @param defaultValue value to return if key does not exist or if a null value is associated
     *                     with the given key.
     * @return a string
     */
    @NonNull
    public String getStringNonNull(@NonNull String key, @NonNull String defaultValue) {
        return mBundle.getString(getMangledKey(key), defaultValue);
    }

    /**
     * Inserts an enum value, replacing any existing value for the given key. The provided enum
     * will be serialized as a string using {@link Enum#name()}.
     *
     * @param key lower camel case alphanumerical identifier
     * @param value an enum, or null
     */
    public <T extends Enum<T>> void putEnum(@NonNull String key, @Nullable T value) {
        putString(key, value != null ? value.name() : null);
    }

    /**
     * Returns the value associated with the given key, or null if no mapping of the desired type
     * exists for the given key.
     *
     * @param key lower camel case alphanumerical identifier
     * @param clazz {@link Enum} class to be used to deserialize the value.
     * @param <T> {@link Enum} type to be returned.
     * @return an enum, or null
     */
    @Nullable
    public <T extends Enum<T>> T getEnum(@NonNull String key, @NonNull Class<T> clazz) {
        String name = getString(key);
        try {
            return name != null ? Enum.valueOf(clazz, name) : null;
        } catch (IllegalArgumentException ex) {
            return null;
        }
    }

    /**
     * Returns the value associated with the given key, or the provided default value if no mapping
     * of the desired type exists for the given key.
     *
     * @param key lower camel case alphanumerical identifier
     * @param clazz {@link Enum} class to be used to deserialize the value.
     * @param defaultValue value to return if key does not exist or if a null value is associated
     *                     with the given key.
     * @param <T> {@link Enum} type to be returned.
     * @return an enum
     */
    @NonNull
    public <T extends Enum<T>> T getEnumNonNull(@NonNull String key, @NonNull Class<T> clazz,
            @NonNull T defaultValue) {
        T result = getEnum(key, clazz);
        return result != null ? result : defaultValue;
    }

    /**
     * Inserts a {@link Bundlable} value, replacing any existing value for the given key.
     *
     * @param key lower camel case alphanumerical identifier
     * @param value a {@link Bundlable}, or null
     */
    public <T extends Bundlable> void putBundlable(@NonNull String key, @Nullable T value) {
        withKeyPrefix(key, () -> {
            putBoolean(IS_NULL_KEY, value == null);
            if (value != null) {
                value.toBundle(this);
            }
        });
    }

    /**
     * Returns the value associated with the given key, or null if no mapping of the desired type
     * exists for the given key. If a non-null "current" instance is provided, then the
     * deserialization would be done in place. Otherwise, a new instance will be created using the
     * provided factory.
     *
     * @param key lower camel case alphanumerical identifier
     * @param current current value (if available) to perform in-place deserialization, or null
     * @param factory a {@link Supplier} capable of providing an instance of a {@link Bundlable} of
     *                type T. The suggested implementation is to pass a reference to the default
     *                constructor of that class.
     * @param <T> {@link Bundlable} type to be returned.
     * @return an instance of type T, or null
     */
    @Nullable
    public <T extends Bundlable> T getBundlable(@NonNull String key, @Nullable T current,
            @NonNull Supplier<T> factory) {
        return withKeyPrefix(key, () -> {
            if (getBoolean(IS_NULL_KEY)) {
                return null;
            }
            T result = current != null ? current : factory.get();
            result.fromBundle(this);
            return result;
        });
    }

    /**
     * Returns the value associated with the given key, or a default value if no mapping of the
     * desired type exists for the given key. If a non-null value is available, then such value
     * will be deserialized in-place on the given "current" instance. Otherwise, a default value
     * will be generated using the provided factory.
     *
     * @param key lower camel case alphanumerical identifier
     * @param current current value to perform in-place deserialization
     * @param factory a {@link Supplier} capable of providing an instance of a {@link Bundlable} of
     *                type T. The suggested implementation is to pass a reference to the default
     *                constructor of that class.
     * @param <T> {@link Bundlable} type to be returned.
     * @return an instance of type T
     */
    @NonNull
    public <T extends Bundlable> T getBundlableNonNull(@NonNull String key, @NonNull T current,
            @NonNull Supplier<T> factory) {
        T result = getBundlable(key, current, factory);
        return result != null ? result : factory.get();
    }

    /**
     * Inserts a {@link List} of {@link Bundlable} values, replacing any existing value for the
     * given key.
     *
     * @param key lower camel case alphanumerical identifier
     * @param values a {@link List} of {@link Bundlable} values, or null
     */
    public <T extends Bundlable> void putBundlableList(@NonNull String key,
            @Nullable List<T> values) {
        withKeyPrefix(key, () -> {
            putInt(SIZE_KEY, values != null ? values.size() : NULL_SIZE);
            if (values != null) {
                int pos = 0;
                // Using for-each as the provided list might not implement random access (e.g. it
                // might be a linked list).
                for (T value : values) {
                    putBundlable(String.valueOf(pos), value);
                    pos++;
                }
            }
        });
    }

    /**
     * Returns the value associated with the given key, or null if no mapping of the desired type
     * exists for the given key. If a non-null "current" list is provided, then the deserialization
     * would be done in place. Otherwise, a new list will be created and items will be instantiated
     * using the provided factory.
     *
     * @param key lower camel case alphanumerical identifier
     * @param current current value (if available) to perform in-place deserialization, or null
     * @param factory a {@link Supplier} capable of providing an instance of a {@link Bundlable} of
     *                type T. The suggested implementation is to pass a reference to the default
     *                constructor of that class.
     * @param <T> {@link Bundlable} type to be returned.
     * @return a list of instances of type T, or null. The resulting list might contain null
     *         elements.
     */
    @Nullable
    public <T extends Bundlable> List<T> getBundlableList(@NonNull String key,
            @Nullable List<T> current, @NonNull Supplier<T> factory) {
        return withKeyPrefix(key, () -> {
            int listSize = getInt(SIZE_KEY);
            if (listSize == NULL_SIZE) {
                return null;
            }
            List<T> result = current != null ? current : new ArrayList<>(listSize);
            if (result.size() > listSize) {
                result.subList(listSize, result.size()).clear();
            }
            for (int pos = 0; pos < listSize; pos++) {
                String subKey = String.valueOf(pos);
                if (pos < result.size()) {
                    result.set(pos, getBundlable(subKey, result.get(pos), factory));
                } else {
                    result.add(getBundlable(String.valueOf(pos),
                            null /* force the creation of a new instance */,
                            factory));
                }
            }
            return result;
        });
    }

    /**
     * Returns the value associated with the given key, or an empty list if no mapping of the
     * desired type exists for the given key. If a non-null "current" list is provided, then the
     * deserialization would be done in place. Otherwise, a new list will be created and items will
     * be instantiated using the provided factory.
     *
     * @param key lower camel case alphanumerical identifier
     * @param current current value (if available) to perform in-place deserialization, or null
     * @param factory a {@link Supplier} capable of providing an instance of a {@link Bundlable} of
     *                type T. The suggested implementation is to pass a reference to the default
     *                constructor of that class.
     * @param <T> {@link Bundlable} type to be returned.
     * @return a list of instances of type T, or an empty list. The resulting list might contain
     *         null elements.
     */
    @NonNull
    public <T extends Bundlable> List<T> getBundlableListNonNull(@NonNull String key,
            @NonNull List<T> current, @NonNull Supplier<T> factory) {
        List<T> result = getBundlableList(key, current, factory);
        return result != null ? result : new ArrayList<>();
    }

    /**
     * Executes the given {@link Runnable} in a context where {@link #getMangledKey(String)}
     * includes the given key as part of the prefix. Calls to this method can be nested (the
     * provided {@link Runnable} can call to this method if needed). This method should be used when
     * serializing or deserializing nested objects.
     * <p>
     * For example: calling to {@link #withKeyPrefix(String, Runnable)} with "foo" as key and
     * a {@link Runnable} that calls {@link #getMangledKey(String)} with "bar" as key, will
     * cause such {@link #getMangledKey(String)} call to return "foo.bar".
     */
    private void withKeyPrefix(@NonNull String key, @NonNull Runnable runnable) {
        String originalKeyPrefix = mKeyPrefix;
        mKeyPrefix = mKeyPrefix + key + KEY_SEPARATOR;
        runnable.run();
        mKeyPrefix = originalKeyPrefix;
    }

    /**
     * Similar to {@link #withKeyPrefix(String, Runnable)} but allows returning a value.
     */
    private <X> X withKeyPrefix(@NonNull String key, @NonNull Supplier<X> supplier) {
        String originalKeyPrefix = mKeyPrefix;
        mKeyPrefix = mKeyPrefix + key + KEY_SEPARATOR;
        X res = supplier.get();
        mKeyPrefix = originalKeyPrefix;
        return res;
    }

    /**
     * Returns a composed key based on the given one and the current serialization/deserialization
     * key prefix (initially empty). This prefix can be temporarily changed with
     * {@link #withKeyPrefix(String, Runnable)} or {@link #withKeyPrefix(String, Supplier)}.
     */
    private String getMangledKey(@NonNull String key) {
        return mKeyPrefix + key;
    }
}