diff --git a/app/src/main/java/com/lyft/kronos/demo/MainActivity.kt b/app/src/main/java/com/lyft/kronos/demo/MainActivity.kt index fbabd8d..f3e92b3 100644 --- a/app/src/main/java/com/lyft/kronos/demo/MainActivity.kt +++ b/app/src/main/java/com/lyft/kronos/demo/MainActivity.kt @@ -29,7 +29,7 @@ class MainActivity : Activity() { } private fun bindDeviceClock() { - findViewById(R.id.android_clock).clock = AndroidClockFactory.createDeviceClock() + findViewById(R.id.android_clock).clock = AndroidClockFactory.createDeviceClock(this) } private fun bindKronosClock() { diff --git a/app/src/main/java/com/lyft/kronos/demo/TextClock.kt b/app/src/main/java/com/lyft/kronos/demo/TextClock.kt index e5cb505..1cbdd66 100644 --- a/app/src/main/java/com/lyft/kronos/demo/TextClock.kt +++ b/app/src/main/java/com/lyft/kronos/demo/TextClock.kt @@ -12,7 +12,7 @@ import java.util.concurrent.TimeUnit class TextClock : TextView { - var clock : Clock = AndroidClockFactory.createDeviceClock() + var clock : Clock = AndroidClockFactory.createDeviceClock(context) private val ticker = object : Runnable { override fun run() { diff --git a/kronos-android/src/main/java/com/lyft/kronos/AndroidClockFactory.kt b/kronos-android/src/main/java/com/lyft/kronos/AndroidClockFactory.kt index 3332522..cbec167 100644 --- a/kronos-android/src/main/java/com/lyft/kronos/AndroidClockFactory.kt +++ b/kronos-android/src/main/java/com/lyft/kronos/AndroidClockFactory.kt @@ -14,7 +14,7 @@ object AndroidClockFactory { * Create a device clock that uses the OS/device specific API to retrieve time */ @JvmStatic - fun createDeviceClock(): Clock = AndroidSystemClock() + fun createDeviceClock(context: Context): Clock = AndroidSystemClock(context) @JvmStatic @JvmOverloads @@ -26,9 +26,9 @@ object AndroidClockFactory { cacheExpirationMs: Long = CACHE_EXPIRATION_MS, maxNtpResponseTimeMs: Long = MAX_NTP_RESPONSE_TIME_MS): KronosClock { - val deviceClock = createDeviceClock() + val deviceClock = createDeviceClock(context) val cache = SharedPreferenceSyncResponseCache(context.getSharedPreferences(SharedPreferenceSyncResponseCache.SHARED_PREFERENCES_NAME, Context.MODE_PRIVATE)) return ClockFactory.createKronosClock(deviceClock, cache, syncListener, ntpHosts, requestTimeoutMs, minWaitTimeBetweenSyncMs, cacheExpirationMs, maxNtpResponseTimeMs) } -} \ No newline at end of file +} diff --git a/kronos-android/src/main/java/com/lyft/kronos/internal/AndroidSystemClock.kt b/kronos-android/src/main/java/com/lyft/kronos/internal/AndroidSystemClock.kt index 2c42e55..4e6bc59 100644 --- a/kronos-android/src/main/java/com/lyft/kronos/internal/AndroidSystemClock.kt +++ b/kronos-android/src/main/java/com/lyft/kronos/internal/AndroidSystemClock.kt @@ -1,9 +1,18 @@ package com.lyft.kronos.internal +import android.content.Context +import android.os.Build import android.os.SystemClock +import android.provider.Settings import com.lyft.kronos.Clock -internal class AndroidSystemClock : Clock { +internal class AndroidSystemClock(private val context: Context) : Clock { override fun getCurrentTimeMs(): Long = System.currentTimeMillis() override fun getElapsedTimeMs(): Long = SystemClock.elapsedRealtime() -} \ No newline at end of file + override fun getBootCount(): Int? = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + Settings.Global.getInt(context.contentResolver, Settings.Global.BOOT_COUNT) + } else { + null + } +} diff --git a/kronos-android/src/main/java/com/lyft/kronos/internal/SharedPreferenceSyncResponseCache.kt b/kronos-android/src/main/java/com/lyft/kronos/internal/SharedPreferenceSyncResponseCache.kt index 3227812..5e24820 100644 --- a/kronos-android/src/main/java/com/lyft/kronos/internal/SharedPreferenceSyncResponseCache.kt +++ b/kronos-android/src/main/java/com/lyft/kronos/internal/SharedPreferenceSyncResponseCache.kt @@ -11,6 +11,16 @@ internal class SharedPreferenceSyncResponseCache(private val sharedPreferences: override var elapsedTime: Long get() = sharedPreferences.getLong(KEY_ELAPSED_TIME, TIME_UNAVAILABLE) set(value) = sharedPreferences.edit().putLong(KEY_ELAPSED_TIME, value).apply() + override var bootCount: Int? + get() = + if (sharedPreferences.contains(KEY_BOOT_COUNT)) + sharedPreferences.getInt(KEY_BOOT_COUNT, 0) + else null + set(value) = + if (value == null) + sharedPreferences.edit().remove(KEY_BOOT_COUNT).apply() + else + sharedPreferences.edit().putInt(KEY_BOOT_COUNT, value).apply() override var currentOffset: Long get() = sharedPreferences.getLong(KEY_OFFSET, TIME_UNAVAILABLE) set(value) = sharedPreferences.edit().putLong(KEY_OFFSET, value).apply() @@ -23,6 +33,7 @@ internal class SharedPreferenceSyncResponseCache(private val sharedPreferences: internal const val SHARED_PREFERENCES_NAME = "com.lyft.kronos.shared_preferences" internal const val KEY_CURRENT_TIME = "com.lyft.kronos.cached_current_time" internal const val KEY_ELAPSED_TIME = "com.lyft.kronos.cached_elapsed_time" + internal const val KEY_BOOT_COUNT = "com.lyft.kronos.cached_boot_count" internal const val KEY_OFFSET = "com.lyft.kronos.cached_offset" } } diff --git a/kronos-java/src/main/java/com/lyft/kronos/Clock.kt b/kronos-java/src/main/java/com/lyft/kronos/Clock.kt index 914e796..401b656 100644 --- a/kronos-java/src/main/java/com/lyft/kronos/Clock.kt +++ b/kronos-java/src/main/java/com/lyft/kronos/Clock.kt @@ -10,6 +10,11 @@ interface Clock { * @return milliseconds since boot, including time spent in sleep. */ fun getElapsedTimeMs(): Long + + /** + * @return boot count. (optional) + */ + fun getBootCount(): Int? } data class KronosTime( diff --git a/kronos-java/src/main/java/com/lyft/kronos/SyncResponseCache.kt b/kronos-java/src/main/java/com/lyft/kronos/SyncResponseCache.kt index bfde435..995077d 100644 --- a/kronos-java/src/main/java/com/lyft/kronos/SyncResponseCache.kt +++ b/kronos-java/src/main/java/com/lyft/kronos/SyncResponseCache.kt @@ -2,7 +2,8 @@ package com.lyft.kronos interface SyncResponseCache { var currentTime : Long - var elapsedTime : Long + var elapsedTime: Long + var bootCount: Int? var currentOffset: Long fun clear() -} \ No newline at end of file +} diff --git a/kronos-java/src/main/java/com/lyft/kronos/internal/KronosClockImpl.kt b/kronos-java/src/main/java/com/lyft/kronos/internal/KronosClockImpl.kt index bef0377..9daefc9 100644 --- a/kronos-java/src/main/java/com/lyft/kronos/internal/KronosClockImpl.kt +++ b/kronos-java/src/main/java/com/lyft/kronos/internal/KronosClockImpl.kt @@ -15,10 +15,12 @@ internal class KronosClockImpl(private val ntpService: SntpService, private val override fun getElapsedTimeMs(): Long = fallbackClock.getElapsedTimeMs() + override fun getBootCount(): Int? = fallbackClock.getBootCount() + override fun getCurrentTime(): KronosTime { val currentTime = ntpService.currentTime() return currentTime ?: KronosTime(posixTimeMs = fallbackClock.getCurrentTimeMs(), timeSinceLastNtpSyncMs = null) } override fun getCurrentNtpTimeMs() : Long? = ntpService.currentTime()?.posixTimeMs -} \ No newline at end of file +} diff --git a/kronos-java/src/main/java/com/lyft/kronos/internal/ntp/SntpClient.java b/kronos-java/src/main/java/com/lyft/kronos/internal/ntp/SntpClient.java index 98a1bc7..fbece67 100644 --- a/kronos-java/src/main/java/com/lyft/kronos/internal/ntp/SntpClient.java +++ b/kronos-java/src/main/java/com/lyft/kronos/internal/ntp/SntpClient.java @@ -145,7 +145,7 @@ public Response requestTime(String host, Long timeout) throws IOException { // use the times on this side of the network latency // (response rather than request time) - return new Response(responseTime, responseTicks, clockOffset, deviceClock); + return new Response(responseTime, responseTicks, deviceClock.getBootCount(), clockOffset, deviceClock); } finally { if (socket != null) { socket.close(); @@ -206,14 +206,18 @@ public static final class Response { // Device system uptime (milliseconds since reboot) private final long deviceElapsedTimestampMs; + // Device boot count. (optional) + private final Integer deviceBootCount; + // delta between NTP and device clock (milliseconds) private final long offsetMs; private final Clock deviceClock; - Response(long deviceCurrentTimestampMs, long deviceElapsedTimestampMs, long offsetMs, Clock deviceClock) { + Response(long deviceCurrentTimestampMs, long deviceElapsedTimestampMs, Integer bootCount, long offsetMs, Clock deviceClock) { this.deviceCurrentTimestampMs = deviceCurrentTimestampMs; this.deviceElapsedTimestampMs = deviceElapsedTimestampMs; + this.deviceBootCount = bootCount; this.offsetMs = offsetMs; this.deviceClock = deviceClock; } @@ -241,6 +245,10 @@ public long getCurrentTimeMs() { return deviceCurrentTimestampMs + offsetMs + getResponseAge(); } + public Integer getDeviceBootCount() { + return deviceBootCount; + } + /** * @return offsetMs between device wall clock time and NTP time (ntpTimestampMs - deviceTime) */ @@ -261,6 +269,9 @@ public long getResponseAge() { * @return True if the system has not been rebooted since this was created, false otherwise. */ boolean isFromSameBoot() { + if (this.deviceBootCount != null) { + return this.deviceBootCount.equals(deviceClock.getBootCount()); + } final long bootTime = this.deviceCurrentTimestampMs - this.deviceElapsedTimestampMs; final long systemCurrentTimeMs = deviceClock.getCurrentTimeMs(); final long systemElapsedTimeMs = deviceClock.getElapsedTimeMs(); diff --git a/kronos-java/src/main/java/com/lyft/kronos/internal/ntp/SntpResponseCache.kt b/kronos-java/src/main/java/com/lyft/kronos/internal/ntp/SntpResponseCache.kt index f209fee..80cb962 100644 --- a/kronos-java/src/main/java/com/lyft/kronos/internal/ntp/SntpResponseCache.kt +++ b/kronos-java/src/main/java/com/lyft/kronos/internal/ntp/SntpResponseCache.kt @@ -22,9 +22,10 @@ internal class SntpResponseCacheImpl( val currentTime = syncResponseCache.currentTime val elapsedTime = syncResponseCache.elapsedTime val currentOffset = syncResponseCache.currentOffset + val bootCount = syncResponseCache.bootCount return when (elapsedTime) { TIME_UNAVAILABLE -> null - else -> SntpClient.Response(currentTime, elapsedTime, currentOffset, deviceClock) + else -> SntpClient.Response(currentTime, elapsedTime, bootCount, currentOffset, deviceClock) } } @@ -32,6 +33,7 @@ internal class SntpResponseCacheImpl( synchronized(this) { syncResponseCache.currentTime = response.deviceCurrentTimestampMs syncResponseCache.elapsedTime = response.deviceElapsedTimestampMs + syncResponseCache.bootCount = response.deviceBootCount syncResponseCache.currentOffset = response.offsetMs } } diff --git a/kronos-java/src/test/java/com/lyft/kronos/internal/ntp/SntpClientTest.java b/kronos-java/src/test/java/com/lyft/kronos/internal/ntp/SntpClientTest.java index 264ea40..47e1f40 100644 --- a/kronos-java/src/test/java/com/lyft/kronos/internal/ntp/SntpClientTest.java +++ b/kronos-java/src/test/java/com/lyft/kronos/internal/ntp/SntpClientTest.java @@ -72,9 +72,10 @@ private void setupDeviceClock(long current) { when(deviceClock.getCurrentTimeMs()).thenReturn(current); } - private void setupDeviceClock(long current, long elapsed) { + private void setupDeviceClock(long current, long elapsed, Integer bootCount) { when(deviceClock.getCurrentTimeMs()).thenReturn(current); when(deviceClock.getElapsedTimeMs()).thenReturn(elapsed); + when(deviceClock.getBootCount()).thenReturn(bootCount); } @Test @@ -177,18 +178,36 @@ public void requestTimeShouldSetTimeout() throws IOException { @Test public void responseShouldNotAdjustWhenBootTimeSeemsEqual() { - final SntpClient.Response original = new SntpClient.Response(10_000_000L, 6_000L, 1L, deviceClock); + final SntpClient.Response original = new SntpClient.Response(10_000_000L, 6_000L, null, 1L, deviceClock); // 10 seconds passed - setupDeviceClock(10_010_000L, 16_000L); + setupDeviceClock(10_010_000L, 16_000L, null); assertThat(original.isFromSameBoot()); } @Test public void responseShouldAdjustWhenBootTimeChanged() { - final SntpClient.Response original = new SntpClient.Response(10_000_000L, 6_000L, 1L, deviceClock); + final SntpClient.Response original = new SntpClient.Response(10_000_000L, 6_000L, null, 1L, deviceClock); // 10 seconds passed, but we rebooted 5 seconds ago - setupDeviceClock(10_010_000L, 5_000L); + setupDeviceClock(10_010_000L, 5_000L, null); + + assertThat(original.isFromSameBoot()).isFalse(); + } + + @Test + public void responseShouldAdjustWhenBootCountSeemsEqual() { + final SntpClient.Response original = new SntpClient.Response(10_000_000L, 6_000L, 10, 1L, deviceClock); + // 10 seconds passed, but we changed system clock forward by 30 seconds. + setupDeviceClock(10_040_000L, 16_000L, 10); + + assertThat(original.isFromSameBoot()); + } + + @Test + public void responseShouldAdjustWhenBootCountChanged() { + final SntpClient.Response original = new SntpClient.Response(10_000_000L, 5_000L, 10, 1L, deviceClock); + // 10 seconds passed, but we rebooted 5 seconds ago and changed system clock backward by 10 seconds + setupDeviceClock(10_000_000L, 5_000L, 11); assertThat(original.isFromSameBoot()).isFalse(); } diff --git a/kronos-java/src/test/java/com/lyft/kronos/internal/ntp/SntpResponseCacheTest.kt b/kronos-java/src/test/java/com/lyft/kronos/internal/ntp/SntpResponseCacheTest.kt index 3ddc7fb..21dfdbb 100644 --- a/kronos-java/src/test/java/com/lyft/kronos/internal/ntp/SntpResponseCacheTest.kt +++ b/kronos-java/src/test/java/com/lyft/kronos/internal/ntp/SntpResponseCacheTest.kt @@ -22,6 +22,7 @@ class SntpResponseCacheTest { init { whenever(deviceClock.getCurrentTimeMs()).thenReturn(CURRENT_TIME_MS) whenever(deviceClock.getElapsedTimeMs()).thenReturn(ELAPSED_MS) + whenever(deviceClock.getBootCount()).thenReturn(BOOT_COUNT) cache = SntpResponseCacheImpl(syncResponseCache, deviceClock) } @@ -38,15 +39,18 @@ class SntpResponseCacheTest { val currentTime = deviceClock.getCurrentTimeMs() val elapsedTime = deviceClock.getElapsedTimeMs() val currentOffset = TimeUnit.MINUTES.toMillis(5) + val bootCount = deviceClock.getBootCount() whenever(syncResponseCache.currentTime).thenReturn(currentTime) whenever(syncResponseCache.elapsedTime).thenReturn(elapsedTime) + whenever(syncResponseCache.bootCount).thenReturn(bootCount) whenever(syncResponseCache.currentOffset).thenReturn(currentOffset) val cachedResponse = cache.get() assertThat(cachedResponse).isNotNull() assertThat(cachedResponse!!.deviceCurrentTimestampMs).isEqualTo(currentTime) assertThat(cachedResponse.deviceElapsedTimestampMs).isEqualTo(elapsedTime) + assertThat(cachedResponse.deviceBootCount).isEqualTo(bootCount) assertThat(cachedResponse.offsetMs).isEqualTo(currentOffset) } @@ -75,11 +79,12 @@ class SntpResponseCacheTest { @Throws(Exception::class) fun testUpdate() { val currentOffset = TimeUnit.HOURS.toMillis(5) - val response = SntpClient.Response(CURRENT_TIME_MS, ELAPSED_MS, currentOffset, deviceClock) + val response = SntpClient.Response(CURRENT_TIME_MS, ELAPSED_MS, BOOT_COUNT, currentOffset, deviceClock) cache.update(response) verify(syncResponseCache, times(1)).currentTime = CURRENT_TIME_MS verify(syncResponseCache, times(1)).elapsedTime = ELAPSED_MS + verify(syncResponseCache, times(1)).bootCount = BOOT_COUNT verify(syncResponseCache, times(1)).currentOffset = currentOffset } @@ -95,5 +100,6 @@ class SntpResponseCacheTest { private val CURRENT_TIME_MS = 1522964196L private val ELAPSED_MS = TimeUnit.HOURS.toMillis(8) + private val BOOT_COUNT = 10 } }