/*
* 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.");
}
}
}
}