Skip to content

Commit 92781b9

Browse files
committed
Surface 30-day trial on welcome license-entry page for apkstore/fdroid/lite
1 parent 5fcc334 commit 92781b9

9 files changed

Lines changed: 211 additions & 6 deletions

File tree

buildsystem/dependencies.gradle

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,12 +110,14 @@ ext {
110110
// testing dependencies
111111

112112
jUnitVersion = '5.11.4'
113+
jUnit4Version = '4.13.2'
113114
assertJVersion = '1.7.1'
114115
mockitoVersion = '5.18.0'
115116
mockitoKotlinVersion = '6.0.0'
116117
mockitoInlineVersion = '5.2.0'
117118
mockitoAndroidVersion = '5.18.0'
118119
hamcrestVersion = '1.3'
120+
robolectricVersion = '4.15'
119121
dexmakerVersion = '1.0'
120122
espressoVersion = '3.7.0'
121123
testingSupportLibVersion = '0.1'
@@ -151,6 +153,7 @@ ext {
151153
appauth : "net.openid:appauth:${appauthVersion}",
152154
documentFile : "androidx.documentfile:documentfile:${androidxDocumentfileVersion}",
153155
recyclerView : "androidx.recyclerview:recyclerview:${androidxRecyclerViewVersion}",
156+
robolectric : "org.robolectric:robolectric:${robolectricVersion}",
154157
androidxSplashscreen : "androidx.core:core-splashscreen:${androidxSplashscreenVersion}",
155158
androidxTestCore : "androidx.test:core:${androidxTestCoreVersion}",
156159
androidxTestJunitKtln : "androidx.test.ext:junit-ktx:${androidxTestJunitKtlnVersion}",
@@ -177,6 +180,7 @@ ext {
177180
junitApi : "org.junit.jupiter:junit-jupiter-api:${jUnitVersion}",
178181
junitEngine : "org.junit.jupiter:junit-jupiter-engine:${jUnitVersion}",
179182
junitParams : "org.junit.jupiter:junit-jupiter-params:${jUnitVersion}",
183+
junit4 : "junit:junit:${jUnit4Version}",
180184
junit4Engine : "org.junit.vintage:junit-vintage-engine:${jUnitVersion}",
181185
minIo : "io.minio:minio:${minIoVersion}",
182186
mockito : "org.mockito:mockito-core:${mockitoVersion}",

presentation/build.gradle

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,12 @@ android {
162162
quiet true
163163
}
164164

165+
testOptions {
166+
unitTests {
167+
includeAndroidResources = true
168+
}
169+
}
170+
165171
namespace 'org.cryptomator.presentation'
166172
}
167173

@@ -278,6 +284,7 @@ dependencies {
278284

279285
androidTestImplementation dependencies.runner
280286
androidTestImplementation dependencies.androidAnnotations
287+
androidTestImplementation dependencies.androidxTestJunitKtln
281288

282289

283290
testImplementation dependencies.junit
@@ -290,6 +297,9 @@ dependencies {
290297
testImplementation dependencies.mockitoKotlin
291298
testImplementation dependencies.mockitoInline
292299
testImplementation dependencies.hamcrest
300+
testImplementation dependencies.robolectric
301+
testImplementation dependencies.androidxTestCore
302+
testImplementation dependencies.junit4
293303
}
294304

295305
configurations {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
package org.cryptomator.presentation.ui.activity
2+
3+
import androidx.test.core.app.ActivityScenario
4+
import androidx.test.espresso.Espresso.onView
5+
import androidx.test.espresso.assertion.ViewAssertions.matches
6+
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
7+
import androidx.test.espresso.matcher.ViewMatchers.withId
8+
import androidx.test.espresso.matcher.ViewMatchers.withText
9+
import androidx.test.ext.junit.runners.AndroidJUnit4
10+
import androidx.test.platform.app.InstrumentationRegistry
11+
import org.cryptomator.presentation.R
12+
import org.cryptomator.presentation.intent.Intents
13+
import org.cryptomator.presentation.licensing.LicenseEnforcer
14+
import org.cryptomator.presentation.presenter.ContextHolder
15+
import org.cryptomator.util.SharedPreferencesHandler
16+
import org.hamcrest.CoreMatchers.allOf
17+
import org.hamcrest.CoreMatchers.not
18+
import org.junit.After
19+
import org.junit.Before
20+
import org.junit.Test
21+
import org.junit.runner.RunWith
22+
import java.util.concurrent.TimeUnit
23+
24+
@RunWith(AndroidJUnit4::class)
25+
class LicenseCheckActivityTrialScopeGuardTest {
26+
27+
private val targetContext by lazy { InstrumentationRegistry.getInstrumentation().targetContext }
28+
private val prefs by lazy { SharedPreferencesHandler(targetContext) }
29+
30+
@Before
31+
fun seedActiveTrial() {
32+
prefs.setTrialExpirationDate(System.currentTimeMillis() + TimeUnit.DAYS.toMillis(15))
33+
prefs.setTrialExpired(false)
34+
}
35+
36+
@After
37+
fun clearTrialState() {
38+
prefs.setTrialExpirationDate(0L)
39+
prefs.setTrialExpired(false)
40+
}
41+
42+
@Test
43+
fun licenseCheckActivity_onApkstore_hidesTrialRow_evenWithLatchedActiveTrial() {
44+
val contextHolder = object : ContextHolder {
45+
override fun context() = targetContext
46+
}
47+
val intent = Intents.licenseCheckIntent()
48+
.withLockedAction(LicenseEnforcer.LockedAction.CREATE_VAULT.name)
49+
.build(contextHolder)
50+
51+
ActivityScenario.launch<LicenseCheckActivity>(intent).use {
52+
onView(withId(R.id.purchaseOptionsGroup)).check(matches(not(isDisplayed())))
53+
onView(withId(R.id.rowTrial)).check(matches(not(isDisplayed())))
54+
onView(withId(R.id.tvInfoText)).check(
55+
matches(allOf(isDisplayed(), withText(R.string.screen_license_check_locked_create_vault)))
56+
)
57+
onView(withId(R.id.tvTrialStatusBadge)).check(matches(not(isDisplayed())))
58+
onView(withId(R.id.tvTrialExpiration)).check(matches(not(isDisplayed())))
59+
}
60+
}
61+
}

presentation/src/main/java/org/cryptomator/presentation/ui/activity/WelcomeActivity.kt

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,14 @@ class WelcomeActivity : BaseActivity<ActivityWelcomeBinding>(ActivityWelcomeBind
6666
return
6767
}
6868
pagerAdapter.licenseFragment?.updateUnlocked(hasWriteAccess, hasPaidLicense)
69+
if (!FlavorConfig.isPremiumFlavor && !FlavorConfig.isFreemiumFlavor && !hasPaidLicense) {
70+
val uiState = licenseEnforcer.evaluateUiState(this@WelcomeActivity)
71+
pagerAdapter.licenseFragment?.updateTrialState(
72+
uiState.trialState.isActive,
73+
uiState.trialState.isExpired,
74+
uiState.trialExpirationText
75+
)
76+
}
6977
}
7078
override fun onTrialStateChanged(active: Boolean, expired: Boolean, expirationText: String?) {
7179
if (!this@WelcomeActivity::pagerAdapter.isInitialized) {
@@ -160,11 +168,7 @@ class WelcomeActivity : BaseActivity<ActivityWelcomeBinding>(ActivityWelcomeBind
160168
override fun onPageSelected(position: Int) {
161169
updateNavigationButtons(position)
162170
when (pages[position]) {
163-
is FragmentPage.License -> {
164-
if (FlavorConfig.isFreemiumFlavor) {
165-
orchestrator.updateState()
166-
}
167-
}
171+
is FragmentPage.License -> orchestrator.updateState()
168172
is FragmentPage.Notifications -> updateNotificationPermissionState()
169173
is FragmentPage.ScreenLock -> updateScreenLockState()
170174
is FragmentPage.Intro -> Unit

presentation/src/main/java/org/cryptomator/presentation/ui/fragment/WelcomeLicenseFragment.kt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,9 @@ class WelcomeLicenseFragment : BaseFragment<FragmentWelcomeLicenseBinding>(Fragm
6161
}
6262

6363
private fun setupLicenseEntryUi() {
64-
licenseContentViewBinder.bindInitialLicenseEntryLayout()
64+
licenseContentViewBinder.bindInitialLicenseEntryWithTrialLayout()
65+
binding.licenseContent.btnTrial.text = getString(R.string.screen_welcome_trial_button)
66+
binding.licenseContent.btnTrial.setOnClickListener { listener?.onStartTrial() }
6567
binding.licenseContent.tvLicenseLink.setOnClickListener { listener?.onOpenLicenseLink() }
6668
binding.licenseContent.btnPurchase.visibility = View.GONE
6769
binding.licenseContent.etLicense.addTextChangedListener(object : TextWatcher {

presentation/src/main/java/org/cryptomator/presentation/ui/layout/LicenseContentViewBinder.kt

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,17 @@ class LicenseContentViewBinder(
4444
binding.tvLicenseLink.text = context.getString(R.string.dialog_enter_license_content)
4545
}
4646

47+
/** Sets the initial visibility state for license-entry mode with the trial row visible (welcome flow only). */
48+
fun bindInitialLicenseEntryWithTrialLayout() {
49+
bindInitialLicenseEntryLayout()
50+
binding.purchaseOptionsGroup.visibility = View.VISIBLE
51+
binding.rowSubscription.visibility = View.GONE
52+
binding.rowLifetime.visibility = View.GONE
53+
binding.dividerTrialSubscription.visibility = View.GONE
54+
binding.dividerSubscriptionLifetime.visibility = View.GONE
55+
binding.rowTrial.visibility = View.VISIBLE
56+
}
57+
4758
/** Sets click listeners on Terms and Privacy links. */
4859
fun bindLegalLinks() {
4960
binding.tvTerms.setOnClickListener {
@@ -114,6 +125,10 @@ class LicenseContentViewBinder(
114125
}
115126
} else {
116127
binding.btnPurchase.isEnabled = !unlocked
128+
if (hasPaidLicense) {
129+
binding.rowTrial.visibility = View.GONE
130+
binding.tvInfoText.visibility = View.GONE
131+
}
117132
}
118133
}
119134

presentation/src/main/res/layout/view_license_check_content.xml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,7 @@
225225
</LinearLayout>
226226

227227
<View
228+
android:id="@+id/dividerTrialSubscription"
228229
android:layout_width="match_parent"
229230
android:layout_height="1dp"
230231
android:background="?android:attr/listDivider" />
@@ -273,6 +274,7 @@
273274
</LinearLayout>
274275

275276
<View
277+
android:id="@+id/dividerSubscriptionLifetime"
276278
android:layout_width="match_parent"
277279
android:layout_height="1dp"
278280
android:background="?android:attr/listDivider" />

presentation/src/main/res/values/strings.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@
107107
<string name="screen_welcome_screen_lock_button">Set screen lock</string>
108108
<string name="screen_welcome_screen_lock_already_set">Screen lock is already set.</string>
109109
<string name="screen_welcome_intro_body">Thanks for choosing Cryptomator to protect your files.\n\nWith Cryptomator, the key to your data is in your hands.\n\nCryptomator encrypts your data quickly and easily.</string>
110+
<string name="screen_welcome_trial_button" translatable="false">@string/screen_license_check_trial_price</string>
110111
<string name="next">Next</string>
111112
<string name="back">Back</string>
112113
<string name="screen_settings_license_unlock_prompt">Unlock write access</string>
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
package org.cryptomator.presentation.ui.layout
2+
3+
import android.content.Context
4+
import android.view.LayoutInflater
5+
import android.view.View
6+
import androidx.test.core.app.ApplicationProvider
7+
import org.cryptomator.presentation.R
8+
import org.cryptomator.presentation.databinding.ViewLicenseCheckContentBinding
9+
import org.hamcrest.CoreMatchers.`is`
10+
import org.hamcrest.MatcherAssert.assertThat
11+
import org.junit.Before
12+
import org.junit.Test
13+
import org.junit.runner.RunWith
14+
import org.robolectric.RobolectricTestRunner
15+
16+
@RunWith(RobolectricTestRunner::class)
17+
class LicenseContentViewBinderTest {
18+
19+
private lateinit var binding: ViewLicenseCheckContentBinding
20+
private lateinit var context: Context
21+
22+
@Before
23+
fun setUp() {
24+
context = ApplicationProvider.getApplicationContext()
25+
context.setTheme(R.style.AppTheme)
26+
binding = ViewLicenseCheckContentBinding.inflate(LayoutInflater.from(context))
27+
}
28+
29+
@Test
30+
fun `bindTrialState with no trial shows trial button group and hides badge and expiration`() {
31+
val binder = LicenseContentViewBinder(binding, isFreemiumFlavor = false)
32+
33+
binder.bindTrialState(active = false, expired = false, expirationText = null)
34+
35+
assertThat(binding.trialButtonGroup.visibility, `is`(View.VISIBLE))
36+
assertThat(binding.tvTrialStatusBadge.visibility, `is`(View.GONE))
37+
assertThat(binding.tvTrialExpiration.visibility, `is`(View.GONE))
38+
assertThat(binding.btnTrial.isEnabled, `is`(true))
39+
}
40+
41+
@Test
42+
fun `bindTrialState with active trial hides button and shows active badge and expiration`() {
43+
val binder = LicenseContentViewBinder(binding, isFreemiumFlavor = false)
44+
val expirationText = "Expiration Date: 2026-05-19"
45+
46+
binder.bindTrialState(active = true, expired = false, expirationText = expirationText)
47+
48+
assertThat(binding.trialButtonGroup.visibility, `is`(View.GONE))
49+
assertThat(binding.tvTrialStatusBadge.visibility, `is`(View.VISIBLE))
50+
assertThat(binding.tvTrialStatusBadge.text.toString(), `is`(context.getString(R.string.screen_license_check_trial_status_active)))
51+
assertThat(binding.tvTrialExpiration.visibility, `is`(View.VISIBLE))
52+
assertThat(binding.tvTrialExpiration.text.toString(), `is`(expirationText))
53+
assertThat(binding.tvInfoText.visibility, `is`(View.GONE))
54+
}
55+
56+
@Test
57+
fun `bindTrialState with expired trial shows expired badge and info text`() {
58+
val binder = LicenseContentViewBinder(binding, isFreemiumFlavor = false)
59+
val expirationText = "Expiration Date: 2026-03-19"
60+
61+
binder.bindTrialState(active = false, expired = true, expirationText = expirationText)
62+
63+
assertThat(binding.trialButtonGroup.visibility, `is`(View.GONE))
64+
assertThat(binding.tvTrialStatusBadge.visibility, `is`(View.VISIBLE))
65+
assertThat(binding.tvTrialStatusBadge.text.toString(), `is`(context.getString(R.string.screen_license_check_trial_status_expired)))
66+
assertThat(binding.tvTrialExpiration.visibility, `is`(View.VISIBLE))
67+
assertThat(binding.tvTrialExpiration.text.toString(), `is`(expirationText))
68+
assertThat(binding.tvInfoText.visibility, `is`(View.VISIBLE))
69+
assertThat(binding.tvInfoText.text.toString(), `is`(context.getString(R.string.screen_license_check_trial_expired_info)))
70+
}
71+
72+
@Test
73+
fun `bindPurchaseState on non-freemium with paid license hides trial row and info text and disables purchase button`() {
74+
val binder = LicenseContentViewBinder(binding, isFreemiumFlavor = false)
75+
76+
binder.bindPurchaseState(unlocked = true, hasPaidLicense = true)
77+
78+
assertThat(binding.rowTrial.visibility, `is`(View.GONE))
79+
assertThat(binding.tvInfoText.visibility, `is`(View.GONE))
80+
assertThat(binding.btnPurchase.isEnabled, `is`(false))
81+
}
82+
83+
@Test
84+
fun `bindPurchaseState on freemium with paid license hides purchase options and trial surfaces`() {
85+
val binder = LicenseContentViewBinder(binding, isFreemiumFlavor = true)
86+
87+
binder.bindPurchaseState(unlocked = true, hasPaidLicense = true)
88+
89+
assertThat(binding.purchaseOptionsGroup.visibility, `is`(View.GONE))
90+
assertThat(binding.tvRestorePurchase.visibility, `is`(View.GONE))
91+
assertThat(binding.tvInfoText.visibility, `is`(View.GONE))
92+
assertThat(binding.tvTrialStatusBadge.visibility, `is`(View.GONE))
93+
assertThat(binding.tvTrialExpiration.visibility, `is`(View.GONE))
94+
}
95+
96+
@Test
97+
fun `transitioning from expired trial to paid license clears info text`() {
98+
val binder = LicenseContentViewBinder(binding, isFreemiumFlavor = false)
99+
100+
binder.bindTrialState(active = false, expired = true, expirationText = "Expiration Date: 2026-03-19")
101+
binder.bindPurchaseState(unlocked = true, hasPaidLicense = true)
102+
103+
assertThat(binding.rowTrial.visibility, `is`(View.GONE))
104+
assertThat(binding.tvInfoText.visibility, `is`(View.GONE))
105+
}
106+
}

0 commit comments

Comments
 (0)