com.achep.base.content.ConfigBase.java Source code

Java tutorial

Introduction

Here is the source code for com.achep.base.content.ConfigBase.java

Source

/*
 * Copyright (C) 2014 AChep@xda <artemchep@gmail.com>
 *
 * 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
 * of the License, 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., 51 Franklin Street, Fifth Floor, Boston,
 * MA  02110-1301, USA.
 */
package com.achep.base.content;

import android.content.Context;
import android.content.SharedPreferences;
import android.content.res.Resources;
import android.os.Handler;
import android.os.Looper;
import android.preference.Preference;
import android.preference.PreferenceScreen;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.text.TextUtils;
import android.util.Log;

import com.achep.base.Device;
import com.achep.base.interfaces.IBackupable;
import com.achep.base.interfaces.IOnLowMemory;
import com.achep.base.interfaces.ISubscriptable;
import com.achep.base.tests.Check;
import com.achep.base.utils.GzipUtils;

import org.apache.commons.lang.builder.EqualsBuilder;
import org.apache.commons.lang.builder.HashCodeBuilder;
import org.json.JSONException;
import org.json.JSONObject;

import java.lang.ref.SoftReference;
import java.lang.ref.WeakReference;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Iterator;
import java.util.Map;

import timber.log.Timber;

import static com.achep.base.Build.DEBUG;

/**
 * Saves all the configurations for the app.
 *
 * @author Artem Chepurnoy
 * @since 21.01.14
 */
