diff --git a/services/core/java/com/android/server/timedetector/NetworkTimeUpdateService.java b/services/core/java/com/android/server/timedetector/NetworkTimeUpdateService.java index 41824de5953c8..b0d301e42634d 100644 --- a/services/core/java/com/android/server/timedetector/NetworkTimeUpdateService.java +++ b/services/core/java/com/android/server/timedetector/NetworkTimeUpdateService.java @@ -16,6 +16,7 @@ package com.android.server.timedetector; +import android.annotation.ElapsedRealtimeLong; import android.annotation.NonNull; import android.annotation.Nullable; import android.annotation.RequiresPermission; @@ -39,12 +40,14 @@ import android.os.ResultReceiver; import android.os.ShellCallback; import android.os.SystemClock; import android.provider.Settings; +import android.util.IndentingPrintWriter; import android.util.LocalLog; import android.util.Log; import android.util.NtpTrustedTime; import android.util.NtpTrustedTime.TimeResult; import com.android.internal.annotations.GuardedBy; +import com.android.internal.annotations.VisibleForTesting; import com.android.internal.util.DumpUtils; import com.android.server.LocalServices; @@ -52,12 +55,17 @@ import java.io.FileDescriptor; import java.io.PrintWriter; import java.time.Duration; import java.util.Objects; +import java.util.function.Supplier; /** - * Monitors the network time. If looking up the network time fails for some reason, it tries a few - * times with a short interval and then resets to checking on longer intervals. + * Refreshes network time periodically, when network connectivity becomes available and when the + * user enables automatic time detection. * - *

When available, the time is always suggested to the {@link + *

For periodic requests, this service attempts to leave an interval between successful requests. + * If a request fails, it retries a number of times with a "short" interval and then resets to the + * normal interval. The process then repeats. + * + *

When a valid network time is available, the time is always suggested to the {@link * com.android.server.timedetector.TimeDetectorService} where it may be used to set the device * system clock, depending on user settings and what other signals are available. */ @@ -72,25 +80,11 @@ public class NetworkTimeUpdateService extends Binder { private final Object mLock = new Object(); private final Context mContext; - private final NtpTrustedTime mNtpTrustedTime; - private final AlarmManager mAlarmManager; - private final TimeDetectorInternal mTimeDetectorInternal; private final ConnectivityManager mCM; - private final PendingIntent mPendingPollIntent; private final PowerManager.WakeLock mWakeLock; - - // Normal polling frequency - private final int mNormalPollingIntervalMillis; - // Try-again polling interval, in case the network request failed - private final int mShortPollingIntervalMillis; - // Number of times to try again - private final int mTryAgainTimesMax; - - /** - * A log that records the decisions to fetch a network time update. - * This is logged in bug reports to assist with debugging issues with network time suggestions. - */ - private final LocalLog mLocalLog = new LocalLog(30, false /* useLocalTimestamps */); + private final NtpTrustedTime mNtpTrustedTime; + private final Engine.RefreshCallbacks mRefreshCallbacks; + private final Engine mEngine; // Blocking NTP lookup is done using this handler private final Handler mHandler; @@ -100,33 +94,43 @@ public class NetworkTimeUpdateService extends Binder { @Nullable private Network mDefaultNetwork = null; - // Keeps track of how many quick attempts were made to fetch NTP time. - // During bootup, the network may not have been up yet, or it's taking time for the - // connection to happen. - // This field is only updated and accessed by the mHandler thread (except dump()). - @GuardedBy("mLock") - private int mTryAgainCounter; - public NetworkTimeUpdateService(@NonNull Context context) { mContext = Objects.requireNonNull(context); - mAlarmManager = mContext.getSystemService(AlarmManager.class); - mTimeDetectorInternal = LocalServices.getService(TimeDetectorInternal.class); mCM = mContext.getSystemService(ConnectivityManager.class); mWakeLock = context.getSystemService(PowerManager.class).newWakeLock( PowerManager.PARTIAL_WAKE_LOCK, TAG); mNtpTrustedTime = NtpTrustedTime.getInstance(context); - mTryAgainTimesMax = mContext.getResources().getInteger( + Supplier elapsedRealtimeMillisSupplier = SystemClock::elapsedRealtime; + int tryAgainTimesMax = mContext.getResources().getInteger( com.android.internal.R.integer.config_ntpRetry); - mNormalPollingIntervalMillis = mContext.getResources().getInteger( + int normalPollingIntervalMillis = mContext.getResources().getInteger( com.android.internal.R.integer.config_ntpPollingInterval); - mShortPollingIntervalMillis = mContext.getResources().getInteger( + int shortPollingIntervalMillis = mContext.getResources().getInteger( com.android.internal.R.integer.config_ntpPollingIntervalShorter); + mEngine = new EngineImpl(elapsedRealtimeMillisSupplier, normalPollingIntervalMillis, + shortPollingIntervalMillis, tryAgainTimesMax, mNtpTrustedTime); + AlarmManager alarmManager = mContext.getSystemService(AlarmManager.class); + TimeDetectorInternal timeDetectorInternal = + LocalServices.getService(TimeDetectorInternal.class); // Broadcast alarms sent by system are immutable Intent pollIntent = new Intent(ACTION_POLL, null); - mPendingPollIntent = PendingIntent.getBroadcast(mContext, POLL_REQUEST, + PendingIntent pendingPollIntent = PendingIntent.getBroadcast(mContext, POLL_REQUEST, pollIntent, PendingIntent.FLAG_IMMUTABLE); + mRefreshCallbacks = new Engine.RefreshCallbacks() { + @Override + public void scheduleNextRefresh(@ElapsedRealtimeLong long elapsedRealtimeMillis) { + alarmManager.cancel(pendingPollIntent); + alarmManager.set( + AlarmManager.ELAPSED_REALTIME, elapsedRealtimeMillis, pendingPollIntent); + } + + @Override + public void submitSuggestion(NetworkTimeSuggestion suggestion) { + timeDetectorInternal.suggestNetworkTime(suggestion); + } + }; HandlerThread thread = new HandlerThread(TAG); thread.start(); @@ -217,12 +221,7 @@ public class NetworkTimeUpdateService extends Binder { } if (network == null) return false; - boolean success = mNtpTrustedTime.forceRefresh(network); - if (success) { - makeNetworkTimeSuggestion(mNtpTrustedTime.getCachedTimeResult(), - "Origin: NetworkTimeUpdateService: forceRefreshForTests"); - } - return success; + return mEngine.forceRefreshForTests(network, mRefreshCallbacks); } finally { Binder.restoreCallingIdentity(token); } @@ -238,96 +237,12 @@ public class NetworkTimeUpdateService extends Binder { mWakeLock.acquire(); try { - onPollNetworkTimeUnderWakeLock(network, reason); + mEngine.refreshIfRequiredAndReschedule(network, reason, mRefreshCallbacks); } finally { mWakeLock.release(); } } - private void onPollNetworkTimeUnderWakeLock( - @NonNull Network network, @NonNull String reason) { - long currentElapsedRealtimeMillis = SystemClock.elapsedRealtime(); - - final int maxNetworkTimeAgeMillis = mNormalPollingIntervalMillis; - // Force an NTP fix when outdated - NtpTrustedTime.TimeResult cachedNtpResult = mNtpTrustedTime.getCachedTimeResult(); - if (cachedNtpResult == null - || cachedNtpResult.getAgeMillis(currentElapsedRealtimeMillis) - >= maxNetworkTimeAgeMillis) { - if (DBG) Log.d(TAG, "Stale NTP fix; forcing refresh using network=" + network); - boolean success = mNtpTrustedTime.forceRefresh(network); - if (success) { - synchronized (mLock) { - mTryAgainCounter = 0; - } - } else { - String logMsg = "forceRefresh() returned false:" - + " cachedNtpResult=" + cachedNtpResult - + ", currentElapsedRealtimeMillis=" + currentElapsedRealtimeMillis; - - if (DBG) { - Log.d(TAG, logMsg); - } - mLocalLog.log(logMsg); - } - - cachedNtpResult = mNtpTrustedTime.getCachedTimeResult(); - } - - if (cachedNtpResult != null - && cachedNtpResult.getAgeMillis(currentElapsedRealtimeMillis) - < maxNetworkTimeAgeMillis) { - // Obtained fresh fix; schedule next normal update - scheduleNextRefresh(mNormalPollingIntervalMillis - - cachedNtpResult.getAgeMillis(currentElapsedRealtimeMillis)); - - makeNetworkTimeSuggestion(cachedNtpResult, reason); - } else { - synchronized (mLock) { - // No fresh fix; schedule retry - mTryAgainCounter++; - if (mTryAgainTimesMax < 0 || mTryAgainCounter <= mTryAgainTimesMax) { - scheduleNextRefresh(mShortPollingIntervalMillis); - } else { - // Try much later - String logMsg = "mTryAgainTimesMax exceeded," - + " cachedNtpResult=" + cachedNtpResult; - if (DBG) { - Log.d(TAG, logMsg); - } - mLocalLog.log(logMsg); - mTryAgainCounter = 0; - - scheduleNextRefresh(mNormalPollingIntervalMillis); - } - } - } - } - - /** Suggests the time to the time detector. It may choose use it to set the system clock. */ - private void makeNetworkTimeSuggestion( - @NonNull TimeResult ntpResult, @NonNull String debugInfo) { - UnixEpochTime timeSignal = new UnixEpochTime( - ntpResult.getElapsedRealtimeMillis(), ntpResult.getTimeMillis()); - NetworkTimeSuggestion timeSuggestion = - new NetworkTimeSuggestion(timeSignal, ntpResult.getUncertaintyMillis()); - timeSuggestion.addDebugInfo(debugInfo); - timeSuggestion.addDebugInfo(ntpResult.toString()); - mTimeDetectorInternal.suggestNetworkTime(timeSuggestion); - } - - /** - * Cancel old alarm and starts a new one for the specified interval. - * - * @param delayMillis when to trigger the alarm, starting from now. - */ - private void scheduleNextRefresh(long delayMillis) { - mAlarmManager.cancel(mPendingPollIntent); - long now = SystemClock.elapsedRealtime(); - long next = now + delayMillis; - mAlarmManager.set(AlarmManager.ELAPSED_REALTIME, next, mPendingPollIntent); - } - // All callbacks will be invoked using mHandler because of how the callback is registered. private class NetworkTimeUpdateCallback extends NetworkCallback { @Override @@ -385,21 +300,10 @@ public class NetworkTimeUpdateService extends Binder { protected void dump(FileDescriptor fd, PrintWriter pw, String[] args) { if (!DumpUtils.checkDumpPermission(mContext, TAG, pw)) return; - pw.println("mNormalPollingIntervalMillis=" - + Duration.ofMillis(mNormalPollingIntervalMillis)); - pw.println("mShortPollingIntervalMillis=" - + Duration.ofMillis(mShortPollingIntervalMillis)); - pw.println("mTryAgainTimesMax=" + mTryAgainTimesMax); synchronized (mLock) { pw.println("mDefaultNetwork=" + mDefaultNetwork); - pw.println("mTryAgainCounter=" + mTryAgainCounter); } - pw.println(); - pw.println("NtpTrustedTime:"); - mNtpTrustedTime.dump(pw); - pw.println(); - pw.println("Local logs:"); - mLocalLog.dump(fd, pw, args); + mEngine.dump(pw); pw.println(); } @@ -409,4 +313,204 @@ public class NetworkTimeUpdateService extends Binder { new NetworkTimeUpdateServiceShellCommand(this).exec( this, in, out, err, args, callback, resultReceiver); } + + /** + * The interface the service uses to interact with the time refresh logic. + * Extracted for testing. + */ + @VisibleForTesting + interface Engine { + interface RefreshCallbacks { + void scheduleNextRefresh(@ElapsedRealtimeLong long elapsedRealtimeMillis); + + void submitSuggestion(@NonNull NetworkTimeSuggestion suggestion); + } + + /** + * Forces the engine to refresh the network time (for tests). See {@link + * NetworkTimeUpdateService#forceRefreshForTests()}. This is a blocking call. This method + * must not schedule any calls. + */ + boolean forceRefreshForTests( + @NonNull Network network, @NonNull RefreshCallbacks refreshCallbacks); + + /** + * Attempts to refresh the network time if required, i.e. if there isn't a recent-enough + * network time available. It must also schedule the next call. This is a blocking call. + * + * @param network the network to use + * @param reason the reason for the refresh (for logging) + */ + void refreshIfRequiredAndReschedule(@NonNull Network network, @NonNull String reason, + @NonNull RefreshCallbacks refreshCallbacks); + + void dump(@NonNull PrintWriter pw); + } + + @VisibleForTesting + static class EngineImpl implements Engine { + + /** + * A log that records the decisions to fetch a network time update. + * This is logged in bug reports to assist with debugging issues with network time + * suggestions. + */ + @NonNull + private final LocalLog mLocalDebugLog = new LocalLog(30, false /* useLocalTimestamps */); + + private final int mNormalPollingIntervalMillis; + private final int mShortPollingIntervalMillis; + private final int mTryAgainTimesMax; + private final NtpTrustedTime mNtpTrustedTime; + + /** + * Keeps track of successive time refresh failures have occurred. This is reset to zero when + * time refresh is successful or if the number exceeds (a non-negative) {@link + * #mTryAgainTimesMax}. + */ + @GuardedBy("this") + private int mTryAgainCounter; + + private final Supplier mElapsedRealtimeMillisSupplier; + + @VisibleForTesting + EngineImpl(@NonNull Supplier elapsedRealtimeMillisSupplier, + int normalPollingIntervalMillis, int shortPollingIntervalMillis, + int tryAgainTimesMax, @NonNull NtpTrustedTime ntpTrustedTime) { + mElapsedRealtimeMillisSupplier = Objects.requireNonNull(elapsedRealtimeMillisSupplier); + mNormalPollingIntervalMillis = normalPollingIntervalMillis; + mShortPollingIntervalMillis = shortPollingIntervalMillis; + mTryAgainTimesMax = tryAgainTimesMax; + mNtpTrustedTime = Objects.requireNonNull(ntpTrustedTime); + } + + @Override + public boolean forceRefreshForTests( + @NonNull Network network, @NonNull RefreshCallbacks refreshCallbacks) { + boolean success = mNtpTrustedTime.forceRefresh(network); + logToDebugAndDumpsys("forceRefreshForTests: success=" + success); + + if (success) { + makeNetworkTimeSuggestion(mNtpTrustedTime.getCachedTimeResult(), + "EngineImpl.forceRefreshForTests()", refreshCallbacks); + } + return success; + } + + @Override + public void refreshIfRequiredAndReschedule( + @NonNull Network network, @NonNull String reason, + @NonNull RefreshCallbacks refreshCallbacks) { + long currentElapsedRealtimeMillis = mElapsedRealtimeMillisSupplier.get(); + + final int maxNetworkTimeAgeMillis = mNormalPollingIntervalMillis; + // Force an NTP fix when outdated + NtpTrustedTime.TimeResult initialTimeResult = mNtpTrustedTime.getCachedTimeResult(); + if (calculateTimeResultAgeMillis(initialTimeResult, currentElapsedRealtimeMillis) + >= maxNetworkTimeAgeMillis) { + if (DBG) Log.d(TAG, "Stale NTP fix; forcing refresh using network=" + network); + boolean successful = mNtpTrustedTime.forceRefresh(network); + if (successful) { + synchronized (this) { + mTryAgainCounter = 0; + } + } else { + String logMsg = "forceRefresh() returned false:" + + " initialTimeResult=" + initialTimeResult + + ", currentElapsedRealtimeMillis=" + currentElapsedRealtimeMillis; + logToDebugAndDumpsys(logMsg); + } + } + + synchronized (this) { + long nextPollDelayMillis; + NtpTrustedTime.TimeResult latestTimeResult = mNtpTrustedTime.getCachedTimeResult(); + if (calculateTimeResultAgeMillis(latestTimeResult, currentElapsedRealtimeMillis) + < maxNetworkTimeAgeMillis) { + // Obtained fresh fix; schedule next normal update + nextPollDelayMillis = mNormalPollingIntervalMillis + - latestTimeResult.getAgeMillis(currentElapsedRealtimeMillis); + + makeNetworkTimeSuggestion(latestTimeResult, reason, refreshCallbacks); + } else { + // No fresh fix; schedule retry + mTryAgainCounter++; + if (mTryAgainTimesMax < 0 || mTryAgainCounter <= mTryAgainTimesMax) { + nextPollDelayMillis = mShortPollingIntervalMillis; + } else { + // Try much later + mTryAgainCounter = 0; + + nextPollDelayMillis = mNormalPollingIntervalMillis; + } + } + long nextRefreshElapsedRealtimeMillis = + currentElapsedRealtimeMillis + nextPollDelayMillis; + refreshCallbacks.scheduleNextRefresh(nextRefreshElapsedRealtimeMillis); + + logToDebugAndDumpsys("refreshIfRequiredAndReschedule:" + + " network=" + network + + ", reason=" + reason + + ", currentElapsedRealtimeMillis=" + currentElapsedRealtimeMillis + + ", initialTimeResult=" + initialTimeResult + + ", latestTimeResult=" + latestTimeResult + + ", mTryAgainCounter=" + mTryAgainCounter + + ", nextPollDelayMillis=" + nextPollDelayMillis + + ", nextRefreshElapsedRealtimeMillis=" + + Duration.ofMillis(nextRefreshElapsedRealtimeMillis) + + " (" + nextRefreshElapsedRealtimeMillis + ")"); + } + } + + private static long calculateTimeResultAgeMillis( + @Nullable TimeResult timeResult, + @ElapsedRealtimeLong long currentElapsedRealtimeMillis) { + return timeResult == null ? Long.MAX_VALUE + : timeResult.getAgeMillis(currentElapsedRealtimeMillis); + } + + /** Suggests the time to the time detector. It may choose use it to set the system clock. */ + private void makeNetworkTimeSuggestion(@NonNull TimeResult ntpResult, + @NonNull String debugInfo, @NonNull RefreshCallbacks refreshCallbacks) { + UnixEpochTime timeSignal = new UnixEpochTime( + ntpResult.getElapsedRealtimeMillis(), ntpResult.getTimeMillis()); + NetworkTimeSuggestion timeSuggestion = + new NetworkTimeSuggestion(timeSignal, ntpResult.getUncertaintyMillis()); + timeSuggestion.addDebugInfo(debugInfo); + timeSuggestion.addDebugInfo(ntpResult.toString()); + refreshCallbacks.submitSuggestion(timeSuggestion); + } + + @Override + public void dump(PrintWriter pw) { + IndentingPrintWriter ipw = new IndentingPrintWriter(pw); + ipw.println("mNormalPollingIntervalMillis=" + mNormalPollingIntervalMillis); + ipw.println("mShortPollingIntervalMillis=" + mShortPollingIntervalMillis); + ipw.println("mTryAgainTimesMax=" + mTryAgainTimesMax); + + synchronized (this) { + ipw.println("mTryAgainCounter=" + mTryAgainCounter); + } + ipw.println(); + + ipw.println("NtpTrustedTime:"); + ipw.increaseIndent(); + mNtpTrustedTime.dump(ipw); + ipw.decreaseIndent(); + ipw.println(); + + ipw.println("Debug log:"); + ipw.increaseIndent(); + mLocalDebugLog.dump(ipw); + ipw.decreaseIndent(); + ipw.println(); + } + + private void logToDebugAndDumpsys(String logMsg) { + if (DBG) { + Log.d(TAG, logMsg); + } + mLocalDebugLog.log(logMsg); + } + } } diff --git a/services/tests/servicestests/src/com/android/server/timedetector/NetworkTimeUpdateServiceTest.java b/services/tests/servicestests/src/com/android/server/timedetector/NetworkTimeUpdateServiceTest.java new file mode 100644 index 0000000000000..08d08b65288a2 --- /dev/null +++ b/services/tests/servicestests/src/com/android/server/timedetector/NetworkTimeUpdateServiceTest.java @@ -0,0 +1,439 @@ +/* + * Copyright (C) 2022 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.server.timedetector; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.reset; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import android.app.time.UnixEpochTime; +import android.net.Network; +import android.util.NtpTrustedTime; + +import androidx.test.runner.AndroidJUnit4; + +import com.android.server.timedetector.NetworkTimeUpdateService.Engine.RefreshCallbacks; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.net.InetSocketAddress; +import java.util.function.Supplier; + +@RunWith(AndroidJUnit4.class) +public class NetworkTimeUpdateServiceTest { + + private static final InetSocketAddress FAKE_SERVER_ADDRESS = + InetSocketAddress.createUnresolved("test", 123); + private static final long ARBITRARY_ELAPSED_REALTIME_MILLIS = 100000000L; + private static final long ARBITRARY_UNIX_EPOCH_TIME_MILLIS = 5555555555L; + private static final int ARBITRARY_UNCERTAINTY_MILLIS = 999; + + private FakeElapsedRealtimeClock mFakeElapsedRealtimeClock; + private NtpTrustedTime mMockNtpTrustedTime; + private Network mDummyNetwork; + + @Before + public void setUp() { + mFakeElapsedRealtimeClock = new FakeElapsedRealtimeClock(); + mMockNtpTrustedTime = mock(NtpTrustedTime.class); + mDummyNetwork = mock(Network.class); + } + + @Test + public void engineImpl_refreshIfRequiredAndReschedule_success() { + mFakeElapsedRealtimeClock.setElapsedRealtimeMillis(ARBITRARY_ELAPSED_REALTIME_MILLIS); + + int normalPollingIntervalMillis = 7777777; + int shortPollingIntervalMillis = 3333; + int tryAgainTimesMax = 5; + NetworkTimeUpdateService.Engine engine = new NetworkTimeUpdateService.EngineImpl( + mFakeElapsedRealtimeClock, + normalPollingIntervalMillis, shortPollingIntervalMillis, tryAgainTimesMax, + mMockNtpTrustedTime); + + // Simulated NTP client behavior: No cached time value available initially, then a + // successful refresh. + NtpTrustedTime.TimeResult timeResult = createNtpTimeResult( + mFakeElapsedRealtimeClock.getElapsedRealtimeMillis() - 1); + when(mMockNtpTrustedTime.getCachedTimeResult()).thenReturn(null, timeResult); + when(mMockNtpTrustedTime.forceRefresh(mDummyNetwork)).thenReturn(true); + + RefreshCallbacks mockCallback = mock(RefreshCallbacks.class); + // Trigger the engine's logic. + engine.refreshIfRequiredAndReschedule(mDummyNetwork, "Test", mockCallback); + + // Expect the refresh attempt to have been made. + verify(mMockNtpTrustedTime).forceRefresh(mDummyNetwork); + + // Check everything happened that was supposed to. + long expectedDelayMillis = calculateRefreshDelayMillisForTimeResult( + timeResult, normalPollingIntervalMillis); + verify(mockCallback).scheduleNextRefresh( + mFakeElapsedRealtimeClock.getElapsedRealtimeMillis() + expectedDelayMillis); + + NetworkTimeSuggestion expectedSuggestion = createExpectedSuggestion(timeResult); + verify(mockCallback).submitSuggestion(expectedSuggestion); + } + + @Test + public void engineImpl_refreshIfRequiredAndReschedule_failThenFailRepeatedly() { + mFakeElapsedRealtimeClock.setElapsedRealtimeMillis(ARBITRARY_ELAPSED_REALTIME_MILLIS); + + int normalPollingIntervalMillis = 7777777; + int shortPollingIntervalMillis = 3333; + int tryAgainTimesMax = 5; + NetworkTimeUpdateService.Engine engine = new NetworkTimeUpdateService.EngineImpl( + mFakeElapsedRealtimeClock, + normalPollingIntervalMillis, shortPollingIntervalMillis, tryAgainTimesMax, + mMockNtpTrustedTime); + + for (int i = 0; i < tryAgainTimesMax + 1; i++) { + // Simulated NTP client behavior: No cached time value available and failure to refresh. + when(mMockNtpTrustedTime.getCachedTimeResult()).thenReturn(null); + when(mMockNtpTrustedTime.forceRefresh(mDummyNetwork)).thenReturn(false); + + RefreshCallbacks mockCallback = mock(RefreshCallbacks.class); + + // Trigger the engine's logic. + engine.refreshIfRequiredAndReschedule(mDummyNetwork, "Test", mockCallback); + + // Expect a refresh attempt each time: there's no currently cached result. + verify(mMockNtpTrustedTime).forceRefresh(mDummyNetwork); + + // Check everything happened that was supposed to. + long expectedDelayMillis; + if (i < tryAgainTimesMax) { + expectedDelayMillis = shortPollingIntervalMillis; + } else { + expectedDelayMillis = normalPollingIntervalMillis; + } + verify(mockCallback).scheduleNextRefresh( + mFakeElapsedRealtimeClock.getElapsedRealtimeMillis() + expectedDelayMillis); + verify(mockCallback, never()).submitSuggestion(any()); + + reset(mMockNtpTrustedTime); + } + } + + @Test + public void engineImpl_refreshIfRequiredAndReschedule_successThenFailRepeatedly() { + mFakeElapsedRealtimeClock.setElapsedRealtimeMillis(ARBITRARY_ELAPSED_REALTIME_MILLIS); + + int normalPollingIntervalMillis = 7777777; + int maxTimeResultAgeMillis = normalPollingIntervalMillis; + int shortPollingIntervalMillis = 3333; + int tryAgainTimesMax = 5; + NetworkTimeUpdateService.Engine engine = new NetworkTimeUpdateService.EngineImpl( + mFakeElapsedRealtimeClock, + normalPollingIntervalMillis, shortPollingIntervalMillis, tryAgainTimesMax, + mMockNtpTrustedTime); + + NtpTrustedTime.TimeResult timeResult = createNtpTimeResult( + mFakeElapsedRealtimeClock.getElapsedRealtimeMillis() - 1); + NetworkTimeSuggestion expectedSuggestion = createExpectedSuggestion(timeResult); + + { + // Simulated NTP client behavior: No cached time value available initially, with a + // successful refresh. + when(mMockNtpTrustedTime.getCachedTimeResult()).thenReturn(null, timeResult); + when(mMockNtpTrustedTime.forceRefresh(mDummyNetwork)).thenReturn(true); + + RefreshCallbacks mockCallback = mock(RefreshCallbacks.class); + + // Trigger the engine's logic. + engine.refreshIfRequiredAndReschedule(mDummyNetwork, "Test", mockCallback); + + // Expect the refresh attempt to have been made: there is no cached network time + // initially. + verify(mMockNtpTrustedTime).forceRefresh(mDummyNetwork); + + long expectedDelayMillis = calculateRefreshDelayMillisForTimeResult( + timeResult, normalPollingIntervalMillis); + verify(mockCallback).scheduleNextRefresh( + mFakeElapsedRealtimeClock.getElapsedRealtimeMillis() + expectedDelayMillis); + verify(mockCallback, times(1)).submitSuggestion(expectedSuggestion); + reset(mMockNtpTrustedTime); + } + + // Increment the current time by enough so that an attempt to refresh the time should be + // made every time refreshIfRequiredAndReschedule() is called. + mFakeElapsedRealtimeClock.incrementMillis(maxTimeResultAgeMillis); + + // Test multiple follow-up calls. + for (int i = 0; i < tryAgainTimesMax + 1; i++) { + // Simulated NTP client behavior: (Too old) cached time value available, unsuccessful + // refresh. + when(mMockNtpTrustedTime.getCachedTimeResult()).thenReturn(timeResult); + when(mMockNtpTrustedTime.forceRefresh(mDummyNetwork)).thenReturn(false); + + RefreshCallbacks mockCallback = mock(RefreshCallbacks.class); + + // Trigger the engine's logic. + engine.refreshIfRequiredAndReschedule(mDummyNetwork, "Test", mockCallback); + + // Expect a refresh attempt each time as the cached network time is too old. + verify(mMockNtpTrustedTime).forceRefresh(mDummyNetwork); + + // Check the scheduling. + long expectedDelayMillis; + if (i < tryAgainTimesMax) { + expectedDelayMillis = shortPollingIntervalMillis; + } else { + expectedDelayMillis = normalPollingIntervalMillis; + } + verify(mockCallback).scheduleNextRefresh( + mFakeElapsedRealtimeClock.getElapsedRealtimeMillis() + expectedDelayMillis); + + // No valid time, no suggestion. + verify(mockCallback, never()).submitSuggestion(any()); + + reset(mMockNtpTrustedTime); + } + } + + @Test + public void engineImpl_refreshIfRequiredAndReschedule_successFailSuccess() { + mFakeElapsedRealtimeClock.setElapsedRealtimeMillis(ARBITRARY_ELAPSED_REALTIME_MILLIS); + + int normalPollingIntervalMillis = 7777777; + int maxTimeResultAgeMillis = normalPollingIntervalMillis; + int shortPollingIntervalMillis = 3333; + int tryAgainTimesMax = 5; + NetworkTimeUpdateService.Engine engine = new NetworkTimeUpdateService.EngineImpl( + mFakeElapsedRealtimeClock, + normalPollingIntervalMillis, shortPollingIntervalMillis, tryAgainTimesMax, + mMockNtpTrustedTime); + + NtpTrustedTime.TimeResult timeResult1 = createNtpTimeResult( + mFakeElapsedRealtimeClock.getElapsedRealtimeMillis() - 1); + { + // Simulated NTP client behavior: No cached time value available initially, with a + // successful refresh. + when(mMockNtpTrustedTime.getCachedTimeResult()).thenReturn(null, timeResult1); + when(mMockNtpTrustedTime.forceRefresh(mDummyNetwork)).thenReturn(true); + + RefreshCallbacks mockCallback = mock(RefreshCallbacks.class); + + // Trigger the engine's logic. + engine.refreshIfRequiredAndReschedule(mDummyNetwork, "Test", mockCallback); + + // Expect the refresh attempt to have been made: there is no cached network time + // initially. + verify(mMockNtpTrustedTime).forceRefresh(mDummyNetwork); + + long expectedDelayMillis = calculateRefreshDelayMillisForTimeResult( + timeResult1, normalPollingIntervalMillis); + verify(mockCallback).scheduleNextRefresh( + mFakeElapsedRealtimeClock.getElapsedRealtimeMillis() + expectedDelayMillis); + NetworkTimeSuggestion expectedSuggestion = createExpectedSuggestion(timeResult1); + verify(mockCallback, times(1)).submitSuggestion(expectedSuggestion); + reset(mMockNtpTrustedTime); + } + + // Increment the current time by enough so that the cached time result is too old and an + // attempt to refresh the time should be made every time refreshIfRequiredAndReschedule() is + // called. + mFakeElapsedRealtimeClock.incrementMillis(maxTimeResultAgeMillis); + + { + // Simulated NTP client behavior: (Old) cached time value available initially, with an + // unsuccessful refresh. + when(mMockNtpTrustedTime.getCachedTimeResult()).thenReturn(timeResult1); + when(mMockNtpTrustedTime.forceRefresh(mDummyNetwork)).thenReturn(false); + + RefreshCallbacks mockCallback = mock(RefreshCallbacks.class); + + // Trigger the engine's logic. + engine.refreshIfRequiredAndReschedule(mDummyNetwork, "Test", mockCallback); + + // Expect the refresh attempt to have been made: the timeResult is too old. + verify(mMockNtpTrustedTime).forceRefresh(mDummyNetwork); + + long expectedDelayMillis = shortPollingIntervalMillis; + verify(mockCallback).scheduleNextRefresh( + mFakeElapsedRealtimeClock.getElapsedRealtimeMillis() + expectedDelayMillis); + + // No valid time, no suggestion. + verify(mockCallback, never()).submitSuggestion(any()); + reset(mMockNtpTrustedTime); + } + + NtpTrustedTime.TimeResult timeResult2 = createNtpTimeResult( + mFakeElapsedRealtimeClock.getElapsedRealtimeMillis() - 1); + + { + // Simulated NTP client behavior: (Old) cached time value available initially, with a + // successful refresh and a new cached time value. + when(mMockNtpTrustedTime.getCachedTimeResult()).thenReturn(timeResult1, timeResult2); + when(mMockNtpTrustedTime.forceRefresh(mDummyNetwork)).thenReturn(true); + + RefreshCallbacks mockCallback = mock(RefreshCallbacks.class); + + // Trigger the engine's logic. + engine.refreshIfRequiredAndReschedule(mDummyNetwork, "Test", mockCallback); + + // Expect the refresh attempt to have been made: the timeResult is too old. + verify(mMockNtpTrustedTime).forceRefresh(mDummyNetwork); + + long expectedDelayMillis = calculateRefreshDelayMillisForTimeResult( + timeResult2, normalPollingIntervalMillis); + verify(mockCallback).scheduleNextRefresh( + mFakeElapsedRealtimeClock.getElapsedRealtimeMillis() + expectedDelayMillis); + NetworkTimeSuggestion expectedSuggestion = createExpectedSuggestion(timeResult2); + verify(mockCallback, times(1)).submitSuggestion(expectedSuggestion); + reset(mMockNtpTrustedTime); + } + } + + /** + * Confirms that if a refreshIfRequiredAndReschedule() call is made, e.g. for reasons besides + * scheduled alerts, and the latest time is not too old, then an NTP refresh won't be attempted. + * A suggestion will still be made. + */ + @Test + public void engineImpl_refreshIfRequiredAndReschedule_noRefreshIfLatestIsNotTooOld() { + mFakeElapsedRealtimeClock.setElapsedRealtimeMillis(ARBITRARY_ELAPSED_REALTIME_MILLIS); + + int normalPollingIntervalMillis = 7777777; + int maxTimeResultAgeMillis = normalPollingIntervalMillis; + int shortPollingIntervalMillis = 3333; + int tryAgainTimesMax = 5; + NetworkTimeUpdateService.Engine engine = new NetworkTimeUpdateService.EngineImpl( + mFakeElapsedRealtimeClock, + normalPollingIntervalMillis, shortPollingIntervalMillis, tryAgainTimesMax, + mMockNtpTrustedTime); + + // Simulated NTP client behavior: A cached time value is available, increment the clock, but + // not enough to consider the cached value too old. + NtpTrustedTime.TimeResult timeResult = createNtpTimeResult( + mFakeElapsedRealtimeClock.getElapsedRealtimeMillis()); + when(mMockNtpTrustedTime.getCachedTimeResult()).thenReturn(timeResult); + mFakeElapsedRealtimeClock.incrementMillis(maxTimeResultAgeMillis - 1); + + RefreshCallbacks mockCallback = mock(RefreshCallbacks.class); + // Trigger the engine's logic. + engine.refreshIfRequiredAndReschedule(mDummyNetwork, "Test", mockCallback); + + // Expect no refresh attempt to have been made. + verify(mMockNtpTrustedTime, never()).forceRefresh(any()); + + // The next wake-up should be rescheduled for when the cached time value will become too + // old. + long expectedDelayMillis = calculateRefreshDelayMillisForTimeResult(timeResult, + normalPollingIntervalMillis); + verify(mockCallback).scheduleNextRefresh( + mFakeElapsedRealtimeClock.getElapsedRealtimeMillis() + expectedDelayMillis); + + // Suggestions must be made every time if the cached time value is not too old in case it + // was refreshed by a different component. + NetworkTimeSuggestion expectedSuggestion = createExpectedSuggestion(timeResult); + verify(mockCallback, times(1)).submitSuggestion(expectedSuggestion); + } + + /** + * Confirms that if a refreshIfRequiredAndReschedule() call is made, e.g. for reasons besides + * scheduled alerts, and the latest time is not too old, then an NTP refresh won't be attempted. + * A suggestion will still be made. + */ + @Test + public void engineImpl_refreshIfRequiredAndReschedule_failureHandlingAfterLatestIsTooOld() { + mFakeElapsedRealtimeClock.setElapsedRealtimeMillis(ARBITRARY_ELAPSED_REALTIME_MILLIS); + + int normalPollingIntervalMillis = 7777777; + int maxTimeResultAgeMillis = normalPollingIntervalMillis; + int shortPollingIntervalMillis = 3333; + int tryAgainTimesMax = 5; + NetworkTimeUpdateService.Engine engine = new NetworkTimeUpdateService.EngineImpl( + mFakeElapsedRealtimeClock, + normalPollingIntervalMillis, shortPollingIntervalMillis, tryAgainTimesMax, + mMockNtpTrustedTime); + + // Simulated NTP client behavior: A cached time value is available, increment the clock, + // enough to consider the cached value too old. The refresh attempt will fail. + NtpTrustedTime.TimeResult timeResult = createNtpTimeResult( + mFakeElapsedRealtimeClock.getElapsedRealtimeMillis()); + when(mMockNtpTrustedTime.getCachedTimeResult()).thenReturn(timeResult); + mFakeElapsedRealtimeClock.incrementMillis(maxTimeResultAgeMillis); + when(mMockNtpTrustedTime.forceRefresh(mDummyNetwork)).thenReturn(false); + + RefreshCallbacks mockCallback = mock(RefreshCallbacks.class); + // Trigger the engine's logic. + engine.refreshIfRequiredAndReschedule(mDummyNetwork, "Test", mockCallback); + + // Expect a refresh attempt to have been made. + verify(mMockNtpTrustedTime, times(1)).forceRefresh(mDummyNetwork); + + // The next wake-up should be rescheduled using the short polling interval. + long expectedDelayMillis = shortPollingIntervalMillis; + verify(mockCallback).scheduleNextRefresh( + mFakeElapsedRealtimeClock.getElapsedRealtimeMillis() + expectedDelayMillis); + + // Suggestions should not be made if the cached time value is too old. + verify(mockCallback, never()).submitSuggestion(any()); + } + + private long calculateRefreshDelayMillisForTimeResult(NtpTrustedTime.TimeResult timeResult, + int normalPollingIntervalMillis) { + long currentElapsedRealtimeMillis = mFakeElapsedRealtimeClock.getElapsedRealtimeMillis(); + long timeResultAgeMillis = timeResult.getAgeMillis(currentElapsedRealtimeMillis); + return normalPollingIntervalMillis - timeResultAgeMillis; + } + + private static NetworkTimeSuggestion createExpectedSuggestion( + NtpTrustedTime.TimeResult timeResult) { + UnixEpochTime unixEpochTime = new UnixEpochTime( + timeResult.getElapsedRealtimeMillis(), timeResult.getTimeMillis()); + return new NetworkTimeSuggestion(unixEpochTime, timeResult.getUncertaintyMillis()); + } + + private static NtpTrustedTime.TimeResult createNtpTimeResult(long elapsedRealtimeMillis) { + return new NtpTrustedTime.TimeResult( + ARBITRARY_UNIX_EPOCH_TIME_MILLIS, + elapsedRealtimeMillis, + ARBITRARY_UNCERTAINTY_MILLIS, + FAKE_SERVER_ADDRESS); + } + + private static class FakeElapsedRealtimeClock implements Supplier { + + private long mElapsedRealtimeMillis; + + public void setElapsedRealtimeMillis(long elapsedRealtimeMillis) { + mElapsedRealtimeMillis = elapsedRealtimeMillis; + } + + public long getElapsedRealtimeMillis() { + return mElapsedRealtimeMillis; + } + + public long incrementMillis(int millis) { + mElapsedRealtimeMillis += millis; + return mElapsedRealtimeMillis; + } + + @Override + public Long get() { + return getElapsedRealtimeMillis(); + } + } +}