Skip to content

Commit 6245ed9

Browse files
committed
Add GitHub repository scanning to skin converter
1 parent 961bcbe commit 6245ed9

File tree

2 files changed

+297
-25
lines changed

2 files changed

+297
-25
lines changed

AvdSkinToCodenameOneSkin.java

Lines changed: 281 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,14 @@
55
import java.io.*;
66
import java.nio.charset.StandardCharsets;
77
import java.nio.file.*;
8+
import java.nio.file.attribute.BasicFileAttributes;
89
import java.time.LocalDateTime;
910
import java.time.format.DateTimeFormatter;
1011
import java.util.*;
1112
import java.util.List;
1213
import java.util.zip.ZipEntry;
1314
import java.util.zip.ZipOutputStream;
15+
import java.util.stream.Stream;
1416
import javax.imageio.ImageIO;
1517
import javax.imageio.spi.IIORegistry;
1618
import javax.imageio.spi.ImageReaderSpi;
@@ -42,38 +44,53 @@ public class AvdSkinToCodenameOneSkin {
4244

4345
private static final double TABLET_INCH_THRESHOLD = 6.5d;
4446

45-
public static void main(String[] args) throws Exception {
46-
if (args.length == 0 || args.length > 2) {
47-
System.err.println("Usage: java AvdSkinToCodenameOneSkin.java <avd-skin-dir> [output.skin]");
48-
System.exit(1);
49-
}
50-
51-
Path skinDirectory = Paths.get(args[0]).toAbsolutePath().normalize();
52-
if (!Files.isDirectory(skinDirectory)) {
53-
error("Input path %s is not a directory".formatted(skinDirectory));
54-
}
47+
private static void printUsage() {
48+
System.err.println("""
49+
Usage:
50+
java AvdSkinToCodenameOneSkin.java <avd-skin-dir> [output.skin]
51+
java AvdSkinToCodenameOneSkin.java --github <repo-url> [--ref <git-ref>] [--output <directory>]
52+
""");
53+
}
5554

56-
Path outputFile;
57-
if (args.length == 2) {
58-
outputFile = Paths.get(args[1]).toAbsolutePath().normalize();
55+
private static Path defaultOutputPath(Path skinDirectory) {
56+
Path absolute = skinDirectory.toAbsolutePath().normalize();
57+
Path parent = absolute.getParent();
58+
String baseName;
59+
Path fileName = absolute.getFileName();
60+
if (fileName == null) {
61+
baseName = "skin";
5962
} else {
60-
outputFile = skinDirectory.getParent().resolve(skinDirectory.getFileName().toString() + ".skin");
63+
baseName = fileName.toString();
64+
if (baseName.isEmpty()) {
65+
baseName = "skin";
66+
}
6167
}
68+
Path unresolved = parent == null
69+
? Paths.get(baseName + ".skin")
70+
: parent.resolve(baseName + ".skin");
71+
return unresolved.toAbsolutePath().normalize();
72+
}
6273

63-
if (Files.exists(outputFile)) {
64-
error("Output file %s already exists".formatted(outputFile));
74+
private static Path convertSkinDirectory(Path skinDirectory, Path outputFile) throws IOException {
75+
Path normalizedInput = skinDirectory.toAbsolutePath().normalize();
76+
if (!Files.isDirectory(normalizedInput)) {
77+
throw new IllegalArgumentException("Input path " + normalizedInput + " is not a directory");
6578
}
6679

67-
Path layoutFile = findLayoutFile(skinDirectory);
68-
LayoutInfo layoutInfo = LayoutInfo.parse(layoutFile, skinDirectory);
69-
HardwareInfo hardwareInfo = HardwareInfo.parse(skinDirectory.resolve("hardware.ini"));
80+
Path normalizedOutput = outputFile.toAbsolutePath().normalize();
81+
if (Files.exists(normalizedOutput)) {
82+
throw new IllegalStateException("Output file " + normalizedOutput + " already exists");
83+
}
7084

85+
Path layoutFile = findLayoutFile(normalizedInput);
86+
LayoutInfo layoutInfo = LayoutInfo.parse(layoutFile, normalizedInput);
7187
if (!layoutInfo.hasBothOrientations()) {
72-
error("Layout file must define portrait and landscape display information");
88+
throw new IllegalStateException("Layout file must define portrait and landscape display information");
7389
}
90+
HardwareInfo hardwareInfo = HardwareInfo.parse(normalizedInput.resolve("hardware.ini"));
7491

75-
DeviceImages portraitImages = buildDeviceImages(skinDirectory, layoutInfo.portrait());
76-
DeviceImages landscapeImages = buildDeviceImages(skinDirectory, layoutInfo.landscape());
92+
DeviceImages portraitImages = buildDeviceImages(normalizedInput, layoutInfo.portrait());
93+
DeviceImages landscapeImages = buildDeviceImages(normalizedInput, layoutInfo.landscape());
7794

7895
boolean isTablet = hardwareInfo.isTabletLike(TABLET_INCH_THRESHOLD);
7996
String overrideNames = isTablet ? "tablet,android,android-tablet" : "phone,android,android-phone";
@@ -91,19 +108,258 @@ public static void main(String[] args) throws Exception {
91108
props.setProperty("pixelRatio", String.format(Locale.US, "%.6f", hardwareInfo.pixelRatio()));
92109
props.setProperty("overrideNames", overrideNames);
93110

94-
Path parent = outputFile.getParent();
111+
Path parent = normalizedOutput.getParent();
95112
if (parent != null) {
96113
Files.createDirectories(parent);
97114
}
98-
try (ZipOutputStream zos = new ZipOutputStream(Files.newOutputStream(outputFile))) {
115+
try (ZipOutputStream zos = new ZipOutputStream(Files.newOutputStream(normalizedOutput))) {
99116
writeEntry(zos, "skin.png", portraitImages.withTransparentDisplay());
100117
writeEntry(zos, "skin_l.png", landscapeImages.withTransparentDisplay());
101118
writeEntry(zos, "skin_map.png", portraitImages.overlay());
102119
writeEntry(zos, "skin_map_l.png", landscapeImages.overlay());
103120
writeProperties(zos, props);
104121
}
122+
return normalizedOutput;
123+
}
124+
125+
private static ConversionSummary convertGithubRepository(String repoUrl, String ref, Path outputDir)
126+
throws IOException, InterruptedException {
127+
Path tempDir = Files.createTempDirectory("avd-github-");
128+
try {
129+
Path repoDir = cloneGitRepository(repoUrl, ref, tempDir);
130+
List<Path> skinDirectories = discoverSkinDirectories(repoDir);
131+
if (skinDirectories.isEmpty()) {
132+
throw new IllegalStateException("No Android skin directories found in repository " + repoUrl);
133+
}
134+
Files.createDirectories(outputDir);
135+
List<Path> generated = new ArrayList<>();
136+
List<ConversionFailure> failures = new ArrayList<>();
137+
for (Path skinDir : skinDirectories) {
138+
Path relative = repoDir.relativize(skinDir);
139+
Path targetFile = uniqueOutputFile(outputDir, relative);
140+
try {
141+
Path created = convertSkinDirectory(skinDir, targetFile);
142+
generated.add(created);
143+
System.out.println("Converted " + relative + " -> " + created);
144+
} catch (Exception err) {
145+
String message = err.getMessage() != null ? err.getMessage() : err.toString();
146+
failures.add(new ConversionFailure(relative.toString(), message));
147+
System.err.println("Failed to convert " + relative + ": " + message);
148+
}
149+
}
150+
if (generated.isEmpty()) {
151+
String details = failures.isEmpty() ? "" : " First failure: " + failures.get(0).message();
152+
throw new IllegalStateException("Unable to convert any skins from repository " + repoUrl + "." + details);
153+
}
154+
return new ConversionSummary(Collections.unmodifiableList(new ArrayList<>(generated)),
155+
Collections.unmodifiableList(new ArrayList<>(failures)));
156+
} finally {
157+
deleteRecursively(tempDir);
158+
}
159+
}
160+
161+
private static Path cloneGitRepository(String repoUrl, String ref, Path workDir)
162+
throws IOException, InterruptedException {
163+
Path destination = workDir.resolve("repo");
164+
List<String> command = new ArrayList<>();
165+
command.add("git");
166+
command.add("clone");
167+
command.add("--depth");
168+
command.add("1");
169+
if (ref != null && !ref.isBlank()) {
170+
command.add("--branch");
171+
command.add(ref);
172+
}
173+
command.add(repoUrl);
174+
command.add(destination.toString());
175+
176+
Process process;
177+
try {
178+
process = new ProcessBuilder(command)
179+
.redirectErrorStream(true)
180+
.start();
181+
} catch (IOException err) {
182+
String message = err.getMessage();
183+
if (message != null && message.contains("No such file or directory")) {
184+
throw new IOException("The 'git' command is required to clone GitHub repositories.", err);
185+
}
186+
throw err;
187+
}
188+
189+
String output;
190+
try (InputStream stdout = process.getInputStream()) {
191+
output = new String(stdout.readAllBytes(), StandardCharsets.UTF_8).trim();
192+
}
193+
int exit = process.waitFor();
194+
if (exit != 0) {
195+
if (!output.isEmpty()) {
196+
throw new IOException("Failed to clone repository: " + output);
197+
}
198+
throw new IOException("Failed to clone repository " + repoUrl + ": git exited with status " + exit);
199+
}
200+
return destination;
201+
}
202+
203+
private static List<Path> discoverSkinDirectories(Path root) throws IOException {
204+
List<Path> skinDirectories = new ArrayList<>();
205+
try (Stream<Path> stream = Files.walk(root)) {
206+
stream.filter(Files::isDirectory)
207+
.filter(path -> !path.equals(root))
208+
.filter(path -> !isIgnoredDirectory(path))
209+
.forEach(path -> {
210+
if (looksLikeSkinDirectory(path)) {
211+
skinDirectories.add(path);
212+
}
213+
});
214+
}
215+
return skinDirectories;
216+
}
217+
218+
private static boolean isIgnoredDirectory(Path path) {
219+
for (Path component : path) {
220+
if (component == null) {
221+
continue;
222+
}
223+
String name = component.toString();
224+
if (name.equals(".git") || name.equals(".hg") || name.equals(".svn")
225+
|| name.equals("node_modules") || name.equals(".idea")) {
226+
return true;
227+
}
228+
}
229+
return false;
230+
}
231+
232+
private static boolean looksLikeSkinDirectory(Path directory) {
233+
try {
234+
findLayoutFile(directory);
235+
return true;
236+
} catch (IllegalStateException | UncheckedIOException err) {
237+
return false;
238+
}
239+
}
240+
241+
private static Path uniqueOutputFile(Path outputDir, Path relativeSkinDir) {
242+
String baseName = sanitizeRelativePath(relativeSkinDir);
243+
Path candidate = outputDir.resolve(baseName + ".skin").toAbsolutePath().normalize();
244+
int counter = 1;
245+
while (Files.exists(candidate)) {
246+
candidate = outputDir.resolve(baseName + "-" + counter + ".skin").toAbsolutePath().normalize();
247+
counter++;
248+
}
249+
return candidate;
250+
}
251+
252+
private static String sanitizeRelativePath(Path relative) {
253+
String raw = relative == null ? "" : relative.toString();
254+
raw = raw.replace('\\', '/');
255+
if (raw.isEmpty()) {
256+
return "skin";
257+
}
258+
String sanitized = raw.replace('/', '_');
259+
sanitized = sanitized.replaceAll("[^A-Za-z0-9._-]", "_");
260+
sanitized = sanitized.replaceAll("_+", "_");
261+
sanitized = sanitized.replaceAll("^_+", "");
262+
sanitized = sanitized.replaceAll("_+$", "");
263+
if (sanitized.isEmpty()) {
264+
sanitized = "skin";
265+
}
266+
return sanitized;
267+
}
268+
269+
private static void deleteRecursively(Path root) {
270+
if (root == null || !Files.exists(root)) {
271+
return;
272+
}
273+
try {
274+
Files.walkFileTree(root, new SimpleFileVisitor<>() {
275+
@Override
276+
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
277+
Files.deleteIfExists(file);
278+
return FileVisitResult.CONTINUE;
279+
}
280+
281+
@Override
282+
public FileVisitResult postVisitDirectory(Path dir, IOException exc) throws IOException {
283+
Files.deleteIfExists(dir);
284+
return FileVisitResult.CONTINUE;
285+
}
286+
});
287+
} catch (IOException ignored) {
288+
}
289+
}
290+
291+
public static void main(String[] args) throws Exception {
292+
if (args.length == 0) {
293+
printUsage();
294+
System.exit(1);
295+
}
296+
297+
if ("--github".equalsIgnoreCase(args[0])) {
298+
if (args.length < 2) {
299+
printUsage();
300+
System.exit(1);
301+
}
302+
String repoUrl = args[1];
303+
String ref = null;
304+
Path outputDir = Paths.get("converted-skins").toAbsolutePath().normalize();
305+
for (int i = 2; i < args.length; i++) {
306+
String arg = args[i];
307+
switch (arg) {
308+
case "--ref", "--branch" -> {
309+
if (i + 1 >= args.length) {
310+
error(arg + " requires an argument");
311+
}
312+
ref = args[++i];
313+
}
314+
case "--output" -> {
315+
if (i + 1 >= args.length) {
316+
error("--output requires an argument");
317+
}
318+
outputDir = Paths.get(args[++i]).toAbsolutePath().normalize();
319+
}
320+
default -> {
321+
printUsage();
322+
System.exit(1);
323+
}
324+
}
325+
}
326+
try {
327+
ConversionSummary summary = convertGithubRepository(repoUrl, ref, outputDir);
328+
System.out.println("Generated " + summary.generatedSkins().size() + " Codename One skin file(s) in " + outputDir);
329+
if (!summary.failures().isEmpty()) {
330+
System.err.println("The following directories could not be converted:");
331+
for (ConversionFailure failure : summary.failures()) {
332+
System.err.println(" - " + failure.relativePath() + ": " + failure.message());
333+
}
334+
}
335+
} catch (Exception err) {
336+
error(err.getMessage() != null ? err.getMessage() : err.toString());
337+
}
338+
return;
339+
}
340+
341+
if (args.length > 2) {
342+
printUsage();
343+
System.exit(1);
344+
}
345+
346+
Path skinDirectory = Paths.get(args[0]).toAbsolutePath().normalize();
347+
Path outputFile = args.length == 2
348+
? Paths.get(args[1]).toAbsolutePath().normalize()
349+
: defaultOutputPath(skinDirectory);
350+
351+
try {
352+
Path generated = convertSkinDirectory(skinDirectory, outputFile);
353+
System.out.println("Codename One skin created at: " + generated);
354+
} catch (Exception err) {
355+
error(err.getMessage() != null ? err.getMessage() : err.toString());
356+
}
357+
}
358+
359+
private record ConversionSummary(List<Path> generatedSkins, List<ConversionFailure> failures) {
360+
}
105361

106-
System.out.println("Codename One skin created at: " + outputFile);
362+
private record ConversionFailure(String relativePath, String message) {
107363
}
108364

109365
private static Path findLayoutFile(Path skinDirectory) {

README.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,19 @@
33
The Skin Designer is a Codename One app that allows you to visually design a Codename One "skin" representing a device type from two images and a bit of device specific details. This application serves both as a demo for working with Codename One and as a real tool that can be used to create device skins.
44

55
This tool was mostly designed for use in the web via the JavaScript port of Codename One, but it can also work thru the desktop port.
6+
7+
## Command Line Usage
8+
9+
To convert a single Android Virtual Device (AVD) skin directory into a Codename One skin archive:
10+
11+
```
12+
java AvdSkinToCodenameOneSkin.java <path-to-avd-skin> [output.skin]
13+
```
14+
15+
The converter can also scan an entire GitHub repository for Android skin definitions and convert each one automatically:
16+
17+
```
18+
java AvdSkinToCodenameOneSkin.java --github <repo-url> [--ref <git-ref>] [--output <directory>]
19+
```
20+
21+
The `--ref` option allows you to specify the branch or tag to clone, and `--output` chooses the directory where the generated `.skin` archives will be stored (defaults to `./converted-skins`). The command requires the `git` client to be available on the system `PATH` when cloning repositories.

0 commit comments

Comments
 (0)