@SuppressWarnings("ConstantConditions")
public abstract class ConfigBase
        implements ISubscriptable<ConfigBase.OnConfigChangedListener>, IOnLowMemory, IBackupable {

    private static final String TAG = "Config";

    protected static final String PREFERENCES_FILE_NAME = "config";

    private final ArrayList<WeakReference<OnConfigChangedListener>> mListenersRefs = new ArrayList<>(6);
    private volatile SoftReference<Map<String, Option>> mMapRef = new SoftReference<>(null);
    private volatile Context mContext;
    private volatile Object mPreviousValue;

    // Threading
    protected final Handler mHandler = new Handler(Looper.getMainLooper());

    /**
     * Interface definition for a callback to be invoked
     * when a config is changed.
     */
    public interface OnConfigChangedListener {

        /**
         * Callback that the config has changed.
         *
         * @param config an instance of config
         * @param value  a new value of changed option
         */
        void onConfigChanged(@NonNull ConfigBase config, @NonNull String key, @NonNull Object value);

    }

    /**
     * {@inheritDoc}
     */
    @Override
    public void onLowMemory() {
        mMapRef.clear(); // it will be recreated in #getMap().
    }

    /**
     * Adds new {@link java.lang.ref.WeakReference weak} listener to the config. Make sure you call
     * {@link #unregisterListener(ConfigBase.OnConfigChangedListener)} later!
     *
     * @param listener a listener to register to config changes.
     * @see #unregisterListener(ConfigBase.OnConfigChangedListener)
     */
    @Override
    public final void registerListener(@NonNull OnConfigChangedListener listener) {
        // Make sure to register listener only once.
        for (WeakReference<OnConfigChangedListener> ref : mListenersRefs) {
            if (ref.get() == listener) {
                Timber.tag(TAG).w("Tried to register already registered listener!");
                return;
            }
        }

        mListenersRefs.add(new WeakReference<>(listener));
    }

    /**
     * Un-registers listener is there's one.
     *
     * @param listener a listener to unregister from config changes.
     * @see #registerListener(ConfigBase.OnConfigChangedListener)
     */
    @Override
    public final void unregisterListener(@NonNull OnConfigChangedListener listener) {
        for (WeakReference<OnConfigChangedListener> ref : mListenersRefs) {
            if (ref.get() == listener) {
                mListenersRefs.remove(ref);
                return;
            }
        }

        Timber.tag(TAG).w("Tried to unregister non-existent listener!");
    }

    /**
     * @return the {@link java.util.HashMap HashMap} with option's keys as the keys, and
     * its {@link Option data} as the values.
     * @see #onCreateMap(java.util.Map)
     */
    @NonNull
    public final Map<String, Option> getMap() {
        Map<String, Option> map = mMapRef.get();
        if (map == null) {
            map = new HashMap<>();
            onCreateMap(map);
            mMapRef = new SoftReference<>(map);
        }
        return map;
    }

    /**
     * @param key The unique key of the option.
     * @throws RuntimeException if failed to find the corresponding option.
     * @see #getMap()
     */
    @NonNull
    public final Option getOption(@NonNull String key) {
        Option option = getMap().get(key);
        if (option != null)
            return option;

        throw new RuntimeException("You have forgotten to put #" + key + " to the hash map of config.");
    }

    /**
     * You may get a context from here only on
     * {@link ConfigBase.OnConfigChangedListener#onConfigChanged(ConfigBase, String, Object) config change}.
     */
    public Context getContext() {
        return mContext;
    }

    /**
     * You may get the previous value from here only on
     * {@link ConfigBase.OnConfigChangedListener#onConfigChanged(ConfigBase, String, Object) config change}.
     */
    @Nullable
    public Object getPreviousValue() {
        return mPreviousValue;
    }

    //-- INTERNAL METHODS -----------------------------------------------------

    /**
     * Gets an instance of the shared preferences of {@link #PREFERENCES_FILE_NAME}. By
     * default, the name is {@link #PREFERENCES_FILE_NAME}.
     */
    @NonNull
    protected SharedPreferences getSharedPreferences(@NonNull Context context) {
        return context.getSharedPreferences(PREFERENCES_FILE_NAME, Context.MODE_PRIVATE);
    }

    /**
     * Fills the {@link java.util.HashMap hash map} with config's options.
     *
     * @see #getMap()
     */
    protected abstract void onCreateMap(@NonNull Map<String, Option> map);

    protected abstract void onOptionChanged(@NonNull Option option, @NonNull String key);

    protected void writeFromMain(final @NonNull Context context, final @NonNull Option option,
            final @NonNull Object value, final @Nullable OnConfigChangedListener listenerToBeIgnored) {
        mHandler.post(new Runnable() {

            @Override
            public void run() {
                write(context, option, value, listenerToBeIgnored);
            }

        });
    }

    protected void write(final @NonNull Context context, final @NonNull Option option, final @NonNull Object value,
            final @Nullable OnConfigChangedListener listenerToBeIgnored) {
        Check.getInstance().isInMainThread();

        if (option.read(ConfigBase.this).equals(value))
            return;
        String key = option.getKey(ConfigBase.this);

        if (DEBUG)
            Log.d(TAG, "Writing \"" + key + "=" + value + "\" to config.");

        // Read the current value from an option.
        mPreviousValue = option.read(this);

        // Set the current value to the field.
        try {
            Field field = getClass().getDeclaredField(option.fieldName);
            field.setAccessible(true);
            field.set(this, value);
        } catch (Exception e) {
            throw new IllegalStateException("");
        }

        // Set the current value to the preferences file.
        SharedPreferences.Editor editor = getSharedPreferences(context).edit();
        if (value instanceof Boolean) {
            editor.putBoolean(key, (Boolean) value);
        } else if (value instanceof Integer) {
            editor.putInt(key, (Integer) value);
        } else if (value instanceof Float) {
            editor.putFloat(key, (Float) value);
        } else if (value instanceof String) {
            editor.putString(key, (String) value);
        } else
            throw new IllegalArgumentException("Unknown option\'s type.");
        editor.apply();

        mContext = context;
        onOptionChanged(option, key);
        notifyConfigChanged(key, value, listenerToBeIgnored);
        mContext = null;
        mPreviousValue = null;
    }

    /**
     * @param key   the key of the option
     * @param value the new value
     * @see ConfigBase.OnConfigChangedListener#onConfigChanged(ConfigBase, String, Object)
     */
    private void notifyConfigChanged(@NonNull String key, @NonNull Object value,
            @Nullable OnConfigChangedListener listenerToBeIgnored) {
        Check.getInstance().isInMainThread();

        for (int i = mListenersRefs.size() - 1; i >= 0; i--) {
            WeakReference<OnConfigChangedListener> ref = mListenersRefs.get(i);
            OnConfigChangedListener l = ref.get();

            if (l == null) {
                // There were no links to this listener except
                // our class.
                Log.w(TAG, "Deleting an addled listener..!");
                mListenersRefs.remove(i);
            } else if (l != listenerToBeIgnored) {
                l.onConfigChanged(this, key, value);
            }
        }
    }

    //-- BACKUP ---------------------------------------------------------------

    /**
     * Stores all the values to a JSON string and compresses it
     * using {@link GzipUtils Gzip}.
     *
     * @return the backup string or {@code null} if failed to generate the one.
     * @see #fromBackupText(Context, String)
     */
    @Override
    @Nullable
    public String toBackupText() {
        JSONObject json;
        try {
            json = new JSONObject();
            /*
            // TODO: Should I protect it somehow?
            json.put("__package__", "");
            json.put("__version__", "");
            */
            // Fill the json with key/value pairs
            for (Map.Entry<String, Option> entry : getMap().entrySet()) {
                json.put(entry.getKey(), entry.getValue());
            }
        } catch (JSONException e) {
            Log.w(TAG, "Failed to generate JSON: " + e.getMessage());
            return null;
        }

        // We compress the result to protect it from noobs' changes
        // and to reduce its size. This is still easy to extract if
        // you know what to do.
        return GzipUtils.compress(json.toString());
    }

    /**
     * Loads all the settings from previously {@link #toBackupText() generated} backup string.
     * Technically this may broke current settings, so it's kinda dangerous.
     *
     * @return {@code true} if the config was successfully restored, {@code false} otherwise.
     * @see #toBackupText()
     */
    @Override
    public boolean fromBackupText(@NonNull Context context, @NonNull String input) {
        String json = GzipUtils.decompress(input);
        if (json == null)
            return false;
        String fallback = toBackupText(); // We can't risk
        return fallback != null && fromBackupText(context, json, fallback);
    }

    private boolean fromBackupText(@NonNull Context context, @NonNull String str, @NonNull String fallback) {
        try {
            JSONObject json = new JSONObject(str);
            Iterator<String> i = json.keys();
            while (i.hasNext()) {
                String key = i.next();
                Object value = json.get(key);
                // Apply the value
                Option option = getMap().get(key);
                if (option != null) {
                    option.write(this, context, value, null);
                } else {
                    Log.w(TAG, "Passed loading an unknown item[" + key + "] from plain text.");
                }
            }
        } catch (Exception e) {
            // Try to fallback to original settings.
            if (!TextUtils.equals(str, fallback))
                fromBackupText(context, fallback, fallback);
            // At this point current config may be partially corrupted and un-recoverable.
            return false;
        }
        return true;
    }

    //-- OTHER ----------------------------------------------------------------

    protected void initInternal(@NonNull Context context) {
        try {
            Resources res = context.getResources();
            SharedPreferences prefs = getSharedPreferences(context);
            for (Map.Entry<String, Option> entry : getMap().entrySet()) {
                final String key = entry.getKey();
                final Option option = entry.getValue();

                // Get the current value.
                Object value = option.getDefault(res);
                if (boolean.class.isAssignableFrom(option.clazz)) {
                    value = prefs.getBoolean(key, (Boolean) value);
                } else if (int.class.isAssignableFrom(option.clazz)) {
                    value = prefs.getInt(key, (Integer) value);
                } else if (float.class.isAssignableFrom(option.clazz)) {
                    value = prefs.getFloat(key, (Float) value);
                } else if (String.class.isAssignableFrom(option.clazz)) {
                    value = prefs.getString(key, (String) value);
                } else
                    throw new IllegalArgumentException("Unknown option\'s type.");

                // Set the current value.
                Field field = getClass().getDeclaredField(option.fieldName);
                field.setAccessible(true);
                field.set(this, value);
            }
        } catch (Exception e) {
            throw new RuntimeException();
        }
    }

    protected void resetInternal(@NonNull Context context) {
        // Reset all values.
        Resources res = context.getResources();
        for (Option option : getMap().values()) {
            Object value = option.getDefault(res);
            option.write(this, context, value, null);
        }
        // Clean the storage.
        SharedPreferences prefs = getSharedPreferences(context);
        prefs.edit().clear().apply();
    }

    //-- SYNCER ---------------------------------------------------------------

    /**
     * A class that syncs {@link android.preference.Preference} with its
     * value in config. Sample class can be found here:
     * {@link com.achep.base.ui.fragments.PreferenceFragment}
     *
     * @author Artem Chepurnoy
     */
    public static class Syncer {

        private final ArrayList<Item> mItems;
        private final Context mContext;
        private final ConfigBase mConfig;

        private boolean mBroadcasting;
        private boolean mStarted;

        private final Preference.OnPreferenceChangeListener mPreferenceListener = new Preference.OnPreferenceChangeListener() {

            @Override
            public boolean onPreferenceChange(Preference preference, Object newValue) {
                if (mBroadcasting) {
                    return true;
                }

                Item item = null;
                for (Item c : mItems) {
                    if (preference == c.preference) {
                        item = c;
                        break;
                    }
                }

                assert item != null;

                newValue = item.setter.getValue(newValue);
                item.option.write(mConfig, mContext, newValue, mConfigListener);
                item.setter.updateSummary(item.preference, item.option, newValue);
                return true;
            }

        };

        private final OnConfigChangedListener mConfigListener = new OnConfigChangedListener() {

            @Override
            public void onConfigChanged(@NonNull ConfigBase config, @NonNull String key, @NonNull Object value) {
                Item item = null;
                for (Item c : mItems) {
                    if (key.equals(c.preference.getKey())) {
                        item = c;
                        break;
                    }
                }

                if (item == null) {
                    return;
                }

                setPreferenceValue(item, value);
            }

        };

        private void setPreferenceValue(@NonNull Item item, @NonNull Object value) {
            mBroadcasting = true;
            item.setter.setValue(item.preference, item.option, value);
            item.setter.updateSummary(item.preference, item.option, value);
            mBroadcasting = false;
        }

        public interface Setter {

            void updateSummary(@NonNull Preference preference, @NonNull Option option, @NonNull Object value);

            /**
             * Sets new value to the preference.
             *
             * @param preference preference to set to
             * @param option     the changed option
             * @param value      new value to set
             */
            void setValue(@NonNull Preference preference, @NonNull Option option, @NonNull Object value);

            @NonNull
            Object getValue(@NonNull Object value);

        }

        /**
         * A class-merge of {@link android.preference.Preference}
         * and its {@link ConfigBase.Option} and its {@link ConfigBase.Syncer.Setter}.
         *
         * @author Artem Chepurnoy
         */
        private final static class Item {
            final Preference preference;
            final Setter setter;
            final Option option;

            public Item(@NonNull ConfigBase config, @NonNull Preference preference, @NonNull Setter setter) {
                this.preference = preference;
                this.setter = setter;
                this.option = config.getOption(preference.getKey());
            }
        }

        public Syncer(@NonNull Context context, @NonNull ConfigBase config) {
            mItems = new ArrayList<>(10);
            mContext = context;
            mConfig = config;
        }

        public void syncPreference(@Nullable PreferenceScreen ps, @NonNull Preference preference,
                @NonNull Setter setter) {
            Item item = new Item(mConfig, preference, setter);
            if (ps != null) {
                // Remove preference from preference screen
                // if needed.
                boolean fitsMax = !Device.hasTargetApi(item.option.maxSdkVersion + 1);
                boolean fitsMin = Device.hasTargetApi(item.option.minSdkVersion);
                if (!fitsMax || !fitsMin) {
                    ps.removePreference(preference);
                    return;
                }
            }
            // Add preference.
            mItems.add(item);
            // Immediately start listening if needed.
            if (mStarted)
                startListeningToItem(item);
        }

        /**
         * Updates all preferences and starts to listen to the changes.
         * Don't forget to call {@link #stop()} later!
         *
         * @see #stop()
         * @see #syncPreference(PreferenceScreen, Preference, Setter)
         */
        public void start() {
            mStarted = true;
            mConfig.registerListener(mConfigListener);
            for (Item item : mItems)
                startListeningToItem(item);
        }

        private void startListeningToItem(@NonNull Item item) {
            item.preference.setOnPreferenceChangeListener(mPreferenceListener);
            setPreferenceValue(item, item.option.read(mConfig));
        }

        /**
         * Stops to listen to the changes.
         *
         * @see #start()
         */
        public void stop() {
            mStarted = false;
            mConfig.unregisterListener(mConfigListener);
            for (Item item : mItems)
                item.preference.setOnPreferenceChangeListener(null);
        }
    }

    /**
     * @author Artem Chepurnoy
     */
    public static class Option {

        @NonNull
        private final String fieldName;
        @Nullable
        private final String setterName;
        @Nullable
        private final String getterName;
        @NonNull
        private final Class clazz;

        private volatile int minSdkVersion = Integer.MIN_VALUE + 1;
        private volatile int maxSdkVersion = Integer.MAX_VALUE - 1;

        private volatile int mDefaultRes = -1;
        private volatile Object mDefault;

        public Option(@NonNull String fieldName, @Nullable String setterName, @Nullable String getterName,
                @NonNull Class clazz) {
            this.fieldName = fieldName;
            this.setterName = setterName;
            this.getterName = getterName;
            this.clazz = clazz;
        }

        @NonNull
        public Option setDefault(Object value) {
            mDefault = value;
            return this;
        }

        @NonNull
        public Option setDefaultRes(int resource) {
            mDefaultRes = resource;
            return this;
        }

        /**
         * Sets minimum {@link android.os.Build.VERSION#SDK_INT sdk version} of this
         * option. This option won't be shown on older systems.
         *
         * @see #setMaxSdkVersion(int)
         */
        @NonNull
        public Option setMinSdkVersion(int version) {
            minSdkVersion = version;
            return this;
        }

        /**
         * Sets maximum {@link android.os.Build.VERSION#SDK_INT sdk version} of this
         * option. This option won't be shown on newer systems.
         *
         * @see #setMinSdkVersion(int)
         */
        @NonNull
        public Option setMaxSdkVersion(int version) {
            maxSdkVersion = version;
            return this;
        }

        /**
         * {@inheritDoc}
         */
        @Override
        public int hashCode() {
            return new HashCodeBuilder(11, 31).append(fieldName).append(setterName).append(getterName).append(clazz)
                    .toHashCode();
        }

        /**
         * {@inheritDoc}
         */
        @Override
        public boolean equals(Object o) {
            if (o == null)
                return false;
            if (o == this)
                return true;
            if (!(o instanceof Option))
                return false;

            Option option = (Option) o;
            return new EqualsBuilder().append(fieldName, option.fieldName).append(setterName, option.setterName)
                    .append(getterName, option.getterName).append(clazz, option.clazz).isEquals();
        }

        /**
         * Extracts and returns the default option's value specified by
         * {@link #setDefault(Object)} or {@link #setDefaultRes(int)}.
         *
         * @see #setDefault(Object)
         * @see #setDefaultRes(int)
         */
        @Nullable
        public final Object getDefault(@NonNull Resources resources) {
            if (mDefaultRes != -1) {
                if (boolean.class.isAssignableFrom(clazz)) {
                    return resources.getBoolean(mDefaultRes);
                } else if (int.class.isAssignableFrom(clazz)) {
                    return resources.getInteger(mDefaultRes);
                } else if (float.class.isAssignableFrom(clazz)) {
                    // Assuming it's a dimension, but not a fraction.
                    return resources.getDimension(mDefaultRes);
                } else if (String.class.isAssignableFrom(clazz)) {
                    return resources.getString(mDefaultRes);
                } else
                    throw new IllegalArgumentException("Unknown option\'s type.");
            }
            return mDefault;
        }

        @NonNull
        public final String getKey(@NonNull ConfigBase config) {
            for (Map.Entry<String, Option> entry : config.getMap().entrySet()) {
                if (entry.getValue().equals(this)) {
                    return entry.getKey();
                }
            }
            throw new RuntimeException();
        }

        //-- READING & WRITING ----------------------------------------------------

        /**
         * Reads an option from given config instance.</br>
         * Reading is done using reflections!
         *
         * @param config a config to read from.
         * @throws RuntimeException if failed to read given config.
         */
        @NonNull
        public final Object read(@NonNull ConfigBase config) {
            return getterName != null ? readFromGetter(config) : readFromField(config);
        }

        @NonNull
        private Object readFromField(@NonNull ConfigBase config) {
            assert fieldName != null;
            try {
                Field field = config.getClass().getDeclaredField(fieldName);
                field.setAccessible(true);
                return field.get(config);
            } catch (Exception e) {
                throw new RuntimeException("Failed to access the " + clazz.getName() + "#" + fieldName + " field.");
            }
        }

        @NonNull
        private Object readFromGetter(@NonNull ConfigBase config) {
            assert getterName != null;
            try {
                Method method = config.getClass().getDeclaredMethod(getterName);
                method.setAccessible(true);
                return method.invoke(config);
            } catch (Exception e) {
                throw new RuntimeException(
                        "Failed to access the " + clazz.getName() + "#" + getterName + " method.");
            }
        }

        /**
         * Writes new value to the option to given config instance.</br>
         * Writing is done using reflections!
         *
         * @param config a config to write to.
         * @throws RuntimeException if failed to read given config.
         */
        public final void write(@NonNull ConfigBase config, @NonNull Context context, @NonNull Object newValue,
                @Nullable OnConfigChangedListener listener) {
            if (setterName != null) {
                // Setter must be calling #writeFromMain by itself.
                writeBySetter(config, context, newValue, listener);
                return;
            }

            config.writeFromMain(context, this, newValue, listener);
        }

        private void writeBySetter(@NonNull ConfigBase config, @NonNull Context context, @NonNull Object newValue,
                @Nullable OnConfigChangedListener listener) {
            assert setterName != null;
            try {
                Method method = config.getClass().getDeclaredMethod(setterName, Context.class, clazz,
                        ConfigBase.OnConfigChangedListener.class);
                method.setAccessible(true);
                method.invoke(config, context, newValue, listener);
            } catch (Exception e) {
                throw new RuntimeException(
                        "Failed to access " + clazz.getName() + "#" + setterName + "(***) method.");
            }
        }

    }

}