55import java .io .*;
66import java .nio .charset .StandardCharsets ;
77import java .nio .file .*;
8+ import java .nio .file .attribute .BasicFileAttributes ;
89import java .time .LocalDateTime ;
910import java .time .format .DateTimeFormatter ;
1011import java .util .*;
1112import java .util .List ;
1213import java .util .zip .ZipEntry ;
1314import java .util .zip .ZipOutputStream ;
15+ import java .util .stream .Stream ;
1416import javax .imageio .ImageIO ;
1517import javax .imageio .spi .IIORegistry ;
1618import 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 ) {
0 commit comments