From b16b39c0cdf36efc39b5d7b0d1705d55d6cc5e8a Mon Sep 17 00:00:00 2001 From: Daniel Alome Date: Mon, 3 Aug 2026 10:01:32 +0100 Subject: [PATCH 1/4] ADFA-4942: Gate GlitchTip and Firebase analytics behind an onboarding opt-out consent --- .../app/strictmode/WhitelistRulesTest.kt | 27 +++ .../helper/HandlePrivacyDisclosureHelper.kt | 37 ++-- app/src/main/AndroidManifest.xml | 11 + .../androidide/analytics/AnalyticsManager.kt | 7 +- .../app/DeviceProtectedApplicationLoader.kt | 82 +++++-- .../app/strictmode/WhitelistEngine.kt | 21 ++ .../onboarding/PermissionsFragment.kt | 209 +++++++++++------- .../analytics/AnalyticsManagerConsentTest.kt | 82 +++++++ .../app/TelemetryConsentMigrationTest.kt | 57 +++++ .../preferences/StatPreferencesTest.kt | 50 +++++ .../preferences/internal/StatPreferences.kt | 65 +++--- resources/src/main/res/values/strings.xml | 5 +- 12 files changed, 501 insertions(+), 152 deletions(-) create mode 100644 app/src/test/java/com/itsaky/androidide/analytics/AnalyticsManagerConsentTest.kt create mode 100644 app/src/test/java/com/itsaky/androidide/app/TelemetryConsentMigrationTest.kt create mode 100644 app/src/test/java/com/itsaky/androidide/preferences/StatPreferencesTest.kt 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..8e73ea781f 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 @@ -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 @@ + + + + + () + 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 From 4938eceaffd58fef0296068032b9998518fdd28b Mon Sep 17 00:00:00 2001 From: Daniel Alome Date: Mon, 3 Aug 2026 16:46:55 +0100 Subject: [PATCH 2/4] Fix spotless check --- .../androidide/app/strictmode/WhitelistRulesTest.kt | 6 +++--- .../androidide/app/strictmode/WhitelistEngine.kt | 11 +++++++---- 2 files changed, 10 insertions(+), 7 deletions(-) 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 8e73ea781f..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", ), ) } diff --git a/app/src/main/java/com/itsaky/androidide/app/strictmode/WhitelistEngine.kt b/app/src/main/java/com/itsaky/androidide/app/strictmode/WhitelistEngine.kt index c56b0d1cb9..c4168e14c4 100644 --- a/app/src/main/java/com/itsaky/androidide/app/strictmode/WhitelistEngine.kt +++ b/app/src/main/java/com/itsaky/androidide/app/strictmode/WhitelistEngine.kt @@ -101,17 +101,17 @@ object WhitelistEngine { listOf( classAndMethod( "com.google.firebase.internal.DataCollectionConfigStorage", - "readAutoDataCollectionEnabled" + "readAutoDataCollectionEnabled", ), classAndMethod( "com.google.firebase.internal.DataCollectionConfigStorage", - "" + "", ), ), listOf( classAndMethod( "com.google.firebase.FirebaseApp\$UserUnlockReceiver", - "onReceive" + "onReceive", ), ), ), @@ -204,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", + ), ) } From ab72cae6dccf4cf1e90b842d331d0398d783b20b Mon Sep 17 00:00:00 2001 From: Daniel Alome Date: Wed, 5 Aug 2026 09:21:14 +0100 Subject: [PATCH 3/4] Fixes from PR review --- .../app/strictmode/WhitelistRulesTest.kt | 33 ++----------------- .../app/strictmode/WhitelistEngine.kt | 32 +++--------------- .../preferences/internal/StatPreferences.kt | 28 ++++++++++++---- 3 files changed, 28 insertions(+), 65 deletions(-) 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 c90f0acd50..c061a2fbc5 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,33 +207,6 @@ 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/main/java/com/itsaky/androidide/app/strictmode/WhitelistEngine.kt b/app/src/main/java/com/itsaky/androidide/app/strictmode/WhitelistEngine.kt index c4168e14c4..a20049ea8e 100644 --- a/app/src/main/java/com/itsaky/androidide/app/strictmode/WhitelistEngine.kt +++ b/app/src/main/java/com/itsaky/androidide/app/strictmode/WhitelistEngine.kt @@ -5,7 +5,6 @@ import android.os.strictmode.DiskWriteViolation import androidx.annotation.VisibleForTesting import com.itsaky.androidide.app.strictmode.FrameMatcher.Companion.anyOf import com.itsaky.androidide.app.strictmode.FrameMatcher.Companion.classAndMethod -import com.itsaky.androidide.app.strictmode.FrameMatcher.Companion.classEquals import android.os.strictmode.Violation as StrictModeViolation /** @@ -101,17 +100,17 @@ object WhitelistEngine { listOf( classAndMethod( "com.google.firebase.internal.DataCollectionConfigStorage", - "readAutoDataCollectionEnabled", + "readAutoDataCollectionEnabled" ), classAndMethod( "com.google.firebase.internal.DataCollectionConfigStorage", - "", + "" ), ), listOf( classAndMethod( "com.google.firebase.FirebaseApp\$UserUnlockReceiver", - "onReceive", + "onReceive" ), ), ), @@ -204,10 +203,7 @@ 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"), ) } @@ -333,26 +329,6 @@ 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/preferences/src/main/java/com/itsaky/androidide/preferences/internal/StatPreferences.kt b/preferences/src/main/java/com/itsaky/androidide/preferences/internal/StatPreferences.kt index 40f751b554..e92486b5fb 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 @@ -20,6 +20,7 @@ package com.itsaky.androidide.preferences.internal import android.content.Context import android.content.SharedPreferences import com.itsaky.androidide.app.BaseApplication +import com.itsaky.androidide.utils.allowThreadDiskReads enum class TelemetryConsent { UNSET, @@ -32,20 +33,33 @@ object StatPreferences { private const val PREFS_FILE = "ide.stats" + @Volatile private var cachedPrefs: SharedPreferences? = null + + @Volatile 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 - } + + return allowThreadDiskReads( + "analytics call sites decide synchronously, on the main thread, whether collection " + + "is allowed, so the telemetry consent cannot be resolved off-thread", + ) { + app + .createDeviceProtectedStorageContext() + .getSharedPreferences(PREFS_FILE, Context.MODE_PRIVATE) + .also { + // Await the asynchronous load here, inside the exemption, so every later + // read and write is served from memory and never touches the disk. + it.getString(TELEMETRY_CONSENT, null) + } + }.also { + cachedPrefs = it + cachedPrefsApp = app + } } var telemetryConsent: TelemetryConsent From f6746db1609e77db881bc9104c9aebfe4dfbc0d6 Mon Sep 17 00:00:00 2001 From: Daniel Alome Date: Wed, 5 Aug 2026 11:19:27 +0100 Subject: [PATCH 4/4] fix strict mode violations on analytics manager --- .../androidide/analytics/AnalyticsManager.kt | 12 ++++----- .../onboarding/PermissionsFragment.kt | 20 ++++++++++++--- .../analytics/AnalyticsManagerConsentTest.kt | 22 ++++------------ .../preferences/internal/StatPreferences.kt | 25 ++++++------------- 4 files changed, 34 insertions(+), 45 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/analytics/AnalyticsManager.kt b/app/src/main/java/com/itsaky/androidide/analytics/AnalyticsManager.kt index 7bb57f9eec..3a252cab94 100644 --- a/app/src/main/java/com/itsaky/androidide/analytics/AnalyticsManager.kt +++ b/app/src/main/java/com/itsaky/androidide/analytics/AnalyticsManager.kt @@ -7,8 +7,6 @@ import com.google.firebase.ktx.Firebase import com.itsaky.androidide.analytics.gradle.BuildCompletedMetric import com.itsaky.androidide.analytics.gradle.BuildStartedMetric import com.itsaky.androidide.analytics.gradle.StrategySelectedMetric -import com.itsaky.androidide.preferences.internal.StatPreferences -import com.itsaky.androidide.preferences.internal.TelemetryConsent import java.util.concurrent.TimeUnit interface IAnalyticsManager { @@ -46,22 +44,24 @@ interface IAnalyticsManager { } class AnalyticsManager : IAnalyticsManager { + @Volatile + private var consentGranted = false + private val analytics: FirebaseAnalytics by lazy { Firebase.analytics.apply { - setAnalyticsCollectionEnabled(isConsentGranted()) + setAnalyticsCollectionEnabled(consentGranted) } } private var sessionStartTime: Long = 0 override fun initialize() { - analytics.setAnalyticsCollectionEnabled(isConsentGranted()) + consentGranted = true + analytics.setAnalyticsCollectionEnabled(true) trackAppOpen() startSession() } - private fun isConsentGranted(): Boolean = StatPreferences.telemetryConsent == TelemetryConsent.GRANTED - override fun trackAppOpen() { val bundle = Bundle().apply { 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 d2a06f5d86..13b8ba7b12 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 @@ -57,10 +57,12 @@ 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.utils.viewLifecycleScopeOrNull import com.itsaky.androidide.viewmodel.InstallationState import com.itsaky.androidide.viewmodel.InstallationViewModel import io.sentry.Sentry import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -99,6 +101,7 @@ class PermissionsFragment : } private var privacyDialog: AlertDialog? = null + private var consentResolutionJob: Job? = null private var isSlideSelected = false companion object { @@ -226,6 +229,7 @@ class PermissionsFragment : super.onDestroyView() privacyDialog?.dismiss() privacyDialog = null + consentResolutionJob = null permissionsBinding = null recyclerView = null finishButton = null @@ -411,13 +415,21 @@ class PermissionsFragment : } private fun showPrivacyDialogIfNeeded() { - if (StatPreferences.telemetryConsent != TelemetryConsent.UNSET) { - return - } - if (privacyDialog?.isShowing == true) { + if (privacyDialog != null || consentResolutionJob?.isActive == true) { return } + val scope = viewLifecycleScopeOrNull ?: return + consentResolutionJob = + scope.launch { + val consent = withContext(Dispatchers.IO) { StatPreferences.telemetryConsent } + if (consent == TelemetryConsent.UNSET && privacyDialog == null) { + showPrivacyDialog() + } + } + } + + private fun showPrivacyDialog() { privacyDialog = MaterialAlertDialogBuilder(requireContext()) .setTitle(com.itsaky.androidide.resources.R.string.privacy_disclosure_title) diff --git a/app/src/test/java/com/itsaky/androidide/analytics/AnalyticsManagerConsentTest.kt b/app/src/test/java/com/itsaky/androidide/analytics/AnalyticsManagerConsentTest.kt index 04f4cea9c6..07a7ddde43 100644 --- a/app/src/test/java/com/itsaky/androidide/analytics/AnalyticsManagerConsentTest.kt +++ b/app/src/test/java/com/itsaky/androidide/analytics/AnalyticsManagerConsentTest.kt @@ -4,11 +4,8 @@ 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 @@ -29,7 +26,6 @@ class AnalyticsManagerConsentTest { firebaseAnalytics = mockk(relaxed = true) mockkStatic("com.google.firebase.analytics.ktx.AnalyticsKt") every { Firebase.analytics } returns firebaseAnalytics - mockkObject(StatPreferences) } @After @@ -38,9 +34,7 @@ class AnalyticsManagerConsentTest { } @Test - fun `track call with consent declined keeps collection disabled`() { - every { StatPreferences.telemetryConsent } returns TelemetryConsent.DECLINED - + fun `track call before initialize keeps collection disabled`() { AnalyticsManager().trackFeatureUsed("editor") verify { firebaseAnalytics.setAnalyticsCollectionEnabled(false) } @@ -48,19 +42,15 @@ class AnalyticsManagerConsentTest { } @Test - fun `track call with consent unset keeps collection disabled`() { - every { StatPreferences.telemetryConsent } returns TelemetryConsent.UNSET - - AnalyticsManager().trackFeatureUsed("editor") + fun `metric call before initialize keeps collection disabled`() { + AnalyticsManager().trackProjectOpened("/sdcard/project") verify { firebaseAnalytics.setAnalyticsCollectionEnabled(false) } verify(exactly = 0) { firebaseAnalytics.setAnalyticsCollectionEnabled(true) } } @Test - fun `initialize with consent granted enables collection`() { - every { StatPreferences.telemetryConsent } returns TelemetryConsent.GRANTED - + fun `initialize enables collection`() { AnalyticsManager().initialize() verify(atLeast = 1) { firebaseAnalytics.setAnalyticsCollectionEnabled(true) } @@ -68,13 +58,11 @@ class AnalyticsManagerConsentTest { } @Test - fun `initialize re-enables collection on an instance created while consent was unset`() { - every { StatPreferences.telemetryConsent } returns TelemetryConsent.UNSET + fun `initialize re-enables collection on an instance that already tracked`() { 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/preferences/src/main/java/com/itsaky/androidide/preferences/internal/StatPreferences.kt b/preferences/src/main/java/com/itsaky/androidide/preferences/internal/StatPreferences.kt index e92486b5fb..af4566f9ab 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 @@ -20,7 +20,6 @@ package com.itsaky.androidide.preferences.internal import android.content.Context import android.content.SharedPreferences import com.itsaky.androidide.app.BaseApplication -import com.itsaky.androidide.utils.allowThreadDiskReads enum class TelemetryConsent { UNSET, @@ -43,23 +42,13 @@ object StatPreferences { get() { val app = BaseApplication.baseInstance cachedPrefs?.takeIf { cachedPrefsApp === app }?.let { return it } - - return allowThreadDiskReads( - "analytics call sites decide synchronously, on the main thread, whether collection " + - "is allowed, so the telemetry consent cannot be resolved off-thread", - ) { - app - .createDeviceProtectedStorageContext() - .getSharedPreferences(PREFS_FILE, Context.MODE_PRIVATE) - .also { - // Await the asynchronous load here, inside the exemption, so every later - // read and write is served from memory and never touches the disk. - it.getString(TELEMETRY_CONSENT, null) - } - }.also { - cachedPrefs = it - cachedPrefsApp = app - } + return app + .createDeviceProtectedStorageContext() + .getSharedPreferences(PREFS_FILE, Context.MODE_PRIVATE) + .also { + cachedPrefs = it + cachedPrefsApp = app + } } var telemetryConsent: TelemetryConsent