Skip to content

Commit 08b863b

Browse files
authored
Refactor Android build scripts into modular helpers (#3962)
1 parent 51de98e commit 08b863b

File tree

5 files changed

+471
-247
lines changed

5 files changed

+471
-247
lines changed
Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
#!/usr/bin/env python3
2+
"""Utilities to normalize generated Gradle build files for Android tests."""
3+
from __future__ import annotations
4+
5+
import argparse
6+
import pathlib
7+
import re
8+
from typing import Tuple
9+
10+
REPOSITORIES_BLOCK = """\
11+
repositories {
12+
google()
13+
mavenCentral()
14+
}
15+
"""
16+
17+
18+
def _ensure_repositories(content: str) -> Tuple[str, bool]:
19+
"""Ensure a repositories block exists with google() and mavenCentral()."""
20+
pattern = re.compile(r"(?ms)^\s*repositories\s*\{.*?\}")
21+
match = pattern.search(content)
22+
block_added = False
23+
24+
if not match:
25+
# Append a canonical repositories block to the end of the file.
26+
if not content.endswith("\n"):
27+
content += "\n"
28+
content += REPOSITORIES_BLOCK
29+
return content, True
30+
31+
block = match.group(0)
32+
if "google()" not in block or "mavenCentral()" not in block:
33+
lines = block.splitlines()
34+
header = lines[0]
35+
body = [line for line in lines[1:-1] if line.strip()]
36+
if "google()" not in block:
37+
body.append(" google()")
38+
if "mavenCentral()" not in block:
39+
body.append(" mavenCentral()")
40+
new_block = "\n".join([header, *sorted(set(body)), lines[-1]])
41+
content = content[: match.start()] + new_block + content[match.end() :]
42+
block_added = True
43+
return content, block_added
44+
45+
46+
def _ensure_android_sdk(content: str, compile_sdk: int, target_sdk: int) -> Tuple[str, bool]:
47+
changed = False
48+
49+
def insert_or_replace(pattern: str, repl: str, search_scope: str) -> Tuple[str, bool]:
50+
nonlocal content
51+
match = re.search(pattern, content, re.MULTILINE)
52+
if match:
53+
new_content = re.sub(pattern, repl, content, count=1, flags=re.MULTILINE)
54+
if new_content != content:
55+
content = new_content
56+
return content, True
57+
return content, False
58+
anchor = re.search(search_scope, content, re.MULTILINE)
59+
if not anchor:
60+
return content, False
61+
start = anchor.end()
62+
content = content[:start] + f"\n{repl}" + content[start:]
63+
return content, True
64+
65+
# Ensure android block exists
66+
if re.search(r"(?m)^\s*android\s*\{", content) is None:
67+
if not content.endswith("\n"):
68+
content += "\n"
69+
content += (
70+
"\nandroid {\n"
71+
f" compileSdkVersion {compile_sdk}\n"
72+
" defaultConfig {\n"
73+
f" targetSdkVersion {target_sdk}\n"
74+
" }\n}"
75+
)
76+
return content, True
77+
78+
new_content, changed_once = insert_or_replace(
79+
pattern=rf"(?m)^\s*compileSdkVersion\s+\d+",
80+
repl=f" compileSdkVersion {compile_sdk}",
81+
search_scope=r"(?m)^\s*android\s*\{",
82+
)
83+
changed = changed or changed_once
84+
85+
default_config = re.search(r"(?ms)^\s*defaultConfig\s*\{.*?^\s*\}", content)
86+
if default_config:
87+
block = default_config.group(0)
88+
if re.search(r"(?m)^\s*targetSdkVersion\s+\d+", block):
89+
updated = re.sub(
90+
r"(?m)^\s*targetSdkVersion\s+\d+",
91+
f" targetSdkVersion {target_sdk}",
92+
block,
93+
)
94+
else:
95+
updated = re.sub(r"{", "{\n targetSdkVersion %d" % target_sdk, block, count=1)
96+
if updated != block:
97+
content = content[: default_config.start()] + updated + content[default_config.end() :]
98+
changed = True
99+
else:
100+
content, changed_once = insert_or_replace(
101+
pattern=r"(?ms)(^\s*android\s*\{)",
102+
repl=" defaultConfig {\n targetSdkVersion %d\n }" % target_sdk,
103+
search_scope=r"(?m)^\s*android\s*\{",
104+
)
105+
changed = changed or changed_once
106+
107+
return content, changed
108+
109+
110+
def _ensure_instrumentation_runner(content: str) -> Tuple[str, bool]:
111+
runner = "androidx.test.runner.AndroidJUnitRunner"
112+
if runner in content:
113+
return content, False
114+
changed = False
115+
pattern = re.compile(r"(?m)^\s*testInstrumentationRunner\s*\".*?\"\s*$")
116+
if pattern.search(content):
117+
new_content = pattern.sub(f" testInstrumentationRunner \"{runner}\"", content)
118+
return new_content, True
119+
default_config = re.search(r"(?ms)^\s*defaultConfig\s*\{", content)
120+
if default_config:
121+
insert_point = default_config.end()
122+
content = (
123+
content[:insert_point]
124+
+ f"\n testInstrumentationRunner \"{runner}\""
125+
+ content[insert_point:]
126+
)
127+
changed = True
128+
else:
129+
android_block = re.search(r"(?ms)^\s*android\s*\{", content)
130+
if android_block:
131+
insert_point = android_block.end()
132+
snippet = (
133+
"\n defaultConfig {\n"
134+
f" testInstrumentationRunner \"{runner}\"\n"
135+
" }"
136+
)
137+
content = content[:insert_point] + snippet + content[insert_point:]
138+
changed = True
139+
return content, changed
140+
141+
142+
def _remove_legacy_use_library(content: str) -> Tuple[str, bool]:
143+
new_content, count = re.subn(
144+
r"(?m)^\s*useLibrary\s+'android\.test\.(?:base|mock|runner)'\s*$",
145+
"",
146+
content,
147+
)
148+
return new_content, bool(count)
149+
150+
151+
def _ensure_test_dependencies(content: str) -> Tuple[str, bool]:
152+
module_view = re.sub(r"(?ms)^\s*(buildscript|pluginManagement)\s*\{.*?^\s*\}", "", content)
153+
uses_modern = re.search(
154+
r"(?m)^\s*(implementation|api|testImplementation|androidTestImplementation)\b",
155+
module_view,
156+
)
157+
configuration = "androidTestImplementation" if uses_modern else "androidTestCompile"
158+
dependencies = [
159+
"androidx.test.ext:junit:1.1.5",
160+
"androidx.test:runner:1.5.2",
161+
"androidx.test:core:1.5.0",
162+
"androidx.test.services:storage:1.4.2",
163+
]
164+
missing = [dep for dep in dependencies if dep not in module_view]
165+
if not missing:
166+
return content, False
167+
block = "\n\ndependencies {\n" + "".join(
168+
f" {configuration} \"{dep}\"\n" for dep in missing
169+
) + "}\n"
170+
if not content.endswith("\n"):
171+
content += "\n"
172+
return content + block, True
173+
174+
175+
def patch_app_build_gradle(path: pathlib.Path, compile_sdk: int, target_sdk: int) -> bool:
176+
text = path.read_text(encoding="utf-8")
177+
changed = False
178+
179+
for transform in (
180+
lambda c: _ensure_android_sdk(c, compile_sdk, target_sdk),
181+
_ensure_instrumentation_runner,
182+
_remove_legacy_use_library,
183+
_ensure_test_dependencies,
184+
):
185+
text, modified = transform(text)
186+
changed = changed or modified
187+
188+
if changed:
189+
path.write_text(text if text.endswith("\n") else text + "\n", encoding="utf-8")
190+
return changed
191+
192+
193+
def patch_root_build_gradle(path: pathlib.Path) -> bool:
194+
text = path.read_text(encoding="utf-8")
195+
text, changed = _ensure_repositories(text)
196+
if changed:
197+
path.write_text(text if text.endswith("\n") else text + "\n", encoding="utf-8")
198+
return changed
199+
200+
201+
def main() -> int:
202+
parser = argparse.ArgumentParser(description="Normalize Gradle build files")
203+
parser.add_argument("--root", required=True, type=pathlib.Path, help="Path to root build.gradle")
204+
parser.add_argument("--app", required=True, type=pathlib.Path, help="Path to app/build.gradle")
205+
parser.add_argument("--compile-sdk", type=int, default=33)
206+
parser.add_argument("--target-sdk", type=int, default=33)
207+
args = parser.parse_args()
208+
209+
modified_root = patch_root_build_gradle(args.root)
210+
modified_app = patch_app_build_gradle(args.app, args.compile_sdk, args.target_sdk)
211+
212+
if modified_root:
213+
print(f"Patched {args.root}")
214+
if modified_app:
215+
print(f"Patched {args.app}")
216+
if not (modified_root or modified_app):
217+
print("Gradle files already normalized")
218+
return 0
219+
220+
221+
if __name__ == "__main__":
222+
raise SystemExit(main())
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
package @PACKAGE@;
2+
3+
import android.app.Activity;
4+
import android.content.Context;
5+
import android.content.Intent;
6+
import android.graphics.Bitmap;
7+
import android.graphics.Canvas;
8+
import android.util.Base64;
9+
import android.util.DisplayMetrics;
10+
import android.view.View;
11+
12+
import androidx.test.core.app.ActivityScenario;
13+
import androidx.test.core.app.ApplicationProvider;
14+
import androidx.test.ext.junit.runners.AndroidJUnit4;
15+
16+
import org.junit.Assert;
17+
import org.junit.Test;
18+
import org.junit.runner.RunWith;
19+
20+
import java.io.ByteArrayOutputStream;
21+
22+
@RunWith(AndroidJUnit4.class)
23+
public class HelloCodenameOneInstrumentedTest {
24+
25+
private static void println(String s) {
26+
System.out.println(s);
27+
}
28+
29+
@Test
30+
public void testUseAppContext_andEmitScreenshot() throws Exception {
31+
Context ctx = ApplicationProvider.getApplicationContext();
32+
String pkg = "@PACKAGE@";
33+
Assert.assertEquals("Package mismatch", pkg, ctx.getPackageName());
34+
35+
Intent launch = ctx.getPackageManager().getLaunchIntentForPackage(pkg);
36+
if (launch == null) {
37+
Intent q = new Intent(Intent.ACTION_MAIN);
38+
q.addCategory(Intent.CATEGORY_LAUNCHER);
39+
q.setPackage(pkg);
40+
launch = q;
41+
}
42+
launch.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
43+
44+
println("CN1SS:INFO: about to launch Activity");
45+
byte[] pngBytes = null;
46+
47+
try (ActivityScenario<Activity> scenario = ActivityScenario.launch(launch)) {
48+
Thread.sleep(750);
49+
50+
println("CN1SS:INFO: activity launched");
51+
52+
final byte[][] holder = new byte[1][];
53+
scenario.onActivity(activity -> {
54+
try {
55+
View root = activity.getWindow().getDecorView().getRootView();
56+
int w = root.getWidth();
57+
int h = root.getHeight();
58+
if (w <= 0 || h <= 0) {
59+
DisplayMetrics dm = activity.getResources().getDisplayMetrics();
60+
w = Math.max(1, dm.widthPixels);
61+
h = Math.max(1, dm.heightPixels);
62+
int sw = View.MeasureSpec.makeMeasureSpec(w, View.MeasureSpec.EXACTLY);
63+
int sh = View.MeasureSpec.makeMeasureSpec(h, View.MeasureSpec.EXACTLY);
64+
root.measure(sw, sh);
65+
root.layout(0, 0, w, h);
66+
println("CN1SS:INFO: forced layout to " + w + "x" + h);
67+
} else {
68+
println("CN1SS:INFO: natural layout " + w + "x" + h);
69+
}
70+
71+
Bitmap bmp = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888);
72+
Canvas c = new Canvas(bmp);
73+
root.draw(c);
74+
75+
ByteArrayOutputStream baos = new ByteArrayOutputStream(Math.max(1024, w * h / 2));
76+
boolean ok = bmp.compress(Bitmap.CompressFormat.PNG, 100, baos);
77+
if (!ok) {
78+
throw new RuntimeException("Bitmap.compress returned false");
79+
}
80+
holder[0] = baos.toByteArray();
81+
println("CN1SS:INFO: png_bytes=" + holder[0].length);
82+
} catch (Throwable t) {
83+
println("CN1SS:ERR: onActivity " + t);
84+
t.printStackTrace(System.out);
85+
}
86+
});
87+
88+
pngBytes = holder[0];
89+
} catch (Throwable t) {
90+
println("CN1SS:ERR: launch " + t);
91+
t.printStackTrace(System.out);
92+
}
93+
94+
if (pngBytes == null || pngBytes.length == 0) {
95+
println("CN1SS:END");
96+
Assert.fail("Screenshot capture produced 0 bytes");
97+
return;
98+
}
99+
100+
String b64 = Base64.encodeToString(pngBytes, Base64.NO_WRAP);
101+
final int chunkSize = 2000;
102+
int count = 0;
103+
for (int pos = 0; pos < b64.length(); pos += chunkSize) {
104+
int end = Math.min(pos + chunkSize, b64.length());
105+
System.out.println("CN1SS:" + String.format("%06d", pos) + ":" + b64.substring(pos, end));
106+
count++;
107+
}
108+
println("CN1SS:INFO: chunks=" + count + " total_b64_len=" + b64.length());
109+
System.out.println("CN1SS:END");
110+
System.out.flush();
111+
}
112+
}

0 commit comments

Comments
 (0)