diff --git a/app/src/androidTest/kotlin/com/itsaky/androidide/app/strictmode/WhitelistRulesTest.kt b/app/src/androidTest/kotlin/com/itsaky/androidide/app/strictmode/WhitelistRulesTest.kt index c061a2fbc5..c90f0acd50 100644 --- a/app/src/androidTest/kotlin/com/itsaky/androidide/app/strictmode/WhitelistRulesTest.kt +++ b/app/src/androidTest/kotlin/com/itsaky/androidide/app/strictmode/WhitelistRulesTest.kt @@ -73,7 +73,7 @@ class WhitelistRulesTest { stackTraceElement("com.google.firebase.components.Lazy", "get", "Lazy.java", 53), stackTraceElement("com.google.firebase.FirebaseApp", "isDataCollectionDefaultEnabled", "FirebaseApp.java", 371), stackTraceElement("com.google.firebase.analytics.connector.AnalyticsConnectorImpl", "getInstance", "play-services-measurement-api@@22.1.2", 31), - stackTraceElement("com.google.firebase.FirebaseApp\$UserUnlockReceiver", "onReceive", "FirebaseApp.java", 672) + stackTraceElement("com.google.firebase.FirebaseApp\$UserUnlockReceiver", "onReceive", "FirebaseApp.java", 672), // @formatter:on ) } @@ -84,7 +84,7 @@ class WhitelistRulesTest { stackTraceElement("com.itsaky.androidide.utils.ServiceLoader", "parse"), stackTraceElement( "com.itsaky.androidide.app.configuration.IJdkDistributionProvider\$Companion", - "_instance_delegate\$lambda\$0" + "_instance_delegate\$lambda\$0", ), ) } @@ -95,7 +95,7 @@ class WhitelistRulesTest { stackTraceElement("java.io.File", "exists"), stackTraceElement( "com.itsaky.androidide.activities.OnboardingActivity", - "checkToolsIsInstalled" + "checkToolsIsInstalled", ), ) } @@ -207,6 +207,33 @@ class WhitelistRulesTest { ) } + @Test + fun allow_DiskRead_on_TelemetryConsentPrefsResolution() { + assertAllowed( + // @formatter:off + stackTraceElement("java.io.File", "exists"), + stackTraceElement("android.app.ContextImpl", "getDataDir"), + stackTraceElement("android.app.ContextImpl", "getPreferencesDir"), + stackTraceElement("android.app.ContextImpl", "getSharedPreferencesPath"), + stackTraceElement("android.app.ContextImpl", "getSharedPreferences"), + stackTraceElement("com.itsaky.androidide.preferences.internal.StatPreferences", "getPrefs"), + stackTraceElement("com.itsaky.androidide.preferences.internal.StatPreferences", "getTelemetryConsent"), + // @formatter:on + ) + } + + @Test + fun allow_DiskRead_on_TelemetryConsentFirstRead() { + assertAllowed( + // @formatter:off + stackTraceElement("android.os.StrictMode\$AndroidBlockGuardPolicy", "onReadFromDisk"), + stackTraceElement("android.app.SharedPreferencesImpl", "awaitLoadedLocked"), + stackTraceElement("android.app.SharedPreferencesImpl", "getString"), + stackTraceElement("com.itsaky.androidide.preferences.internal.StatPreferences", "getTelemetryConsent"), + // @formatter:on + ) + } + @Test fun allow_DiskWrite_on_MtkAsyncDrawableCache_OsChmod() { assertAllowed( diff --git a/app/src/androidTest/kotlin/com/itsaky/androidide/helper/HandlePrivacyDisclosureHelper.kt b/app/src/androidTest/kotlin/com/itsaky/androidide/helper/HandlePrivacyDisclosureHelper.kt index 1721e62e3b..ceded7d292 100644 --- a/app/src/androidTest/kotlin/com/itsaky/androidide/helper/HandlePrivacyDisclosureHelper.kt +++ b/app/src/androidTest/kotlin/com/itsaky/androidide/helper/HandlePrivacyDisclosureHelper.kt @@ -3,7 +3,8 @@ package com.itsaky.androidide.helper import android.util.Log import androidx.test.platform.app.InstrumentationRegistry import androidx.test.uiautomator.UiSelector -import com.itsaky.androidide.preferences.internal.prefManager +import com.itsaky.androidide.preferences.internal.StatPreferences +import com.itsaky.androidide.preferences.internal.TelemetryConsent import com.kaspersky.kaspresso.testcases.core.testcontext.TestContext import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue @@ -11,36 +12,32 @@ import com.itsaky.androidide.resources.R as ResourcesR private const val TAG = "PrivacyDisclosure" -// Mirrors PermissionsFragment.KEY_PRIVACY_DISCLOSURE_SHOWN (private there). -// If the dialog unexpectedly appears on a rerun or is expected but absent, -// check that the fragment's key has not been renamed. -private const val KEY_PRIVACY_DISCLOSURE_SHOWN = "privacy.disclosure.shown" private const val PRIVACY_DIALOG_APPEAR_TIMEOUT_MS = 10_000L private const val PRIVACY_DIALOG_ABSENT_TIMEOUT_MS = 2_000L private const val PRIVACY_FLAG_PERSIST_TIMEOUT_MS = 5_000L /** - * Verifies and dismisses the privacy disclosure dialog on the onboarding + * Verifies and accepts the telemetry consent dialog on the onboarding * permissions screen. * - * The app shows the dialog only while the persisted - * `privacy.disclosure.shown` flag is unset, so the flow - * branches on that flag instead of on whether the dialog happened to render in - * time: a fresh install hard-asserts the dialog appears and accepts it, while a - * rerun on a device that already accepted asserts it stays hidden. + * The app shows the dialog only while the persisted telemetry consent is + * [TelemetryConsent.UNSET], so the flow branches on that value instead of on + * whether the dialog happened to render in time: a fresh install hard-asserts + * the dialog appears and accepts it, while a rerun on a device that already + * answered asserts it stays hidden. */ fun TestContext.handlePrivacyDisclosure() { val targetContext = InstrumentationRegistry.getInstrumentation().targetContext val dialogTitle = targetContext.getString(ResourcesR.string.privacy_disclosure_title) - val expectDialog = - !prefManager.getBoolean(KEY_PRIVACY_DISCLOSURE_SHOWN, false) + val expectDialog = StatPreferences.telemetryConsent == TelemetryConsent.UNSET if (expectDialog) { - Log.i(TAG, "Privacy disclosure flag unset; expecting dialog and accepting it") + Log.i(TAG, "Telemetry consent unset; expecting dialog and accepting it") step("Verify and accept privacy disclosure") { val d = device.uiDevice val acceptText = targetContext.getString(ResourcesR.string.privacy_disclosure_accept) + val declineText = targetContext.getString(ResourcesR.string.privacy_disclosure_decline) val learnMoreText = targetContext.getString(ResourcesR.string.privacy_disclosure_learn_more) @@ -51,6 +48,10 @@ fun TestContext.handlePrivacyDisclosure() { .waitForExists(PRIVACY_DIALOG_APPEAR_TIMEOUT_MS), ) assertTrue("Accept button missing", d.findObject(UiSelector().text(acceptText)).exists()) + assertTrue( + "Keep offline button missing", + d.findObject(UiSelector().text(declineText)).exists(), + ) assertTrue( "Learn more button missing", d.findObject(UiSelector().text(learnMoreText)).exists(), @@ -60,16 +61,16 @@ fun TestContext.handlePrivacyDisclosure() { d.waitForIdle() // The accessibility click is dispatched asynchronously; retry until the - // dialog's positive-button listener has persisted the flag. + // dialog's positive-button listener has persisted the consent. flakySafely(timeoutMs = PRIVACY_FLAG_PERSIST_TIMEOUT_MS) { assertTrue( - "Accepting the disclosure did not persist the shown flag", - prefManager.getBoolean(KEY_PRIVACY_DISCLOSURE_SHOWN, false), + "Accepting the disclosure did not persist the consent", + StatPreferences.telemetryConsent == TelemetryConsent.GRANTED, ) } } } else { - Log.i(TAG, "Privacy disclosure already accepted (flag set); verifying dialog stays hidden") + Log.i(TAG, "Telemetry consent already answered; verifying dialog stays hidden") } step("Verify privacy dialog is not shown") { diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 2cd24756d1..cf216f8b6c 100755 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -158,6 +158,17 @@ + + + + + " + "", ), ), listOf( classAndMethod( "com.google.firebase.FirebaseApp\$UserUnlockReceiver", - "onReceive" + "onReceive", ), ), ), @@ -203,7 +204,10 @@ object WhitelistEngine { matchFramesInOrder( classAndMethod("com.itsaky.androidide.utils.ServiceLoader", "parse"), - classAndMethod("com.itsaky.androidide.app.configuration.IJdkDistributionProvider\$Companion", "_instance_delegate\$lambda\$0"), + classAndMethod( + "com.itsaky.androidide.app.configuration.IJdkDistributionProvider\$Companion", + "_instance_delegate\$lambda\$0", + ), ) } @@ -329,6 +333,26 @@ object WhitelistEngine { ), ) } + + rule { + ofType() + allow( + """ + StatPreferences stores the telemetry consent in device-protected + SharedPreferences. Resolving and loading that file is a once-per-process + read on the startup path that gates telemetry init (ADFA-4942), and cannot + be deferred. + """.trimIndent(), + ) + + matchFramesInOrder( + anyOf( + classAndMethod("android.app.ContextImpl", "getSharedPreferences"), + classAndMethod("android.app.SharedPreferencesImpl", "awaitLoadedLocked"), + ), + classEquals("com.itsaky.androidide.preferences.internal.StatPreferences"), + ) + } } /** diff --git a/app/src/main/java/com/itsaky/androidide/fragments/onboarding/PermissionsFragment.kt b/app/src/main/java/com/itsaky/androidide/fragments/onboarding/PermissionsFragment.kt index 750b6ac2fb..d2a06f5d86 100644 --- a/app/src/main/java/com/itsaky/androidide/fragments/onboarding/PermissionsFragment.kt +++ b/app/src/main/java/com/itsaky/androidide/fragments/onboarding/PermissionsFragment.kt @@ -28,6 +28,7 @@ import android.view.ViewGroup import android.view.animation.Animation import android.view.animation.AnimationUtils import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.app.AlertDialog import androidx.core.net.toUri import androidx.fragment.app.viewModels import androidx.lifecycle.Lifecycle @@ -41,17 +42,20 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.itsaky.androidide.R import com.itsaky.androidide.activities.OnboardingActivity import com.itsaky.androidide.adapters.onboarding.OnboardingPermissionsAdapter +import com.itsaky.androidide.app.DeviceProtectedApplicationLoader +import com.itsaky.androidide.app.IDEApplication import com.itsaky.androidide.buildinfo.BuildInfo import com.itsaky.androidide.databinding.LayoutOnboardingPermissionsBinding import com.itsaky.androidide.events.InstallationEvent -import com.itsaky.androidide.preferences.internal.prefManager +import com.itsaky.androidide.preferences.internal.StatPreferences +import com.itsaky.androidide.preferences.internal.TelemetryConsent import com.itsaky.androidide.tasks.doAsyncWithProgress import com.itsaky.androidide.utils.OverlayPermissionGuide import com.itsaky.androidide.utils.PermissionsHelper import com.itsaky.androidide.utils.flashError -import com.itsaky.androidide.utils.isTestMode import com.itsaky.androidide.utils.flashSuccess import com.itsaky.androidide.utils.isAtLeastR +import com.itsaky.androidide.utils.isTestMode import com.itsaky.androidide.utils.viewLifecycleScope import com.itsaky.androidide.viewmodel.InstallationState import com.itsaky.androidide.viewmodel.InstallationViewModel @@ -74,7 +78,7 @@ class PermissionsFragment : private var permissionsBinding: LayoutOnboardingPermissionsBinding? = null private var recyclerView: RecyclerView? = null private var finishButton: MaterialButton? = null - private lateinit var pulseAnimation: Animation + private lateinit var pulseAnimation: Animation private val storagePermissionRequestLauncher = registerForActivityResult( @@ -94,9 +98,11 @@ class PermissionsFragment : PermissionsHelper.getRequiredPermissions(requireContext()) } + private var privacyDialog: AlertDialog? = null + private var isSlideSelected = false + companion object { private val logger = LoggerFactory.getLogger(PermissionsFragment::class.java) - private const val KEY_PRIVACY_DISCLOSURE_SHOWN = "privacy.disclosure.shown" private var awaitingOverlayGrantResult = false @@ -152,14 +158,17 @@ class PermissionsFragment : override fun onResume() { super.onResume() - (activity as? OnboardingActivity)?.setOnboardingChromeVisible(false) + (activity as? OnboardingActivity)?.setOnboardingChromeVisible(false) onPermissionsUpdated() + if (isSlideSelected) { + showPrivacyDialogIfNeeded() + } } - override fun onPause() { - (activity as? OnboardingActivity)?.setOnboardingChromeVisible(true) - super.onPause() - } + override fun onPause() { + (activity as? OnboardingActivity)?.setOnboardingChromeVisible(true) + super.onPause() + } private fun observeViewModelState() { viewLifecycleScope.launch { @@ -176,7 +185,10 @@ class PermissionsFragment : viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { viewModel.events.collect { event -> when (event) { - is InstallationEvent.ShowError -> activity?.flashError(event.message) + is InstallationEvent.ShowError -> { + activity?.flashError(event.message) + } + is InstallationEvent.InstallationResultEvent -> {} } } @@ -189,18 +201,22 @@ class PermissionsFragment : is InstallationState.InstallationPending -> { disableFinishButton() } + is InstallationState.InstallationGranted -> { enableFinishButton() } + is InstallationState.Installing -> { - disableFinishButton() + disableFinishButton() } + is InstallationState.InstallationComplete -> { finishButton?.text = getString(R.string.finish_installation) activity?.flashSuccess(getString(R.string.ide_setup_complete)) } + is InstallationState.InstallationError -> { - enableFinishButton() + enableFinishButton() finishButton?.text = getString(R.string.finish_installation) } } @@ -208,6 +224,8 @@ class PermissionsFragment : override fun onDestroyView() { super.onDestroyView() + privacyDialog?.dismiss() + privacyDialog = null permissionsBinding = null recyclerView = null finishButton = null @@ -228,20 +246,20 @@ class PermissionsFragment : viewModel.onPermissionsUpdated(allGranted) } - private fun handlePostOverlayPermissionState() { - if (!awaitingOverlayGrantResult) { - return - } - awaitingOverlayGrantResult = false - - viewLifecycleScope.launch { - viewLifecycleOwner.withResumed { - if (!PermissionsHelper.canDrawOverlays(requireContext())) { - OverlayPermissionGuide.showRestrictedSettingsDialog(requireContext()) - } - } - } - } + private fun handlePostOverlayPermissionState() { + if (!awaitingOverlayGrantResult) { + return + } + awaitingOverlayGrantResult = false + + viewLifecycleScope.launch { + viewLifecycleOwner.withResumed { + if (!PermissionsHelper.canDrawOverlays(requireContext())) { + OverlayPermissionGuide.showRestrictedSettingsDialog(requireContext()) + } + } + } + } private fun startIdeSetup() { viewLifecycleScope.launch { @@ -261,13 +279,14 @@ class PermissionsFragment : builder.title(getString(R.string.ide_setup_in_progress)) }, ) { flashbar, _ -> - val progressJob = launch(Dispatchers.Main) { - viewModel.installationProgress.collect { progress -> - if (progress.isNotEmpty()) { - flashbar.flashbarView.setMessage(progress) + val progressJob = + launch(Dispatchers.Main) { + viewModel.installationProgress.collect { progress -> + if (progress.isNotEmpty()) { + flashbar.flashbarView.setMessage(progress) + } } } - } viewModel.startIdeSetup(requireContext()) @@ -280,8 +299,14 @@ class PermissionsFragment : } true } - is InstallationState.InstallationError -> true - else -> false + + is InstallationState.InstallationError -> { + true + } + + else -> { + false + } } } } finally { @@ -293,34 +318,44 @@ class PermissionsFragment : private fun requestPermission(permission: String) { when (permission) { - Manifest.permission_group.STORAGE -> requestStoragePermission() - Manifest.permission.REQUEST_INSTALL_PACKAGES -> + Manifest.permission_group.STORAGE -> { + requestStoragePermission() + } + + Manifest.permission.REQUEST_INSTALL_PACKAGES -> { requestSettingsTogglePermission( Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, ) + } + + Manifest.permission.SYSTEM_ALERT_WINDOW -> { + requestOverlayPermission() + } - Manifest.permission.SYSTEM_ALERT_WINDOW -> requestOverlayPermission() - Manifest.permission.POST_NOTIFICATIONS -> + Manifest.permission.POST_NOTIFICATIONS -> { requestSettingsTogglePermission( Settings.ACTION_APP_NOTIFICATION_SETTINGS, setData = false, ) + } } } - private fun requestOverlayPermission() { - val state = PermissionsHelper.getOverlayPermissionState(requireContext()) + private fun requestOverlayPermission() { + val state = PermissionsHelper.getOverlayPermissionState(requireContext()) + + when (state) { + PermissionsHelper.OverlayPermissionState.UNSUPPORTED -> { + flashError(getString(R.string.permission_overlay_unsupported_hint)) + } + + PermissionsHelper.OverlayPermissionState.REQUESTABLE -> { + awaitingOverlayGrantResult = requestSettingsTogglePermission(Settings.ACTION_MANAGE_OVERLAY_PERMISSION) + } - when (state) { - PermissionsHelper.OverlayPermissionState.UNSUPPORTED -> { - flashError(getString(R.string.permission_overlay_unsupported_hint)) - } - PermissionsHelper.OverlayPermissionState.REQUESTABLE -> { - awaitingOverlayGrantResult = requestSettingsTogglePermission(Settings.ACTION_MANAGE_OVERLAY_PERMISSION) - } - PermissionsHelper.OverlayPermissionState.GRANTED -> {} - } - } + PermissionsHelper.OverlayPermissionState.GRANTED -> {} + } + } private fun requestStoragePermission() { if (isAtLeastR()) { @@ -367,36 +402,42 @@ class PermissionsFragment : } override fun onSlideSelected() { - if (!isPrivacyDisclosureShown()) { - showPrivacyDialog() - } + isSlideSelected = true + showPrivacyDialogIfNeeded() } override fun onSlideDeselected() { + isSlideSelected = false } - private fun showPrivacyDialog() { - MaterialAlertDialogBuilder(requireContext()) - .setTitle(com.itsaky.androidide.resources.R.string.privacy_disclosure_title) - .setMessage(com.itsaky.androidide.resources.R.string.privacy_disclosure_message) - .setPositiveButton(com.itsaky.androidide.resources.R.string.privacy_disclosure_accept) { dialog, _ -> - markPrivacyDisclosureAsShown() - dialog.dismiss() - } - .setNeutralButton(com.itsaky.androidide.resources.R.string.privacy_disclosure_learn_more) { _, _ -> - openPrivacyPolicy() - markPrivacyDisclosureAsShown() - } - .setCancelable(false) - .show() - } - - private fun isPrivacyDisclosureShown(): Boolean { - return prefManager.getBoolean(KEY_PRIVACY_DISCLOSURE_SHOWN, false) - } + private fun showPrivacyDialogIfNeeded() { + if (StatPreferences.telemetryConsent != TelemetryConsent.UNSET) { + return + } + if (privacyDialog?.isShowing == true) { + return + } - private fun markPrivacyDisclosureAsShown() { - prefManager.putBoolean(KEY_PRIVACY_DISCLOSURE_SHOWN, true) + privacyDialog = + MaterialAlertDialogBuilder(requireContext()) + .setTitle(com.itsaky.androidide.resources.R.string.privacy_disclosure_title) + .setMessage(com.itsaky.androidide.resources.R.string.privacy_disclosure_message) + .setPositiveButton(com.itsaky.androidide.resources.R.string.privacy_disclosure_accept) { dialog, _ -> + StatPreferences.telemetryConsent = TelemetryConsent.GRANTED + DeviceProtectedApplicationLoader.onTelemetryConsentGranted(IDEApplication.instance) + dialog.dismiss() + }.setNegativeButton(com.itsaky.androidide.resources.R.string.privacy_disclosure_decline) { dialog, _ -> + StatPreferences.telemetryConsent = TelemetryConsent.DECLINED + Sentry.close() + dialog.dismiss() + }.setNeutralButton(com.itsaky.androidide.resources.R.string.privacy_disclosure_learn_more, null) + .setCancelable(false) + .show() + .also { dialog -> + dialog.getButton(AlertDialog.BUTTON_NEUTRAL).setOnClickListener { + openPrivacyPolicy() + } + } } private fun openPrivacyPolicy() { @@ -409,15 +450,15 @@ class PermissionsFragment : } } - private fun enableFinishButton() { - finishButton?.isEnabled = true - if (!isTestMode()) { - finishButton?.startAnimation(pulseAnimation) - } - } - - private fun disableFinishButton() { - finishButton?.isEnabled = false - finishButton?.clearAnimation() - } + private fun enableFinishButton() { + finishButton?.isEnabled = true + if (!isTestMode()) { + finishButton?.startAnimation(pulseAnimation) + } + } + + private fun disableFinishButton() { + finishButton?.isEnabled = false + finishButton?.clearAnimation() + } } diff --git a/app/src/test/java/com/itsaky/androidide/analytics/AnalyticsManagerConsentTest.kt b/app/src/test/java/com/itsaky/androidide/analytics/AnalyticsManagerConsentTest.kt new file mode 100644 index 0000000000..04f4cea9c6 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/analytics/AnalyticsManagerConsentTest.kt @@ -0,0 +1,82 @@ + +package com.itsaky.androidide.analytics + +import com.google.firebase.analytics.FirebaseAnalytics +import com.google.firebase.analytics.ktx.analytics +import com.google.firebase.ktx.Firebase +import com.itsaky.androidide.preferences.internal.StatPreferences +import com.itsaky.androidide.preferences.internal.TelemetryConsent +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import io.mockk.verify +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class AnalyticsManagerConsentTest { + private lateinit var firebaseAnalytics: FirebaseAnalytics + + @Before + fun setUp() { + System.setProperty("androidide.test.mode", "true") + + firebaseAnalytics = mockk(relaxed = true) + mockkStatic("com.google.firebase.analytics.ktx.AnalyticsKt") + every { Firebase.analytics } returns firebaseAnalytics + mockkObject(StatPreferences) + } + + @After + fun tearDown() { + unmockkAll() + } + + @Test + fun `track call with consent declined keeps collection disabled`() { + every { StatPreferences.telemetryConsent } returns TelemetryConsent.DECLINED + + AnalyticsManager().trackFeatureUsed("editor") + + verify { firebaseAnalytics.setAnalyticsCollectionEnabled(false) } + verify(exactly = 0) { firebaseAnalytics.setAnalyticsCollectionEnabled(true) } + } + + @Test + fun `track call with consent unset keeps collection disabled`() { + every { StatPreferences.telemetryConsent } returns TelemetryConsent.UNSET + + AnalyticsManager().trackFeatureUsed("editor") + + verify { firebaseAnalytics.setAnalyticsCollectionEnabled(false) } + verify(exactly = 0) { firebaseAnalytics.setAnalyticsCollectionEnabled(true) } + } + + @Test + fun `initialize with consent granted enables collection`() { + every { StatPreferences.telemetryConsent } returns TelemetryConsent.GRANTED + + AnalyticsManager().initialize() + + verify(atLeast = 1) { firebaseAnalytics.setAnalyticsCollectionEnabled(true) } + verify(exactly = 0) { firebaseAnalytics.setAnalyticsCollectionEnabled(false) } + } + + @Test + fun `initialize re-enables collection on an instance created while consent was unset`() { + every { StatPreferences.telemetryConsent } returns TelemetryConsent.UNSET + val manager = AnalyticsManager() + manager.trackFeatureUsed("editor") + verify { firebaseAnalytics.setAnalyticsCollectionEnabled(false) } + + every { StatPreferences.telemetryConsent } returns TelemetryConsent.GRANTED + manager.initialize() + + verify { firebaseAnalytics.setAnalyticsCollectionEnabled(true) } + } +} diff --git a/app/src/test/java/com/itsaky/androidide/app/TelemetryConsentMigrationTest.kt b/app/src/test/java/com/itsaky/androidide/app/TelemetryConsentMigrationTest.kt new file mode 100644 index 0000000000..7cc9091573 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/app/TelemetryConsentMigrationTest.kt @@ -0,0 +1,57 @@ + +package com.itsaky.androidide.app + +import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.preferences.internal.TelemetryConsent +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class TelemetryConsentMigrationTest { + @Before + fun setUp() { + System.setProperty("androidide.test.mode", "true") + } + + @Test + fun `unset consent with legacy acceptance migrates`() { + assertThat( + DeviceProtectedApplicationLoader.shouldMigrateLegacyConsent( + currentConsent = TelemetryConsent.UNSET, + legacyDisclosureShown = true, + ), + ).isTrue() + } + + @Test + fun `unset consent without legacy acceptance does not migrate`() { + assertThat( + DeviceProtectedApplicationLoader.shouldMigrateLegacyConsent( + currentConsent = TelemetryConsent.UNSET, + legacyDisclosureShown = false, + ), + ).isFalse() + } + + @Test + fun `granted consent never re-migrates`() { + assertThat( + DeviceProtectedApplicationLoader.shouldMigrateLegacyConsent( + currentConsent = TelemetryConsent.GRANTED, + legacyDisclosureShown = true, + ), + ).isFalse() + } + + @Test + fun `declined consent is never overridden by legacy acceptance`() { + assertThat( + DeviceProtectedApplicationLoader.shouldMigrateLegacyConsent( + currentConsent = TelemetryConsent.DECLINED, + legacyDisclosureShown = true, + ), + ).isFalse() + } +} diff --git a/app/src/test/java/com/itsaky/androidide/preferences/StatPreferencesTest.kt b/app/src/test/java/com/itsaky/androidide/preferences/StatPreferencesTest.kt new file mode 100644 index 0000000000..18fa671d60 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/preferences/StatPreferencesTest.kt @@ -0,0 +1,50 @@ + + +package com.itsaky.androidide.preferences + +import android.content.Context +import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.app.BaseApplication +import com.itsaky.androidide.preferences.internal.StatPreferences +import com.itsaky.androidide.preferences.internal.TelemetryConsent +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class StatPreferencesTest { + @Before + fun setUp() { + System.setProperty("androidide.test.mode", "true") + } + + @Test + fun `consent defaults to UNSET when nothing is stored`() { + assertThat(StatPreferences.telemetryConsent).isEqualTo(TelemetryConsent.UNSET) + } + + @Test + fun `GRANTED round-trips through device-protected storage`() { + StatPreferences.telemetryConsent = TelemetryConsent.GRANTED + assertThat(StatPreferences.telemetryConsent).isEqualTo(TelemetryConsent.GRANTED) + } + + @Test + fun `DECLINED round-trips through device-protected storage`() { + StatPreferences.telemetryConsent = TelemetryConsent.DECLINED + assertThat(StatPreferences.telemetryConsent).isEqualTo(TelemetryConsent.DECLINED) + } + + @Test + fun `corrupt stored value degrades to UNSET`() { + BaseApplication.baseInstance + .createDeviceProtectedStorageContext() + .getSharedPreferences("ide.stats", Context.MODE_PRIVATE) + .edit() + .putString(StatPreferences.TELEMETRY_CONSENT, "garbage") + .commit() + + assertThat(StatPreferences.telemetryConsent).isEqualTo(TelemetryConsent.UNSET) + } +} diff --git a/preferences/src/main/java/com/itsaky/androidide/preferences/internal/StatPreferences.kt b/preferences/src/main/java/com/itsaky/androidide/preferences/internal/StatPreferences.kt index 499be8d572..40f751b554 100644 --- a/preferences/src/main/java/com/itsaky/androidide/preferences/internal/StatPreferences.kt +++ b/preferences/src/main/java/com/itsaky/androidide/preferences/internal/StatPreferences.kt @@ -17,31 +17,44 @@ package com.itsaky.androidide.preferences.internal -/** - * @author Akash Yadav - */ -@Suppress("MemberVisibilityCanBePrivate") +import android.content.Context +import android.content.SharedPreferences +import com.itsaky.androidide.app.BaseApplication + +enum class TelemetryConsent { + UNSET, + GRANTED, + DECLINED, +} + object StatPreferences { + const val TELEMETRY_CONSENT = "ide.stats.telemetryConsent" + + private const val PREFS_FILE = "ide.stats" + + private var cachedPrefs: SharedPreferences? = null + private var cachedPrefsApp: BaseApplication? = null + + private val prefs: SharedPreferences + get() { + val app = BaseApplication.baseInstance + cachedPrefs?.takeIf { cachedPrefsApp === app }?.let { return it } + return app + .createDeviceProtectedStorageContext() + .getSharedPreferences(PREFS_FILE, Context.MODE_PRIVATE) + .also { + cachedPrefs = it + cachedPrefsApp = app + } + } - const val STAT_COLLECTION_CONSENT_SHOWN = "ide.stats.consentShown" - const val STAT_OPT_IN = "ide.stats.optIn" - const val STAT_LAST_REPORTED = "ide.stats.lastReported" - - var statConsentDialogShown: Boolean - get() = prefManager.getBoolean(STAT_COLLECTION_CONSENT_SHOWN, false) - set(value) { - prefManager.putBoolean(STAT_COLLECTION_CONSENT_SHOWN, value) - } - - var statOptIn: Boolean - get() = prefManager.getBoolean(STAT_OPT_IN, true) - set(value) { - prefManager.putBoolean(STAT_OPT_IN, value) - } - - var statLastReported: Long - get() = prefManager.getLong(STAT_LAST_REPORTED, 0L) - set(value) { - prefManager.putLong(STAT_LAST_REPORTED, value) - } -} \ No newline at end of file + var telemetryConsent: TelemetryConsent + get() = + prefs + .getString(TELEMETRY_CONSENT, null) + ?.let { stored -> TelemetryConsent.entries.firstOrNull { it.name == stored } } + ?: TelemetryConsent.UNSET + set(value) { + prefs.edit().putString(TELEMETRY_CONSENT, value.name).apply() + } +} diff --git a/resources/src/main/res/values/strings.xml b/resources/src/main/res/values/strings.xml index c56ca9ac40..9f5d8150b7 100644 --- a/resources/src/main/res/values/strings.xml +++ b/resources/src/main/res/values/strings.xml @@ -664,8 +664,9 @@ Privacy Privacy & analytics - Code on the Go uses Firebase Analytics and GlitchTip to help us improve the app.\n\nFirebase Analytics collects anonymous usage data to help us understand how the app is used. \n\nGlitchTip helps us track and fix errors.\n\nNo personal information is collected or shared. All data is processed in accordance with our privacy policy. - I understand + Code on the Go uses Firebase Analytics and GlitchTip to help us improve the app.\n\nFirebase Analytics collects anonymous usage data to help us understand how the app is used. \n\nGlitchTip helps us track and fix errors.\n\nNo personal information is collected or shared. All data is processed in accordance with our privacy policy.\n\nChoose whether to share this anonymous data. If you choose Keep offline, analytics and crash reports are never sent. + Share anonymous data + Keep offline Learn more Unique ID