Skip to content

Commit 2d06892

Browse files
committed
Add sticky trial expiry flag to block clock rollback
1 parent aba415d commit 2d06892

File tree

3 files changed

+112
-3
lines changed

3 files changed

+112
-3
lines changed

presentation/src/main/java/org/cryptomator/presentation/licensing/LicenseEnforcer.kt

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -89,16 +89,27 @@ class LicenseEnforcer @Inject constructor(private val sharedPreferencesHandler:
8989
sharedPreferencesHandler.setTrialExpirationDate(trialExpiration)
9090
}
9191

92+
private fun observeTrialExpiry() {
93+
val trialExpiration = sharedPreferencesHandler.trialExpirationDate()
94+
val now = System.currentTimeMillis()
95+
if (trialExpiration > 0 && trialExpiration <= now && !sharedPreferencesHandler.isTrialExpired()) {
96+
sharedPreferencesHandler.setTrialExpired(true)
97+
}
98+
}
99+
92100
fun hasActiveTrial(): Boolean {
101+
observeTrialExpiry()
93102
val trialExpiration = sharedPreferencesHandler.trialExpirationDate()
94-
return trialExpiration > 0 && trialExpiration > System.currentTimeMillis()
103+
return trialExpiration > 0 && trialExpiration > System.currentTimeMillis() && !sharedPreferencesHandler.isTrialExpired()
95104
}
96105

