@@ -18,6 +18,14 @@ val KNOWN_ABIS = mapOf(
1818 " x86_64-linux-android" to " x86_64" ,
1919)
2020
21+ val osArch = System .getProperty(" os.arch" )
22+ val NATIVE_ABI = mapOf (
23+ " aarch64" to " arm64-v8a" ,
24+ " amd64" to " x86_64" ,
25+ " arm64" to " arm64-v8a" ,
26+ " x86_64" to " x86_64" ,
27+ )[osArch] ? : throw GradleException (" Unknown os.arch '$osArch '" )
28+
2129// Discover prefixes.
2230val prefixes = ArrayList <File >()
2331if (inSourceTree) {
@@ -149,6 +157,9 @@ android {
149157 testOptions {
150158 managedDevices {
151159 localDevices {
160+ // systemImageSource should use what its documentation calls an
161+ // "explicit source", i.e. the sdkmanager package name format, because
162+ // that will be required in CreateEmulatorTask below.
152163 create(" minVersion" ) {
153164 device = " Small Phone"
154165
@@ -157,13 +168,13 @@ android {
157168
158169 // ATD devices are smaller and faster, but have a minimum
159170 // API level of 30.
160- systemImageSource = if (apiLevel >= 30 ) " aosp-atd " else " aosp "
171+ systemImageSource = if (apiLevel >= 30 ) " aosp_atd " else " default "
161172 }
162173
163174 create(" maxVersion" ) {
164175 device = " Small Phone"
165176 apiLevel = defaultConfig.targetSdk!!
166- systemImageSource = " aosp-atd "
177+ systemImageSource = " aosp_atd "
167178 }
168179 }
169180
@@ -189,6 +200,136 @@ dependencies {
189200}
190201
191202
203+ afterEvaluate {
204+ // Every new emulator has a maximum of 2 GB RAM, regardless of its hardware profile
205+ // (https://cs.android.com/android-studio/platform/tools/base/+/refs/tags/studio-2025.3.2:sdklib/src/main/java/com/android/sdklib/internal/avd/EmulatedProperties.java;l=68).
206+ // This is barely enough to test Python, and not enough to test Pandas
207+ // (https://github.com/python/cpython/pull/137186#issuecomment-3136301023,
208+ // https://github.com/pandas-dev/pandas/pull/63405#issuecomment-3667846159).
209+ // So we'll increase it by editing the emulator configuration files.
210+ //
211+ // If the emulator doesn't exist yet, we want to edit it after it's created, but
212+ // before it starts for the first time. Otherwise it'll need to be cold-booted
213+ // again, which would slow down the first run, which is likely the only run in CI
214+ // environments. But the Setup task both creates and starts the emulator if it
215+ // doesn't already exist. So we create it ourselves before the Setup task runs.
216+ for (device in android.testOptions.managedDevices.localDevices) {
217+ val createTask = tasks.register<CreateEmulatorTask >(" ${device.name} Create" ) {
218+ this .device = device.device
219+ apiLevel = device.apiLevel
220+ systemImageSource = device.systemImageSource
221+ abi = NATIVE_ABI
222+ }
223+ tasks.named(" ${device.name} Setup" ) {
224+ dependsOn(createTask)
225+ }
226+ }
227+ }
228+
229+ abstract class CreateEmulatorTask : DefaultTask () {
230+ @get:Input abstract val device: Property <String >
231+ @get:Input abstract val apiLevel: Property <Int >
232+ @get:Input abstract val systemImageSource: Property <String >
233+ @get:Input abstract val abi: Property <String >
234+ @get:Inject abstract val execOps: ExecOperations
235+
236+ private val avdName by lazy {
237+ listOf (
238+ " dev${apiLevel.get()} " ,
239+ systemImageSource.get(),
240+ abi.get(),
241+ device.get().replace(' ' , ' _' ),
242+ ).joinToString(" _" )
243+ }
244+
245+ private val avdDir by lazy {
246+ val userHome =
247+ System .getenv(" ANDROID_USER_HOME" ) ? :
248+ (System .getProperty(" user.home" )!! + " /.android" )
249+ File (" $userHome /avd/gradle-managed" , " $avdName .avd" )
250+ }
251+
252+ @TaskAction
253+ fun run () {
254+ if (! avdDir.exists()) {
255+ createAvd()
256+ }
257+ updateAvd()
258+ }
259+
260+ fun createAvd () {
261+ val systemImage = listOf (
262+ " system-images" ,
263+ " android-${apiLevel.get()} " ,
264+ systemImageSource.get(),
265+ abi.get(),
266+ ).joinToString(" ;" )
267+
268+ runCmdlineTool(" sdkmanager" , systemImage)
269+ runCmdlineTool(
270+ " avdmanager" , " create" , " avd" ,
271+ " --name" , avdName,
272+ " --path" , avdDir,
273+ " --device" , device.get().lowercase().replace(" " , " _" ),
274+ " --package" , systemImage,
275+ )
276+
277+ val iniName = " $avdName .ini"
278+ if (! File (avdDir.parentFile.parentFile, iniName).renameTo(
279+ File (avdDir.parentFile, iniName)
280+ )) {
281+ throw GradleException (" Failed to rename $iniName " )
282+ }
283+ }
284+
285+ fun updateAvd () {
286+ for (filename in listOf (
287+ " config.ini" , // Created by avdmanager; always exists
288+ " hardware-qemu.ini" , // Created on first run; might not exist
289+ )) {
290+ val iniFile = File (avdDir, filename)
291+ if (! iniFile.exists()) {
292+ if (filename == " config.ini" ) {
293+ throw GradleException (" $iniFile does not exist" )
294+ }
295+ continue
296+ }
297+
298+ val iniText = iniFile.readText()
299+ val pattern = Regex (
300+ """ ^\s*hw.ramSize\s*=\s*(.+?)\s*$""" , RegexOption .MULTILINE
301+ )
302+ val matches = pattern.findAll(iniText).toList()
303+ if (matches.size != 1 ) {
304+ throw GradleException (
305+ " Found ${matches.size} instances of $pattern in $iniFile ; expected 1"
306+ )
307+ }
308+
309+ val expectedRam = " 4096"
310+ if (matches[0 ].groupValues[1 ] != expectedRam) {
311+ iniFile.writeText(
312+ iniText.replace(pattern, " hw.ramSize = $expectedRam " )
313+ )
314+ }
315+ }
316+ }
317+
318+ fun runCmdlineTool (tool : String , vararg args : Any ) {
319+ val androidHome = System .getenv(" ANDROID_HOME" )!!
320+ val exeSuffix =
321+ if (System .getProperty(" os.name" ).lowercase().startsWith(" win" )) " .exe"
322+ else " "
323+ val command =
324+ listOf (" $androidHome /cmdline-tools/latest/bin/$tool$exeSuffix " , * args)
325+ println (command.joinToString(" " ))
326+ execOps.exec {
327+ commandLine(command)
328+ }
329+ }
330+ }
331+
332+
192333// Create some custom tasks to copy Python and its standard library from
193334// elsewhere in the repository.
194335androidComponents.onVariants { variant ->
0 commit comments