Skip to content
Open
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 @@ -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
)
}
Expand All @@ -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",
),
)
}
Expand All @@ -95,7 +95,7 @@ class WhitelistRulesTest {
stackTraceElement("java.io.File", "exists"),
stackTraceElement(
"com.itsaky.androidide.activities.OnboardingActivity",
"checkToolsIsInstalled"
"checkToolsIsInstalled",
),
)
}
Expand Down Expand Up @@ -207,6 +207,33 @@ class WhitelistRulesTest {
)
}

@Test
fun allow_DiskRead_on_TelemetryConsentPrefsResolution() {
assertAllowed<DiskReadViolation>(
// @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<DiskReadViolation>(
// @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
)
}
Comment on lines +210 to +235

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These seem to be originated from first-party code. Please look into fixing the violations instead of whitelisting them.


@Test
fun allow_DiskWrite_on_MtkAsyncDrawableCache_OsChmod() {
assertAllowed<DiskWriteViolation>(
Expand Down
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 @@ -7,6 +7,8 @@ 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 {
Expand Down Expand Up @@ -46,17 +48,20 @@ interface IAnalyticsManager {
class AnalyticsManager : IAnalyticsManager {
private val analytics: FirebaseAnalytics by lazy {
Firebase.analytics.apply {
setAnalyticsCollectionEnabled(true)
setAnalyticsCollectionEnabled(isConsentGranted())
}
}

private var sessionStartTime: Long = 0

override fun initialize() {
analytics.setAnalyticsCollectionEnabled(isConsentGranted())
trackAppOpen()
startSession()
}

private fun isConsentGranted(): Boolean = StatPreferences.telemetryConsent == TelemetryConsent.GRANTED

override fun trackAppOpen() {
val bundle =
Bundle().apply {
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
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ 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

/**
Expand Down Expand Up @@ -100,17 +101,17 @@ object WhitelistEngine {
listOf(
classAndMethod(
"com.google.firebase.internal.DataCollectionConfigStorage",
"readAutoDataCollectionEnabled"
"readAutoDataCollectionEnabled",
),
classAndMethod(
"com.google.firebase.internal.DataCollectionConfigStorage",
"<init>"
"<init>",
),
),
listOf(
classAndMethod(
"com.google.firebase.FirebaseApp\$UserUnlockReceiver",
"onReceive"
"onReceive",
),
),
),
Expand Down Expand Up @@ -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",
),
)
}

Expand Down Expand Up @@ -329,6 +333,26 @@ object WhitelistEngine {
),
)
}

rule {
ofType<DiskReadViolation>()
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"),
)
}
Comment on lines +337 to +355

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems to be originated from first-party code. Please look into fixing the violations instead of whitelisting them.

If it is absolutely not possible to use deferred logic, in that case you can use the allowThreadDiskReads for that piece of code. Example:

import com.itsaky.androidide.utils.allowThreadDiskReads

val diskReadResult = allowThreadDiskReads("reason") {
  // read disk here
}

}

/**
Expand Down
Loading
Loading