Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -3,44 +3,41 @@ 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
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<Unit>.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)

Expand All @@ -51,6 +48,10 @@ fun TestContext<Unit>.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(),
Expand All @@ -60,16 +61,16 @@ fun TestContext<Unit>.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") {
Expand Down
11 changes: 11 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,17 @@
<meta-data
android:name="io.sentry.logs.enabled"
android:value="true" />

<!-- Suppress SDK auto-init; re-enabled at runtime only with telemetry consent -->
<meta-data
android:name="io.sentry.auto-init"
android:value="false" />
<meta-data
android:name="firebase_analytics_collection_enabled"
android:value="false" />
<meta-data
android:name="firebase_data_collection_default_enabled"
android:value="false" />
<provider
android:name=".provider.IDEDocumentsProvider"
android:authorities="com.itsaky.androidide.documents"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,15 +44,20 @@ interface IAnalyticsManager {
}

class AnalyticsManager : IAnalyticsManager {
@Volatile
private var consentGranted = false

private val analytics: FirebaseAnalytics by lazy {
Firebase.analytics.apply {
setAnalyticsCollectionEnabled(true)
setAnalyticsCollectionEnabled(consentGranted)
}
}

private var sessionStartTime: Long = 0

override fun initialize() {
consentGranted = true
analytics.setAnalyticsCollectionEnabled(true)
trackAppOpen()
startSession()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import com.itsaky.androidide.events.ProjectsApiEventsIndex
import com.itsaky.androidide.handlers.CrashEventSubscriber
import com.itsaky.androidide.handlers.GlitchTipDiagnosticsContext
import com.itsaky.androidide.logging.provider.IdeLogRouter
import com.itsaky.androidide.preferences.internal.StatPreferences
import com.itsaky.androidide.preferences.internal.TelemetryConsent
import com.itsaky.androidide.syntax.colorschemes.SchemeAndroidIDE
import com.itsaky.androidide.ui.themes.IThemeManager
import com.itsaky.androidide.utils.Environment
Expand All @@ -37,6 +39,7 @@ import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.slf4j.LoggerFactory
import org.slf4j.event.Level
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.system.exitProcess

/**
Expand All @@ -51,6 +54,10 @@ internal object DeviceProtectedApplicationLoader :
private val crashEventSubscriber = CrashEventSubscriber()
val analyticsManager: IAnalyticsManager by inject()

private val telemetryInitialized = AtomicBoolean(false)

private const val KEY_LEGACY_PRIVACY_DISCLOSURE_SHOWN = "privacy.disclosure.shown"

override suspend fun load(app: IDEApplication) {
logger.info("Loading device protected storage context components...")

Expand All @@ -73,6 +80,41 @@ internal object DeviceProtectedApplicationLoader :
),
)

migrateLegacyConsent(app)
initTelemetryIfConsented(app)

ShizukuSettings.initialize()

EventBus
.builder()
.addIndex(AppEventsIndex())
.addIndex(EditorEventsIndex())
.addIndex(ProjectsApiEventsIndex())
.addIndex(LspApiEventsIndex())
.addIndex(LspJavaEventsIndex())
.installDefaultEventBus(true)

EventBus.getDefault().register(crashEventSubscriber)

EditorColorScheme.setDefault(SchemeAndroidIDE.newInstance(null))

ReflectionUtils.bypassHiddenAPIReflectionRestrictions()

app.coroutineScope.launch(Dispatchers.IO) {
IThemeManager.getInstance()
}
}

suspend fun initTelemetryIfConsented(app: IDEApplication) {
if (StatPreferences.telemetryConsent != TelemetryConsent.GRANTED) {
logger.info("Telemetry not initialized (consent={})", StatPreferences.telemetryConsent)
return
}

if (!telemetryInitialized.compareAndSet(false, true)) {
return
}

runCatching {
// Initialize the Sentry SDK; it reports to our GlitchTip backend
// (GlitchTip is Sentry-protocol-compatible), so the SDK types stay io.sentry.
Expand Down Expand Up @@ -117,30 +159,28 @@ internal object DeviceProtectedApplicationLoader :
logger.error("Failed to initialize crash and log reporting", it)
}

ShizukuSettings.initialize()

EventBus
.builder()
.addIndex(AppEventsIndex())
.addIndex(EditorEventsIndex())
.addIndex(ProjectsApiEventsIndex())
.addIndex(LspApiEventsIndex())
.addIndex(LspJavaEventsIndex())
.installDefaultEventBus(true)

EventBus.getDefault().register(crashEventSubscriber)

EditorColorScheme.setDefault(SchemeAndroidIDE.newInstance(null))

ReflectionUtils.bypassHiddenAPIReflectionRestrictions()
withContext(Dispatchers.Main) {
initializeAnalytics()
}
}

app.coroutineScope.launch(Dispatchers.IO) {
// early-init theme manager since it may need to perform disk reads
IThemeManager.getInstance()
fun onTelemetryConsentGranted(app: IDEApplication) {
app.coroutineScope.launch(Dispatchers.Default) {
initTelemetryIfConsented(app)
}
}

withContext(Dispatchers.Main) {
initializeAnalytics()
internal fun shouldMigrateLegacyConsent(
currentConsent: TelemetryConsent,
legacyDisclosureShown: Boolean,
): Boolean = currentConsent == TelemetryConsent.UNSET && legacyDisclosureShown

private fun migrateLegacyConsent(app: IDEApplication) {
val legacyDisclosureShown =
app.prefManager.getBoolean(KEY_LEGACY_PRIVACY_DISCLOSURE_SHOWN, false)
if (shouldMigrateLegacyConsent(StatPreferences.telemetryConsent, legacyDisclosureShown)) {
logger.info("Migrating legacy privacy disclosure acceptance to telemetry consent")
StatPreferences.telemetryConsent = TelemetryConsent.GRANTED
}
}

Expand Down
Loading
Loading