Commit cc2a64eef93c59d3230cbd1ba67a82d47de3eabe

Authored by Mumfrey
1 parent 3fb6dc86

Add CloudConfig convenience class for mods to interact with webprefs

src/client/java/com/mumfrey/liteloader/modconfig/CloudConfig.java 0 → 100644
  1 +/*
  2 + * This file is part of LiteLoader.
  3 + * Copyright (C) 2012-16 Adam Mummery-Smith
  4 + * All Rights Reserved.
  5 + */
  6 +package com.mumfrey.liteloader.modconfig;
  7 +
  8 +import java.io.File;
  9 +import java.lang.reflect.Field;
  10 +import java.lang.reflect.Modifier;
  11 +import java.util.ArrayList;
  12 +import java.util.Iterator;
  13 +import java.util.List;
  14 +import java.util.UUID;
  15 +import java.util.regex.Pattern;
  16 +
  17 +import com.mojang.authlib.GameProfile;
  18 +import com.mumfrey.liteloader.InitCompleteListener;
  19 +import com.mumfrey.liteloader.core.LiteLoader;
  20 +import com.mumfrey.liteloader.util.log.LiteLoaderLogger;
  21 +import com.mumfrey.webprefs.WebPreferencesManager;
  22 +import com.mumfrey.webprefs.interfaces.IWebPreferences;
  23 +
  24 +import net.minecraft.client.Minecraft;
  25 +import net.minecraft.entity.player.EntityPlayer;
  26 +
  27 +/**
  28 + * Convenience class for mods to work with cloud-based config. This class
  29 + * encapsulates a typical lifecycle for working with the webprefs framework.
  30 + *
  31 + * <p>To use this class, simply create a subclass with fields you wish to
  32 + * synchronise. Fields can be {@link String}, <tt>int</tt>, <tt>float</tt> or
  33 + * <tt>boolean</tt>, <tt>transient</tt> and <tt>static</tt> fields are ignored.
  34 + * </p>
  35 + *
  36 + * <p>Since fields are accessed via reflection, an update interval is defined in
  37 + * order to reduce the overhead thus incurred. The default interval is 5 seconds
  38 + * which provides a good balance between reliable replication and reduction of
  39 + * overhead. Calling {@link #poll} temporarily reduces the update interval to 1
  40 + * second until a response is received from the server. The interval is restored
  41 + * to the specified update interval once a value is received, or after a minute
  42 + * elapses with no new data from the server.</p>
  43 + */
  44 +public abstract class CloudConfig
  45 +{
  46 + /**
  47 + * Minecraft TPS
  48 + */
  49 + public static final int TICKS_PER_SECOND = 20;
  50 +
  51 + /**
  52 + * 5 seconds
  53 + */
  54 + public static final int DEFAULT_UPDATE_INTERVAL = 5 * CloudConfig.TICKS_PER_SECOND;
  55 +
  56 + /**
  57 + * Reduced update interval to use following a call to {@link #poll}
  58 + */
  59 + private static final int POLL_UPDATE_INTERVAL = 1 * CloudConfig.TICKS_PER_SECOND;
  60 +
  61 + /**
  62 + * Amount of time to wait following a call to {@link #poll} before we revert
  63 + * to normal polling interval
  64 + */
  65 + private static final int POLL_RESET_INTERVAL = 60 * CloudConfig.TICKS_PER_SECOND;
  66 +
  67 + /**
  68 + * Same as normal key pattern except that $ gets translated to . for key
  69 + */
  70 + private static final Pattern keyPattern = Pattern.compile("^[a-z0-9_\\-\\$]{1,32}$");
  71 +
  72 + /**
  73 + * A field value tracker
  74 + */
  75 + abstract class TrackedField
  76 + {
  77 + protected final String name;
  78 +
  79 + private final Field handle;
  80 +
  81 + private boolean dirty;
  82 +
  83 + protected boolean initialUpdate = true;
  84 +
  85 + TrackedField(Field handle)
  86 + {
  87 + this.handle = handle;
  88 + this.name = handle.getName().replace("$", ".");
  89 + }
  90 +
  91 + @SuppressWarnings("unchecked")
  92 + protected <T> T getValue() throws IllegalAccessException
  93 + {
  94 + return (T)this.handle.get(CloudConfig.this);
  95 + }
  96 +
  97 + protected <T> void setValue(T value) throws IllegalAccessException
  98 + {
  99 + this.handle.set(CloudConfig.this, value);
  100 + this.dirty = true;
  101 + }
  102 +
  103 + @Override
  104 + public final String toString()
  105 + {
  106 + return this.name;
  107 + }
  108 +
  109 + final boolean isDirty()
  110 + {
  111 + boolean dirty = this.dirty;
  112 + this.dirty = false;
  113 + return dirty;
  114 + }
  115 +
  116 + abstract void sync() throws IllegalAccessException;
  117 + }
  118 +
  119 + /**
  120 + * String field tracker
  121 + */
  122 + class TrackedStringField extends TrackedField
  123 + {
  124 + private String localValue;
  125 +
  126 + TrackedStringField(Field handle) throws IllegalAccessException
  127 + {
  128 + super(handle);
  129 + this.localValue = this.<String>getValue();
  130 + }
  131 +
  132 + @Override
  133 + void sync() throws IllegalAccessException
  134 + {
  135 + String value = this.<String>getValue();
  136 + if (this.initialUpdate)
  137 + {
  138 + this.initialUpdate = false;
  139 + }
  140 + else if (value != this.localValue && value != null)
  141 + {
  142 + CloudConfig.this.preferences.set(this.name, value);
  143 + }
  144 + else if (CloudConfig.this.preferences.has(this.name))
  145 + {
  146 + String remoteValue = CloudConfig.this.preferences.get(this.name);
  147 + if (value != remoteValue)
  148 + {
  149 + value = remoteValue;
  150 + this.setValue(value);
  151 + }
  152 + }
  153 +
  154 + this.localValue = value;
  155 + }
  156 + }
  157 +
  158 + /**
  159 + * Integer field tracker
  160 + */
  161 + class TrackedIntegerField extends TrackedField
  162 + {
  163 + private int localValue;
  164 +
  165 + TrackedIntegerField(Field handle)
  166 + {
  167 + super(handle);
  168 + }
  169 +
  170 + @Override
  171 + void sync() throws IllegalAccessException
  172 + {
  173 + int value = this.<Integer>getValue().intValue();
  174 + if (this.initialUpdate)
  175 + {
  176 + this.initialUpdate = false;
  177 + }
  178 + else if (value != this.localValue)
  179 + {
  180 + CloudConfig.this.preferences.set(this.name, String.valueOf(value));
  181 + }
  182 + else if (CloudConfig.this.preferences.has(this.name))
  183 + {
  184 + int remoteValue = this.tryParse(CloudConfig.this.preferences.get(this.name), value);
  185 + if (value != remoteValue)
  186 + {
  187 + value = remoteValue;
  188 + this.setValue(value);
  189 + }
  190 + }
  191 +
  192 + this.localValue = value;
  193 + }
  194 +
  195 + private int tryParse(String string, int defaultValue)
  196 + {
  197 + try
  198 + {
  199 + return Integer.parseInt(string);
  200 + }
  201 + catch (NumberFormatException ex)
  202 + {
  203 + return defaultValue;
  204 + }
  205 + }
  206 + }
  207 +
  208 + /**
  209 + * Float field tracker
  210 + */
  211 + class TrackedFloatField extends TrackedField
  212 + {
  213 + private float localValue;
  214 +
  215 + TrackedFloatField(Field handle)
  216 + {
  217 + super(handle);
  218 + }
  219 +
  220 + @Override
  221 + void sync() throws IllegalAccessException
  222 + {
  223 + float value = this.<Float>getValue().floatValue();
  224 + if (this.initialUpdate)
  225 + {
  226 + this.initialUpdate = false;
  227 + }
  228 + else if (value != this.localValue)
  229 + {
  230 + CloudConfig.this.preferences.set(this.name, String.valueOf(value));
  231 + }
  232 + else if (CloudConfig.this.preferences.has(this.name))
  233 + {
  234 + float remoteValue = this.tryParse(CloudConfig.this.preferences.get(this.name), value);
  235 + if (value != remoteValue)
  236 + {
  237 + value = remoteValue;
  238 + this.setValue(value);
  239 + }
  240 + }
  241 +
  242 + this.localValue = value;
  243 + }
  244 +
  245 + private float tryParse(String string, float defaultValue)
  246 + {
  247 + try
  248 + {
  249 + return Float.parseFloat(string);
  250 + }
  251 + catch (NumberFormatException ex)
  252 + {
  253 + return defaultValue;
  254 + }
  255 + }
  256 + }
  257 +
  258 + /**
  259 + * Bool field tracker
  260 + */
  261 + class TrackedBooleanField extends TrackedField
  262 + {
  263 + private boolean localValue;
  264 +
  265 + TrackedBooleanField(Field handle) throws IllegalAccessException
  266 + {
  267 + super(handle);
  268 + this.localValue = this.<Boolean>getValue().booleanValue();
  269 + }
  270 +
  271 + @Override
  272 + void sync() throws IllegalAccessException
  273 + {
  274 + boolean value = this.<Boolean>getValue().booleanValue();
  275 + if (this.initialUpdate)
  276 + {
  277 + this.initialUpdate = false;
  278 + }
  279 + else if (value != this.localValue)
  280 + {
  281 + CloudConfig.this.preferences.set(this.name, String.valueOf(value));
  282 + }
  283 + else if (CloudConfig.this.preferences.has(this.name))
  284 + {
  285 + boolean remoteValue = "true".equals(CloudConfig.this.preferences.get(this.name));
  286 + if (value != remoteValue)
  287 + {
  288 + value = remoteValue;
  289 + this.setValue(value);
  290 + }
  291 + }
  292 +
  293 + this.localValue = value;
  294 + }
  295 + }
  296 +
  297 + static final class UpdateTicker implements InitCompleteListener
  298 + {
  299 + private static UpdateTicker instance;
  300 +
  301 + private final List<CloudConfig> prefs = new ArrayList<CloudConfig>();
  302 +
  303 + static UpdateTicker getInstance()
  304 + {
  305 + if (UpdateTicker.instance == null)
  306 + {
  307 + UpdateTicker.instance = new UpdateTicker();
  308 + LiteLoader.getInterfaceManager().registerListener(UpdateTicker.instance);
  309 + }
  310 +
  311 + return UpdateTicker.instance;
  312 + }
  313 +
  314 + private UpdateTicker()
  315 + {
  316 + }
  317 +
  318 + void register(CloudConfig pref)
  319 + {
  320 + this.prefs.add(pref);
  321 + }
  322 +
  323 + @Override
  324 + public void onTick(Minecraft minecraft, float partialTicks, boolean inGame, boolean clock)
  325 + {
  326 + for (CloudConfig pref : this.prefs)
  327 + {
  328 + if (clock)
  329 + {
  330 + pref.onTick();
  331 + }
  332 + }
  333 + }
  334 +
  335 + @Override
  336 + public void onInitCompleted(Minecraft minecraft, LiteLoader loader)
  337 + {
  338 + for (CloudConfig pref : this.prefs)
  339 + {
  340 + pref.poll();
  341 + }
  342 + }
  343 +
  344 + @Override
  345 + public final String getVersion()
  346 + {
  347 + return "N/A";
  348 + }
  349 +
  350 + @Override
  351 + public final void init(File configPath)
  352 + {
  353 + }
  354 +
  355 + @Override
  356 + public final void upgradeSettings(String version, File configPath, File oldConfigPath)
  357 + {
  358 + }
  359 +
  360 + @Override
  361 + public final String getName()
  362 + {
  363 + return "UpdateTicker";
  364 + }
  365 + }
  366 +
  367 + /**
  368 + * Web preferences manager (service in use)
  369 + */
  370 + protected final WebPreferencesManager manager;
  371 +
  372 + /**
  373 + * Preferences instance
  374 + */
  375 + protected final IWebPreferences preferences;
  376 +
  377 + /**
  378 + * Field trackers
  379 + */
  380 + private final List<TrackedField> fields = new ArrayList<TrackedField>();
  381 +
  382 + /**
  383 + * Update interval specified
  384 + */
  385 + private final int desiredUpdateInterval;
  386 +
  387 + /**
  388 + * Current update interval, reduced following a call to {@link poll} in
  389 + * order to apply updates as soon as they arrive.
  390 + */
  391 + private int updateInterval;
  392 +
  393 + /**
  394 + * Ticks since thing
  395 + */
  396 + private int updateCounter, pendingResetCounter;
  397 +
  398 + protected CloudConfig(boolean privatePrefs)
  399 + {
  400 + this(WebPreferencesManager.getDefault(), privatePrefs, CloudConfig.DEFAULT_UPDATE_INTERVAL);
  401 + }
  402 +
  403 + protected CloudConfig(WebPreferencesManager manager, boolean privatePrefs)
  404 + {
  405 + this(manager, privatePrefs, CloudConfig.DEFAULT_UPDATE_INTERVAL);
  406 + }
  407 +
  408 + protected CloudConfig(WebPreferencesManager manager, boolean privatePrefs, int updateInterval)
  409 + {
  410 + this(manager, manager.getLocalPreferences(privatePrefs), updateInterval);
  411 + }
  412 +
  413 + protected CloudConfig(WebPreferencesManager manager, EntityPlayer player, boolean privatePrefs)
  414 + {
  415 + this(manager, player, privatePrefs, CloudConfig.DEFAULT_UPDATE_INTERVAL);
  416 + }
  417 +
  418 + protected CloudConfig(WebPreferencesManager manager, EntityPlayer player, boolean privatePrefs, int updateInterval)
  419 + {
  420 + this(manager, manager.getPreferences(player, privatePrefs), updateInterval);
  421 + }
  422 +
  423 + protected CloudConfig(WebPreferencesManager manager, GameProfile profile, boolean privatePrefs)
  424 + {
  425 + this(manager, profile, privatePrefs, CloudConfig.DEFAULT_UPDATE_INTERVAL);
  426 + }
  427 +
  428 + protected CloudConfig(WebPreferencesManager manager, GameProfile profile, boolean privatePrefs, int updateInterval)
  429 + {
  430 + this(manager, manager.getPreferences(profile, privatePrefs), updateInterval);
  431 + }
  432 +
  433 + protected CloudConfig(WebPreferencesManager manager, UUID uuid, boolean privatePrefs)
  434 + {
  435 + this(manager, uuid, privatePrefs, CloudConfig.DEFAULT_UPDATE_INTERVAL);
  436 + }
  437 +
  438 + protected CloudConfig(WebPreferencesManager manager, UUID uuid, boolean privatePrefs, int updateInterval)
  439 + {
  440 + this(manager, manager.getPreferences(uuid, privatePrefs), updateInterval);
  441 + }
  442 +
  443 + private CloudConfig(WebPreferencesManager manager, IWebPreferences preferences, int updateInterval)
  444 + {
  445 + this.manager = manager;
  446 + this.preferences = preferences;
  447 + this.updateInterval = this.desiredUpdateInterval = updateInterval;
  448 + this.initFields();
  449 + }
  450 +
  451 + /**
  452 + * Force an update request to be sent to the server
  453 + */
  454 + public final void poll()
  455 + {
  456 + this.preferences.poll();
  457 + this.updateInterval = Math.min(this.desiredUpdateInterval, CloudConfig.POLL_UPDATE_INTERVAL);
  458 + this.pendingResetCounter = CloudConfig.POLL_RESET_INTERVAL;
  459 + }
  460 +
  461 + /**
  462 + * Force local changes to be pushed to the server
  463 + */
  464 + public final void commit()
  465 + {
  466 + this.preferences.commit(false);
  467 + }
  468 +
  469 + private void initFields()
  470 + {
  471 + for (Field field : this.getClass().getDeclaredFields())
  472 + {
  473 + int modifiers = field.getModifiers();
  474 + if (Modifier.isTransient(modifiers) || Modifier.isStatic(modifiers))
  475 + {
  476 + LiteLoaderLogger.debug("Skipping transient field %s in %s", field.getName(), this.getClass().getName());
  477 + }
  478 +
  479 + if (!CloudConfig.keyPattern.matcher(field.getName()).matches())
  480 + {
  481 + LiteLoaderLogger.warning("Skipping field with invalid name %s in %s", field.getName(), this.getClass().getName());
  482 + }
  483 +
  484 + Class<?> type = field.getType();
  485 + try
  486 + {
  487 + field.setAccessible(true);
  488 + if (type == String.class)
  489 + {
  490 + this.fields.add(new TrackedStringField(field));
  491 + continue;
  492 + }
  493 + if (type == Integer.TYPE)
  494 + {
  495 + this.fields.add(new TrackedIntegerField(field));
  496 + continue;
  497 + }
  498 + if (type == Float.TYPE)
  499 + {
  500 + this.fields.add(new TrackedFloatField(field));
  501 + continue;
  502 + }
  503 + if (type == Boolean.TYPE)
  504 + {
  505 + this.fields.add(new TrackedBooleanField(field));
  506 + continue;
  507 + }
  508 + }
  509 + catch (IllegalAccessException ex)
  510 + {
  511 + LiteLoaderLogger.warning("Skipping inaccessible field %s in %s", field.getName(), this.getClass().getName());
  512 + ex.printStackTrace();
  513 + }
  514 +
  515 + LiteLoaderLogger.warning("Skipping field %s with unsupported type %s in %s", field.getName(), type, this.getClass().getName());
  516 + }
  517 +
  518 + if (this.fields.size() > 0)
  519 + {
  520 + UpdateTicker.getInstance().register(this);
  521 + }
  522 + }
  523 +
  524 + final void onTick()
  525 + {
  526 + // Reset to desired interval if we don't receive an update following poll
  527 + if (this.pendingResetCounter > 0)
  528 + {
  529 + this.pendingResetCounter--;
  530 + if (this.pendingResetCounter == 0)
  531 + {
  532 + this.updateInterval = this.desiredUpdateInterval;
  533 + }
  534 + }
  535 +
  536 + if (++this.updateCounter > this.updateInterval)
  537 + {
  538 + this.updateCounter = 0;
  539 +
  540 + boolean dirty = false;
  541 + for (Iterator<TrackedField> iter = this.fields.iterator(); iter.hasNext();)
  542 + {
  543 + TrackedField field = iter.next();
  544 + try
  545 + {
  546 + field.sync();
  547 + dirty |= field.isDirty();
  548 + }
  549 + catch (IllegalAccessException ex)
  550 + {
  551 + LiteLoaderLogger.warning("Removing invalid field %s in %s", field, this.getClass().getName());
  552 + ex.printStackTrace();
  553 + iter.remove();
  554 + }
  555 + }
  556 +
  557 + if (dirty)
  558 + {
  559 + this.updateInterval = this.desiredUpdateInterval;
  560 + this.pendingResetCounter = 0;
  561 + this.onUpdated();
  562 + }
  563 + }
  564 + }
  565 +
  566 + /**
  567 + * Stub for subclasses, called when any value change is received from the
  568 + * server
  569 + */
  570 + protected void onUpdated()
  571 + {
  572 + }
  573 +}