97106
fun evaluateTrialState(): TrialState {
107+
observeTrialExpiry()
98108
val trialExpiration = sharedPreferencesHandler.trialExpirationDate()
99109
val now = System.currentTimeMillis()
100-
val active = trialExpiration > 0 && trialExpiration > now
101-
val expired = trialExpiration > 0 && trialExpiration <= now
110+
val sticky = sharedPreferencesHandler.isTrialExpired()
111+
val active = trialExpiration > 0 && trialExpiration > now && !sticky
112+
val expired = trialExpiration > 0 && (trialExpiration <= now || sticky)
102113
val formattedDate = if (active || expired) {
103114
DateFormat.getDateInstance().format(Date(trialExpiration))
104115
} else null

presentation/src/test/java/org/cryptomator/presentation/licensing/LicenseEnforcerTest.kt

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import org.junit.jupiter.api.BeforeEach
1616
import org.junit.jupiter.api.Test
1717
import org.mockito.ArgumentCaptor
1818
import org.mockito.ArgumentMatchers.any
19+
import org.mockito.ArgumentMatchers.anyBoolean
1920
import org.mockito.ArgumentMatchers.anyLong
2021
import org.mockito.ArgumentMatchers.eq
2122
import org.mockito.Mockito.mock
@@ -59,6 +60,7 @@ class LicenseEnforcerTest {
5960
`when`(sharedPreferencesHandler.licenseToken()).thenReturn("")
6061
`when`(sharedPreferencesHandler.hasRunningSubscription()).thenReturn(false)
6162
`when`(sharedPreferencesHandler.trialExpirationDate()).thenReturn(System.currentTimeMillis() + 86400000L)
63+
`when`(sharedPreferencesHandler.isTrialExpired()).thenReturn(false)
6264

6365
assertTrue(licenseEnforcer.hasWriteAccess())
6466
}
@@ -69,6 +71,7 @@ class LicenseEnforcerTest {
6971
`when`(sharedPreferencesHandler.licenseToken()).thenReturn("")
7072
`when`(sharedPreferencesHandler.hasRunningSubscription()).thenReturn(false)
7173
`when`(sharedPreferencesHandler.trialExpirationDate()).thenReturn(System.currentTimeMillis() - 1000L)
74+
`when`(sharedPreferencesHandler.isTrialExpired()).thenReturn(false)
7275

7376
assertFalse(licenseEnforcer.hasWriteAccess())
7477
}
@@ -150,6 +153,7 @@ class LicenseEnforcerTest {
150153
`when`(sharedPreferencesHandler.licenseToken()).thenReturn("")
151154
`when`(sharedPreferencesHandler.hasRunningSubscription()).thenReturn(false)
152155
`when`(sharedPreferencesHandler.trialExpirationDate()).thenReturn(System.currentTimeMillis() + 86400000L)
156+
`when`(sharedPreferencesHandler.isTrialExpired()).thenReturn(false)
153157

154158
assertTrue(licenseEnforcer.hasWriteAccessForVault(vault))
155159
}
@@ -260,13 +264,15 @@ class LicenseEnforcerTest {
260264
@Test
261265
fun `hasActiveTrial returns true when trial expiration is in the future`() {
262266
`when`(sharedPreferencesHandler.trialExpirationDate()).thenReturn(System.currentTimeMillis() + 86400000L)
267+
`when`(sharedPreferencesHandler.isTrialExpired()).thenReturn(false)
263268

264269
assertTrue(licenseEnforcer.hasActiveTrial())
265270
}
266271

267272
@Test
268273
fun `hasActiveTrial returns false when trial expiration is in the past`() {
269274
`when`(sharedPreferencesHandler.trialExpirationDate()).thenReturn(System.currentTimeMillis() - 1000L)
275+
`when`(sharedPreferencesHandler.isTrialExpired()).thenReturn(false)
270276

271277
assertFalse(licenseEnforcer.hasActiveTrial())
272278
}
@@ -319,6 +325,7 @@ class LicenseEnforcerTest {
319325
fun `evaluateTrialState returns active with formatted date when trial is active`() {
320326
val futureDate = System.currentTimeMillis() + 86400000L
321327
`when`(sharedPreferencesHandler.trialExpirationDate()).thenReturn(futureDate)
328+
`when`(sharedPreferencesHandler.isTrialExpired()).thenReturn(false)
322329

323330
val state = licenseEnforcer.evaluateTrialState()
324331

@@ -330,6 +337,7 @@ class LicenseEnforcerTest {
330337
@Test
331338
fun `evaluateTrialState returns expired with formatted date when trial is expired`() {
332339
`when`(sharedPreferencesHandler.trialExpirationDate()).thenReturn(System.currentTimeMillis() - 1000L)
340+
`when`(sharedPreferencesHandler.isTrialExpired()).thenReturn(false)
333341

334342
val state = licenseEnforcer.evaluateTrialState()
335343

@@ -349,6 +357,85 @@ class LicenseEnforcerTest {
349357
assertNull(state.formattedExpirationDate)
350358
}
351359

360+
// -- sticky trial expiry --
361+
362+
@Test
363+
fun `hasActiveTrial latches isTrialExpired when expiration is in the past`() {
364+
`when`(sharedPreferencesHandler.trialExpirationDate()).thenReturn(System.currentTimeMillis() - 1000L)
365+
`when`(sharedPreferencesHandler.isTrialExpired()).thenReturn(false)
366+
367+
assertFalse(licenseEnforcer.hasActiveTrial())
368+
verify(sharedPreferencesHandler).setTrialExpired(true)
369+
}
370+
371+
@Test
372+
fun `hasActiveTrial returns false when isTrialExpired is already true even if date is in the future`() {
373+
`when`(sharedPreferencesHandler.trialExpirationDate()).thenReturn(System.currentTimeMillis() + 86400000L)
374+
`when`(sharedPreferencesHandler.isTrialExpired()).thenReturn(true)
375+
376+
assertFalse(licenseEnforcer.hasActiveTrial())
377+
verify(sharedPreferencesHandler, never()).setTrialExpired(anyBoolean())
378+
}
379+
380+
@Test
381+
fun `evaluateTrialState latches isTrialExpired and returns expired when date is in the past`() {
382+
`when`(sharedPreferencesHandler.trialExpirationDate()).thenReturn(System.currentTimeMillis() - 1000L)
383+
`when`(sharedPreferencesHandler.isTrialExpired()).thenReturn(false)
384+
385+
val state = licenseEnforcer.evaluateTrialState()
386+
387+
assertFalse(state.isActive)
388+
assertTrue(state.isExpired)
389+
verify(sharedPreferencesHandler).setTrialExpired(true)
390+
}
391+
392+
@Test
393+
fun `evaluateTrialState returns expired when isTrialExpired is already true even if date is in the future`() {
394+
`when`(sharedPreferencesHandler.trialExpirationDate()).thenReturn(System.currentTimeMillis() + 86400000L)
395+
`when`(sharedPreferencesHandler.isTrialExpired()).thenReturn(true)
396+
397+
val state = licenseEnforcer.evaluateTrialState()
398+
399+
assertFalse(state.isActive)
400+
assertTrue(state.isExpired)
401+
assertNotNull(state.formattedExpirationDate)
402+
verify(sharedPreferencesHandler, never()).setTrialExpired(anyBoolean())
403+
}
404+
405+
@Test
406+
fun `observeTrialExpiry idempotent once flag is set`() {
407+
`when`(sharedPreferencesHandler.trialExpirationDate()).thenReturn(System.currentTimeMillis() - 1000L)
408+
`when`(sharedPreferencesHandler.isTrialExpired()).thenReturn(true)
409+
410+
licenseEnforcer.hasActiveTrial()
411+
licenseEnforcer.evaluateTrialState()
412+
413+
verify(sharedPreferencesHandler, never()).setTrialExpired(anyBoolean())
414+
}
415+
416+
@Test
417+
fun `startTrial does not reset sticky trialExpired flag`() {
418+
`when`(sharedPreferencesHandler.trialExpirationDate()).thenReturn(0L)
419+
`when`(sharedPreferencesHandler.isTrialExpired()).thenReturn(true)
420+
421+
licenseEnforcer.startTrial()
422+
423+
verify(sharedPreferencesHandler, never()).setTrialExpired(anyBoolean())
424+
}
425+
426+
@Test
427+
fun `fresh install with no trial and no flag behaves unchanged`() {
428+
`when`(sharedPreferencesHandler.trialExpirationDate()).thenReturn(0L)
429+
`when`(sharedPreferencesHandler.isTrialExpired()).thenReturn(false)
430+
431+
assertFalse(licenseEnforcer.hasActiveTrial())
432+
val state = licenseEnforcer.evaluateTrialState()
433+
assertFalse(state.isActive)
434+
assertFalse(state.isExpired)
435+
assertNull(state.formattedExpirationDate)
436+
verify(sharedPreferencesHandler, never()).setTrialExpired(anyBoolean())
437+
}
438+
352439
// -- evaluateUiState --
353440

354441
@Test
@@ -358,6 +445,7 @@ class LicenseEnforcerTest {
358445
`when`(sharedPreferencesHandler.licenseToken()).thenReturn("")
359446
`when`(sharedPreferencesHandler.hasRunningSubscription()).thenReturn(false)
360447
`when`(sharedPreferencesHandler.trialExpirationDate()).thenReturn(System.currentTimeMillis() + 86400000L)
448+
`when`(sharedPreferencesHandler.isTrialExpired()).thenReturn(false)
361449
`when`(context.getString(eq(R.string.screen_license_check_trial_expiration), any())).thenReturn("Expiration Date: Mar 28, 2026")
362450

363451
val uiState = licenseEnforcer.evaluateUiState(context)
@@ -375,6 +463,7 @@ class LicenseEnforcerTest {
375463
`when`(sharedPreferencesHandler.licenseToken()).thenReturn("")
376464
`when`(sharedPreferencesHandler.hasRunningSubscription()).thenReturn(false)
377465
`when`(sharedPreferencesHandler.trialExpirationDate()).thenReturn(System.currentTimeMillis() - 1000L)
466+
`when`(sharedPreferencesHandler.isTrialExpired()).thenReturn(false)
378467
`when`(context.getString(eq(R.string.screen_license_check_trial_expiration), any())).thenReturn("Expiration Date: Mar 26, 2026")
379468

380469
val uiState = licenseEnforcer.evaluateUiState(context)

util/src/main/java/org/cryptomator/util/SharedPreferencesHandler.kt

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,14 @@ override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key
219219
defaultSharedPreferences.setValue(TRIAL_EXPIRATION_DATE, date)
220220
}
221221

222+
fun isTrialExpired(): Boolean {
223+
return defaultSharedPreferences.getValue(TRIAL_EXPIRED, false)
224+
}
225+
226+
fun setTrialExpired(value: Boolean) {
227+
defaultSharedPreferences.setValue(TRIAL_EXPIRED, value)
228+
}
229+
222230
fun hasRunningSubscription(): Boolean {
223231
return defaultSharedPreferences.getValue(HAS_RUNNING_SUBSCRIPTION, false)
224232
}
@@ -372,6 +380,7 @@ override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key
372380
private const val LAST_UPDATE_CHECK = "lastUpdateCheck"
373381
private const val WELCOME_FLOW_COMPLETED = "welcomeFlowCompleted"
374382
private const val TRIAL_EXPIRATION_DATE = "trialExpirationDate"
383+
private const val TRIAL_EXPIRED = "trialExpired"
375384
private const val HAS_RUNNING_SUBSCRIPTION = "hasRunningSubscription"
376385
const val DEBUG_MODE = "debugMode"
377386
const val DISABLE_APP_WHEN_OBSCURED = "disableAppWhenObscured"

0 commit comments

Comments
 (0)