package com.achep.base.content; import android.annotation.SuppressLint; 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.util.Log; import com.achep.base.Device; import com.achep.base.interfaces.IOnLowMemory; import org.apache.commons.lang.builder.EqualsBuilder; import org.apache.commons.lang.builder.HashCodeBuilder; import java.lang.ref.Reference; import java.lang.ref.WeakReference; import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; /** * Config class that stores all application's options and provides an easy way * to sync them with a GUI. * * @author Artem Chepurnoy */ public abstract class CfgBase implements IOnLowMemory { private static final boolean DEBUG = true; private static final String TAG = "cfg_base"; /** * @return the filename of this config */ @NonNull public abstract String getPreferencesFileName(); /** * Loads all settings from the storage. Should be called on the begin * of app's lifecycle. */ public final void load(@NonNull Context context) { synchronized (this) { Resources res = context.getResources(); SharedPreferences prefs = getSharedPreferences(context); for (Option option : getMap().values()) { // Get the current value. Object value = option.getDefault(res); if (boolean.class.isAssignableFrom(option.clazz)) { value = prefs.getBoolean(option.key, (Boolean) value); } else if (int.class.isAssignableFrom(option.clazz)) { value = prefs.getInt(option.key, (Integer) value); } else if (float.class.isAssignableFrom(option.clazz)) { value = prefs.getFloat(option.key, (Float) value); } else if (String.class.isAssignableFrom(option.clazz)) { value = prefs.getString(option.key, (String) value); } else if (long.class.isAssignableFrom(option.clazz)) { value = prefs.getLong(option.key, (Long) value); } else throw new IllegalArgumentException("Unknown option\'s type."); Log.d(TAG, "Init option=" + option.key + " with " + value); // Set the current value. option.setValue(value); } } } /** * Resets all options to their default values. */ public final void reset(@NonNull Context context) { synchronized (this) { Transaction transaction = new Transaction(true /* fake putting */); transaction.beginTransaction(context); // Reset all values. Resources res = context.getResources(); for (Option option : getMap().values()) { Object value = option.getDefault(res); transaction.put(option, value, null /* notify everyone */); } // Clean the storage. transaction.clear(); // not faked transaction.endTransaction(); } } public final void put(@NonNull Context context, @NonNull String key, @NonNull Object obj) { put(context, key, obj, null); } public final void put(@NonNull Context context, @NonNull String key, @NonNull Object obj, @Nullable OnConfigChangedListener listenerIgnored) { synchronized (this) { put(context, new Change(key, obj, listenerIgnored)); } } public final void put(@NonNull Context context, @NonNull Change change) { synchronized (this) { put(context, new Change[]{change}); } } public final void put(@NonNull Context context, @NonNull Change[] changes) { synchronized (this) { Transaction transaction = new Transaction(); transaction.beginTransaction(context); for (Change change : changes) transaction.put(change); transaction.endTransaction(); } } //-- BEGIN ---------------------------------------------------------------- @NonNull private final ArrayList<Reference<OnConfigChangedListener>> mListenersRefs = new ArrayList<>(6); private final ArrayList<HandlerHolder> mListenersHolders = new ArrayList<>(); private Map<String, Option> mMap; /** * {@inheritDoc} */ @Override public void onLowMemory() { /* unused */ } /** * Gets an instance of the shared preferences. */ @NonNull protected SharedPreferences getSharedPreferences(@NonNull Context context) { return context.getSharedPreferences(getPreferencesFileName(), Context.MODE_PRIVATE); } protected abstract void onConfigChanged(@NonNull Transaction transaction, @NonNull Option option); /** * Here you should create and add all options. */ protected abstract void onCreateMap(@NonNull Map<String, Option> map); protected final void putOption(@NonNull Map<String, Option> map, @NonNull Option option) { map.put(option.key, option); } /** * @return the {@link java.util.HashMap HashMap} with option's keys as the keys, and * its {@link Option data} as the values. * @see #onCreateMap(Map) */ @NonNull public final Map<String, Option> getMap() { if (mMap == null) { mMap = new ConcurrentHashMap<>(); onCreateMap(mMap); } return mMap; } /** * @param key The unique key of the option. * @throws IllegalArgumentException 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) { throw new IllegalArgumentException("No existent option for " + key + " not found!"); } return option; } public final boolean contains(@NonNull String key) { return getMap().containsKey(key); } //-- READ ----------------------------------------------------------------- public boolean getBoolean(@NonNull String key) { return (boolean) getObject(key); } public float getFloat(@NonNull String key) { return (float) getObject(key); } public int getInt(@NonNull String key) { return (int) getObject(key); } public long getLong(@NonNull String key) { return (long) getObject(key); } @NonNull public String getString(@NonNull String key) { return (String) getObject(key); } @NonNull public Object getObject(@NonNull String key) { return getOption(key).getValue(); } //-- LISTENER ------------------------------------------------------------- /** * Adds new {@link WeakReference weak} listener to the config. Make sure you call * {@link #unregisterListener(OnConfigChangedListener)} later! * * @param listener a listener to register to config changes. * @see #unregisterListener(OnConfigChangedListener) */ public final void registerListener(@NonNull OnConfigChangedListener listener) { synchronized (mListenersRefs) { // Make sure to register listener only once. for (Reference<OnConfigChangedListener> ref : mListenersRefs) { if (ref.get() == listener) { Log.w(TAG, "Tried to register already registered listener!"); return; } } addListenerRef(new WeakReference<>(listener), listener); } } /** * Un-registers listener is there's one. * * @param listener a listener to unregister from config changes. * @see #registerListener(OnConfigChangedListener) */ public final void unregisterListener(@NonNull OnConfigChangedListener listener) { synchronized (mListenersRefs) { for (Reference<OnConfigChangedListener> ref : mListenersRefs) { if (ref.get() == listener) { removeListenerRef(ref); return; } } Log.w(TAG, "Tried to unregister non-existent listener!"); } } private void addListenerRef(@NonNull Reference<OnConfigChangedListener> ref, @NonNull OnConfigChangedListener listener) { mListenersRefs.add(ref); if (!(listener instanceof UiThreadedConfigChangedListener)) return; UiThreadedConfigChangedListener uil = (UiThreadedConfigChangedListener) listener; // Yes, it looks weird that I check for a Looper here, but // if you check the HandlerHolder class you'll see why it // will work normally. //noinspection SuspiciousMethodCalls int index = mListenersHolders.indexOf(uil.mLooper); if (index != -1) { if (DEBUG) Log.d(TAG, "Adding a new ref=" + ref + " to looper=" + uil.mLooper); mListenersHolders.get(index).list.add(ref); } else { if (DEBUG) Log.d(TAG, "Creating a new ref=" + ref + " of looper=" + uil.mLooper); HandlerHolder hh = new HandlerHolder(uil.mLooper); hh.list.add(ref); mListenersHolders.add(hh); } } private void removeListenerRef(@NonNull Reference<OnConfigChangedListener> ref) { mListenersRefs.remove(ref); final OnConfigChangedListener listener = ref.get(); if (listener != null && !(listener instanceof UiThreadedConfigChangedListener)) return; final int length = mListenersHolders.size(); for (int i = 0; i < length; i++) { final HandlerHolder hh = mListenersHolders.get(i); if (hh.list.contains(ref)) { hh.list.remove(ref); if (DEBUG) Log.d(TAG, "Removed ref=" + ref + " from looper=" + hh); if (hh.list.isEmpty()) { if (DEBUG) Log.d(TAG, "Removed looper=" + hh); mListenersHolders.remove(i); } break; } } } /** * Interface definition for a callback to be invoked * when an option is changed. * * @author Artem Chepurnoy */ public interface OnConfigChangedListener { /** * Callback that an option has changed. */ void onConfigChanged(@NonNull Transaction transaction, @NonNull Option option); } /** * @author Artem Chepurnoy */ public static abstract class UiThreadedConfigChangedListener implements OnConfigChangedListener { @NonNull private final Looper mLooper; public UiThreadedConfigChangedListener(@NonNull Looper looper) { if (looper == null) looper = Looper.getMainLooper(); mLooper = looper; } /** * {@inheritDoc} */ // This may be called on a wrong looper. @Override public void onConfigChanged(@NonNull final Transaction transaction, @NonNull final Option option) { /* empty */ } public abstract boolean onKeyCheck(@NonNull String key); /** * Same as {@link #onConfigChanged(Transaction, Option)} but may be called only on * a set {@link Looper looper} when the option fits {@link #onKeyCheck(String)}. */ public abstract void onUiThreadedConfigChanged( @NonNull Transaction transaction, @NonNull Option option); } /** * A simple {@link Handler}. class that checks equality by only its * internal {@link Looper looper}. Note that {@link #equals(Object)} * methods works if you pass a {@link Looper looper} to it. * * @author Artem Chepurnoy */ private static class HandlerHolder extends Handler { @NonNull final List<Reference<OnConfigChangedListener>> list; public HandlerHolder(@NonNull Looper looper) { super(looper); list = new ArrayList<>(); } @Override public int hashCode() { return getLooper().hashCode(); } @Override public boolean equals(Object o) { if (o == null) return false; if (o == this) return true; final Looper looper; if (o instanceof HandlerHolder) { HandlerHolder hh = (HandlerHolder) o; looper = hh.getLooper(); } else if (o instanceof Looper) { looper = (Looper) o; } else return false; return getLooper().equals(looper); } } //-- OTHER ---------------------------------------------------------------- /** * @author Artem Chepurnoy */ public final static class Change { @NonNull public final String key; @NonNull public final Object value; @Nullable public final OnConfigChangedListener listenerIgnored; public Change(@NonNull String key, @NonNull Object value, @Nullable OnConfigChangedListener listenerIgnored) { this.key = key; this.value = value; this.listenerIgnored = listenerIgnored; } } //-- TRANSACTION ---------------------------------------------------------- /** * @author Artem Chepurnoy */ public final class Transaction { private Context mContext; private SharedPreferences.Editor mEditor; private final boolean mFake; public Transaction() { this(false); } public Transaction(boolean fake) { mFake = fake; } @SuppressLint("CommitPrefEdits") @NonNull public Transaction beginTransaction(@NonNull Context context) { mContext = context.getApplicationContext(); mEditor = getSharedPreferences(mContext).edit(); return this; } @NonNull public Transaction put(@NonNull Change change) { return put(getOption(change.key), change.value, change.listenerIgnored); } @NonNull public Transaction put(final @NonNull Option option, @NonNull Object value, final @Nullable OnConfigChangedListener listenerIgnored) { if (option.getValue().equals(value)) return this; // No need to put an equal value // Set the value Log.d(TAG, "Saving `" + option.key + "`=`" + value + "`"); option.setValue(value); onConfigChanged(this, option); // Notify listeners synchronized (mListenersRefs) { for (int i = mListenersRefs.size() - 1; i >= 0; i--) { Reference<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..!"); removeListenerRef(ref); } else if (l != listenerIgnored) { l.onConfigChanged(this, option); } } final int li = mListenersHolders.size(); for (int i = 0; i < li; i++) { final HandlerHolder hh = mListenersHolders.get(i); final List<Reference<OnConfigChangedListener>> list = hh.list; // Check if we really need to notify about this event // somebody there. boolean post = false; for (Reference<OnConfigChangedListener> ref : list) { OnConfigChangedListener l = ref.get(); if (l != null) { UiThreadedConfigChangedListener uil = (UiThreadedConfigChangedListener) l; if (uil.onKeyCheck(option.key)) { post = true; break; // No need to check more. } } } if (!post) continue; hh.post(new Runnable() { @Override public void run() { synchronized (mListenersRefs) { // You may have unregistered it. for (Reference<OnConfigChangedListener> ref : list) { OnConfigChangedListener l = ref.get(); if (l == null || l == listenerIgnored) continue; UiThreadedConfigChangedListener uil = (UiThreadedConfigChangedListener) l; if (!uil.onKeyCheck(option.key)) continue; uil.onUiThreadedConfigChanged(Transaction.this, option); Log.d(TAG, "Notifying from looper=" + uil.mLooper + " l=" + Looper.myLooper()); } } } }); } } if (mFake) return this; if (value instanceof Boolean) { mEditor.putBoolean(option.key, (Boolean) value); } else if (value instanceof Integer) { mEditor.putInt(option.key, (Integer) value); } else if (value instanceof Float) { mEditor.putFloat(option.key, (Float) value); } else if (value instanceof String) { mEditor.putString(option.key, (String) value); } else if (value instanceof Long) { mEditor.putLong(option.key, (Long) value); } else throw new IllegalArgumentException("Unknown value\'s type."); return this; } @NonNull public Transaction clear() { mEditor.clear(); return this; } @NonNull public Transaction endTransaction() { mEditor.apply(); return this; } // //////////////////// // Additional stuff // //////////////////// @NonNull public Context getContext() { return mContext; } } //-- OPTION --------------------------------------------------------------- /** * One single option that may be synced with preference. * * @author Artem Chepurnoy */ public static class Option { @NonNull public final Class clazz; @NonNull public final String key; // Defaults public final Object defaultValue; public final int defaultRes; // Sdk public final int minSdkVersion; public final int maxSdkVersion; private volatile Object mValue; public Option(@NonNull Class clazz, @NonNull String key, Object defaultValue, int defaultRes, int minSdkVersion, int maxSdkVersion) { this.clazz = clazz; this.key = key; this.defaultValue = defaultValue; this.defaultRes = defaultRes; this.minSdkVersion = minSdkVersion; this.maxSdkVersion = maxSdkVersion; } /** * {@inheritDoc} */ @Override public int hashCode() { return new HashCodeBuilder(11, 31) .append(clazz) .append(key) .append(defaultValue) .append(defaultRes) .append(minSdkVersion) .append(maxSdkVersion) .append(mValue) .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(clazz, option.clazz) .append(key, option.key) .append(defaultValue, option.defaultValue) .append(defaultRes, option.defaultRes) .append(minSdkVersion, option.minSdkVersion) .append(maxSdkVersion, option.maxSdkVersion) .append(mValue, option.mValue) .isEquals(); } public void setValue(@NonNull Object value) { mValue = value; } @NonNull public Object getValue() { return mValue; } // //////////////////// // Additional stuff // //////////////////// /** * Extracts and returns the default option's value. */ @NonNull public final Object getDefault(@NonNull Resources resources) { // check if defaultValue already set if (defaultValue == null) { if (defaultRes != -1) { if (boolean.class.isAssignableFrom(clazz)) { return resources.getBoolean(defaultRes); } else if (int.class.isAssignableFrom(clazz)) { return resources.getInteger(defaultRes); } else if (float.class.isAssignableFrom(clazz)) { // Assuming it's a dimension, but not a fraction. return resources.getDimension(defaultRes); } else if (String.class.isAssignableFrom(clazz)) { return resources.getString(defaultRes); } else throw new IllegalArgumentException("Unknown option\'s type."); } else throw new IllegalStateException(); } return defaultValue; } /** * @author Artem Chepurnoy */ public static class Builder { private final Class mClass; private final String mKey; // Defaults private Object mDefaultValue; private int mDefaultRes = -1; // Sdk private int mMinSdkVersion = Integer.MIN_VALUE + 1; private int mMaxSdkVersion = Integer.MAX_VALUE - 1; public Builder(@NonNull Class clazz, @NonNull String key) { mClass = clazz; mKey = key; } @NonNull public Builder setDefault(@NonNull Object value) { mDefaultValue = value; return this; } @NonNull public Builder 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 Builder setMinSdkVersion(int version) { mMinSdkVersion = version; return this; } /** * Sets maximum {@link android.os.Build.VERSION#SDK_INT sdk version} of this * option. This option shouldn't be shown on newer systems. * * @see #setMinSdkVersion(int) */ @NonNull public Builder setMaxSdkVersion(int version) { mMaxSdkVersion = version; return this; } /** * Bakes this builder into an option. */ @NonNull public Option build() { return new Option(mClass, mKey, mDefaultValue, mDefaultRes, mMinSdkVersion, mMaxSdkVersion); } } } //-- SYNCER --------------------------------------------------------------- /** * A class for syncing an {@link CfgBase.Option} with * corresponding {@link Preference}. * * @author Artem Chepurnoy */ public final static class Syncer { private final CfgBase mCfg; private final ArrayList<Item> mItems; private final Context mContext; private boolean mBroadcastingPref; private boolean mStarted; @NonNull private final Handler mHandler = new Handler(Looper.getMainLooper()); @NonNull private final Preference.OnPreferenceChangeListener mPreferenceListener = new Preference.OnPreferenceChangeListener() { @Override public boolean onPreferenceChange(Preference preference, Object newValue) { if (mBroadcastingPref) { return true; } Item item = null; for (Item c : mItems) { if (preference == c.preference) { item = c; break; } } assert item != null; newValue = item.setter.getValue(newValue); Change change = new Change(item.option.key, newValue, mConfigListener); item.cfg.put(mContext, change); // Update preference's summary item.setter.updateSummary(item.preference, item.option, newValue); return true; } }; @NonNull private final OnConfigChangedListener mConfigListener = new OnConfigChangedListener() { @Override public void onConfigChanged(@NonNull Transaction transaction, @NonNull Option option) { Item item = null; for (Item c : mItems) { if (option.key.equals(c.preference.getKey())) { item = c; break; } } if (item == null) { return; } setPreferenceValue(item, option.getValue()); } }; public Syncer(@NonNull Context context, @NonNull CfgBase cfg) { mCfg = cfg; mItems = new ArrayList<>(10); mContext = context; } private void setPreferenceValue(final @NonNull Item item, final @NonNull Object value) { mHandler.post(new Runnable() { @Override public void run() { mBroadcastingPref = true; item.setter.setValue(item.preference, item.option, value); item.setter.updateSummary(item.preference, item.option, value); mBroadcastingPref = false; } }); } public void sync( @Nullable PreferenceScreen ps, @NonNull Preference preference, @NonNull Setter setter) { Item item = new Item(preference, setter, mCfg); 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 #sync(PreferenceScreen, Preference, Setter) */ public void start() { mStarted = true; if (mItems.size() > 0) { mItems.get(0).cfg.registerListener(mConfigListener); for (Item item : mItems) startListeningToItem(item); } } private void startListeningToItem(@NonNull Item item) { item.preference.setOnPreferenceChangeListener(mPreferenceListener); setPreferenceValue(item, item.option.getValue()); } /** * Stops to listen to the changes. * * @see #start() */ public void stop() { mStarted = false; mCfg.unregisterListener(mConfigListener); for (Item item : mItems) item.preference.setOnPreferenceChangeListener(null); } // //////////////////// // Additional stuff // //////////////////// 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 Preference} * and its {@link Option} and its {@link Syncer.Setter}. * * @author Artem Chepurnoy */ private final static class Item { @NonNull final CfgBase cfg; @NonNull final Preference preference; @NonNull final Setter setter; @NonNull final Option option; public Item(@NonNull Preference preference, @NonNull Setter setter, @NonNull CfgBase cfg) { this.preference = preference; this.setter = setter; this.cfg = cfg; this.option = cfg.getOption(preference.getKey()); } } } }