From 085f7eb1219f270faa8317a5c6069a562213cb83 Mon Sep 17 00:00:00 2001 From: Makoto Onuki Date: Fri, 1 Oct 2010 17:40:51 -0700 Subject: [PATCH] Extract the throttling part from ThrottlingCursorLoader Extracted into the Throttle class as I'll need this logic elsewhere. Bonus: Now it has tests. Change-Id: Ie9a5933f8e5015dda6985ba76814f1f945266178 --- src/com/android/email/Throttle.java | 179 +++++++++++++++ .../email/data/ThrottlingCursorLoader.java | 168 +++----------- tests/src/com/android/email/ThrottleTest.java | 207 ++++++++++++++++++ .../data/ThrottlingCursorLoaderTest.java | 57 ----- 4 files changed, 411 insertions(+), 200 deletions(-) create mode 100644 src/com/android/email/Throttle.java create mode 100644 tests/src/com/android/email/ThrottleTest.java delete mode 100644 tests/src/com/android/email/data/ThrottlingCursorLoaderTest.java diff --git a/src/com/android/email/Throttle.java b/src/com/android/email/Throttle.java new file mode 100644 index 000000000..5b0a1eca6 --- /dev/null +++ b/src/com/android/email/Throttle.java @@ -0,0 +1,179 @@ +/* + * Copyright (C) 2010 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 com.android.email; + +import android.os.Handler; +import android.util.Log; + +import java.security.InvalidParameterException; +import java.util.Timer; +import java.util.TimerTask; + +/** + * This class used to "throttle" a flow of events. + * + * When {@link #onEvent()} is called, it calls the callback in a certain timeout later. + * Initially {@link #mMinTimeout} is used as the timeout, but if it gets multiple {@link #onEvent} + * calls in a certain amount of time, it extends the timeout, until it reaches {@link #mMaxTimeout}. + * + * This class is primarily used to throttle content changed events. + */ +public class Throttle { + public static final boolean DEBUG = false; // Don't submit with true + + public static final int DEFAULT_MIN_TIMEOUT = 150; + public static final int DEFAULT_MAX_TIMEOUT = 2500; + /* package */ static final int TIMEOUT_EXTEND_INTERVAL = 500; + + private static Timer TIMER = new Timer(); + + private final Clock mClock; + private final Timer mTimer; + + /** Name of the instance. Only for logging. */ + private final String mName; + + /** Handler for UI thread. */ + private final Handler mHandler; + + /** Callback to be called */ + private final Runnable mCallback; + + /** Minimum (default) timeout, in milliseconds. */ + private final int mMinTimeout; + + /** Max timeout, in milliseconds. */ + private final int mMaxTimeout; + + /** Current timeout, in milliseconds. */ + private int mTimeout; + + /** When {@link #onEvent()} was last called. */ + private long mLastEventTime; + + private MyTimerTask mRunningTimerTask; + + /** Constructor with default timeout */ + public Throttle(String name, Runnable callback, Handler handler) { + this(name, callback, handler, DEFAULT_MIN_TIMEOUT, DEFAULT_MAX_TIMEOUT); + } + + /** Constructor that takes custom timeout */ + public Throttle(String name, Runnable callback, Handler handler,int minTimeout, + int maxTimeout) { + this(name, callback, handler, minTimeout, maxTimeout, Clock.INSTANCE, TIMER); + } + + /** Constructor for tests */ + /* package */ Throttle(String name, Runnable callback, Handler handler,int minTimeout, + int maxTimeout, Clock clock, Timer timer) { + if (maxTimeout < minTimeout) { + throw new InvalidParameterException(); + } + mName = name; + mCallback = callback; + mClock = clock; + mTimer = timer; + mHandler = handler; + mMinTimeout = minTimeout; + mMaxTimeout = maxTimeout; + mTimeout = mMinTimeout; + } + + private void debugLog(String message) { + Log.d(Email.LOG_TAG, "Throttle: [" + mName + "] " + message); + } + + private boolean isCallbackScheduled() { + return mRunningTimerTask != null; + } + + public void cancelScheduledCallback() { + if (mRunningTimerTask != null) { + if (DEBUG) debugLog("Canceling scheduled callback"); + mRunningTimerTask.cancel(); + mRunningTimerTask = null; + } + } + + /* package */ void updateTimeout() { + final long now = mClock.getTime(); + if ((now - mLastEventTime) <= TIMEOUT_EXTEND_INTERVAL) { + mTimeout *= 2; + if (mTimeout >= mMaxTimeout) { + mTimeout = mMaxTimeout; + } + if (DEBUG) debugLog("Timeout extended " + mTimeout); + } else { + mTimeout = mMinTimeout; + if (DEBUG) debugLog("Timeout reset to " + mTimeout); + } + + mLastEventTime = now; + } + + public void onEvent() { + if (DEBUG) debugLog("onEvent"); + + updateTimeout(); + + if (isCallbackScheduled()) { + if (DEBUG) debugLog(" callback already scheduled"); + } else { + if (DEBUG) debugLog(" scheduling callback"); + mRunningTimerTask = new MyTimerTask(); + mTimer.schedule(mRunningTimerTask, mTimeout); + } + } + + /** + * Timer task called on timeout, + */ + private class MyTimerTask extends TimerTask { + private boolean mCanceled; + + @Override + public void run() { + mHandler.post(new HandlerRunnable()); + } + + @Override + public boolean cancel() { + mCanceled = true; + return super.cancel(); + } + + private class HandlerRunnable implements Runnable { + @Override + public void run() { + mRunningTimerTask = null; + if (!mCanceled) { // This check has to be done on the UI thread. + if (DEBUG) debugLog("Kicking callback"); + mCallback.run(); + } + } + } + } + + /* package */ int getTimeoutForTest() { + return mTimeout; + } + + /* package */ long getLastEventTimeForTest() { + return mLastEventTime; + } +} diff --git a/src/com/android/email/data/ThrottlingCursorLoader.java b/src/com/android/email/data/ThrottlingCursorLoader.java index dfb652352..a54735a64 100644 --- a/src/com/android/email/data/ThrottlingCursorLoader.java +++ b/src/com/android/email/data/ThrottlingCursorLoader.java @@ -16,7 +16,8 @@ package com.android.email.data; -import com.android.email.Clock; +import com.android.email.Email; +import com.android.email.Throttle; import android.content.Context; import android.content.CursorLoader; @@ -24,183 +25,64 @@ import android.net.Uri; import android.os.Handler; import android.util.Log; -import java.security.InvalidParameterException; -import java.util.Timer; -import java.util.TimerTask; - /** - * A {@link CursorLoader} variant that throttle auto-requery on content changes. - * - * This class overrides {@link android.content.Loader#onContentChanged}, and instead of immediately - * requerying, it waits until the specified timeout before doing so. - * - * There are two timeout settings: {@link #mMinTimeout} and {@link #mMaxTimeout}. - * We normally use {@link #mMinTimeout}, but if we detect more than one change in - * the {@link #TIMEOUT_EXTEND_INTERVAL} period, we double it, until it reaches {@link #mMaxTimeout}. + * A {@link CursorLoader} variant that throttle auto-requery on content changes using + * {@link Throttle}. */ public class ThrottlingCursorLoader extends CursorLoader { - private static final boolean DEBUG = false; // Don't submit with true - - /* package */ static final int TIMEOUT_EXTEND_INTERVAL = 500; - - private static final int DEFAULT_MIN_TIMEOUT = 150; - private static final int DEFAULT_MAX_TIMEOUT = 2500; - - private static Timer sTimer = new Timer(); - - private final Clock mClock; - - /** Handler for the UI thread. */ - private final Handler mHandler = new Handler(); - - /** Minimum (default) timeout */ - private final int mMinTimeout; - - /** Max timeout */ - private final int mMaxTimeout; - - /** Content change auto-requery timeout, in milliseconds. */ - private int mTimeout; - - /** When onChanged() was last called. */ - private long mLastOnChangedTime; - - private ForceLoadTimerTask mRunningForceLoadTimerTask; + private final Throttle mThrottle; /** Constructor with default timeout */ public ThrottlingCursorLoader(Context context, Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) { - this(context, uri, projection, selection, selectionArgs, sortOrder, DEFAULT_MIN_TIMEOUT, - DEFAULT_MAX_TIMEOUT); + this(context, uri, projection, selection, selectionArgs, sortOrder, + Throttle.DEFAULT_MIN_TIMEOUT, Throttle.DEFAULT_MAX_TIMEOUT); } /** Constructor that takes custom timeout */ public ThrottlingCursorLoader(Context context, Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder, int minTimeout, int maxTimeout) { - this(context, uri, projection, selection, selectionArgs, sortOrder, minTimeout, maxTimeout, - Clock.INSTANCE); - } - - /** Constructor for tests. Clock is injectable. */ - /* package */ ThrottlingCursorLoader(Context context, Uri uri, String[] projection, - String selection, String[] selectionArgs, String sortOrder, - int minTimeout, int maxTimeout, Clock clock) { super(context, uri, projection, selection, selectionArgs, sortOrder); - mClock = clock; - if (maxTimeout < minTimeout) { - throw new InvalidParameterException(); - } - mMinTimeout = minTimeout; - mMaxTimeout = maxTimeout; - mTimeout = mMinTimeout; + + Runnable forceLoadRunnable = new Runnable() { + @Override + public void run() { + forceLoad(); + } + }; + mThrottle = new Throttle(uri.toString(), forceLoadRunnable, new Handler(), + minTimeout, maxTimeout); } private void debugLog(String message) { - Log.d("ThrottlingCursorLoader", "[" + getUri() + "] " + message); - } - - /** - * @return true if forceLoad() is scheduled. - */ - private boolean isForceLoadScheduled() { - return mRunningForceLoadTimerTask != null; - } - - /** - * Cancel the scheduled forceLoad(), if exists. - */ - private void cancelScheduledForceLoad() { - if (DEBUG) debugLog("cancelScheduledForceLoad"); - if (mRunningForceLoadTimerTask != null) { - mRunningForceLoadTimerTask.cancel(); - mRunningForceLoadTimerTask = null; - } + Log.d(Email.LOG_TAG, "ThrottlingCursorLoader: [" + getUri() + "] " + message); } @Override public void startLoading() { - if (DEBUG) debugLog("startLoading"); - cancelScheduledForceLoad(); + if (Throttle.DEBUG) debugLog("startLoading"); + mThrottle.cancelScheduledCallback(); super.startLoading(); } @Override public void forceLoad() { - if (DEBUG) debugLog("forceLoad"); - cancelScheduledForceLoad(); + if (Throttle.DEBUG) debugLog("forceLoad"); + mThrottle.cancelScheduledCallback(); super.forceLoad(); } @Override public void stopLoading() { - if (DEBUG) debugLog("stopLoading"); - cancelScheduledForceLoad(); + if (Throttle.DEBUG) debugLog("stopLoading"); + mThrottle.cancelScheduledCallback(); super.stopLoading(); } - /* package */ void updateTimeout() { - final long now = mClock.getTime(); - if ((now - mLastOnChangedTime) <= TIMEOUT_EXTEND_INTERVAL) { - if (DEBUG) debugLog("Extending timeout: " + mTimeout); - mTimeout *= 2; - if (mTimeout >= mMaxTimeout) { - mTimeout = mMaxTimeout; - } - } else { - if (DEBUG) debugLog("Resetting timeout."); - mTimeout = mMinTimeout; - } - - mLastOnChangedTime = now; - } - @Override public void onContentChanged() { - if (DEBUG) debugLog("onContentChanged"); + if (Throttle.DEBUG) debugLog("onContentChanged"); - updateTimeout(); - - if (isForceLoadScheduled()) { - if (DEBUG) debugLog(" forceLoad already scheduled."); - } else { - if (DEBUG) debugLog(" scheduling forceLoad."); - mRunningForceLoadTimerTask = new ForceLoadTimerTask(); - sTimer.schedule(mRunningForceLoadTimerTask, mTimeout); - } - } - - /** - * A {@link TimerTask} to call {@link #forceLoad} on the UI thread. - */ - private class ForceLoadTimerTask extends TimerTask { - private boolean mCanceled; - - @Override - public void run() { - mHandler.post(new ForceLoadRunnable()); - } - - @Override - public boolean cancel() { - mCanceled = true; - return super.cancel(); - } - - private class ForceLoadRunnable implements Runnable { - @Override - public void run() { - if (!mCanceled) { // This check has to be done on the UI thread. - forceLoad(); - } - } - } - } - - /* package */ int getTimeoutForTest() { - return mTimeout; - } - - /* package */ long getLastOnChangedTimeForTest() { - return mLastOnChangedTime; + mThrottle.onEvent(); } } diff --git a/tests/src/com/android/email/ThrottleTest.java b/tests/src/com/android/email/ThrottleTest.java new file mode 100644 index 000000000..6171fc38b --- /dev/null +++ b/tests/src/com/android/email/ThrottleTest.java @@ -0,0 +1,207 @@ +/* + * Copyright (C) 2010 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 com.android.email; + +import android.os.Handler; +import android.os.Message; +import android.test.AndroidTestCase; + +import java.util.Timer; +import java.util.TimerTask; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.LinkedBlockingQueue; + +public class ThrottleTest extends AndroidTestCase { + private static final int MIN_TIMEOUT = 100; + private static final int MAX_TIMEOUT = 500; + + private final CountingRunnable mRunnable = new CountingRunnable(); + private final MockClock mClock = new MockClock(); + private final MockTimer mTimer = new MockTimer(mClock); + private final Throttle mTarget = new Throttle("test", mRunnable, new CallItNowHandler(), + MIN_TIMEOUT, MAX_TIMEOUT, mClock, mTimer); + + /** + * Advance the clock. + */ + private void advanceClock(int milliseconds) { + mClock.advance(milliseconds); + mTimer.runExpiredTasks(); + } + + /** + * Gets two events. They're far apart enough that the timeout won't be extended. + */ + public void testSingleCalls() { + // T + 0 + mTarget.onEvent(); + advanceClock(0); + assertEquals(0, mRunnable.mCounter); + + // T + 99 + advanceClock(99); + assertEquals(0, mRunnable.mCounter); // Still not called + + // T + 100 + advanceClock(1); + assertEquals(1, mRunnable.mCounter); // Called + + // T + 10100 + advanceClock(10000); + assertEquals(1, mRunnable.mCounter); + + // Do the same thing again. Should work in the same way. + + // T + 0 + mTarget.onEvent(); + advanceClock(0); + assertEquals(1, mRunnable.mCounter); + + // T + 99 + advanceClock(99); + assertEquals(1, mRunnable.mCounter); // Still not called + + // T + 100 + advanceClock(1); + assertEquals(2, mRunnable.mCounter); // Called + + // T + 10100 + advanceClock(10000); + assertEquals(2, mRunnable.mCounter); + } + + /** + * Gets 5 events in a row in a short period. + * + * We only roughly check the consequence, as the detailed spec isn't really important. + * Here, we check if the timeout is extended, and the callback get called less than + * 5 times. + */ + public void testMultiCalls() { + mTarget.onEvent(); + advanceClock(1); + mTarget.onEvent(); + advanceClock(1); + mTarget.onEvent(); + advanceClock(1); + mTarget.onEvent(); + advanceClock(1); + mTarget.onEvent(); + + // Timeout should be extended + assertTrue(mTarget.getTimeoutForTest() > 100); + + // Shouldn't result in 5 callback calls. + advanceClock(2000); + assertTrue(mRunnable.mCounter < 5); + } + + public void testUpdateTimeout() { + // Check initial value + assertEquals(100, mTarget.getTimeoutForTest()); + + // First call -- won't change the timeout + mTarget.updateTimeout(); + assertEquals(100, mTarget.getTimeoutForTest()); + + // Call again in 10 ms -- will extend timeout. + mClock.advance(10); + mTarget.updateTimeout(); + assertEquals(200, mTarget.getTimeoutForTest()); + + // Call again in TIMEOUT_EXTEND_INTERAVL ms -- will extend timeout. + mClock.advance(Throttle.TIMEOUT_EXTEND_INTERVAL); + mTarget.updateTimeout(); + assertEquals(400, mTarget.getTimeoutForTest()); + + // Again -- timeout reaches max. + mClock.advance(Throttle.TIMEOUT_EXTEND_INTERVAL); + mTarget.updateTimeout(); + assertEquals(500, mTarget.getTimeoutForTest()); + + // Call in TIMEOUT_EXTEND_INTERAVL + 1 ms -- timeout will get reset. + mClock.advance(Throttle.TIMEOUT_EXTEND_INTERVAL + 1); + mTarget.updateTimeout(); + assertEquals(100, mTarget.getTimeoutForTest()); + } + + private static class CountingRunnable implements Runnable { + public int mCounter; + + @Override + public void run() { + mCounter++; + } + } + + /** + * Dummy {@link Handler} that executes {@link Runnable}s passed to {@link Handler#post} + * immediately on the current thread. + */ + private static class CallItNowHandler extends Handler { + @Override + public boolean sendMessageAtTime(Message msg, long uptimeMillis) { + msg.getCallback().run(); + return true; + } + } + + /** + * Substitute for {@link Timer} that works based on the provided {@link Clock}. + */ + private static class MockTimer extends Timer { + private final Clock mClock; + + private static class Entry { + public long mScheduledTime; + public TimerTask mTask; + } + + private final BlockingQueue mTasks = new LinkedBlockingQueue(); + + public MockTimer(Clock clock) { + mClock = clock; + } + + @Override + public void schedule(TimerTask task, long delay) { + if (delay == 0) { + task.run(); + } else { + Entry e = new Entry(); + e.mScheduledTime = mClock.getTime() + delay; + e.mTask = task; + mTasks.offer(e); + } + } + + /** + * {@link MockTimer} can't know when the clock advances. This method must be called + * whenever the (mock) current time changes. + */ + public void runExpiredTasks() { + while (!mTasks.isEmpty()) { + Entry e = mTasks.peek(); + if (e.mScheduledTime > mClock.getTime()) { + break; + } + e.mTask.run(); + mTasks.poll(); + } + } + } +} diff --git a/tests/src/com/android/email/data/ThrottlingCursorLoaderTest.java b/tests/src/com/android/email/data/ThrottlingCursorLoaderTest.java deleted file mode 100644 index b5761f72e..000000000 --- a/tests/src/com/android/email/data/ThrottlingCursorLoaderTest.java +++ /dev/null @@ -1,57 +0,0 @@ -/* - * Copyright (C) 2010 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 com.android.email.data; - -import com.android.email.MockClock; - -import android.test.AndroidTestCase; - -public class ThrottlingCursorLoaderTest extends AndroidTestCase { - - public void testUpdateTimeout() { - MockClock clock = new MockClock(); - ThrottlingCursorLoader l = new ThrottlingCursorLoader(getContext(), null, null, null, null, - null, 100, 500, clock); - - // Check initial value - assertEquals(100, l.getTimeoutForTest()); - - // First call -- won't change the timeout - l.updateTimeout(); - assertEquals(100, l.getTimeoutForTest()); - - // Call again in 10 ms -- will extend timeout. - clock.advance(10); - l.updateTimeout(); - assertEquals(200, l.getTimeoutForTest()); - - // Call again in TIMEOUT_EXTEND_INTERAVL ms -- will extend timeout. - clock.advance(ThrottlingCursorLoader.TIMEOUT_EXTEND_INTERVAL); - l.updateTimeout(); - assertEquals(400, l.getTimeoutForTest()); - - // Again -- timeout reaches max. - clock.advance(ThrottlingCursorLoader.TIMEOUT_EXTEND_INTERVAL); - l.updateTimeout(); - assertEquals(500, l.getTimeoutForTest()); - - // Call in TIMEOUT_EXTEND_INTERAVL + 1 ms -- timeout will get reset. - clock.advance(ThrottlingCursorLoader.TIMEOUT_EXTEND_INTERVAL + 1); - l.updateTimeout(); - assertEquals(100, l.getTimeoutForTest()); - } -}