apkdev 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
apkdev/builder.py ADDED
@@ -0,0 +1,671 @@
1
+ """APK Builder — create projects, compile, sign."""
2
+
3
+ import os
4
+ import shutil
5
+ import subprocess
6
+ from pathlib import Path
7
+
8
+ from .utils import check_tool, run, ensure_android_home, is_termux
9
+
10
+
11
+ def create_project(name: str) -> Path:
12
+ """Scaffold a new Kotlin + Gradle APK project."""
13
+ proj_dir = Path.home() / name
14
+ src = proj_dir / "app" / "src" / "main"
15
+ java_dir = src / "java" / "com" / "example" / name.lower()
16
+ res_dir = src / "res"
17
+
18
+ java_dir.mkdir(parents=True, exist_ok=True)
19
+ (res_dir / "layout").mkdir(parents=True, exist_ok=True)
20
+ (res_dir / "values").mkdir(parents=True, exist_ok=True)
21
+
22
+ # settings.gradle.kts
23
+ (proj_dir / "settings.gradle.kts").write_text(
24
+ """pluginManagement {
25
+ repositories { google(); mavenCentral(); gradlePluginPortal() }
26
+ }
27
+ dependencyResolutionManagement {
28
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
29
+ repositories { google(); mavenCentral() }
30
+ }
31
+ rootProject.name = "%s"
32
+ include(":app")
33
+ """ % name
34
+ )
35
+
36
+ # build.gradle.kts (root)
37
+ (proj_dir / "build.gradle.kts").write_text(
38
+ """plugins {
39
+ id("com.android.application") version "8.7.3" apply false
40
+ kotlin("android") version "2.0.21" apply false
41
+ id("org.jetbrains.kotlin.plugin.compose") version "2.0.21" apply false
42
+ }
43
+ """
44
+ )
45
+
46
+ # app/build.gradle.kts
47
+ (proj_dir / "app" / "build.gradle.kts").write_text(
48
+ """plugins { id("com.android.application"); kotlin("android"); id("org.jetbrains.kotlin.plugin.compose") }
49
+ android {
50
+ namespace = "com.example.%s"
51
+ compileSdk = 34
52
+ defaultConfig {
53
+ applicationId = "com.example.%s"
54
+ minSdk = 26; targetSdk = 34; versionCode = 1; versionName = "1.0"
55
+ }
56
+ buildFeatures { compose = true }
57
+ compileOptions { sourceCompatibility = JavaVersion.VERSION_17; targetCompatibility = JavaVersion.VERSION_17 }
58
+ kotlinOptions { jvmTarget = "17" }
59
+ }
60
+ dependencies {
61
+ val composeBom = platform("androidx.compose:compose-bom:2024.10.00")
62
+ implementation(composeBom)
63
+ implementation("androidx.compose.ui:ui")
64
+ implementation("androidx.compose.ui:ui-graphics")
65
+ implementation("androidx.compose.ui:ui-tooling-preview")
66
+ implementation("androidx.compose.material3:material3")
67
+ implementation("androidx.compose.material:material-icons-core")
68
+ implementation("androidx.activity:activity-compose:1.9.3")
69
+ implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
70
+ implementation("androidx.core:core-ktx:1.13.1")
71
+ debugImplementation("androidx.compose.ui:ui-tooling")
72
+ }
73
+ """ % (name.lower(), name.lower())
74
+ )
75
+
76
+ # AndroidManifest.xml
77
+ manifest = (
78
+ '<?xml version="1.0" encoding="utf-8"?>\n'
79
+ '<manifest xmlns:android="http://schemas.android.com/apk/res/android">\n'
80
+ ' <application android:allowBackup="true" android:label="%s"\n'
81
+ ' android:supportsRtl="true"\n'
82
+ ' android:theme="@android:style/Theme.Material.Light.NoActionBar">\n'
83
+ ' <activity android:name=".MainActivity" android:exported="true"\n'
84
+ ' android:windowSoftInputMode="adjustResize">\n'
85
+ ' <intent-filter>\n'
86
+ ' <action android:name="android.intent.action.MAIN" />\n'
87
+ ' <category android:name="android.intent.category.LAUNCHER" />\n'
88
+ ' </intent-filter>\n'
89
+ ' </activity>\n'
90
+ ' </application>\n'
91
+ '</manifest>\n'
92
+ ) % name
93
+ (src / "AndroidManifest.xml").write_text(manifest)
94
+
95
+ # MainActivity.kt
96
+ (java_dir / "MainActivity.kt").write_text(
97
+ """package com.example.%s
98
+
99
+ import android.os.Bundle
100
+ import androidx.activity.ComponentActivity
101
+ import androidx.activity.compose.setContent
102
+ import androidx.compose.foundation.layout.fillMaxSize
103
+ import androidx.compose.material3.MaterialTheme
104
+ import androidx.compose.material3.Surface
105
+ import androidx.compose.ui.Modifier
106
+ import androidx.compose.ui.graphics.Color
107
+ import com.example.%s.ui.theme.NotesTheme
108
+
109
+ class MainActivity : ComponentActivity() {
110
+ override fun onCreate(savedInstanceState: Bundle?) {
111
+ super.onCreate(savedInstanceState)
112
+ setContent {
113
+ NotesTheme {
114
+ Surface(
115
+ modifier = Modifier.fillMaxSize(),
116
+ color = MaterialTheme.colorScheme.background
117
+ ) {
118
+ NotesApp()
119
+ }
120
+ }
121
+ }
122
+ }
123
+ }
124
+ """ % (name.lower(), name.lower())
125
+ )
126
+
127
+ # Create directories for Compose UI files
128
+ ui_dir = java_dir / "ui" / "theme"
129
+ ui_dir.mkdir(parents=True, exist_ok=True)
130
+
131
+ # Theme.kt — Material 3 with dynamic colors
132
+ (ui_dir / "Theme.kt").write_text(
133
+ """package com.example.%s.ui.theme
134
+
135
+ import android.app.Activity
136
+ import android.os.Build
137
+ import androidx.compose.foundation.isSystemInDarkTheme
138
+ import androidx.compose.material3.MaterialTheme
139
+ import androidx.compose.material3.darkColorScheme
140
+ import androidx.compose.material3.dynamicDarkColorScheme
141
+ import androidx.compose.material3.dynamicLightColorScheme
142
+ import androidx.compose.material3.lightColorScheme
143
+ import androidx.compose.runtime.Composable
144
+ import androidx.compose.runtime.SideEffect
145
+ import androidx.compose.ui.graphics.Color
146
+ import androidx.compose.ui.graphics.toArgb
147
+ import androidx.compose.ui.platform.LocalContext
148
+ import androidx.compose.ui.platform.LocalView
149
+ import androidx.core.view.WindowCompat
150
+
151
+ private val DarkColorScheme = darkColorScheme(
152
+ primary = Color(0xFF00D2FF),
153
+ onPrimary = Color(0xFF003544),
154
+ primaryContainer = Color(0xFF004D62),
155
+ onPrimaryContainer = Color(0xFFB3EBFF),
156
+ secondary = Color(0xFF4DD0E1),
157
+ tertiary = Color(0xFFB388FF),
158
+ background = Color(0xFF0D0D1A),
159
+ surface = Color(0xFF1A1A2E),
160
+ surfaceVariant = Color(0xFF16213E),
161
+ onBackground = Color(0xFFE6E1E5),
162
+ onSurface = Color(0xFFE6E1E5),
163
+ outline = Color(0xFF3A3A5C),
164
+ )
165
+
166
+ private val LightColorScheme = lightColorScheme(
167
+ primary = Color(0xFF006D82),
168
+ onPrimary = Color.White,
169
+ primaryContainer = Color(0xFFB3EBFF),
170
+ onPrimaryContainer = Color(0xFF001F28),
171
+ secondary = Color(0xFF008C9E),
172
+ tertiary = Color(0xFF7C52CC),
173
+ background = Color(0xFFFEFBFF),
174
+ surface = Color(0xFFFEFBFF),
175
+ surfaceVariant = Color(0xFFDCE5F0),
176
+ onBackground = Color(0xFF1B1B1F),
177
+ onSurface = Color(0xFF1B1B1F),
178
+ )
179
+
180
+ @Composable
181
+ fun NotesTheme(
182
+ darkTheme: Boolean = isSystemInDarkTheme(),
183
+ dynamicColor: Boolean = true,
184
+ content: @Composable () -> Unit
185
+ ) {
186
+ val colorScheme = when {
187
+ dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
188
+ val context = LocalContext.current
189
+ if (darkTheme) dynamicDarkColorScheme(context)
190
+ else dynamicLightColorScheme(context)
191
+ }
192
+ darkTheme -> DarkColorScheme
193
+ else -> LightColorScheme
194
+ }
195
+ val view = LocalView.current
196
+ if (!view.isInEditMode) {
197
+ SideEffect {
198
+ val window = (view.context as Activity).window
199
+ window.statusBarColor = colorScheme.background.toArgb()
200
+ WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme
201
+ }
202
+ }
203
+ MaterialTheme(
204
+ colorScheme = colorScheme,
205
+ content = content
206
+ )
207
+ }
208
+ """ % name.lower()
209
+ )
210
+
211
+ # Color.kt
212
+ (ui_dir / "Color.kt").write_text(
213
+ """package com.example.%s.ui.theme
214
+
215
+ import androidx.compose.ui.graphics.Color
216
+
217
+ val Purple80 = Color(0xFFD0BCFF)
218
+ val PurpleGrey80 = Color(0xFFCCC2DC)
219
+ val Pink80 = Color(0xFFEFB8C8)
220
+ val Purple40 = Color(0xFF6650a4)
221
+ val PurpleGrey40 = Color(0xFF625b71)
222
+ val Pink40 = Color(0xFF7D5260)
223
+ """ % name.lower()
224
+ )
225
+
226
+ # Main composable: NotesApp.kt
227
+ (java_dir / "NotesApp.kt").write_text(
228
+ """package com.example.%s
229
+
230
+ import android.content.Context
231
+ import androidx.compose.animation.*
232
+ import androidx.compose.foundation.background
233
+ import androidx.compose.foundation.layout.*
234
+ import androidx.compose.foundation.lazy.LazyColumn
235
+ import androidx.compose.foundation.lazy.items
236
+ import androidx.compose.foundation.shape.RoundedCornerShape
237
+ import androidx.compose.material.icons.Icons
238
+ import androidx.compose.material.icons.filled.*
239
+ import androidx.compose.material.icons.outlined.*
240
+ import androidx.compose.material3.*
241
+ import androidx.compose.runtime.*
242
+ import androidx.compose.ui.Alignment
243
+ import androidx.compose.ui.Modifier
244
+ import androidx.compose.ui.draw.clip
245
+ import androidx.compose.ui.text.font.FontWeight
246
+ import androidx.compose.ui.text.style.TextDecoration
247
+ import androidx.compose.ui.text.style.TextOverflow
248
+ import androidx.compose.ui.unit.dp
249
+ import androidx.compose.ui.unit.sp
250
+
251
+ @OptIn(ExperimentalMaterial3Api::class)
252
+ @Composable
253
+ fun NotesApp() {
254
+ var notes by remember { mutableStateOf(listOf<Note>()) }
255
+ var showDialog by remember { mutableStateOf(false) }
256
+ var editingNote by remember { mutableStateOf<Note?>(null) }
257
+ var searchQuery by remember { mutableStateOf("") }
258
+
259
+ val filteredNotes = remember(notes, searchQuery) {
260
+ if (searchQuery.isBlank()) notes
261
+ else notes.filter { it.title.contains(searchQuery, ignoreCase = true) || it.content.contains(searchQuery, ignoreCase = true) }
262
+ }
263
+
264
+ Scaffold(
265
+ topBar = {
266
+ TopAppBar(
267
+ title = { Text("Notes", fontWeight = FontWeight.Bold) },
268
+ colors = TopAppBarDefaults.topAppBarColors(
269
+ containerColor = MaterialTheme.colorScheme.surface,
270
+ titleContentColor = MaterialTheme.colorScheme.onSurface
271
+ ),
272
+ actions = {
273
+ IconButton(onClick = { /* sort */ }) {
274
+ Icon(Icons.Default.Menu, contentDescription = "Menu")
275
+ }
276
+ }
277
+ )
278
+ },
279
+ floatingActionButton = {
280
+ ExtendedFloatingActionButton(
281
+ onClick = {
282
+ editingNote = null
283
+ showDialog = true
284
+ },
285
+ icon = { Icon(Icons.Default.Add, contentDescription = null) },
286
+ text = { Text("New Note") },
287
+ containerColor = MaterialTheme.colorScheme.primary,
288
+ contentColor = MaterialTheme.colorScheme.onPrimary
289
+ )
290
+ }
291
+ ) { padding ->
292
+ Column(modifier = Modifier.padding(padding)) {
293
+ // Search bar
294
+ OutlinedTextField(
295
+ value = searchQuery,
296
+ onValueChange = { searchQuery = it },
297
+ modifier = Modifier
298
+ .fillMaxWidth()
299
+ .padding(horizontal = 16.dp, vertical = 8.dp),
300
+ placeholder = { Text("Search notes...") },
301
+ leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
302
+ trailingIcon = {
303
+ if (searchQuery.isNotEmpty()) {
304
+ IconButton(onClick = { searchQuery = "" }) {
305
+ Icon(Icons.Default.Close, contentDescription = "Clear")
306
+ }
307
+ }
308
+ },
309
+ singleLine = true,
310
+ shape = RoundedCornerShape(24.dp),
311
+ colors = OutlinedTextFieldDefaults.colors(
312
+ focusedBorderColor = MaterialTheme.colorScheme.primary,
313
+ unfocusedBorderColor = MaterialTheme.colorScheme.outline
314
+ )
315
+ )
316
+
317
+ if (filteredNotes.isEmpty()) {
318
+ // Empty state
319
+ Box(
320
+ modifier = Modifier.fillMaxSize(),
321
+ contentAlignment = Alignment.Center
322
+ ) {
323
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
324
+ Icon(
325
+ Icons.Default.Create,
326
+ contentDescription = null,
327
+ modifier = Modifier.size(80.dp),
328
+ tint = MaterialTheme.colorScheme.outline
329
+ )
330
+ Spacer(Modifier.height(16.dp))
331
+ Text(
332
+ "No notes yet",
333
+ style = MaterialTheme.typography.titleLarge,
334
+ color = MaterialTheme.colorScheme.outline
335
+ )
336
+ Text(
337
+ "Tap + to create your first note",
338
+ style = MaterialTheme.typography.bodyMedium,
339
+ color = MaterialTheme.colorScheme.outline
340
+ )
341
+ }
342
+ }
343
+ } else {
344
+ LazyColumn(
345
+ contentPadding = PaddingValues(16.dp),
346
+ verticalArrangement = Arrangement.spacedBy(12.dp)
347
+ ) {
348
+ items(filteredNotes, key = { it.id }) { note ->
349
+ NoteCard(
350
+ note = note,
351
+ onToggle = {
352
+ notes = notes.map { if (it.id == note.id) it.copy(done = !it.done) else it }
353
+ saveNotes(notes)
354
+ },
355
+ onEdit = {
356
+ editingNote = note
357
+ showDialog = true
358
+ },
359
+ onDelete = {
360
+ notes = notes.filter { it.id != note.id }
361
+ saveNotes(notes)
362
+ }
363
+ )
364
+ }
365
+ }
366
+ }
367
+ }
368
+ }
369
+
370
+ if (showDialog) {
371
+ NoteDialog(
372
+ note = editingNote,
373
+ onDismiss = { showDialog = false; editingNote = null },
374
+ onSave = { title, content ->
375
+ if (editingNote != null) {
376
+ notes = notes.map { if (it.id == editingNote!!.id) it.copy(title = title, content = content) else it }
377
+ } else {
378
+ notes = listOf(Note(title = title, content = content)) + notes
379
+ }
380
+ saveNotes(notes)
381
+ showDialog = false
382
+ editingNote = null
383
+ }
384
+ )
385
+ }
386
+ }
387
+
388
+ @Composable
389
+ fun NoteCard(note: Note, onToggle: () -> Unit, onEdit: () -> Unit, onDelete: () -> Unit) {
390
+ val surfaceColor = MaterialTheme.colorScheme.surfaceVariant
391
+ Card(
392
+ onClick = onEdit,
393
+ modifier = Modifier.fillMaxWidth(),
394
+ shape = RoundedCornerShape(16.dp),
395
+ colors = CardDefaults.cardColors(containerColor = surfaceColor),
396
+ elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
397
+ ) {
398
+ Row(
399
+ modifier = Modifier.padding(16.dp),
400
+ verticalAlignment = Alignment.Top
401
+ ) {
402
+ Checkbox(
403
+ checked = note.done,
404
+ onCheckedChange = { onToggle() },
405
+ colors = CheckboxDefaults.colors(
406
+ checkedColor = MaterialTheme.colorScheme.primary
407
+ )
408
+ )
409
+ Spacer(Modifier.width(12.dp))
410
+ Column(modifier = Modifier.weight(1f)) {
411
+ Text(
412
+ text = note.title,
413
+ style = MaterialTheme.typography.titleMedium,
414
+ fontWeight = FontWeight.SemiBold,
415
+ textDecoration = if (note.done) TextDecoration.LineThrough else TextDecoration.None,
416
+ color = if (note.done) MaterialTheme.colorScheme.outline else MaterialTheme.colorScheme.onSurface,
417
+ maxLines = 2,
418
+ overflow = TextOverflow.Ellipsis
419
+ )
420
+ if (note.content.isNotBlank()) {
421
+ Spacer(Modifier.height(4.dp))
422
+ Text(
423
+ text = note.content,
424
+ style = MaterialTheme.typography.bodyMedium,
425
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
426
+ maxLines = 3,
427
+ overflow = TextOverflow.Ellipsis
428
+ )
429
+ }
430
+ }
431
+ Spacer(Modifier.width(8.dp))
432
+ Column {
433
+ IconButton(onClick = onEdit, modifier = Modifier.size(32.dp)) {
434
+ Icon(Icons.Default.Edit, contentDescription = "Edit", tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(18.dp))
435
+ }
436
+ IconButton(onClick = onDelete, modifier = Modifier.size(32.dp)) {
437
+ Icon(Icons.Default.Delete, contentDescription = "Delete", tint = MaterialTheme.colorScheme.error, modifier = Modifier.size(18.dp))
438
+ }
439
+ }
440
+ }
441
+ }
442
+ }
443
+
444
+ @Composable
445
+ fun NoteDialog(note: Note?, onDismiss: () -> Unit, onSave: (String, String) -> Unit) {
446
+ var title by remember { mutableStateOf(note?.title ?: "") }
447
+ var content by remember { mutableStateOf(note?.content ?: "") }
448
+
449
+ AlertDialog(
450
+ onDismissRequest = onDismiss,
451
+ title = { Text(if (note != null) "Edit Note" else "New Note", fontWeight = FontWeight.Bold) },
452
+ text = {
453
+ Column {
454
+ OutlinedTextField(
455
+ value = title,
456
+ onValueChange = { title = it },
457
+ label = { Text("Title") },
458
+ singleLine = true,
459
+ modifier = Modifier.fillMaxWidth(),
460
+ shape = RoundedCornerShape(12.dp)
461
+ )
462
+ Spacer(Modifier.height(12.dp))
463
+ OutlinedTextField(
464
+ value = content,
465
+ onValueChange = { content = it },
466
+ label = { Text("Content") },
467
+ modifier = Modifier
468
+ .fillMaxWidth()
469
+ .heightIn(min = 120.dp),
470
+ shape = RoundedCornerShape(12.dp),
471
+ maxLines = 5
472
+ )
473
+ }
474
+ },
475
+ confirmButton = {
476
+ Button(
477
+ onClick = { onSave(title, content) },
478
+ enabled = title.isNotBlank()
479
+ ) {
480
+ Text("Save")
481
+ }
482
+ },
483
+ dismissButton = {
484
+ TextButton(onClick = onDismiss) {
485
+ Text("Cancel")
486
+ }
487
+ },
488
+ containerColor = MaterialTheme.colorScheme.surface,
489
+ titleContentColor = MaterialTheme.colorScheme.onSurface,
490
+ textContentColor = MaterialTheme.colorScheme.onSurfaceVariant
491
+ )
492
+ }
493
+
494
+ data class Note(
495
+ val id: Long = System.currentTimeMillis(),
496
+ val title: String,
497
+ val content: String = "",
498
+ val done: Boolean = false
499
+ )
500
+
501
+ private fun saveNotes(notes: List<Note>) {
502
+ // Notes are persisted via context or ViewModel in a real app
503
+ // For this demo, they persist during app session
504
+ }
505
+
506
+ private fun loadNotes(): MutableList<Note> {
507
+ return mutableListOf()
508
+ }
509
+ """ % name.lower()
510
+ )
511
+
512
+ # Remove XML layout (we use Compose)
513
+ layout_file = res_dir / "layout" / "activity_main.xml"
514
+
515
+
516
+ # strings.xml
517
+ app_name_str = "%s" % name
518
+ (res_dir / "values" / "strings.xml").write_text(
519
+ f"""<?xml version="1.0" encoding="utf-8"?>
520
+ <resources><string name="app_name">{app_name_str}</string></resources>
521
+ """
522
+ )
523
+
524
+ # local.properties pointing to SDK
525
+ sdk_home = os.environ.get("ANDROID_HOME", str(Path.home() / "android-sdk"))
526
+ (proj_dir / "local.properties").write_text(f"sdk.dir={sdk_home}\n")
527
+
528
+ # gradle.properties with AndroidX
529
+ (proj_dir / "gradle.properties").write_text(
530
+ "org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8\n"
531
+ "android.useAndroidX=true\n"
532
+ "android.enableJetifier=true\n"
533
+ )
534
+
535
+ # Gradle wrapper (created manually — no Gradle install needed)
536
+ _create_gradle_wrapper(proj_dir)
537
+
538
+ return proj_dir
539
+
540
+
541
+ def _create_gradle_wrapper(proj_dir: Path):
542
+ """Create Gradle wrapper files manually."""
543
+ wrapper_dir = proj_dir / "gradle" / "wrapper"
544
+ wrapper_dir.mkdir(parents=True, exist_ok=True)
545
+
546
+ # gradle-wrapper.properties
547
+ (wrapper_dir / "gradle-wrapper.properties").write_text(
548
+ "distributionBase=GRADLE_USER_HOME\n"
549
+ "distributionPath=wrapper/dists\n"
550
+ "distributionUrl=https\\://services.gradle.org/distributions/gradle-8.9-bin.zip\n"
551
+ "networkTimeout=10000\n"
552
+ "validateDistributionUrl=true\n"
553
+ "zipStoreBase=GRADLE_USER_HOME\n"
554
+ "zipStorePath=wrapper/dists\n"
555
+ )
556
+
557
+ # Download wrapper JAR if not present
558
+ jar_path = wrapper_dir / "gradle-wrapper.jar"
559
+ if not jar_path.exists() or jar_path.stat().st_size < 1000:
560
+ try:
561
+ import urllib.request
562
+ # Try Gradle's official wrapper JAR
563
+ urllib.request.urlretrieve(
564
+ "https://raw.githubusercontent.com/gradle/gradle/v8.9.0/gradle/wrapper/gradle-wrapper.jar",
565
+ str(jar_path)
566
+ )
567
+ except Exception:
568
+ pass
569
+
570
+ # Create gradlew script (simplified but functional)
571
+ gradlew_path = proj_dir / "gradlew"
572
+ gradlew_path.write_text(
573
+ '#!/bin/sh\n'
574
+ 'PRG="$0"\n'
575
+ 'while [ -h "$PRG" ]; do ls=`ls -ld "$PRG"`; link=`expr "$ls" : \'.*-> \\(.*\\)$\'`; '
576
+ 'if expr "$link" : \'/.*\' > /dev/null; then PRG="$link"; else PRG=`dirname "$PRG"`/"$link"; fi; done\n'
577
+ 'SAVED="`pwd`"; cd "`dirname \"$PRG\"`/" >/dev/null; APP_HOME="`pwd -P`"; cd "$SAVED" >/dev/null\n'
578
+ 'APP_NAME="Gradle"\n'
579
+ 'APP_BASE_NAME=`basename "$0"`\n'
580
+ 'MAX_FD="maximum"\n'
581
+ 'DEFAULT_JVM_OPTS="-Xmx64m -Xms64m"\n'
582
+ 'CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar\n'
583
+ 'JAVACMD="java"\n'
584
+ 'exec "$JAVACMD" $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS '
585
+ '"-Dorg.gradle.appname=$APP_BASE_NAME" -classpath "$CLASSPATH" '
586
+ 'org.gradle.wrapper.GradleWrapperMain "$@"\n'
587
+ )
588
+ gradlew_path.chmod(0o755)
589
+
590
+
591
+ def _patch_aapt2():
592
+ """Point AGP to the system aapt2 so it skips the bundled x86_64 one."""
593
+ if not is_termux():
594
+ return
595
+
596
+ native = shutil.which("aapt2")
597
+ if not native:
598
+ return
599
+
600
+ # Tell AGP to use our native aapt2 via gradle.properties
601
+ # This avoids the x86_64 vs aarch64 incompatibility entirely.
602
+ native_aapt2_prop = f"android.aapt2FromMavenOverride={native}\n"
603
+
604
+ # Write to project gradle.properties (takes precedence)
605
+ cwd = Path(os.getcwd())
606
+ for props_path in [cwd / "gradle.properties", Path.home() / ".gradle" / "gradle.properties"]:
607
+ try:
608
+ if props_path.exists():
609
+ content = props_path.read_text()
610
+ if "android.aapt2FromMavenOverride" not in content:
611
+ props_path.write_text(content + native_aapt2_prop)
612
+ else:
613
+ props_path.parent.mkdir(parents=True, exist_ok=True)
614
+ props_path.write_text(native_aapt2_prop)
615
+ except Exception:
616
+ pass
617
+
618
+ # Also clean stale transforms
619
+ cache = Path.home() / ".gradle" / "caches"
620
+ for d in cache.rglob("transformed/aapt2-*linux"):
621
+ if d.is_dir():
622
+ shutil.rmtree(d, ignore_errors=True)
623
+
624
+
625
+ def build_apk(directory: str | None = None) -> None:
626
+ """Run gradle assembleDebug."""
627
+ ensure_android_home()
628
+ cwd = directory or os.getcwd()
629
+
630
+ # Patch AGP's bundled aapt2 before building (it ships x86_64 binary)
631
+ _patch_aapt2()
632
+
633
+ gradlew = Path(cwd) / "gradlew"
634
+ if gradlew.exists():
635
+ gradlew.chmod(0o755)
636
+ run([str(gradlew), "assembleDebug"], cwd=cwd)
637
+ else:
638
+ gradle = check_tool("gradle")
639
+ run([gradle, "assembleDebug"], cwd=cwd)
640
+
641
+ # Print APK path
642
+ apk_dir = Path(cwd) / "app" / "build" / "outputs" / "apk" / "debug"
643
+ apks = list(apk_dir.glob("*.apk"))
644
+ if apks:
645
+ print(f"\n✅ APK: {apks[0]}")
646
+
647
+
648
+ def sign_apk(apk_path: str) -> None:
649
+ """Sign an APK with debug keystore."""
650
+ keystore = Path.home() / ".android" / "debug.keystore"
651
+ if not keystore.exists():
652
+ # Create it
653
+ keystore.parent.mkdir(parents=True, exist_ok=True)
654
+ keytool = check_tool("keytool")
655
+ run([
656
+ keytool, "-genkey", "-v", "-keystore", str(keystore),
657
+ "-storepass", "android", "-alias", "androiddebugkey",
658
+ "-keypass", "android", "-keyalg", "RSA", "-keysize", "2048",
659
+ "-validity", "10000",
660
+ "-dname", "CN=Android Debug,O=Android,C=US",
661
+ ])
662
+
663
+ apksigner = check_tool("apksigner")
664
+ run([
665
+ apksigner, "sign", "--ks", str(keystore),
666
+ "--ks-key-alias", "androiddebugkey",
667
+ "--ks-pass", "pass:android",
668
+ "--key-pass", "pass:android",
669
+ apk_path,
670
+ ])
671
+ print(f"✅ Signed: {apk_path}")