diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml new file mode 100644 index 0000000..63fd8be --- /dev/null +++ b/.github/workflows/code-quality.yml @@ -0,0 +1,108 @@ +name: Code Quality + +on: + push: + branches: [ "main", "develop" ] + pull_request: + branches: [ "main" ] + +permissions: + contents: read + pull-requests: write + +jobs: + test-and-coverage: + name: Unit Tests & Code Coverage + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 # Full history for better analysis + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'temurin' + cache: maven + + - name: Run tests with coverage + run: mvn -B clean test jacoco:report + + - name: Generate coverage report + run: mvn jacoco:report + + - name: Upload coverage reports to Codecov + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} + + - name: Archive coverage report + uses: actions/upload-artifact@v4 + with: + name: coverage-report + path: target/site/jacoco/ + retention-days: 30 + + - name: Comment coverage on PR + if: github.event_name == 'pull_request' + uses: madrapps/jacoco-report@v1.6.1 + with: + paths: ${{ github.workspace }}/target/site/jacoco/jacoco.xml + token: ${{ secrets.GITHUB_TOKEN }} + min-coverage-overall: 50 + min-coverage-changed-files: 60 + title: 'πŸ“Š Code Coverage Report' + update-comment: true + + code-analysis: + name: Static Code Analysis + runs-on: ubuntu-latest + needs: test-and-coverage + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'temurin' + cache: maven + + - name: Compile project + run: mvn -B compile test-compile + + - name: Run Maven verify + run: mvn -B verify -DskipTests + + build-quality: + name: Build Quality Check + runs-on: ubuntu-latest + needs: [test-and-coverage, code-analysis] + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'temurin' + cache: maven + + - name: Full build with all checks + run: mvn -B clean package + + - name: Archive artifacts + uses: actions/upload-artifact@v4 + with: + name: build-artifacts + path: | + target/*.jar + target/site/jacoco/ + retention-days: 7 diff --git a/README.md b/README.md index 819a65e..075d522 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ # Android Emulator Manager [![Java CI with Maven](https://github.com/NmurtasDev/AndroidEmulatorManager/actions/workflows/maven.yml/badge.svg)](https://github.com/NmurtasDev/AndroidEmulatorManager/actions/workflows/maven.yml) +[![Code Quality](https://github.com/NmurtasDev/AndroidEmulatorManager/actions/workflows/code-quality.yml/badge.svg)](https://github.com/NmurtasDev/AndroidEmulatorManager/actions/workflows/code-quality.yml) +[![codecov](https://codecov.io/gh/NmurtasDev/AndroidEmulatorManager/branch/main/graph/badge.svg)](https://codecov.io/gh/NmurtasDev/AndroidEmulatorManager) [![CodeQL](https://github.com/NmurtasDev/AndroidEmulatorManager/actions/workflows/codeql.yml/badge.svg)](https://github.com/NmurtasDev/AndroidEmulatorManager/actions/workflows/codeql.yml) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Java Version](https://img.shields.io/badge/Java-21-blue.svg)](https://www.oracle.com/java/technologies/javase/jdk21-archive-downloads.html) @@ -169,11 +171,14 @@ This version introduces major architectural improvements over previous versions: - βœ… **Dark theme support** with automatic system theme detection - βœ… **Rename AVD functionality** directly from UI -### CI/CD +### CI/CD & Quality - βœ… **Automated releases** via GitHub Actions on tag push - βœ… **Multi-platform builds** (JAR, Windows EXE) - βœ… **Pre-release support** for beta/RC versions - βœ… **CodeQL security scanning** on every commit +- βœ… **Automated code coverage** with JaCoCo and Codecov +- βœ… **Code quality checks** on every PR +- βœ… **Unit test execution** with detailed coverage reports (55 tests) ## Release Pipeline diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 0000000..697876f --- /dev/null +++ b/codecov.yml @@ -0,0 +1,43 @@ +codecov: + require_ci_to_pass: yes + notify: + wait_for_ci: yes + +coverage: + precision: 2 + round: down + range: "50...100" + + status: + project: + default: + target: 50% + threshold: 5% + if_ci_failed: error + + patch: + default: + target: 60% + threshold: 10% + + changes: + default: + if_ci_failed: error + +comment: + layout: "reach,diff,flags,tree,footer" + behavior: default + require_changes: false + require_base: no + require_head: yes + +ignore: + - "src/test/**" + - "**/*Test.java" + - "**/test/**" + +flags: + unittests: + paths: + - src/main/java/ + carryforward: true diff --git a/pom.xml b/pom.xml index ffdf787..228cd43 100644 --- a/pom.xml +++ b/pom.xml @@ -39,6 +39,7 @@ 5.10.1 2.0.9 1.5.19 + 1.18.30 @@ -59,6 +60,14 @@ ${logback.version} + + + org.projectlombok + lombok + ${lombok.version} + provided + + org.junit.jupiter @@ -66,6 +75,20 @@ ${junit.version} test + + + org.mockito + mockito-core + 5.8.0 + test + + + + org.mockito + mockito-junit-jupiter + 5.8.0 + test + @@ -90,6 +113,48 @@ 3.2.2 + + + org.jacoco + jacoco-maven-plugin + 0.8.11 + + + prepare-agent + + prepare-agent + + + + report + test + + report + + + + jacoco-check + + check + + + + + PACKAGE + + + LINE + COVEREDRATIO + 0.50 + + + + + + + + + org.apache.maven.plugins diff --git a/src/main/java/net/nicolamurtas/android/emulator/AndroidEmulatorManager.java b/src/main/java/net/nicolamurtas/android/emulator/AndroidEmulatorManager.java index 795b5fe..d40e407 100644 --- a/src/main/java/net/nicolamurtas/android/emulator/AndroidEmulatorManager.java +++ b/src/main/java/net/nicolamurtas/android/emulator/AndroidEmulatorManager.java @@ -1,1284 +1,33 @@ package net.nicolamurtas.android.emulator; -import net.nicolamurtas.android.emulator.service.ConfigService; -import net.nicolamurtas.android.emulator.service.EmulatorService; -import net.nicolamurtas.android.emulator.service.SdkDownloadService; +import net.nicolamurtas.android.emulator.controller.MainController; import net.nicolamurtas.android.emulator.util.PlatformUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import javax.swing.*; -import java.awt.*; -import java.awt.Desktop; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.*; -import java.util.List; /** - * Main application class for Android Emulator Manager. + * Main entry point for Android Emulator Manager application. * * A modern Java 21 application for managing Android SDK and emulators. + * This class serves as the application entry point, delegating all logic + * to the MainController following MVC pattern. * * @author Nicola Murtas * @version 3.0.0 */ -public class AndroidEmulatorManager extends JFrame { +public class AndroidEmulatorManager { private static final Logger logger = LoggerFactory.getLogger(AndroidEmulatorManager.class); - private final ConfigService configService; - private final SdkDownloadService sdkDownloadService; - private EmulatorService emulatorService; - - // UI Components - private JTextField sdkPathField; - private JTextArea logArea; - private JProgressBar progressBar; - private JPanel logPanel; - private JScrollPane logScrollPane; - private boolean logExpanded = false; - - // SDK accordion UI - private JPanel sdkPanel; - private JPanel sdkContentPanel; - private boolean sdkExpanded = false; - - // Device cards UI - private JPanel devicesGridPanel; - private List allAvds = new ArrayList<>(); - private int currentPage = 0; - private static final int CARDS_PER_PAGE = 10; - private JLabel pageLabel; - private JButton prevPageButton; - private JButton nextPageButton; - - public AndroidEmulatorManager() { - this.configService = new ConfigService(); - this.sdkDownloadService = new SdkDownloadService(); - - Path sdkPath = configService.getSdkPath(); - if (Files.exists(sdkPath)) { - this.emulatorService = new EmulatorService(sdkPath); - } - - initializeUI(); - loadConfiguration(); - refreshAvdList(); - - logger.info("Android Emulator Manager started"); - } - - private void initializeUI() { - setTitle("Android Emulator Manager v3.0"); - setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); - setSize(1000, 800); - setLocationRelativeTo(null); - - // Set system look and feel - try { - UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName()); - } catch (Exception e) { - logger.warn("Failed to set system look and feel", e); - } - - // Main layout - setLayout(new BorderLayout(10, 10)); - - // SDK Configuration Panel - add(createSdkPanel(), BorderLayout.NORTH); - - // Center panel with AVD list and accordion log - JPanel centerPanel = new JPanel(new BorderLayout()); - centerPanel.add(createAvdPanel(), BorderLayout.CENTER); - centerPanel.add(createLogAccordion(), BorderLayout.SOUTH); - add(centerPanel, BorderLayout.CENTER); - - // Progress bar - progressBar = new JProgressBar(); - progressBar.setStringPainted(true); - progressBar.setVisible(false); - add(progressBar, BorderLayout.SOUTH); - - // Window closing handler - addWindowListener(new java.awt.event.WindowAdapter() { - @Override - public void windowClosing(java.awt.event.WindowEvent e) { - onClosing(); - } - }); - } - - private JPanel createSdkPanel() { - sdkPanel = new JPanel(new BorderLayout()); - sdkPanel.setBorder(BorderFactory.createLineBorder(UIManager.getColor("Panel.background").darker(), 1)); - - // Header panel with toggle button - JPanel headerPanel = new JPanel(new BorderLayout()); - Color panelBg = UIManager.getColor("Panel.background"); - Color headerBg = panelBg != null ? - (isDarkTheme() ? panelBg.brighter() : panelBg.darker()) : - new Color(240, 240, 240); - headerPanel.setBackground(headerBg); - headerPanel.setBorder(BorderFactory.createEmptyBorder(5, 10, 5, 10)); - - // Check if SDK is configured to determine initial state - boolean sdkConfigured = configService.isSdkConfigured(); - sdkExpanded = !sdkConfigured; // Collapsed if SDK is configured, expanded if not - - JLabel sdkLabel = new JLabel(sdkExpanded ? "β–Ό SDK Configuration" : "β–Ά SDK Configuration"); - sdkLabel.setFont(sdkLabel.getFont().deriveFont(Font.BOLD, 13f)); - sdkLabel.setCursor(new Cursor(Cursor.HAND_CURSOR)); - sdkLabel.setForeground(UIManager.getColor("Label.foreground")); - - // Status indicator - JLabel statusLabel = new JLabel(sdkConfigured ? "βœ“ Configured" : "⚠ Not Configured"); - statusLabel.setFont(statusLabel.getFont().deriveFont(Font.PLAIN, 11f)); - statusLabel.setForeground(sdkConfigured ? new Color(76, 175, 80) : new Color(255, 152, 0)); - - headerPanel.add(sdkLabel, BorderLayout.WEST); - headerPanel.add(statusLabel, BorderLayout.EAST); - - // SDK content panel - sdkContentPanel = new JPanel(new BorderLayout(5, 5)); - sdkContentPanel.setBorder(BorderFactory.createEmptyBorder(10, 10, 10, 10)); - - JPanel topPanel = new JPanel(new FlowLayout(FlowLayout.LEFT)); - topPanel.add(new JLabel("SDK Path:")); - - sdkPathField = new JTextField(40); - topPanel.add(sdkPathField); - - JButton browseButton = new JButton("Browse..."); - browseButton.addActionListener(e -> browseSdkPath()); - topPanel.add(browseButton); - - JPanel buttonPanel = new JPanel(new FlowLayout(FlowLayout.LEFT)); - - JButton downloadButton = new JButton("Download SDK"); - downloadButton.setBackground(new Color(76, 175, 80)); - downloadButton.setForeground(Color.WHITE); - downloadButton.addActionListener(e -> downloadSdk()); - buttonPanel.add(downloadButton); - - JButton verifyButton = new JButton("Verify SDK"); - verifyButton.addActionListener(e -> verifySdk()); - buttonPanel.add(verifyButton); - - sdkContentPanel.add(topPanel, BorderLayout.NORTH); - sdkContentPanel.add(buttonPanel, BorderLayout.SOUTH); - - // Set initial visibility - sdkContentPanel.setVisible(sdkExpanded); - - // Toggle functionality - headerPanel.addMouseListener(new java.awt.event.MouseAdapter() { - @Override - public void mouseClicked(java.awt.event.MouseEvent e) { - toggleSdk(sdkLabel, statusLabel); - } - }); - - sdkLabel.addMouseListener(new java.awt.event.MouseAdapter() { - @Override - public void mouseClicked(java.awt.event.MouseEvent e) { - toggleSdk(sdkLabel, statusLabel); - } - }); - - sdkPanel.add(headerPanel, BorderLayout.NORTH); - sdkPanel.add(sdkContentPanel, BorderLayout.CENTER); - - return sdkPanel; - } - - private void toggleSdk(JLabel label, JLabel statusLabel) { - sdkExpanded = !sdkExpanded; - - if (sdkExpanded) { - label.setText("β–Ό SDK Configuration"); - sdkContentPanel.setVisible(true); - } else { - label.setText("β–Ά SDK Configuration"); - sdkContentPanel.setVisible(false); - } - - sdkPanel.revalidate(); - sdkPanel.repaint(); - } - - private JPanel createAvdPanel() { - JPanel panel = new JPanel(new BorderLayout(5, 5)); - panel.setBorder(BorderFactory.createTitledBorder("Android Virtual Devices")); - - // Devices grid panel (5 columns x 2 rows = 10 cards) - devicesGridPanel = new JPanel(new GridLayout(2, 5, 10, 10)); - devicesGridPanel.setBorder(BorderFactory.createEmptyBorder(10, 10, 10, 10)); - - JScrollPane scrollPane = new JScrollPane(devicesGridPanel); - scrollPane.setVerticalScrollBarPolicy(JScrollPane.VERTICAL_SCROLLBAR_AS_NEEDED); - scrollPane.setHorizontalScrollBarPolicy(JScrollPane.HORIZONTAL_SCROLLBAR_NEVER); - panel.add(scrollPane, BorderLayout.CENTER); - - // Bottom panel with pagination and buttons - JPanel bottomPanel = new JPanel(new BorderLayout(5, 5)); - - // Pagination controls - JPanel paginationPanel = new JPanel(new FlowLayout(FlowLayout.CENTER)); - prevPageButton = new JButton("β—„ Previous"); - prevPageButton.addActionListener(e -> changePage(-1)); - prevPageButton.setEnabled(false); - - pageLabel = new JLabel("Page 1", SwingConstants.CENTER); - pageLabel.setPreferredSize(new Dimension(100, 25)); - - nextPageButton = new JButton("Next β–Ί"); - nextPageButton.addActionListener(e -> changePage(1)); - nextPageButton.setEnabled(false); - - paginationPanel.add(prevPageButton); - paginationPanel.add(pageLabel); - paginationPanel.add(nextPageButton); - - // Action buttons - JPanel buttonPanel = new JPanel(new FlowLayout(FlowLayout.LEFT)); - - JButton createButton = new JButton("Create New AVD"); - createButton.setBackground(new Color(76, 175, 80)); - createButton.setForeground(Color.WHITE); - createButton.addActionListener(e -> createAvdDialog()); - buttonPanel.add(createButton); - - JButton refreshButton = new JButton("Refresh"); - refreshButton.addActionListener(e -> refreshAvdList()); - buttonPanel.add(refreshButton); - - bottomPanel.add(paginationPanel, BorderLayout.NORTH); - bottomPanel.add(buttonPanel, BorderLayout.SOUTH); - - panel.add(bottomPanel, BorderLayout.SOUTH); - - return panel; - } - - private JPanel createLogAccordion() { - logPanel = new JPanel(new BorderLayout()); - logPanel.setBorder(BorderFactory.createLineBorder(UIManager.getColor("Panel.background").darker(), 1)); - - // Header panel with toggle button - use system colors - JPanel headerPanel = new JPanel(new BorderLayout()); - // Use slightly darker/lighter version of panel background for contrast - Color panelBg = UIManager.getColor("Panel.background"); - Color headerBg = panelBg != null ? - (isDarkTheme() ? panelBg.brighter() : panelBg.darker()) : - new Color(240, 240, 240); - headerPanel.setBackground(headerBg); - headerPanel.setBorder(BorderFactory.createEmptyBorder(5, 10, 5, 10)); - - JLabel logLabel = new JLabel("β–Ά Log"); - logLabel.setFont(logLabel.getFont().deriveFont(Font.BOLD, 13f)); - logLabel.setCursor(new Cursor(Cursor.HAND_CURSOR)); - // Ensure label uses system text color - logLabel.setForeground(UIManager.getColor("Label.foreground")); - - JButton clearButton = new JButton("Clear"); - clearButton.setFont(clearButton.getFont().deriveFont(10f)); - clearButton.setMargin(new Insets(2, 8, 2, 8)); - clearButton.addActionListener(e -> logArea.setText("")); - clearButton.setVisible(false); // Initially hidden - - headerPanel.add(logLabel, BorderLayout.WEST); - headerPanel.add(clearButton, BorderLayout.EAST); - - // Log content panel (initially hidden) - use system colors - logArea = new JTextArea(10, 0); - logArea.setEditable(false); - logArea.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 11)); - // Set text area colors to match system theme - logArea.setBackground(UIManager.getColor("TextArea.background")); - logArea.setForeground(UIManager.getColor("TextArea.foreground")); - logArea.setCaretColor(UIManager.getColor("TextArea.caretForeground")); - - logScrollPane = new JScrollPane(logArea); - logScrollPane.setPreferredSize(new Dimension(0, 0)); - logScrollPane.setVisible(false); - - // Toggle functionality - headerPanel.addMouseListener(new java.awt.event.MouseAdapter() { - @Override - public void mouseClicked(java.awt.event.MouseEvent e) { - toggleLog(logLabel, clearButton); - } - }); - - logLabel.addMouseListener(new java.awt.event.MouseAdapter() { - @Override - public void mouseClicked(java.awt.event.MouseEvent e) { - toggleLog(logLabel, clearButton); - } - }); - - logPanel.add(headerPanel, BorderLayout.NORTH); - logPanel.add(logScrollPane, BorderLayout.CENTER); - - return logPanel; - } - - private void toggleLog(JLabel label, JButton clearButton) { - logExpanded = !logExpanded; - - if (logExpanded) { - label.setText("β–Ό Log"); - logScrollPane.setPreferredSize(new Dimension(0, 200)); - logScrollPane.setVisible(true); - clearButton.setVisible(true); - } else { - label.setText("β–Ά Log"); - logScrollPane.setPreferredSize(new Dimension(0, 0)); - logScrollPane.setVisible(false); - clearButton.setVisible(false); - } - - logPanel.revalidate(); - logPanel.repaint(); - } - - /** - * Creates a device card panel for an AVD. - */ - private JPanel createDeviceCard(EmulatorService.AvdInfo avd) { - JPanel card = new JPanel(new BorderLayout(5, 5)); - card.setBorder(BorderFactory.createCompoundBorder( - BorderFactory.createLineBorder(UIManager.getColor("Panel.border"), 2), - BorderFactory.createEmptyBorder(10, 10, 10, 10) - )); - card.setPreferredSize(new Dimension(180, 200)); - - // Top panel with name and info - JPanel topPanel = new JPanel(new BorderLayout(3, 3)); - - // Device name - JLabel nameLabel = new JLabel(avd.name(), SwingConstants.CENTER); - nameLabel.setFont(nameLabel.getFont().deriveFont(Font.BOLD, 14f)); - topPanel.add(nameLabel, BorderLayout.NORTH); - - // Info panel with version and device type (ALWAYS VISIBLE) - JPanel infoPanel = new JPanel(new GridLayout(0, 1, 2, 2)); - - // Extract API level from config.ini (more reliable than target string) - String apiLevel = extractApiLevelFromPath(avd.path()); - String androidVersion = getAndroidVersionName(apiLevel); - JLabel versionLabel = new JLabel(androidVersion, SwingConstants.CENTER); - versionLabel.setFont(versionLabel.getFont().deriveFont(Font.PLAIN, 11f)); - infoPanel.add(versionLabel); - - // Show device type - String deviceType = extractDeviceType(avd.path()); - if (deviceType != null && !deviceType.isEmpty()) { - JLabel deviceLabel = new JLabel(deviceType, SwingConstants.CENTER); - deviceLabel.setFont(deviceLabel.getFont().deriveFont(Font.PLAIN, 10f)); - deviceLabel.setForeground(Color.GRAY); - infoPanel.add(deviceLabel); - } - - // Check if running - boolean isRunning = emulatorService != null && emulatorService.isEmulatorRunning(avd.name()); - JLabel statusLabel = new JLabel(isRunning ? "● Running" : "β—‹ Stopped", SwingConstants.CENTER); - statusLabel.setFont(statusLabel.getFont().deriveFont(Font.BOLD, 10f)); - statusLabel.setForeground(isRunning ? new Color(76, 175, 80) : Color.GRAY); - infoPanel.add(statusLabel); - - topPanel.add(infoPanel, BorderLayout.CENTER); - card.add(topPanel, BorderLayout.NORTH); - - // Action buttons panel - JPanel actionsPanel = new JPanel(new GridLayout(2, 2, 5, 5)); - - JButton startBtn = new JButton("β–Ά"); - startBtn.setToolTipText("Start"); - startBtn.setBackground(new Color(76, 175, 80)); - startBtn.setForeground(Color.WHITE); - startBtn.addActionListener(e -> startEmulatorByName(avd.name())); - - JButton stopBtn = new JButton("β– "); - stopBtn.setToolTipText("Stop"); - stopBtn.setBackground(new Color(244, 67, 54)); - stopBtn.setForeground(Color.WHITE); - stopBtn.addActionListener(e -> stopEmulatorByName(avd.name())); - - JButton renameBtn = new JButton("✎"); - renameBtn.setToolTipText("Rename"); - renameBtn.addActionListener(e -> renameAvd(avd.name())); - - JButton deleteBtn = new JButton("πŸ—‘"); - deleteBtn.setToolTipText("Delete"); - deleteBtn.setBackground(new Color(244, 67, 54)); - deleteBtn.setForeground(Color.WHITE); - deleteBtn.addActionListener(e -> deleteAvdByName(avd.name())); - - actionsPanel.add(startBtn); - actionsPanel.add(stopBtn); - actionsPanel.add(renameBtn); - actionsPanel.add(deleteBtn); - - card.add(actionsPanel, BorderLayout.SOUTH); - - return card; - } - - /** - * Extracts API level from AVD config.ini file. - * This is more reliable than parsing the target string. - */ - private String extractApiLevelFromPath(String avdPath) { - if (avdPath == null) return "Unknown"; - - try { - Path configIni = Path.of(avdPath).resolve("config.ini"); - if (Files.exists(configIni)) { - String content = Files.readString(configIni); - // Look for image.sysdir.1 = system-images/android-35/google_apis/x86_64/ - for (String line : content.split("\n")) { - line = line.trim(); - if (line.startsWith("image.sysdir.1")) { - // Parse "image.sysdir.1 = system-images/android-35/google_apis/x86_64/" - int equalPos = line.indexOf('='); - if (equalPos > 0) { - String sysdir = line.substring(equalPos + 1).trim(); - // Extract API number from: system-images/android-35/google_apis/x86_64/ - String[] parts = sysdir.split("/"); - for (String part : parts) { - if (part.startsWith("android-")) { - String api = part.substring(8); // Remove "android-" prefix - logger.debug("Extracted API level {} from sysdir: {}", api, sysdir); - return api; - } - } - } - } - } - } - } catch (Exception e) { - logger.debug("Could not extract API level from path: {}", avdPath, e); - } - - return "Unknown"; - } - - /** - * Extracts API level from target string (fallback method). - */ - private String extractApiLevel(String target) { - if (target == null) return "Unknown"; - // Target format: "Android X.Y (API level Z)" or similar - if (target.contains("API level")) { - int start = target.indexOf("API level") + 10; - int end = target.indexOf(")", start); - if (end > start) { - return target.substring(start, end).trim(); - } - } - // Try to extract just the number - String[] parts = target.split("\\s+"); - for (String part : parts) { - if (part.matches("\\d+")) { - return part; - } - } - return "Unknown"; - } - - /** - * Converts API level to Android version name. - */ - private String getAndroidVersionName(String apiLevel) { - if (apiLevel == null || apiLevel.equals("Unknown")) { - return "Android (Unknown)"; - } - - return switch (apiLevel) { - case "36" -> "Android 16"; - case "35" -> "Android 15"; - case "34" -> "Android 14"; - case "33" -> "Android 13"; - case "32" -> "Android 12L"; - case "31" -> "Android 12"; - case "30" -> "Android 11"; - case "29" -> "Android 10"; - case "28" -> "Android 9"; - case "27" -> "Android 8.1"; - case "26" -> "Android 8.0"; - case "25" -> "Android 7.1"; - case "24" -> "Android 7.0"; - case "23" -> "Android 6.0"; - case "22" -> "Android 5.1"; - case "21" -> "Android 5.0"; - default -> "Android API " + apiLevel; - }; - } - - /** - * Extracts device type from AVD path. - * Example: /home/user/.android/avd/MyDevice.avd -> looks in config.ini for hw.device.name - */ - private String extractDeviceType(String avdPath) { - if (avdPath == null) return null; - - try { - Path configIni = Path.of(avdPath).resolve("config.ini"); - if (Files.exists(configIni)) { - String content = Files.readString(configIni); - // Look for hw.device.name = pixel_7 - for (String line : content.split("\n")) { - line = line.trim(); - if (line.startsWith("hw.device.name")) { - // Parse "hw.device.name = pixel_7" - int equalPos = line.indexOf('='); - if (equalPos > 0) { - String deviceName = line.substring(equalPos + 1).trim(); - // Format device name: pixel_7 -> Pixel 7 - logger.debug("Extracted device name: {}", deviceName); - return formatDeviceName(deviceName); - } - } - } - } - } catch (Exception e) { - logger.debug("Could not extract device type from path: {}", avdPath, e); - } - - return null; - } - - /** - * Formats device name for display. - * Example: pixel_7 -> Pixel 7, pixel -> Pixel - */ - private String formatDeviceName(String deviceName) { - if (deviceName == null || deviceName.isEmpty()) { - return deviceName; - } - - // Replace underscores with spaces and capitalize words - String[] parts = deviceName.replace("_", " ").split(" "); - StringBuilder formatted = new StringBuilder(); - - for (String part : parts) { - if (!part.isEmpty()) { - formatted.append(Character.toUpperCase(part.charAt(0))); - if (part.length() > 1) { - formatted.append(part.substring(1)); - } - formatted.append(" "); - } - } - - return formatted.toString().trim(); - } - - /** - * Changes the current page of devices. - */ - private void changePage(int delta) { - int totalPages = (int) Math.ceil((double) allAvds.size() / CARDS_PER_PAGE); - currentPage = Math.max(0, Math.min(currentPage + delta, totalPages - 1)); - updateDeviceCards(); - } - - /** - * Updates the device cards display for the current page. - */ - private void updateDeviceCards() { - SwingUtilities.invokeLater(() -> { - devicesGridPanel.removeAll(); - - int start = currentPage * CARDS_PER_PAGE; - int end = Math.min(start + CARDS_PER_PAGE, allAvds.size()); - - for (int i = start; i < end; i++) { - devicesGridPanel.add(createDeviceCard(allAvds.get(i))); - } - - // Fill empty slots with placeholder panels - int cardsShown = end - start; - for (int i = cardsShown; i < CARDS_PER_PAGE; i++) { - JPanel placeholder = new JPanel(); - placeholder.setPreferredSize(new Dimension(180, 200)); - placeholder.setBorder(BorderFactory.createLineBorder(Color.LIGHT_GRAY, 1, true)); - placeholder.setBackground(UIManager.getColor("Panel.background")); - devicesGridPanel.add(placeholder); - } - - // Update pagination controls - int totalPages = Math.max(1, (int) Math.ceil((double) allAvds.size() / CARDS_PER_PAGE)); - pageLabel.setText("Page " + (currentPage + 1) + " / " + totalPages); - prevPageButton.setEnabled(currentPage > 0); - nextPageButton.setEnabled(currentPage < totalPages - 1); - - devicesGridPanel.revalidate(); - devicesGridPanel.repaint(); - }); - } - - /** - * Validates AVD name to ensure it doesn't contain spaces or invalid characters. - * AVD names should only contain letters, numbers, underscores, and hyphens. - */ - private boolean isValidAvdName(String name) { - if (name == null || name.isEmpty()) { - return false; - } - - // Check for spaces - if (name.contains(" ")) { - return false; - } - - // AVD names should only contain: letters, numbers, underscores, hyphens - // Pattern: ^[a-zA-Z0-9_-]+$ - return name.matches("^[a-zA-Z0-9_-]+$"); - } - - private void loadConfiguration() { - Path sdkPath = configService.getSdkPath(); - sdkPathField.setText(sdkPath.toString()); - } - - private void browseSdkPath() { - JFileChooser chooser = new JFileChooser(); - chooser.setFileSelectionMode(JFileChooser.DIRECTORIES_ONLY); - chooser.setDialogTitle("Select Android SDK Directory"); - - if (chooser.showOpenDialog(this) == JFileChooser.APPROVE_OPTION) { - Path path = chooser.getSelectedFile().toPath(); - sdkPathField.setText(path.toString()); - configService.setSdkPath(path); - configService.saveConfig(); - emulatorService = new EmulatorService(path); - log("SDK path set to: " + path); - } - } - - private void downloadSdk() { - String pathText = sdkPathField.getText().trim(); - if (pathText.isEmpty()) { - pathText = PlatformUtils.getDefaultSdkPath().toString(); - sdkPathField.setText(pathText); - } - - Path sdkPath = Path.of(pathText); - - // Show license agreement first - if (!showLicenseAgreementDialog()) { - log("SDK download cancelled: License not accepted"); - return; - } - - // Show component selection dialog - List selectedComponents = showSdkComponentSelectionDialog(); - if (selectedComponents == null || selectedComponents.isEmpty()) { - log("SDK download cancelled by user"); - return; - } - - new Thread(() -> { - try { - showProgress(true); - log("=== Starting SDK Download ==="); - log("Target path: " + sdkPath); - log("Selected components: " + selectedComponents.size()); - - sdkDownloadService.downloadAndInstallSdk(sdkPath, selectedComponents, this::updateProgress); - - configService.setSdkPath(sdkPath); - configService.saveConfig(); - emulatorService = new EmulatorService(sdkPath); - - log("=== SDK Installation Completed Successfully ==="); - JOptionPane.showMessageDialog(this, - "SDK downloaded and installed successfully!", - "Success", JOptionPane.INFORMATION_MESSAGE); - - } catch (Exception e) { - logger.error("SDK download failed", e); - log("ERROR: " + e.getMessage()); - JOptionPane.showMessageDialog(this, - "SDK download failed: " + e.getMessage(), - "Error", JOptionPane.ERROR_MESSAGE); - } finally { - showProgress(false); - } - }).start(); - } - - /** - * Shows the Android SDK License Agreement dialog. - * User must accept to proceed with SDK download. - * - * @return true if user accepted, false otherwise - */ - private boolean showLicenseAgreementDialog() { - String licenseText = """ - ANDROID SOFTWARE DEVELOPMENT KIT LICENSE AGREEMENT - - 1. Introduction - - 1.1 The Android Software Development Kit (referred to in the License Agreement as the "SDK" - and specifically including the Android system files, packaged APIs, and Google APIs add-ons) - is licensed to you subject to the terms of the License Agreement. The License Agreement forms - a legally binding contract between you and Google in relation to your use of the SDK. - - 1.2 "Android" means the Android software stack for devices, as made available under the - Android Open Source Project, which is located at the following URL: - https://source.android.com/, as updated from time to time. - - 1.3 A "compatible implementation" means any Android device that (i) complies with the Android - Compatibility Definition document, which can be found at the Android compatibility website - (https://source.android.com/compatibility) and which may be updated from time to time; and - (ii) successfully passes the Android Compatibility Test Suite (CTS). - - 1.4 "Google" means Google LLC, a Delaware corporation with principal place of business at - 1600 Amphitheatre Parkway, Mountain View, CA 94043, United States. - - - 2. Accepting this License Agreement - - 2.1 In order to use the SDK, you must first agree to the License Agreement. You may not use - the SDK if you do not accept the License Agreement. - - 2.2 By clicking to accept, you hereby agree to the terms of the License Agreement. - - 2.3 You may not use the SDK and may not accept the License Agreement if you are a person - barred from receiving the SDK under the laws of the United States or other countries, - including the country in which you are resident or from which you use the SDK. - - 2.4 If you are agreeing to be bound by the License Agreement on behalf of your employer or - other entity, you represent and warrant that you have full legal authority to bind your - employer or such entity to the License Agreement. If you do not have the requisite authority, - you may not accept the License Agreement or use the SDK on behalf of your employer or other - entity. - - - 3. SDK License from Google - - 3.1 Subject to the terms of the License Agreement, Google grants you a limited, worldwide, - royalty-free, non-assignable, non-exclusive, and non-sublicensable license to use the SDK - solely to develop applications for compatible implementations of Android. - - 3.2 You may not use this SDK to develop applications for other platforms (including - non-compatible implementations of Android) or to develop another SDK. You are of course free - to develop applications for other platforms, including non-compatible implementations of - Android, provided that this SDK is not used for that purpose. - - 3.3 You agree that Google or third parties own all legal right, title and interest in and to - the SDK, including any Intellectual Property Rights that subsist in the SDK. "Intellectual - Property Rights" means any and all rights under patent law, copyright law, trade secret law, - trademark law, and any and all other proprietary rights. Google reserves all rights not - expressly granted to you. - - - For the complete license agreement, please visit: - https://developer.android.com/studio/terms - - - BY CLICKING "I ACCEPT" BELOW, YOU ACKNOWLEDGE THAT YOU HAVE READ AND UNDERSTOOD THE ABOVE - TERMS AND CONDITIONS AND AGREE TO BE BOUND BY THEM. - """; - - JTextArea textArea = new JTextArea(licenseText); - textArea.setEditable(false); - textArea.setLineWrap(true); - textArea.setWrapStyleWord(true); - textArea.setCaretPosition(0); - textArea.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 11)); - - JScrollPane scrollPane = new JScrollPane(textArea); - scrollPane.setPreferredSize(new Dimension(700, 500)); - - JPanel panel = new JPanel(new BorderLayout(10, 10)); - panel.setBorder(BorderFactory.createEmptyBorder(10, 10, 10, 10)); - - JLabel titleLabel = new JLabel("Android SDK License Agreement"); - titleLabel.setFont(titleLabel.getFont().deriveFont(Font.BOLD, 16f)); - panel.add(titleLabel, BorderLayout.NORTH); - - panel.add(scrollPane, BorderLayout.CENTER); - - JPanel bottomPanel = new JPanel(new BorderLayout()); - JLabel noteLabel = new JLabel("Note: You must accept this license to download and use the Android SDK."); - noteLabel.setBorder(BorderFactory.createEmptyBorder(5, 0, 5, 0)); - bottomPanel.add(noteLabel, BorderLayout.NORTH); - - JPanel linkPanel = new JPanel(new FlowLayout(FlowLayout.LEFT)); - JButton linkButton = new JButton("View Full License at developer.android.com"); - linkButton.setBorderPainted(false); - linkButton.setContentAreaFilled(false); - linkButton.setForeground(new Color(33, 150, 243)); - linkButton.setCursor(new Cursor(Cursor.HAND_CURSOR)); - linkButton.addActionListener(e -> { - try { - Desktop.getDesktop().browse(new java.net.URI("https://developer.android.com/studio/terms")); - } catch (Exception ex) { - logger.warn("Could not open browser", ex); - } - }); - linkPanel.add(linkButton); - bottomPanel.add(linkPanel, BorderLayout.SOUTH); - - panel.add(bottomPanel, BorderLayout.SOUTH); - - Object[] options = {"I Accept", "I Decline"}; - int result = JOptionPane.showOptionDialog( - this, - panel, - "Android SDK License Agreement", - JOptionPane.YES_NO_OPTION, - JOptionPane.PLAIN_MESSAGE, - null, - options, - options[1] // Default to "I Decline" - ); - - boolean accepted = (result == JOptionPane.YES_OPTION); - if (accepted) { - log("Android SDK License Agreement accepted by user"); - } else { - log("Android SDK License Agreement declined by user"); - } - - return accepted; - } - - private List showSdkComponentSelectionDialog() { - JPanel panel = new JPanel(new GridBagLayout()); - GridBagConstraints gbc = new GridBagConstraints(); - gbc.insets = new Insets(5, 5, 5, 5); - gbc.fill = GridBagConstraints.HORIZONTAL; - gbc.anchor = GridBagConstraints.WEST; - - // Title - gbc.gridx = 0; gbc.gridy = 0; gbc.gridwidth = 2; - JLabel titleLabel = new JLabel("Select SDK Components to Install:"); - titleLabel.setFont(titleLabel.getFont().deriveFont(Font.BOLD, 14f)); - panel.add(titleLabel, gbc); - - // Essential components (always selected, disabled) - gbc.gridy++; gbc.gridwidth = 1; - JLabel essentialLabel = new JLabel("Essential Components (required):"); - essentialLabel.setFont(essentialLabel.getFont().deriveFont(Font.BOLD)); - panel.add(essentialLabel, gbc); - - List essentialCheckboxes = new ArrayList<>(); - String[] essentialComponents = {"platform-tools", "emulator", "build-tools;35.0.0"}; - for (String component : essentialComponents) { - gbc.gridy++; - JCheckBox cb = new JCheckBox(component, true); - cb.setEnabled(false); - essentialCheckboxes.add(cb); - panel.add(cb, gbc); - } - - // API levels - gbc.gridy++; - JLabel apiLabel = new JLabel("Android API Levels:"); - apiLabel.setFont(apiLabel.getFont().deriveFont(Font.BOLD)); - panel.add(apiLabel, gbc); - - Map apiCheckboxes = new LinkedHashMap<>(); - for (int api = 36; api >= 30; api--) { - gbc.gridy++; - JCheckBox platformCb = new JCheckBox("Android " + api + " (Platform + System Image)", api >= 34); - apiCheckboxes.put(String.valueOf(api), platformCb); - panel.add(platformCb, gbc); - } - - // Select/Deselect all buttons - gbc.gridy++; - JPanel buttonPanel = new JPanel(new FlowLayout(FlowLayout.LEFT)); - JButton selectAllBtn = new JButton("Select All"); - JButton deselectAllBtn = new JButton("Deselect All"); - - selectAllBtn.addActionListener(e -> - apiCheckboxes.values().forEach(cb -> cb.setSelected(true))); - deselectAllBtn.addActionListener(e -> - apiCheckboxes.values().forEach(cb -> cb.setSelected(false))); - - buttonPanel.add(selectAllBtn); - buttonPanel.add(deselectAllBtn); - panel.add(buttonPanel, gbc); - - // Show dialog - int result = JOptionPane.showConfirmDialog(this, new JScrollPane(panel), - "SDK Component Selection", JOptionPane.OK_CANCEL_OPTION, JOptionPane.PLAIN_MESSAGE); - - if (result != JOptionPane.OK_OPTION) { - return null; - } - - // Build selected components list - List selectedComponents = new ArrayList<>(); - - // Add essential components - selectedComponents.add("platform-tools"); - selectedComponents.add("emulator"); - selectedComponents.add("build-tools;35.0.0"); - - // Add selected APIs - for (Map.Entry entry : apiCheckboxes.entrySet()) { - if (entry.getValue().isSelected()) { - String api = entry.getKey(); - selectedComponents.add("platforms;android-" + api); - selectedComponents.add("system-images;android-" + api + ";google_apis;x86_64"); - } - } - - return selectedComponents; - } - - private void verifySdk() { - if (configService.isSdkConfigured()) { - JOptionPane.showMessageDialog(this, - "SDK is properly configured!", - "SDK Verification", JOptionPane.INFORMATION_MESSAGE); - log("SDK verification: OK"); - } else { - JOptionPane.showMessageDialog(this, - "SDK is not properly configured. Please download SDK first.", - "SDK Verification", JOptionPane.WARNING_MESSAGE); - log("SDK verification: FAILED"); - } - } - - private void createAvdDialog() { - if (emulatorService == null) { - JOptionPane.showMessageDialog(this, - "Please configure SDK first", - "Error", JOptionPane.ERROR_MESSAGE); - return; - } - - // Enhanced AVD creation dialog - JTextField nameField = new JTextField("MyDevice"); - - // Main API levels (30-36) - String[] apiLevels = {"36", "35", "34", "33", "32", "31", "30"}; - JComboBox apiCombo = new JComboBox<>(apiLevels); - apiCombo.setSelectedItem("35"); - - // Legacy API levels (< 30) - JCheckBox legacyCheckBox = new JCheckBox("Show Legacy APIs (< 30)"); - String[] legacyApiLevels = {"29", "28", "27", "26", "25", "24", "23", "22", "21"}; - JComboBox legacyApiCombo = new JComboBox<>(legacyApiLevels); - legacyApiCombo.setEnabled(false); - legacyApiCombo.setVisible(false); - - legacyCheckBox.addActionListener(e -> { - boolean showLegacy = legacyCheckBox.isSelected(); - apiCombo.setEnabled(!showLegacy); - legacyApiCombo.setEnabled(showLegacy); - legacyApiCombo.setVisible(showLegacy); - }); - - String[] devices = {"pixel", "pixel_2", "pixel_3", "pixel_4", "pixel_5", - "pixel_6", "pixel_7", "pixel_8"}; - JComboBox deviceCombo = new JComboBox<>(devices); - deviceCombo.setSelectedItem("pixel_7"); - - JPanel panel = new JPanel(new GridBagLayout()); - GridBagConstraints gbc = new GridBagConstraints(); - gbc.insets = new Insets(5, 5, 5, 5); - gbc.fill = GridBagConstraints.HORIZONTAL; - - // Row 0: Name - gbc.gridx = 0; gbc.gridy = 0; gbc.anchor = GridBagConstraints.WEST; - panel.add(new JLabel("Name:"), gbc); - gbc.gridx = 1; gbc.gridwidth = 2; - panel.add(nameField, gbc); - - // Row 1: API Level - gbc.gridx = 0; gbc.gridy = 1; gbc.gridwidth = 1; - panel.add(new JLabel("API Level:"), gbc); - gbc.gridx = 1; gbc.gridwidth = 2; - panel.add(apiCombo, gbc); - - // Row 2: Legacy checkbox - gbc.gridx = 1; gbc.gridy = 2; gbc.gridwidth = 2; - panel.add(legacyCheckBox, gbc); - - // Row 3: Legacy API combo (initially hidden) - gbc.gridx = 1; gbc.gridy = 3; gbc.gridwidth = 2; - panel.add(legacyApiCombo, gbc); - - // Row 4: Device - gbc.gridx = 0; gbc.gridy = 4; gbc.gridwidth = 1; - panel.add(new JLabel("Device:"), gbc); - gbc.gridx = 1; gbc.gridwidth = 2; - panel.add(deviceCombo, gbc); - - int result = JOptionPane.showConfirmDialog(this, panel, - "Create New AVD", JOptionPane.OK_CANCEL_OPTION); - - if (result == JOptionPane.OK_OPTION) { - String avdName = nameField.getText().trim(); - - // Validate AVD name - if (!isValidAvdName(avdName)) { - JOptionPane.showMessageDialog(this, - "Invalid AVD name!\n\n" + - "The name cannot contain spaces or special characters.\n" + - "Use letters, numbers, underscores, and hyphens only.", - "Invalid Name", JOptionPane.ERROR_MESSAGE); - return; - } - - new Thread(() -> { - try { - // Determine which API level to use (standard or legacy) - String selectedApi = legacyCheckBox.isSelected() ? - (String) legacyApiCombo.getSelectedItem() : - (String) apiCombo.getSelectedItem(); - - log("Creating AVD: " + avdName + " (API " + selectedApi + ")"); - - // Show progress bar for potential API installation - showProgress(true); - - boolean success = emulatorService.createAvd( - avdName, - selectedApi, - (String) deviceCombo.getSelectedItem(), - this::updateProgress - ); - - if (success) { - log("AVD created successfully"); - refreshAvdList(); - } else { - log("ERROR: Failed to create AVD"); - } - } catch (Exception e) { - logger.error("Failed to create AVD", e); - log("ERROR: " + e.getMessage()); - } finally { - showProgress(false); - } - }).start(); - } - } - - private void startEmulatorByName(String avdName) { - if (emulatorService == null) { - JOptionPane.showMessageDialog(this, - "EmulatorService not initialized", - "Error", JOptionPane.ERROR_MESSAGE); - return; - } - - new Thread(() -> { - try { - log("Starting emulator: " + avdName); - emulatorService.startEmulator(avdName); - log("Emulator started: " + avdName); - // Refresh cards to update status - refreshAvdList(); - } catch (Exception e) { - logger.error("Failed to start emulator", e); - log("ERROR: " + e.getMessage()); - } - }).start(); - } - - private void stopEmulatorByName(String avdName) { - if (emulatorService == null) { - JOptionPane.showMessageDialog(this, - "EmulatorService not initialized", - "Error", JOptionPane.ERROR_MESSAGE); - return; - } - - emulatorService.stopEmulator(avdName); - log("Emulator stopped: " + avdName); - // Refresh cards to update status - refreshAvdList(); - } - - private void deleteAvdByName(String avdName) { - int result = JOptionPane.showConfirmDialog(this, - "Delete AVD '" + avdName + "'?", - "Confirm Deletion", JOptionPane.YES_NO_OPTION); - - if (result == JOptionPane.YES_OPTION) { - new Thread(() -> { - try { - log("Deleting AVD: " + avdName); - boolean success = emulatorService.deleteAvd(avdName); - if (success) { - log("AVD deleted successfully"); - refreshAvdList(); - } else { - log("ERROR: Failed to delete AVD"); - } - } catch (Exception e) { - logger.error("Failed to delete AVD", e); - log("ERROR: " + e.getMessage()); - } - }).start(); - } - } - - private void renameAvd(String oldName) { - String newName = JOptionPane.showInputDialog(this, - "Enter new name for AVD '" + oldName + "':\n\n" + - "(letters, numbers, underscores, and hyphens only)", - "Rename AVD", - JOptionPane.PLAIN_MESSAGE); - - if (newName != null && !newName.trim().isEmpty() && !newName.equals(oldName)) { - newName = newName.trim(); - - // Validate AVD name - if (!isValidAvdName(newName)) { - JOptionPane.showMessageDialog(this, - "Invalid AVD name!\n\n" + - "The name cannot contain spaces or special characters.\n" + - "Use letters, numbers, underscores, and hyphens only.", - "Invalid Name", JOptionPane.ERROR_MESSAGE); - return; - } - - String finalNewName = newName; - new Thread(() -> { - try { - log("Renaming AVD: " + oldName + " -> " + finalNewName); - - // Get AVD path - EmulatorService.AvdInfo avdInfo = allAvds.stream() - .filter(avd -> avd.name().equals(oldName)) - .findFirst() - .orElse(null); - - if (avdInfo == null || avdInfo.path() == null) { - log("ERROR: Could not find AVD path"); - return; - } - - Path avdPath = Path.of(avdInfo.path()); - Path iniFile = avdPath.getParent().resolve(oldName + ".ini"); - Path newAvdPath = avdPath.getParent().resolve(finalNewName + ".avd"); - Path newIniFile = avdPath.getParent().resolve(finalNewName + ".ini"); - - // Rename .avd directory - if (Files.exists(avdPath)) { - Files.move(avdPath, newAvdPath); - } - - // Rename .ini file - if (Files.exists(iniFile)) { - Files.move(iniFile, newIniFile); - // Update path in ini file - String iniContent = Files.readString(newIniFile); - iniContent = iniContent.replace(oldName + ".avd", finalNewName + ".avd"); - Files.writeString(newIniFile, iniContent); - } - - log("AVD renamed successfully"); - refreshAvdList(); - } catch (Exception e) { - logger.error("Failed to rename AVD", e); - log("ERROR: " + e.getMessage()); - JOptionPane.showMessageDialog(this, - "Failed to rename AVD: " + e.getMessage(), - "Error", JOptionPane.ERROR_MESSAGE); - } - }).start(); - } - } - - private void refreshAvdList() { - if (emulatorService == null) { - return; - } - - new Thread(() -> { - try { - var avds = emulatorService.listAvds(); - SwingUtilities.invokeLater(() -> { - allAvds = new ArrayList<>(avds); - currentPage = 0; - updateDeviceCards(); - }); - log("Refreshed AVD list (" + avds.size() + " devices)"); - } catch (Exception e) { - logger.error("Failed to list AVDs", e); - log("ERROR: " + e.getMessage()); - } - }).start(); - } - - private void updateProgress(int value, String message) { - SwingUtilities.invokeLater(() -> { - progressBar.setValue(value); - progressBar.setString(message); - log(message); - }); - } - - private void showProgress(boolean show) { - SwingUtilities.invokeLater(() -> { - progressBar.setVisible(show); - if (show) { - progressBar.setValue(0); - } - }); - } - - private void log(String message) { - SwingUtilities.invokeLater(() -> { - logArea.append(message + "\n"); - logArea.setCaretPosition(logArea.getDocument().getLength()); - }); - } - - /** - * Detects if the current system theme is dark. - * Uses panel background brightness to determine theme. - */ - private boolean isDarkTheme() { - Color bg = UIManager.getColor("Panel.background"); - if (bg == null) { - return false; - } - // Calculate perceived brightness using standard formula - int brightness = (int) Math.sqrt( - bg.getRed() * bg.getRed() * 0.241 + - bg.getGreen() * bg.getGreen() * 0.691 + - bg.getBlue() * bg.getBlue() * 0.068 - ); - return brightness < 130; // Dark theme if brightness < 130 - } - - private void onClosing() { - if (emulatorService != null && !emulatorService.getRunningEmulators().isEmpty()) { - int result = JOptionPane.showConfirmDialog(this, - "There are running emulators. Stop them and exit?", - "Confirm Exit", JOptionPane.YES_NO_OPTION); - - if (result == JOptionPane.YES_OPTION) { - emulatorService.stopAllEmulators(); - System.exit(0); - } - } else { - System.exit(0); - } - } - public static void main(String[] args) { logger.info("Starting Android Emulator Manager v3.0"); logger.info("Java version: {}", System.getProperty("java.version")); logger.info("Operating System: {}", PlatformUtils.getOperatingSystem()); SwingUtilities.invokeLater(() -> { - AndroidEmulatorManager app = new AndroidEmulatorManager(); - app.setVisible(true); + MainController controller = new MainController(); + controller.show(); }); } } diff --git a/src/main/java/net/nicolamurtas/android/emulator/controller/MainController.java b/src/main/java/net/nicolamurtas/android/emulator/controller/MainController.java new file mode 100644 index 0000000..720a756 --- /dev/null +++ b/src/main/java/net/nicolamurtas/android/emulator/controller/MainController.java @@ -0,0 +1,387 @@ +package net.nicolamurtas.android.emulator.controller; + +import net.nicolamurtas.android.emulator.service.ConfigService; +import net.nicolamurtas.android.emulator.service.EmulatorService; +import net.nicolamurtas.android.emulator.service.SdkDownloadService; +import net.nicolamurtas.android.emulator.util.PlatformUtils; +import net.nicolamurtas.android.emulator.view.AvdGridPanel; +import net.nicolamurtas.android.emulator.view.DialogFactory; +import net.nicolamurtas.android.emulator.view.MainView; +import net.nicolamurtas.android.emulator.view.SdkConfigPanel; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.swing.*; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +/** + * Main controller for the Android Emulator Manager application. + * Coordinates between views and services, handling all business logic. + * + * @author Nicola Murtas + * @version 3.0.0 + */ +public class MainController { + private static final Logger logger = LoggerFactory.getLogger(MainController.class); + + private final MainView view; + private final ConfigService configService; + private final SdkDownloadService sdkDownloadService; + private EmulatorService emulatorService; + + public MainController() { + this.configService = new ConfigService(); + this.sdkDownloadService = new SdkDownloadService(); + + // Initialize emulator service if SDK is configured + Path sdkPath = configService.getSdkPath(); + if (Files.exists(sdkPath)) { + this.emulatorService = new EmulatorService(sdkPath); + } + + // Create view + this.view = new MainView(configService.isSdkConfigured()); + + // Initialize view components + initializeView(); + + // Setup event handlers + setupEventHandlers(); + + // Load initial data + loadConfiguration(); + refreshAvdList(); + + logger.info("Android Emulator Manager controller initialized"); + } + + private void initializeView() { + // Set window close handler + view.addWindowListener(new java.awt.event.WindowAdapter() { + @Override + public void windowClosing(java.awt.event.WindowEvent e) { + handleWindowClosing(); + } + }); + } + + private void setupEventHandlers() { + SdkConfigPanel sdkPanel = view.getSdkConfigPanel(); + AvdGridPanel avdPanel = view.getAvdGridPanel(); + + // SDK panel actions + sdkPanel.setOnBrowse(this::handleBrowseSdk); + sdkPanel.setOnDownload(this::handleDownloadSdk); + sdkPanel.setOnVerify(this::handleVerifySdk); + + // AVD panel actions + avdPanel.setOnCreateAvd(this::handleCreateAvd); + avdPanel.setOnRefresh(this::refreshAvdList); + avdPanel.setOnStartEmulator(this::handleStartEmulator); + avdPanel.setOnStopEmulator(this::handleStopEmulator); + avdPanel.setOnRenameAvd(this::handleRenameAvd); + avdPanel.setOnDeleteAvd(this::handleDeleteAvd); + + // Set emulator service in AVD panel for status checking + if (emulatorService != null) { + avdPanel.setEmulatorService(emulatorService); + } + } + + private void loadConfiguration() { + Path sdkPath = configService.getSdkPath(); + view.getSdkConfigPanel().setSdkPath(sdkPath.toString()); + } + + private void handleBrowseSdk() { + JFileChooser chooser = new JFileChooser(); + chooser.setFileSelectionMode(JFileChooser.DIRECTORIES_ONLY); + chooser.setDialogTitle("Select Android SDK Directory"); + + if (chooser.showOpenDialog(view) == JFileChooser.APPROVE_OPTION) { + Path path = chooser.getSelectedFile().toPath(); + view.getSdkConfigPanel().setSdkPath(path.toString()); + configService.setSdkPath(path); + configService.saveConfig(); + emulatorService = new EmulatorService(path); + view.getAvdGridPanel().setEmulatorService(emulatorService); + view.getSdkConfigPanel().setConfigured(true); + view.log("SDK path set to: " + path); + } + } + + private void handleDownloadSdk() { + String pathText = view.getSdkConfigPanel().getSdkPath().trim(); + if (pathText.isEmpty()) { + pathText = PlatformUtils.getDefaultSdkPath().toString(); + view.getSdkConfigPanel().setSdkPath(pathText); + } + + Path sdkPath = Path.of(pathText); + + // Show license agreement first + if (!DialogFactory.showLicenseAgreementDialog(view)) { + view.log("SDK download cancelled: License not accepted"); + return; + } + + // Show component selection dialog + List selectedComponents = DialogFactory.showSdkComponentSelectionDialog(view); + if (selectedComponents == null || selectedComponents.isEmpty()) { + view.log("SDK download cancelled by user"); + return; + } + + new Thread(() -> { + try { + view.showProgress(true); + view.log("=== Starting SDK Download ==="); + view.log("Target path: " + sdkPath); + view.log("Selected components: " + selectedComponents.size()); + + sdkDownloadService.downloadAndInstallSdk(sdkPath, selectedComponents, + (progress, message) -> { + view.updateProgress(progress, message); + view.log(message); + }); + + configService.setSdkPath(sdkPath); + configService.saveConfig(); + emulatorService = new EmulatorService(sdkPath); + view.getAvdGridPanel().setEmulatorService(emulatorService); + view.getSdkConfigPanel().setConfigured(true); + + view.log("=== SDK Installation Completed Successfully ==="); + JOptionPane.showMessageDialog(view, + "SDK downloaded and installed successfully!", + "Success", JOptionPane.INFORMATION_MESSAGE); + + } catch (Exception e) { + logger.error("SDK download failed", e); + view.log("ERROR: " + e.getMessage()); + JOptionPane.showMessageDialog(view, + "SDK download failed: " + e.getMessage(), + "Error", JOptionPane.ERROR_MESSAGE); + } finally { + view.showProgress(false); + } + }).start(); + } + + private void handleVerifySdk() { + if (configService.isSdkConfigured()) { + JOptionPane.showMessageDialog(view, + "SDK is properly configured!", + "SDK Verification", JOptionPane.INFORMATION_MESSAGE); + view.log("SDK verification: OK"); + } else { + JOptionPane.showMessageDialog(view, + "SDK is not properly configured. Please download SDK first.", + "SDK Verification", JOptionPane.WARNING_MESSAGE); + view.log("SDK verification: FAILED"); + } + } + + private void handleCreateAvd() { + if (emulatorService == null) { + JOptionPane.showMessageDialog(view, + "Please configure SDK first", + "Error", JOptionPane.ERROR_MESSAGE); + return; + } + + DialogFactory.AvdCreationParams params = DialogFactory.showCreateAvdDialog(view); + if (params == null) { + return; // User cancelled or invalid input + } + + new Thread(() -> { + try { + view.log("Creating AVD: " + params.name + " (API " + params.apiLevel + ")"); + view.showProgress(true); + + boolean success = emulatorService.createAvd( + params.name, + params.apiLevel, + params.device, + (progress, message) -> { + view.updateProgress(progress, message); + view.log(message); + } + ); + + if (success) { + view.log("AVD created successfully"); + refreshAvdList(); + } else { + view.log("ERROR: Failed to create AVD"); + } + } catch (Exception e) { + logger.error("Failed to create AVD", e); + view.log("ERROR: " + e.getMessage()); + } finally { + view.showProgress(false); + } + }).start(); + } + + private void handleStartEmulator(String avdName) { + if (emulatorService == null) { + JOptionPane.showMessageDialog(view, + "EmulatorService not initialized", + "Error", JOptionPane.ERROR_MESSAGE); + return; + } + + new Thread(() -> { + try { + view.log("Starting emulator: " + avdName); + emulatorService.startEmulator(avdName); + view.log("Emulator started: " + avdName); + refreshAvdList(); // Refresh to update status + } catch (Exception e) { + logger.error("Failed to start emulator", e); + view.log("ERROR: " + e.getMessage()); + } + }).start(); + } + + private void handleStopEmulator(String avdName) { + if (emulatorService == null) { + JOptionPane.showMessageDialog(view, + "EmulatorService not initialized", + "Error", JOptionPane.ERROR_MESSAGE); + return; + } + + emulatorService.stopEmulator(avdName); + view.log("Emulator stopped: " + avdName); + refreshAvdList(); // Refresh to update status + } + + private void handleDeleteAvd(String avdName) { + if (!DialogFactory.showDeleteConfirmation(view, avdName)) { + return; + } + + new Thread(() -> { + try { + view.log("Deleting AVD: " + avdName); + boolean success = emulatorService.deleteAvd(avdName); + if (success) { + view.log("AVD deleted successfully"); + refreshAvdList(); + } else { + view.log("ERROR: Failed to delete AVD"); + } + } catch (Exception e) { + logger.error("Failed to delete AVD", e); + view.log("ERROR: " + e.getMessage()); + } + }).start(); + } + + private void handleRenameAvd(String oldName) { + String newName = DialogFactory.showRenameAvdDialog(view, oldName); + if (newName == null) { + return; // User cancelled or invalid name + } + + new Thread(() -> { + try { + view.log("Renaming AVD: " + oldName + " -> " + newName); + + // Get AVD path + EmulatorService.AvdInfo avdInfo = view.getAvdGridPanel().getAllAvds().stream() + .filter(avd -> avd.name().equals(oldName)) + .findFirst() + .orElse(null); + + if (avdInfo == null || avdInfo.path() == null) { + view.log("ERROR: Could not find AVD path"); + return; + } + + Path avdPath = Path.of(avdInfo.path()); + Path iniFile = avdPath.getParent().resolve(oldName + ".ini"); + Path newAvdPath = avdPath.getParent().resolve(newName + ".avd"); + Path newIniFile = avdPath.getParent().resolve(newName + ".ini"); + + // Rename .avd directory + if (Files.exists(avdPath)) { + Files.move(avdPath, newAvdPath); + } + + // Rename .ini file + if (Files.exists(iniFile)) { + Files.move(iniFile, newIniFile); + // Update path in ini file + String iniContent = Files.readString(newIniFile); + iniContent = iniContent.replace(oldName + ".avd", newName + ".avd"); + Files.writeString(newIniFile, iniContent); + } + + view.log("AVD renamed successfully"); + refreshAvdList(); + } catch (Exception e) { + logger.error("Failed to rename AVD", e); + view.log("ERROR: " + e.getMessage()); + JOptionPane.showMessageDialog(view, + "Failed to rename AVD: " + e.getMessage(), + "Error", JOptionPane.ERROR_MESSAGE); + } + }).start(); + } + + private void refreshAvdList() { + if (emulatorService == null) { + return; + } + + new Thread(() -> { + try { + var avds = emulatorService.listAvds(); + SwingUtilities.invokeLater(() -> { + view.getAvdGridPanel().updateAvdList(avds); + }); + view.log("Refreshed AVD list (" + avds.size() + " devices)"); + } catch (Exception e) { + logger.error("Failed to list AVDs", e); + view.log("ERROR: " + e.getMessage()); + } + }).start(); + } + + private void handleWindowClosing() { + if (emulatorService != null && !emulatorService.getRunningEmulators().isEmpty()) { + int result = JOptionPane.showConfirmDialog(view, + "There are running emulators. Stop them and exit?", + "Confirm Exit", JOptionPane.YES_NO_OPTION); + + if (result == JOptionPane.YES_OPTION) { + emulatorService.stopAllEmulators(); + System.exit(0); + } + } else { + System.exit(0); + } + } + + /** + * Shows the main view. + */ + public void show() { + view.setVisible(true); + } + + /** + * Gets the main view. + * + * @return Main view instance + */ + public MainView getView() { + return view; + } +} diff --git a/src/main/java/net/nicolamurtas/android/emulator/model/DeviceConfiguration.java b/src/main/java/net/nicolamurtas/android/emulator/model/DeviceConfiguration.java new file mode 100644 index 0000000..6b1c423 --- /dev/null +++ b/src/main/java/net/nicolamurtas/android/emulator/model/DeviceConfiguration.java @@ -0,0 +1,61 @@ +package net.nicolamurtas.android.emulator.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +/** + * Configuration details for an Android Virtual Device. + * Contains hardware specifications and features. + * + * @author Nicola Murtas + * @version 3.0.0 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class DeviceConfiguration { + /** + * Device type (e.g., "pixel_7", "pixel_8") + */ + private String deviceType; + + /** + * CPU architecture (e.g., "x86_64", "arm64-v8a") + */ + private String cpuArch; + + /** + * RAM size in megabytes + */ + private int ramMb; + + /** + * Internal storage size in megabytes + */ + private int internalStorageMb; + + /** + * Screen resolution (e.g., "1080x2400") + */ + private String screenResolution; + + /** + * List of hardware features enabled (e.g., "GPS", "Camera") + */ + private List hardwareFeatures; + + /** + * API level (e.g., "35", "34") + */ + private String apiLevel; + + /** + * Android version name (e.g., "Android 15", "Android 14") + */ + private String androidVersion; +} diff --git a/src/main/java/net/nicolamurtas/android/emulator/model/DownloadProgress.java b/src/main/java/net/nicolamurtas/android/emulator/model/DownloadProgress.java new file mode 100644 index 0000000..24192d8 --- /dev/null +++ b/src/main/java/net/nicolamurtas/android/emulator/model/DownloadProgress.java @@ -0,0 +1,38 @@ +package net.nicolamurtas.android.emulator.model; + +import lombok.Value; + +/** + * Immutable value object representing download progress. + * Used for tracking SDK component downloads. + * + * @author Nicola Murtas + * @version 3.0.0 + */ +@Value +public class DownloadProgress { + /** + * Download completion percentage (0-100) + */ + int percentage; + + /** + * Number of bytes downloaded so far + */ + long bytesDownloaded; + + /** + * Total number of bytes to download + */ + long totalBytes; + + /** + * Name of the file currently being downloaded + */ + String currentFile; + + /** + * Human-readable status message + */ + String message; +} diff --git a/src/main/java/net/nicolamurtas/android/emulator/model/EmulatorStatus.java b/src/main/java/net/nicolamurtas/android/emulator/model/EmulatorStatus.java new file mode 100644 index 0000000..0c76ca1 --- /dev/null +++ b/src/main/java/net/nicolamurtas/android/emulator/model/EmulatorStatus.java @@ -0,0 +1,34 @@ +package net.nicolamurtas.android.emulator.model; + +/** + * Represents the current status of an Android Virtual Device. + * + * @author Nicola Murtas + * @version 3.0.0 + */ +public enum EmulatorStatus { + /** + * Emulator is not running + */ + STOPPED, + + /** + * Emulator is in the process of starting up + */ + STARTING, + + /** + * Emulator is running and operational + */ + RUNNING, + + /** + * Emulator is in the process of shutting down + */ + STOPPING, + + /** + * Emulator encountered an error + */ + ERROR +} diff --git a/src/main/java/net/nicolamurtas/android/emulator/model/SdkConfiguration.java b/src/main/java/net/nicolamurtas/android/emulator/model/SdkConfiguration.java new file mode 100644 index 0000000..15ab35d --- /dev/null +++ b/src/main/java/net/nicolamurtas/android/emulator/model/SdkConfiguration.java @@ -0,0 +1,52 @@ +package net.nicolamurtas.android.emulator.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.nio.file.Path; +import java.util.List; + +/** + * Configuration for the Android SDK. + * Contains SDK path, installed packages, and tool versions. + * + * @author Nicola Murtas + * @version 3.0.0 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class SdkConfiguration { + /** + * Path to the Android SDK directory + */ + private Path sdkPath; + + /** + * Whether the SDK is properly configured and available + */ + private boolean configured; + + /** + * Platform tools version (e.g., "35.0.0") + */ + private String platformToolsVersion; + + /** + * Build tools version (e.g., "35.0.0") + */ + private String buildToolsVersion; + + /** + * List of installed SDK packages + */ + private List installedPackages; + + /** + * Whether Android SDK licenses have been accepted + */ + private boolean licensesAccepted; +} diff --git a/src/main/java/net/nicolamurtas/android/emulator/util/AndroidVersionMapper.java b/src/main/java/net/nicolamurtas/android/emulator/util/AndroidVersionMapper.java new file mode 100644 index 0000000..f36d497 --- /dev/null +++ b/src/main/java/net/nicolamurtas/android/emulator/util/AndroidVersionMapper.java @@ -0,0 +1,111 @@ +package net.nicolamurtas.android.emulator.util; + +/** + * Utility class for mapping Android API levels to human-readable version names. + * + * @author Nicola Murtas + * @version 3.0.0 + */ +public final class AndroidVersionMapper { + + private AndroidVersionMapper() { + throw new UnsupportedOperationException("Utility class cannot be instantiated"); + } + + /** + * Converts API level to Android version name. + * + * @param apiLevel The API level as a string (e.g., "35", "34") + * @return Android version name (e.g., "Android 15", "Android 14") + */ + public static String getAndroidVersionName(String apiLevel) { + if (apiLevel == null || apiLevel.equals("Unknown")) { + return "Android (Unknown)"; + } + + return switch (apiLevel) { + case "36" -> "Android 16"; + case "35" -> "Android 15"; + case "34" -> "Android 14"; + case "33" -> "Android 13"; + case "32" -> "Android 12L"; + case "31" -> "Android 12"; + case "30" -> "Android 11"; + case "29" -> "Android 10"; + case "28" -> "Android 9"; + case "27" -> "Android 8.1"; + case "26" -> "Android 8.0"; + case "25" -> "Android 7.1"; + case "24" -> "Android 7.0"; + case "23" -> "Android 6.0"; + case "22" -> "Android 5.1"; + case "21" -> "Android 5.0"; + default -> "Android API " + apiLevel; + }; + } + + /** + * Extracts API level from AVD config.ini file path. + * More reliable than parsing the target string. + * + * @param configContent Content of the config.ini file + * @return API level as a string, or "Unknown" if not found + */ + public static String extractApiLevelFromConfig(String configContent) { + if (configContent == null) { + return "Unknown"; + } + + // Look for image.sysdir.1 = system-images/android-35/google_apis/x86_64/ + for (String line : configContent.split("\n")) { + line = line.trim(); + if (line.startsWith("image.sysdir.1")) { + // Parse "image.sysdir.1 = system-images/android-35/google_apis/x86_64/" + int equalPos = line.indexOf('='); + if (equalPos > 0) { + String sysdir = line.substring(equalPos + 1).trim(); + // Extract API number from: system-images/android-35/google_apis/x86_64/ + String[] parts = sysdir.split("/"); + for (String part : parts) { + if (part.startsWith("android-")) { + return part.substring(8); // Remove "android-" prefix + } + } + } + } + } + + return "Unknown"; + } + + /** + * Extracts API level from target string (fallback method). + * + * @param target Target string (e.g., "Android X.Y (API level Z)") + * @return API level as a string, or "Unknown" if not found + */ + public static String extractApiLevelFromTarget(String target) { + if (target == null) { + return "Unknown"; + } + + // Target format: "Android X.Y (API level Z)" or similar + if (target.contains("API level")) { + int start = target.indexOf("API level") + 10; + int end = target.indexOf(")", start); + if (end > start) { + return target.substring(start, end).trim(); + } + } + + // Try to extract just the number + String[] parts = target.split("\\s+"); + for (String part : parts) { + if (part.matches("\\d+")) { + return part; + } + } + + return "Unknown"; + } +} diff --git a/src/main/java/net/nicolamurtas/android/emulator/util/DeviceNameFormatter.java b/src/main/java/net/nicolamurtas/android/emulator/util/DeviceNameFormatter.java new file mode 100644 index 0000000..006b845 --- /dev/null +++ b/src/main/java/net/nicolamurtas/android/emulator/util/DeviceNameFormatter.java @@ -0,0 +1,65 @@ +package net.nicolamurtas.android.emulator.util; + +/** + * Utility class for formatting device names for display. + * + * @author Nicola Murtas + * @version 3.0.0 + */ +public final class DeviceNameFormatter { + + private DeviceNameFormatter() { + throw new UnsupportedOperationException("Utility class cannot be instantiated"); + } + + /** + * Formats device name for display. + * Example: "pixel_7" -> "Pixel 7", "pixel" -> "Pixel" + * + * @param deviceName Raw device name (e.g., "pixel_7") + * @return Formatted device name (e.g., "Pixel 7") + */ + public static String formatDeviceName(String deviceName) { + if (deviceName == null || deviceName.isEmpty()) { + return deviceName; + } + + // Replace underscores with spaces and capitalize words + String[] parts = deviceName.replace("_", " ").split(" "); + StringBuilder formatted = new StringBuilder(); + + for (String part : parts) { + if (!part.isEmpty()) { + formatted.append(Character.toUpperCase(part.charAt(0))); + if (part.length() > 1) { + formatted.append(part.substring(1)); + } + formatted.append(" "); + } + } + + return formatted.toString().trim(); + } + + /** + * Validates AVD name to ensure it doesn't contain spaces or invalid characters. + * AVD names should only contain letters, numbers, underscores, and hyphens. + * + * @param name AVD name to validate + * @return true if valid, false otherwise + */ + public static boolean isValidAvdName(String name) { + if (name == null || name.isEmpty()) { + return false; + } + + // Check for spaces + if (name.contains(" ")) { + return false; + } + + // AVD names should only contain: letters, numbers, underscores, hyphens + // Pattern: ^[a-zA-Z0-9_-]+$ + return name.matches("^[a-zA-Z0-9_-]+$"); + } +} diff --git a/src/main/java/net/nicolamurtas/android/emulator/util/ThemeUtils.java b/src/main/java/net/nicolamurtas/android/emulator/util/ThemeUtils.java new file mode 100644 index 0000000..7bc7229 --- /dev/null +++ b/src/main/java/net/nicolamurtas/android/emulator/util/ThemeUtils.java @@ -0,0 +1,67 @@ +package net.nicolamurtas.android.emulator.util; + +import javax.swing.*; +import java.awt.*; + +/** + * Utility class for theme detection and color management. + * + * @author Nicola Murtas + * @version 3.0.0 + */ +public final class ThemeUtils { + + private ThemeUtils() { + throw new UnsupportedOperationException("Utility class cannot be instantiated"); + } + + /** + * Detects if the current system theme is dark. + * Uses panel background brightness to determine theme. + * + * @return true if dark theme, false otherwise + */ + public static boolean isDarkTheme() { + Color bg = UIManager.getColor("Panel.background"); + if (bg == null) { + return false; + } + + // Calculate perceived brightness using standard formula + int brightness = (int) Math.sqrt( + bg.getRed() * bg.getRed() * 0.241 + + bg.getGreen() * bg.getGreen() * 0.691 + + bg.getBlue() * bg.getBlue() * 0.068 + ); + + return brightness < 130; // Dark theme if brightness < 130 + } + + /** + * Gets a header background color based on the current theme. + * Returns a slightly darker color for light themes and brighter for dark themes. + * + * @return Header background color + */ + public static Color getHeaderBackgroundColor() { + Color panelBg = UIManager.getColor("Panel.background"); + Color headerBg = panelBg != null ? + (isDarkTheme() ? panelBg.brighter() : panelBg.darker()) : + new Color(240, 240, 240); + return headerBg; + } + + /** + * Standard color constants for UI elements. + */ + public static final class Colors { + public static final Color SUCCESS = new Color(76, 175, 80); + public static final Color WARNING = new Color(255, 152, 0); + public static final Color ERROR = new Color(244, 67, 54); + public static final Color INFO = new Color(33, 150, 243); + + private Colors() { + throw new UnsupportedOperationException("Constants class cannot be instantiated"); + } + } +} diff --git a/src/main/java/net/nicolamurtas/android/emulator/view/AvdGridPanel.java b/src/main/java/net/nicolamurtas/android/emulator/view/AvdGridPanel.java new file mode 100644 index 0000000..14a8d15 --- /dev/null +++ b/src/main/java/net/nicolamurtas/android/emulator/view/AvdGridPanel.java @@ -0,0 +1,351 @@ +package net.nicolamurtas.android.emulator.view; + +import net.nicolamurtas.android.emulator.service.EmulatorService; +import net.nicolamurtas.android.emulator.util.AndroidVersionMapper; +import net.nicolamurtas.android.emulator.util.DeviceNameFormatter; +import net.nicolamurtas.android.emulator.util.ThemeUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.swing.*; +import java.awt.*; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; + +/** + * Panel displaying Android Virtual Devices in a paginated grid layout. + * Shows device cards with actions (start, stop, rename, delete). + * + * @author Nicola Murtas + * @version 3.0.0 + */ +public class AvdGridPanel extends JPanel { + private static final Logger logger = LoggerFactory.getLogger(AvdGridPanel.class); + private static final int CARDS_PER_PAGE = 10; + + private final JPanel devicesGridPanel; + private JLabel pageLabel; + private JButton prevPageButton; + private JButton nextPageButton; + + private List allAvds = new ArrayList<>(); + private int currentPage = 0; + private EmulatorService emulatorService; + + // Action handlers (set by controller) + private Runnable onCreateAvd; + private Runnable onRefresh; + private Consumer onStartEmulator; + private Consumer onStopEmulator; + private Consumer onRenameAvd; + private Consumer onDeleteAvd; + + public AvdGridPanel() { + setLayout(new BorderLayout(5, 5)); + setBorder(BorderFactory.createTitledBorder("Android Virtual Devices")); + + // Devices grid panel (5 columns x 2 rows = 10 cards) + devicesGridPanel = new JPanel(new GridLayout(2, 5, 10, 10)); + devicesGridPanel.setBorder(BorderFactory.createEmptyBorder(10, 10, 10, 10)); + + JScrollPane scrollPane = new JScrollPane(devicesGridPanel); + scrollPane.setVerticalScrollBarPolicy(JScrollPane.VERTICAL_SCROLLBAR_AS_NEEDED); + scrollPane.setHorizontalScrollBarPolicy(JScrollPane.HORIZONTAL_SCROLLBAR_NEVER); + add(scrollPane, BorderLayout.CENTER); + + // Bottom panel with pagination and buttons + JPanel bottomPanel = createBottomPanel(); + add(bottomPanel, BorderLayout.SOUTH); + } + + private JPanel createBottomPanel() { + JPanel bottomPanel = new JPanel(new BorderLayout(5, 5)); + + // Pagination controls + JPanel paginationPanel = new JPanel(new FlowLayout(FlowLayout.CENTER)); + prevPageButton = new JButton("β—„ Previous"); + prevPageButton.addActionListener(e -> changePage(-1)); + prevPageButton.setEnabled(false); + + pageLabel = new JLabel("Page 1", SwingConstants.CENTER); + pageLabel.setPreferredSize(new Dimension(100, 25)); + + nextPageButton = new JButton("Next β–Ί"); + nextPageButton.addActionListener(e -> changePage(1)); + nextPageButton.setEnabled(false); + + paginationPanel.add(prevPageButton); + paginationPanel.add(pageLabel); + paginationPanel.add(nextPageButton); + + // Action buttons + JPanel buttonPanel = new JPanel(new FlowLayout(FlowLayout.LEFT)); + + JButton createButton = new JButton("Create New AVD"); + createButton.setBackground(ThemeUtils.Colors.SUCCESS); + createButton.setForeground(Color.WHITE); + createButton.addActionListener(e -> { + if (onCreateAvd != null) onCreateAvd.run(); + }); + buttonPanel.add(createButton); + + JButton refreshButton = new JButton("Refresh"); + refreshButton.addActionListener(e -> { + if (onRefresh != null) onRefresh.run(); + }); + buttonPanel.add(refreshButton); + + bottomPanel.add(paginationPanel, BorderLayout.NORTH); + bottomPanel.add(buttonPanel, BorderLayout.SOUTH); + + return bottomPanel; + } + + /** + * Creates a device card panel for an AVD. + */ + private JPanel createDeviceCard(EmulatorService.AvdInfo avd) { + JPanel card = new JPanel(new BorderLayout(5, 5)); + card.setBorder(BorderFactory.createCompoundBorder( + BorderFactory.createLineBorder(UIManager.getColor("Panel.border"), 2), + BorderFactory.createEmptyBorder(10, 10, 10, 10) + )); + card.setPreferredSize(new Dimension(180, 200)); + + // Top panel with name and info + JPanel topPanel = createCardTopPanel(avd); + card.add(topPanel, BorderLayout.NORTH); + + // Action buttons panel + JPanel actionsPanel = createCardActionsPanel(avd); + card.add(actionsPanel, BorderLayout.SOUTH); + + return card; + } + + private JPanel createCardTopPanel(EmulatorService.AvdInfo avd) { + JPanel topPanel = new JPanel(new BorderLayout(3, 3)); + + // Device name + JLabel nameLabel = new JLabel(avd.name(), SwingConstants.CENTER); + nameLabel.setFont(nameLabel.getFont().deriveFont(Font.BOLD, 14f)); + topPanel.add(nameLabel, BorderLayout.NORTH); + + // Info panel with version and device type + JPanel infoPanel = new JPanel(new GridLayout(0, 1, 2, 2)); + + // Extract API level and show Android version + String apiLevel = extractApiLevelFromPath(avd.path()); + String androidVersion = AndroidVersionMapper.getAndroidVersionName(apiLevel); + JLabel versionLabel = new JLabel(androidVersion, SwingConstants.CENTER); + versionLabel.setFont(versionLabel.getFont().deriveFont(Font.PLAIN, 11f)); + infoPanel.add(versionLabel); + + // Show device type + String deviceType = extractDeviceType(avd.path()); + if (deviceType != null && !deviceType.isEmpty()) { + JLabel deviceLabel = new JLabel(deviceType, SwingConstants.CENTER); + deviceLabel.setFont(deviceLabel.getFont().deriveFont(Font.PLAIN, 10f)); + deviceLabel.setForeground(Color.GRAY); + infoPanel.add(deviceLabel); + } + + // Check if running + boolean isRunning = emulatorService != null && emulatorService.isEmulatorRunning(avd.name()); + JLabel statusLabel = new JLabel(isRunning ? "● Running" : "β—‹ Stopped", SwingConstants.CENTER); + statusLabel.setFont(statusLabel.getFont().deriveFont(Font.BOLD, 10f)); + statusLabel.setForeground(isRunning ? ThemeUtils.Colors.SUCCESS : Color.GRAY); + infoPanel.add(statusLabel); + + topPanel.add(infoPanel, BorderLayout.CENTER); + return topPanel; + } + + private JPanel createCardActionsPanel(EmulatorService.AvdInfo avd) { + JPanel actionsPanel = new JPanel(new GridLayout(2, 2, 5, 5)); + + JButton startBtn = new JButton("β–Ά"); + startBtn.setToolTipText("Start"); + startBtn.setBackground(ThemeUtils.Colors.SUCCESS); + startBtn.setForeground(Color.WHITE); + startBtn.addActionListener(e -> { + if (onStartEmulator != null) onStartEmulator.accept(avd.name()); + }); + + JButton stopBtn = new JButton("β– "); + stopBtn.setToolTipText("Stop"); + stopBtn.setBackground(ThemeUtils.Colors.ERROR); + stopBtn.setForeground(Color.WHITE); + stopBtn.addActionListener(e -> { + if (onStopEmulator != null) onStopEmulator.accept(avd.name()); + }); + + JButton renameBtn = new JButton("✎"); + renameBtn.setToolTipText("Rename"); + renameBtn.addActionListener(e -> { + if (onRenameAvd != null) onRenameAvd.accept(avd.name()); + }); + + JButton deleteBtn = new JButton("πŸ—‘"); + deleteBtn.setToolTipText("Delete"); + deleteBtn.setBackground(ThemeUtils.Colors.ERROR); + deleteBtn.setForeground(Color.WHITE); + deleteBtn.addActionListener(e -> { + if (onDeleteAvd != null) onDeleteAvd.accept(avd.name()); + }); + + actionsPanel.add(startBtn); + actionsPanel.add(stopBtn); + actionsPanel.add(renameBtn); + actionsPanel.add(deleteBtn); + + return actionsPanel; + } + + /** + * Extracts API level from AVD config.ini file. + */ + private String extractApiLevelFromPath(String avdPath) { + if (avdPath == null) return "Unknown"; + + try { + Path configIni = Path.of(avdPath).resolve("config.ini"); + if (Files.exists(configIni)) { + String content = Files.readString(configIni); + return AndroidVersionMapper.extractApiLevelFromConfig(content); + } + } catch (Exception e) { + logger.debug("Could not extract API level from path: {}", avdPath, e); + } + + return "Unknown"; + } + + /** + * Extracts device type from AVD path. + */ + private String extractDeviceType(String avdPath) { + if (avdPath == null) return null; + + try { + Path configIni = Path.of(avdPath).resolve("config.ini"); + if (Files.exists(configIni)) { + String content = Files.readString(configIni); + for (String line : content.split("\n")) { + line = line.trim(); + if (line.startsWith("hw.device.name")) { + int equalPos = line.indexOf('='); + if (equalPos > 0) { + String deviceName = line.substring(equalPos + 1).trim(); + return DeviceNameFormatter.formatDeviceName(deviceName); + } + } + } + } + } catch (Exception e) { + logger.debug("Could not extract device type from path: {}", avdPath, e); + } + + return null; + } + + /** + * Changes the current page of devices. + */ + private void changePage(int delta) { + int totalPages = (int) Math.ceil((double) allAvds.size() / CARDS_PER_PAGE); + currentPage = Math.max(0, Math.min(currentPage + delta, totalPages - 1)); + updateDeviceCards(); + } + + /** + * Updates the device cards display for the current page. + */ + private void updateDeviceCards() { + SwingUtilities.invokeLater(() -> { + devicesGridPanel.removeAll(); + + int start = currentPage * CARDS_PER_PAGE; + int end = Math.min(start + CARDS_PER_PAGE, allAvds.size()); + + for (int i = start; i < end; i++) { + devicesGridPanel.add(createDeviceCard(allAvds.get(i))); + } + + // Fill empty slots with placeholder panels + int cardsShown = end - start; + for (int i = cardsShown; i < CARDS_PER_PAGE; i++) { + JPanel placeholder = new JPanel(); + placeholder.setPreferredSize(new Dimension(180, 200)); + placeholder.setBorder(BorderFactory.createLineBorder(Color.LIGHT_GRAY, 1, true)); + devicesGridPanel.add(placeholder); + } + + // Update pagination controls + int totalPages = Math.max(1, (int) Math.ceil((double) allAvds.size() / CARDS_PER_PAGE)); + pageLabel.setText("Page " + (currentPage + 1) + " / " + totalPages); + prevPageButton.setEnabled(currentPage > 0); + nextPageButton.setEnabled(currentPage < totalPages - 1); + + devicesGridPanel.revalidate(); + devicesGridPanel.repaint(); + }); + } + + /** + * Updates the AVD list and refreshes the display. + * + * @param avds List of AVDs to display + */ + public void updateAvdList(List avds) { + this.allAvds = new ArrayList<>(avds); + this.currentPage = 0; + updateDeviceCards(); + } + + /** + * Sets the emulator service for checking running status. + * + * @param emulatorService Emulator service instance + */ + public void setEmulatorService(EmulatorService emulatorService) { + this.emulatorService = emulatorService; + } + + // Action handler setters + public void setOnCreateAvd(Runnable action) { + this.onCreateAvd = action; + } + + public void setOnRefresh(Runnable action) { + this.onRefresh = action; + } + + public void setOnStartEmulator(Consumer action) { + this.onStartEmulator = action; + } + + public void setOnStopEmulator(Consumer action) { + this.onStopEmulator = action; + } + + public void setOnRenameAvd(Consumer action) { + this.onRenameAvd = action; + } + + public void setOnDeleteAvd(Consumer action) { + this.onDeleteAvd = action; + } + + /** + * Gets the list of all AVDs currently displayed. + * + * @return List of AVDs + */ + public List getAllAvds() { + return new ArrayList<>(allAvds); + } +} diff --git a/src/main/java/net/nicolamurtas/android/emulator/view/DialogFactory.java b/src/main/java/net/nicolamurtas/android/emulator/view/DialogFactory.java new file mode 100644 index 0000000..31f293f --- /dev/null +++ b/src/main/java/net/nicolamurtas/android/emulator/view/DialogFactory.java @@ -0,0 +1,401 @@ +package net.nicolamurtas.android.emulator.view; + +import net.nicolamurtas.android.emulator.util.DeviceNameFormatter; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.swing.*; +import java.awt.*; +import java.awt.Desktop; +import java.util.*; +import java.util.List; + +/** + * Factory class for creating standardized dialogs. + * Centralizes dialog creation logic for consistency. + * + * @author Nicola Murtas + * @version 3.0.0 + */ +public final class DialogFactory { + private static final Logger logger = LoggerFactory.getLogger(DialogFactory.class); + + private DialogFactory() { + throw new UnsupportedOperationException("Utility class cannot be instantiated"); + } + + /** + * Shows the Android SDK License Agreement dialog. + * + * @param parent Parent component + * @return true if user accepted, false otherwise + */ + public static boolean showLicenseAgreementDialog(Component parent) { + String licenseText = """ + ANDROID SOFTWARE DEVELOPMENT KIT LICENSE AGREEMENT + + 1. Introduction + + 1.1 The Android Software Development Kit (referred to in the License Agreement as the "SDK" + and specifically including the Android system files, packaged APIs, and Google APIs add-ons) + is licensed to you subject to the terms of the License Agreement. The License Agreement forms + a legally binding contract between you and Google in relation to your use of the SDK. + + 1.2 "Android" means the Android software stack for devices, as made available under the + Android Open Source Project, which is located at the following URL: + https://source.android.com/, as updated from time to time. + + 1.3 A "compatible implementation" means any Android device that (i) complies with the Android + Compatibility Definition document, which can be found at the Android compatibility website + (https://source.android.com/compatibility) and which may be updated from time to time; and + (ii) successfully passes the Android Compatibility Test Suite (CTS). + + 1.4 "Google" means Google LLC, a Delaware corporation with principal place of business at + 1600 Amphitheatre Parkway, Mountain View, CA 94043, United States. + + + 2. Accepting this License Agreement + + 2.1 In order to use the SDK, you must first agree to the License Agreement. You may not use + the SDK if you do not accept the License Agreement. + + 2.2 By clicking to accept, you hereby agree to the terms of the License Agreement. + + 2.3 You may not use the SDK and may not accept the License Agreement if you are a person + barred from receiving the SDK under the laws of the United States or other countries, + including the country in which you are resident or from which you use the SDK. + + 2.4 If you are agreeing to be bound by the License Agreement on behalf of your employer or + other entity, you represent and warrant that you have full legal authority to bind your + employer or such entity to the License Agreement. If you do not have the requisite authority, + you may not accept the License Agreement or use the SDK on behalf of your employer or other + entity. + + + 3. SDK License from Google + + 3.1 Subject to the terms of the License Agreement, Google grants you a limited, worldwide, + royalty-free, non-assignable, non-exclusive, and non-sublicensable license to use the SDK + solely to develop applications for compatible implementations of Android. + + 3.2 You may not use this SDK to develop applications for other platforms (including + non-compatible implementations of Android) or to develop another SDK. You are of course free + to develop applications for other platforms, including non-compatible implementations of + Android, provided that this SDK is not used for that purpose. + + 3.3 You agree that Google or third parties own all legal right, title and interest in and to + the SDK, including any Intellectual Property Rights that subsist in the SDK. "Intellectual + Property Rights" means any and all rights under patent law, copyright law, trade secret law, + trademark law, and any and all other proprietary rights. Google reserves all rights not + expressly granted to you. + + + For the complete license agreement, please visit: + https://developer.android.com/studio/terms + + + BY CLICKING "I ACCEPT" BELOW, YOU ACKNOWLEDGE THAT YOU HAVE READ AND UNDERSTOOD THE ABOVE + TERMS AND CONDITIONS AND AGREE TO BE BOUND BY THEM. + """; + + JTextArea textArea = new JTextArea(licenseText); + textArea.setEditable(false); + textArea.setLineWrap(true); + textArea.setWrapStyleWord(true); + textArea.setCaretPosition(0); + textArea.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 11)); + + JScrollPane scrollPane = new JScrollPane(textArea); + scrollPane.setPreferredSize(new Dimension(700, 500)); + + JPanel panel = new JPanel(new BorderLayout(10, 10)); + panel.setBorder(BorderFactory.createEmptyBorder(10, 10, 10, 10)); + + JLabel titleLabel = new JLabel("Android SDK License Agreement"); + titleLabel.setFont(titleLabel.getFont().deriveFont(Font.BOLD, 16f)); + panel.add(titleLabel, BorderLayout.NORTH); + + panel.add(scrollPane, BorderLayout.CENTER); + + JPanel bottomPanel = new JPanel(new BorderLayout()); + JLabel noteLabel = new JLabel("Note: You must accept this license to download and use the Android SDK."); + noteLabel.setBorder(BorderFactory.createEmptyBorder(5, 0, 5, 0)); + bottomPanel.add(noteLabel, BorderLayout.NORTH); + + JPanel linkPanel = new JPanel(new FlowLayout(FlowLayout.LEFT)); + JButton linkButton = new JButton("View Full License at developer.android.com"); + linkButton.setBorderPainted(false); + linkButton.setContentAreaFilled(false); + linkButton.setForeground(new Color(33, 150, 243)); + linkButton.setCursor(new Cursor(Cursor.HAND_CURSOR)); + linkButton.addActionListener(e -> { + try { + Desktop.getDesktop().browse(new java.net.URI("https://developer.android.com/studio/terms")); + } catch (Exception ex) { + logger.warn("Could not open browser", ex); + } + }); + linkPanel.add(linkButton); + bottomPanel.add(linkPanel, BorderLayout.SOUTH); + + panel.add(bottomPanel, BorderLayout.SOUTH); + + Object[] options = {"I Accept", "I Decline"}; + int result = JOptionPane.showOptionDialog( + parent, + panel, + "Android SDK License Agreement", + JOptionPane.YES_NO_OPTION, + JOptionPane.PLAIN_MESSAGE, + null, + options, + options[1] // Default to "I Decline" + ); + + boolean accepted = (result == JOptionPane.YES_OPTION); + logger.info("Android SDK License Agreement {}", accepted ? "accepted" : "declined"); + return accepted; + } + + /** + * Shows SDK component selection dialog. + * + * @param parent Parent component + * @return List of selected components, or null if cancelled + */ + public static List showSdkComponentSelectionDialog(Component parent) { + JPanel panel = new JPanel(new GridBagLayout()); + GridBagConstraints gbc = new GridBagConstraints(); + gbc.insets = new Insets(5, 5, 5, 5); + gbc.fill = GridBagConstraints.HORIZONTAL; + gbc.anchor = GridBagConstraints.WEST; + + // Title + gbc.gridx = 0; gbc.gridy = 0; gbc.gridwidth = 2; + JLabel titleLabel = new JLabel("Select SDK Components to Install:"); + titleLabel.setFont(titleLabel.getFont().deriveFont(Font.BOLD, 14f)); + panel.add(titleLabel, gbc); + + // Essential components (always selected, disabled) + gbc.gridy++; gbc.gridwidth = 1; + JLabel essentialLabel = new JLabel("Essential Components (required):"); + essentialLabel.setFont(essentialLabel.getFont().deriveFont(Font.BOLD)); + panel.add(essentialLabel, gbc); + + String[] essentialComponents = {"platform-tools", "emulator", "build-tools;35.0.0"}; + for (String component : essentialComponents) { + gbc.gridy++; + JCheckBox cb = new JCheckBox(component, true); + cb.setEnabled(false); + panel.add(cb, gbc); + } + + // API levels + gbc.gridy++; + JLabel apiLabel = new JLabel("Android API Levels:"); + apiLabel.setFont(apiLabel.getFont().deriveFont(Font.BOLD)); + panel.add(apiLabel, gbc); + + Map apiCheckboxes = new LinkedHashMap<>(); + for (int api = 36; api >= 30; api--) { + gbc.gridy++; + JCheckBox platformCb = new JCheckBox("Android " + api + " (Platform + System Image)", api >= 34); + apiCheckboxes.put(String.valueOf(api), platformCb); + panel.add(platformCb, gbc); + } + + // Select/Deselect all buttons + gbc.gridy++; + JPanel buttonPanel = new JPanel(new FlowLayout(FlowLayout.LEFT)); + JButton selectAllBtn = new JButton("Select All"); + JButton deselectAllBtn = new JButton("Deselect All"); + + selectAllBtn.addActionListener(e -> + apiCheckboxes.values().forEach(cb -> cb.setSelected(true))); + deselectAllBtn.addActionListener(e -> + apiCheckboxes.values().forEach(cb -> cb.setSelected(false))); + + buttonPanel.add(selectAllBtn); + buttonPanel.add(deselectAllBtn); + panel.add(buttonPanel, gbc); + + // Show dialog + int result = JOptionPane.showConfirmDialog(parent, new JScrollPane(panel), + "SDK Component Selection", JOptionPane.OK_CANCEL_OPTION, JOptionPane.PLAIN_MESSAGE); + + if (result != JOptionPane.OK_OPTION) { + return null; + } + + // Build selected components list + List selectedComponents = new ArrayList<>(); + selectedComponents.add("platform-tools"); + selectedComponents.add("emulator"); + selectedComponents.add("build-tools;35.0.0"); + + // Add selected APIs + for (Map.Entry entry : apiCheckboxes.entrySet()) { + if (entry.getValue().isSelected()) { + String api = entry.getKey(); + selectedComponents.add("platforms;android-" + api); + selectedComponents.add("system-images;android-" + api + ";google_apis;x86_64"); + } + } + + return selectedComponents; + } + + /** + * Data class for AVD creation parameters. + */ + public static class AvdCreationParams { + public final String name; + public final String apiLevel; + public final String device; + + public AvdCreationParams(String name, String apiLevel, String device) { + this.name = name; + this.apiLevel = apiLevel; + this.device = device; + } + } + + /** + * Shows create AVD dialog. + * + * @param parent Parent component + * @return AVD creation parameters, or null if cancelled + */ + public static AvdCreationParams showCreateAvdDialog(Component parent) { + JTextField nameField = new JTextField("MyDevice"); + + // Main API levels (30-36) + String[] apiLevels = {"36", "35", "34", "33", "32", "31", "30"}; + JComboBox apiCombo = new JComboBox<>(apiLevels); + apiCombo.setSelectedItem("35"); + + // Legacy API levels (< 30) + JCheckBox legacyCheckBox = new JCheckBox("Show Legacy APIs (< 30)"); + String[] legacyApiLevels = {"29", "28", "27", "26", "25", "24", "23", "22", "21"}; + JComboBox legacyApiCombo = new JComboBox<>(legacyApiLevels); + legacyApiCombo.setEnabled(false); + legacyApiCombo.setVisible(false); + + legacyCheckBox.addActionListener(e -> { + boolean showLegacy = legacyCheckBox.isSelected(); + apiCombo.setEnabled(!showLegacy); + legacyApiCombo.setEnabled(showLegacy); + legacyApiCombo.setVisible(showLegacy); + }); + + String[] devices = {"pixel", "pixel_2", "pixel_3", "pixel_4", "pixel_5", + "pixel_6", "pixel_7", "pixel_8"}; + JComboBox deviceCombo = new JComboBox<>(devices); + deviceCombo.setSelectedItem("pixel_7"); + + JPanel panel = new JPanel(new GridBagLayout()); + GridBagConstraints gbc = new GridBagConstraints(); + gbc.insets = new Insets(5, 5, 5, 5); + gbc.fill = GridBagConstraints.HORIZONTAL; + + // Row 0: Name + gbc.gridx = 0; gbc.gridy = 0; gbc.anchor = GridBagConstraints.WEST; + panel.add(new JLabel("Name:"), gbc); + gbc.gridx = 1; gbc.gridwidth = 2; + panel.add(nameField, gbc); + + // Row 1: API Level + gbc.gridx = 0; gbc.gridy = 1; gbc.gridwidth = 1; + panel.add(new JLabel("API Level:"), gbc); + gbc.gridx = 1; gbc.gridwidth = 2; + panel.add(apiCombo, gbc); + + // Row 2: Legacy checkbox + gbc.gridx = 1; gbc.gridy = 2; gbc.gridwidth = 2; + panel.add(legacyCheckBox, gbc); + + // Row 3: Legacy API combo (initially hidden) + gbc.gridx = 1; gbc.gridy = 3; gbc.gridwidth = 2; + panel.add(legacyApiCombo, gbc); + + // Row 4: Device + gbc.gridx = 0; gbc.gridy = 4; gbc.gridwidth = 1; + panel.add(new JLabel("Device:"), gbc); + gbc.gridx = 1; gbc.gridwidth = 2; + panel.add(deviceCombo, gbc); + + int result = JOptionPane.showConfirmDialog(parent, panel, + "Create New AVD", JOptionPane.OK_CANCEL_OPTION); + + if (result != JOptionPane.OK_OPTION) { + return null; + } + + String avdName = nameField.getText().trim(); + + // Validate AVD name + if (!DeviceNameFormatter.isValidAvdName(avdName)) { + JOptionPane.showMessageDialog(parent, + "Invalid AVD name!\n\n" + + "The name cannot contain spaces or special characters.\n" + + "Use letters, numbers, underscores, and hyphens only.", + "Invalid Name", JOptionPane.ERROR_MESSAGE); + return null; + } + + // Determine which API level to use (standard or legacy) + String selectedApi = legacyCheckBox.isSelected() ? + (String) legacyApiCombo.getSelectedItem() : + (String) apiCombo.getSelectedItem(); + + return new AvdCreationParams(avdName, selectedApi, (String) deviceCombo.getSelectedItem()); + } + + /** + * Shows rename AVD dialog. + * + * @param parent Parent component + * @param oldName Current AVD name + * @return New name, or null if cancelled or invalid + */ + public static String showRenameAvdDialog(Component parent, String oldName) { + String newName = JOptionPane.showInputDialog(parent, + "Enter new name for AVD '" + oldName + "':\n\n" + + "(letters, numbers, underscores, and hyphens only)", + "Rename AVD", + JOptionPane.PLAIN_MESSAGE); + + if (newName != null && !newName.trim().isEmpty() && !newName.equals(oldName)) { + newName = newName.trim(); + + // Validate AVD name + if (!DeviceNameFormatter.isValidAvdName(newName)) { + JOptionPane.showMessageDialog(parent, + "Invalid AVD name!\n\n" + + "The name cannot contain spaces or special characters.\n" + + "Use letters, numbers, underscores, and hyphens only.", + "Invalid Name", JOptionPane.ERROR_MESSAGE); + return null; + } + + return newName; + } + + return null; + } + + /** + * Shows delete confirmation dialog. + * + * @param parent Parent component + * @param avdName AVD name to delete + * @return true if confirmed, false otherwise + */ + public static boolean showDeleteConfirmation(Component parent, String avdName) { + int result = JOptionPane.showConfirmDialog(parent, + "Delete AVD '" + avdName + "'?", + "Confirm Deletion", JOptionPane.YES_NO_OPTION); + + return result == JOptionPane.YES_OPTION; + } +} diff --git a/src/main/java/net/nicolamurtas/android/emulator/view/LogPanel.java b/src/main/java/net/nicolamurtas/android/emulator/view/LogPanel.java new file mode 100644 index 0000000..c7b0755 --- /dev/null +++ b/src/main/java/net/nicolamurtas/android/emulator/view/LogPanel.java @@ -0,0 +1,132 @@ +package net.nicolamurtas.android.emulator.view; + +import net.nicolamurtas.android.emulator.util.ThemeUtils; + +import javax.swing.*; +import java.awt.*; + +/** + * Panel for displaying application logs with accordion toggle. + * Provides an expandable/collapsible log view. + * + * @author Nicola Murtas + * @version 3.0.0 + */ +public class LogPanel extends JPanel { + private final JTextArea logArea; + private final JScrollPane logScrollPane; + private JLabel logLabel; + private JButton clearButton; + private boolean expanded = false; + + public LogPanel() { + setLayout(new BorderLayout()); + setBorder(BorderFactory.createLineBorder(UIManager.getColor("Panel.background").darker(), 1)); + + // Header panel with toggle button + JPanel headerPanel = createHeaderPanel(); + add(headerPanel, BorderLayout.NORTH); + + // Log content panel (initially hidden) + logArea = createLogArea(); + logScrollPane = new JScrollPane(logArea); + logScrollPane.setPreferredSize(new Dimension(0, 0)); + logScrollPane.setVisible(false); + add(logScrollPane, BorderLayout.CENTER); + } + + private JPanel createHeaderPanel() { + JPanel headerPanel = new JPanel(new BorderLayout()); + headerPanel.setBackground(ThemeUtils.getHeaderBackgroundColor()); + headerPanel.setBorder(BorderFactory.createEmptyBorder(5, 10, 5, 10)); + + logLabel = new JLabel("β–Ά Log"); + logLabel.setFont(logLabel.getFont().deriveFont(Font.BOLD, 13f)); + logLabel.setCursor(new Cursor(Cursor.HAND_CURSOR)); + logLabel.setForeground(UIManager.getColor("Label.foreground")); + + clearButton = new JButton("Clear"); + clearButton.setFont(clearButton.getFont().deriveFont(10f)); + clearButton.setMargin(new Insets(2, 8, 2, 8)); + clearButton.addActionListener(e -> logArea.setText("")); + clearButton.setVisible(false); + + headerPanel.add(logLabel, BorderLayout.WEST); + headerPanel.add(clearButton, BorderLayout.EAST); + + // Toggle functionality + headerPanel.addMouseListener(new java.awt.event.MouseAdapter() { + @Override + public void mouseClicked(java.awt.event.MouseEvent e) { + toggleLog(); + } + }); + + logLabel.addMouseListener(new java.awt.event.MouseAdapter() { + @Override + public void mouseClicked(java.awt.event.MouseEvent e) { + toggleLog(); + } + }); + + return headerPanel; + } + + private JTextArea createLogArea() { + JTextArea area = new JTextArea(10, 0); + area.setEditable(false); + area.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 11)); + area.setBackground(UIManager.getColor("TextArea.background")); + area.setForeground(UIManager.getColor("TextArea.foreground")); + area.setCaretColor(UIManager.getColor("TextArea.caretForeground")); + return area; + } + + private void toggleLog() { + expanded = !expanded; + + if (expanded) { + logLabel.setText("β–Ό Log"); + logScrollPane.setPreferredSize(new Dimension(0, 200)); + logScrollPane.setVisible(true); + clearButton.setVisible(true); + } else { + logLabel.setText("β–Ά Log"); + logScrollPane.setPreferredSize(new Dimension(0, 0)); + logScrollPane.setVisible(false); + clearButton.setVisible(false); + } + + revalidate(); + repaint(); + } + + /** + * Appends a message to the log. + * Thread-safe: automatically invokes on EDT if needed. + * + * @param message Message to append + */ + public void appendLog(String message) { + SwingUtilities.invokeLater(() -> { + logArea.append(message + "\n"); + logArea.setCaretPosition(logArea.getDocument().getLength()); + }); + } + + /** + * Clears all log content. + */ + public void clearLog() { + SwingUtilities.invokeLater(() -> logArea.setText("")); + } + + /** + * Returns whether the log panel is currently expanded. + * + * @return true if expanded, false otherwise + */ + public boolean isExpanded() { + return expanded; + } +} diff --git a/src/main/java/net/nicolamurtas/android/emulator/view/MainView.java b/src/main/java/net/nicolamurtas/android/emulator/view/MainView.java new file mode 100644 index 0000000..a5a2328 --- /dev/null +++ b/src/main/java/net/nicolamurtas/android/emulator/view/MainView.java @@ -0,0 +1,127 @@ +package net.nicolamurtas.android.emulator.view; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.swing.*; +import java.awt.*; + +/** + * Main application window. + * Orchestrates all UI components (SDK panel, AVD grid, log panel, progress bar). + * + * @author Nicola Murtas + * @version 3.0.0 + */ +public class MainView extends JFrame { + private static final Logger logger = LoggerFactory.getLogger(MainView.class); + + private SdkConfigPanel sdkConfigPanel; + private AvdGridPanel avdGridPanel; + private LogPanel logPanel; + private JProgressBar progressBar; + + public MainView(boolean sdkConfigured) { + // Set system look and feel FIRST, before creating any components + try { + UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName()); + } catch (Exception e) { + logger.warn("Failed to set system look and feel", e); + } + + // Now create components AFTER Look and Feel is set + this.sdkConfigPanel = new SdkConfigPanel(sdkConfigured); + this.avdGridPanel = new AvdGridPanel(); + this.logPanel = new LogPanel(); + this.progressBar = new JProgressBar(); + + initializeUI(); + } + + private void initializeUI() { + setTitle("Android Emulator Manager v3.0"); + setDefaultCloseOperation(JFrame.DO_NOTHING_ON_CLOSE); // Let controller handle closing + setSize(1000, 800); + setLocationRelativeTo(null); + + // Main layout + setLayout(new BorderLayout(10, 10)); + + // SDK Configuration Panel + add(sdkConfigPanel, BorderLayout.NORTH); + + // Center panel with AVD list and accordion log + JPanel centerPanel = new JPanel(new BorderLayout()); + centerPanel.add(avdGridPanel, BorderLayout.CENTER); + centerPanel.add(logPanel, BorderLayout.SOUTH); + add(centerPanel, BorderLayout.CENTER); + + // Progress bar + progressBar.setStringPainted(true); + progressBar.setVisible(false); + add(progressBar, BorderLayout.SOUTH); + } + + /** + * Gets the SDK configuration panel. + * + * @return SDK config panel + */ + public SdkConfigPanel getSdkConfigPanel() { + return sdkConfigPanel; + } + + /** + * Gets the AVD grid panel. + * + * @return AVD grid panel + */ + public AvdGridPanel getAvdGridPanel() { + return avdGridPanel; + } + + /** + * Gets the log panel. + * + * @return Log panel + */ + public LogPanel getLogPanel() { + return logPanel; + } + + /** + * Shows or hides the progress bar. + * + * @param show Whether to show the progress bar + */ + public void showProgress(boolean show) { + SwingUtilities.invokeLater(() -> { + progressBar.setVisible(show); + if (show) { + progressBar.setValue(0); + } + }); + } + + /** + * Updates progress bar value and message. + * + * @param value Progress value (0-100) + * @param message Progress message + */ + public void updateProgress(int value, String message) { + SwingUtilities.invokeLater(() -> { + progressBar.setValue(value); + progressBar.setString(message); + }); + } + + /** + * Appends a message to the log. + * + * @param message Log message + */ + public void log(String message) { + logPanel.appendLog(message); + } +} diff --git a/src/main/java/net/nicolamurtas/android/emulator/view/SdkConfigPanel.java b/src/main/java/net/nicolamurtas/android/emulator/view/SdkConfigPanel.java new file mode 100644 index 0000000..baaf6d9 --- /dev/null +++ b/src/main/java/net/nicolamurtas/android/emulator/view/SdkConfigPanel.java @@ -0,0 +1,176 @@ +package net.nicolamurtas.android.emulator.view; + +import net.nicolamurtas.android.emulator.util.ThemeUtils; + +import javax.swing.*; +import java.awt.*; +import java.util.function.Consumer; + +/** + * Panel for SDK configuration with accordion-style toggle. + * Allows users to browse, download, and verify Android SDK. + * + * @author Nicola Murtas + * @version 3.0.0 + */ +public class SdkConfigPanel extends JPanel { + private final JTextField sdkPathField; + private final JPanel contentPanel; + private JLabel headerLabel; + private JLabel statusLabel; + private boolean expanded = false; + + // Action handlers (set by controller) + private Runnable onBrowse; + private Runnable onDownload; + private Runnable onVerify; + + public SdkConfigPanel(boolean sdkConfigured) { + setLayout(new BorderLayout()); + setBorder(BorderFactory.createLineBorder(UIManager.getColor("Panel.background").darker(), 1)); + + // Collapsed if SDK is configured, expanded if not + this.expanded = !sdkConfigured; + + // Header panel with toggle button + JPanel headerPanel = createHeaderPanel(sdkConfigured); + add(headerPanel, BorderLayout.NORTH); + + // SDK content panel + sdkPathField = new JTextField(40); + contentPanel = createContentPanel(); + contentPanel.setVisible(expanded); + add(contentPanel, BorderLayout.CENTER); + } + + private JPanel createHeaderPanel(boolean sdkConfigured) { + JPanel headerPanel = new JPanel(new BorderLayout()); + headerPanel.setBackground(ThemeUtils.getHeaderBackgroundColor()); + headerPanel.setBorder(BorderFactory.createEmptyBorder(5, 10, 5, 10)); + + headerLabel = new JLabel(expanded ? "β–Ό SDK Configuration" : "β–Ά SDK Configuration"); + headerLabel.setFont(headerLabel.getFont().deriveFont(Font.BOLD, 13f)); + headerLabel.setCursor(new Cursor(Cursor.HAND_CURSOR)); + headerLabel.setForeground(UIManager.getColor("Label.foreground")); + + statusLabel = new JLabel(sdkConfigured ? "βœ“ Configured" : "⚠ Not Configured"); + statusLabel.setFont(statusLabel.getFont().deriveFont(Font.PLAIN, 11f)); + statusLabel.setForeground(sdkConfigured ? ThemeUtils.Colors.SUCCESS : ThemeUtils.Colors.WARNING); + + headerPanel.add(headerLabel, BorderLayout.WEST); + headerPanel.add(statusLabel, BorderLayout.EAST); + + // Toggle functionality + headerPanel.addMouseListener(new java.awt.event.MouseAdapter() { + @Override + public void mouseClicked(java.awt.event.MouseEvent e) { + toggle(); + } + }); + + headerLabel.addMouseListener(new java.awt.event.MouseAdapter() { + @Override + public void mouseClicked(java.awt.event.MouseEvent e) { + toggle(); + } + }); + + return headerPanel; + } + + private JPanel createContentPanel() { + JPanel panel = new JPanel(new BorderLayout(5, 5)); + panel.setBorder(BorderFactory.createEmptyBorder(10, 10, 10, 10)); + + // Top panel with path field + JPanel topPanel = new JPanel(new FlowLayout(FlowLayout.LEFT)); + topPanel.add(new JLabel("SDK Path:")); + topPanel.add(sdkPathField); + + JButton browseButton = new JButton("Browse..."); + browseButton.addActionListener(e -> { + if (onBrowse != null) onBrowse.run(); + }); + topPanel.add(browseButton); + + // Button panel + JPanel buttonPanel = new JPanel(new FlowLayout(FlowLayout.LEFT)); + + JButton downloadButton = new JButton("Download SDK"); + downloadButton.setBackground(ThemeUtils.Colors.SUCCESS); + downloadButton.setForeground(Color.WHITE); + downloadButton.addActionListener(e -> { + if (onDownload != null) onDownload.run(); + }); + buttonPanel.add(downloadButton); + + JButton verifyButton = new JButton("Verify SDK"); + verifyButton.addActionListener(e -> { + if (onVerify != null) onVerify.run(); + }); + buttonPanel.add(verifyButton); + + panel.add(topPanel, BorderLayout.NORTH); + panel.add(buttonPanel, BorderLayout.SOUTH); + + return panel; + } + + private void toggle() { + expanded = !expanded; + + if (expanded) { + headerLabel.setText("β–Ό SDK Configuration"); + contentPanel.setVisible(true); + } else { + headerLabel.setText("β–Ά SDK Configuration"); + contentPanel.setVisible(false); + } + + revalidate(); + repaint(); + } + + /** + * Sets the SDK path in the text field. + * + * @param path SDK path to display + */ + public void setSdkPath(String path) { + sdkPathField.setText(path); + } + + /** + * Gets the current SDK path from the text field. + * + * @return Current SDK path + */ + public String getSdkPath() { + return sdkPathField.getText(); + } + + /** + * Updates the configuration status indicator. + * + * @param configured Whether SDK is configured + */ + public void setConfigured(boolean configured) { + SwingUtilities.invokeLater(() -> { + statusLabel.setText(configured ? "βœ“ Configured" : "⚠ Not Configured"); + statusLabel.setForeground(configured ? ThemeUtils.Colors.SUCCESS : ThemeUtils.Colors.WARNING); + }); + } + + // Action handler setters + public void setOnBrowse(Runnable action) { + this.onBrowse = action; + } + + public void setOnDownload(Runnable action) { + this.onDownload = action; + } + + public void setOnVerify(Runnable action) { + this.onVerify = action; + } +} diff --git a/src/test/java/net/nicolamurtas/android/emulator/AndroidEmulatorManagerTest.java b/src/test/java/net/nicolamurtas/android/emulator/AndroidEmulatorManagerTest.java new file mode 100644 index 0000000..1d5fca2 --- /dev/null +++ b/src/test/java/net/nicolamurtas/android/emulator/AndroidEmulatorManagerTest.java @@ -0,0 +1,52 @@ +package net.nicolamurtas.android.emulator; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Unit tests for AndroidEmulatorManager main class. + * + * @author Nicola Murtas + * @version 3.0.0 + */ +class AndroidEmulatorManagerTest { + + @Test + void testMainClassExists() { + // Verify the main class exists and can be loaded + assertDoesNotThrow(() -> { + Class mainClass = Class.forName("net.nicolamurtas.android.emulator.AndroidEmulatorManager"); + assertNotNull(mainClass); + }); + } + + @Test + void testMainMethodExists() throws NoSuchMethodException { + // Verify the main method exists with correct signature + Class mainClass = AndroidEmulatorManager.class; + var mainMethod = mainClass.getMethod("main", String[].class); + assertNotNull(mainMethod); + assertEquals(void.class, mainMethod.getReturnType()); + } + + @Test + void testMainMethodIsPublicStatic() throws NoSuchMethodException { + var mainMethod = AndroidEmulatorManager.class.getMethod("main", String[].class); + assertTrue(java.lang.reflect.Modifier.isPublic(mainMethod.getModifiers())); + assertTrue(java.lang.reflect.Modifier.isStatic(mainMethod.getModifiers())); + } + + @Test + void testClassIsPublic() { + assertTrue(java.lang.reflect.Modifier.isPublic(AndroidEmulatorManager.class.getModifiers())); + } + + @Test + void testClassHasNoArgsConstructor() { + assertDoesNotThrow(() -> { + var constructor = AndroidEmulatorManager.class.getDeclaredConstructor(); + assertNotNull(constructor); + }); + } +} diff --git a/src/test/java/net/nicolamurtas/android/emulator/controller/MainControllerTest.java b/src/test/java/net/nicolamurtas/android/emulator/controller/MainControllerTest.java new file mode 100644 index 0000000..11c4f34 --- /dev/null +++ b/src/test/java/net/nicolamurtas/android/emulator/controller/MainControllerTest.java @@ -0,0 +1,145 @@ +package net.nicolamurtas.android.emulator.controller; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; + +import java.awt.GraphicsEnvironment; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Unit tests for MainController. + * + * Note: Most tests verify basic instantiation and structure. + * Full integration testing would require a headless display + * environment or extensive mocking. + * + * @author Nicola Murtas + * @version 3.0.0 + */ +class MainControllerTest { + + @Test + @EnabledIfEnvironmentVariable(named = "DISPLAY", matches = ".*") + void testMainController_CanBeInstantiated() { + // This test only runs if a display is available + assertDoesNotThrow(() -> { + MainController controller = new MainController(); + assertNotNull(controller); + assertNotNull(controller.getView()); + }); + } + + @Test + void testMainController_InstantiationWithoutDisplay() { + // In headless mode, controller creation might fail due to Swing dependencies + // This is expected behavior and not a bug + // We just verify it doesn't throw unexpected exceptions types + + try { + MainController controller = new MainController(); + // If we get here, display was available + assertNotNull(controller); + } catch (java.awt.HeadlessException e) { + // Expected in headless environments (CI/CD) + assertTrue(GraphicsEnvironment.isHeadless()); + } catch (Exception e) { + // Any other exception should be investigated + fail("Unexpected exception: " + e.getClass().getName() + ": " + e.getMessage()); + } + } + + @Test + void testMainController_ViewNotNull() { + try { + MainController controller = new MainController(); + assertNotNull(controller.getView(), "View should not be null after initialization"); + } catch (java.awt.HeadlessException e) { + // Expected in headless environments + assertTrue(GraphicsEnvironment.isHeadless()); + } + } + + @Test + @EnabledIfEnvironmentVariable(named = "DISPLAY", matches = ".*") + void testMainController_ShowDoesNotThrow() { + assertDoesNotThrow(() -> { + MainController controller = new MainController(); + controller.show(); + // Note: We can't easily verify the window is visible without + // complex GUI testing frameworks + }); + } + + @Test + @EnabledIfEnvironmentVariable(named = "DISPLAY", matches = ".*") + void testMainController_ViewComponentsInitialized() { + MainController controller = new MainController(); + var view = controller.getView(); + + assertNotNull(view.getSdkConfigPanel(), "SDK config panel should be initialized"); + assertNotNull(view.getAvdGridPanel(), "AVD grid panel should be initialized"); + assertNotNull(view.getLogPanel(), "Log panel should be initialized"); + } + + @Test + void testMainController_HeadlessEnvironmentDetection() { + boolean isHeadless = GraphicsEnvironment.isHeadless(); + + if (isHeadless) { + assertThrows(java.awt.HeadlessException.class, () -> { + new MainController(); + }, "Should throw HeadlessException in headless environment"); + } else { + assertDoesNotThrow(() -> { + MainController controller = new MainController(); + assertNotNull(controller); + }, "Should not throw in non-headless environment"); + } + } + + @Test + @EnabledIfEnvironmentVariable(named = "DISPLAY", matches = ".*") + void testMainController_MultipleInstances() { + // Test that multiple controllers can be created + assertDoesNotThrow(() -> { + MainController controller1 = new MainController(); + MainController controller2 = new MainController(); + + assertNotNull(controller1); + assertNotNull(controller2); + assertNotSame(controller1, controller2); + assertNotSame(controller1.getView(), controller2.getView()); + }); + } + + @Test + void testMainController_ClassStructure() { + // Verify class has required methods + assertDoesNotThrow(() -> { + var showMethod = MainController.class.getMethod("show"); + var getViewMethod = MainController.class.getMethod("getView"); + + assertNotNull(showMethod); + assertNotNull(getViewMethod); + + assertEquals(void.class, showMethod.getReturnType()); + assertNotNull(getViewMethod.getReturnType()); + }); + } + + @Test + void testMainController_HasPublicConstructor() { + var constructors = MainController.class.getConstructors(); + assertTrue(constructors.length > 0, "Should have at least one public constructor"); + + boolean hasNoArgsConstructor = false; + for (var constructor : constructors) { + if (constructor.getParameterCount() == 0) { + hasNoArgsConstructor = true; + break; + } + } + assertTrue(hasNoArgsConstructor, "Should have a no-args constructor"); + } +} diff --git a/src/test/java/net/nicolamurtas/android/emulator/model/DeviceConfigurationTest.java b/src/test/java/net/nicolamurtas/android/emulator/model/DeviceConfigurationTest.java new file mode 100644 index 0000000..ccda3a3 --- /dev/null +++ b/src/test/java/net/nicolamurtas/android/emulator/model/DeviceConfigurationTest.java @@ -0,0 +1,295 @@ +package net.nicolamurtas.android.emulator.model; + +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Unit tests for DeviceConfiguration domain object. + * + * @author Nicola Murtas + * @version 3.0.0 + */ +class DeviceConfigurationTest { + + @Test + void testDeviceConfiguration_Builder() { + List features = Arrays.asList("android.hardware.wifi", "android.hardware.bluetooth"); + + DeviceConfiguration device = DeviceConfiguration.builder() + .deviceType("pixel_7") + .cpuArch("x86_64") + .ramMb(4096) + .internalStorageMb(8192) + .screenResolution("1080x2400") + .hardwareFeatures(features) + .apiLevel("34") + .androidVersion("Android 14") + .build(); + + assertEquals("pixel_7", device.getDeviceType()); + assertEquals("x86_64", device.getCpuArch()); + assertEquals(4096, device.getRamMb()); + assertEquals(8192, device.getInternalStorageMb()); + assertEquals("1080x2400", device.getScreenResolution()); + assertEquals(features, device.getHardwareFeatures()); + assertEquals("34", device.getApiLevel()); + assertEquals("Android 14", device.getAndroidVersion()); + } + + @Test + void testDeviceConfiguration_DefaultValues() { + DeviceConfiguration device = DeviceConfiguration.builder().build(); + + assertNull(device.getDeviceType()); + assertNull(device.getCpuArch()); + assertEquals(0, device.getRamMb()); + assertEquals(0, device.getInternalStorageMb()); + assertNull(device.getScreenResolution()); + assertNull(device.getHardwareFeatures()); + assertNull(device.getApiLevel()); + assertNull(device.getAndroidVersion()); + } + + @Test + void testDeviceConfiguration_EmptyFeaturesList() { + DeviceConfiguration device = DeviceConfiguration.builder() + .hardwareFeatures(Collections.emptyList()) + .build(); + + assertNotNull(device.getHardwareFeatures()); + assertTrue(device.getHardwareFeatures().isEmpty()); + } + + @Test + void testDeviceConfiguration_SettersAndGetters() { + DeviceConfiguration device = new DeviceConfiguration(); + List features = Arrays.asList("android.hardware.camera"); + + device.setDeviceType("pixel_5"); + device.setCpuArch("arm64-v8a"); + device.setRamMb(2048); + device.setInternalStorageMb(4096); + device.setScreenResolution("1080x1920"); + device.setHardwareFeatures(features); + device.setApiLevel("33"); + device.setAndroidVersion("Android 13"); + + assertEquals("pixel_5", device.getDeviceType()); + assertEquals("arm64-v8a", device.getCpuArch()); + assertEquals(2048, device.getRamMb()); + assertEquals(4096, device.getInternalStorageMb()); + assertEquals("1080x1920", device.getScreenResolution()); + assertEquals(features, device.getHardwareFeatures()); + assertEquals("33", device.getApiLevel()); + assertEquals("Android 13", device.getAndroidVersion()); + } + + @Test + void testDeviceConfiguration_Equality() { + List features = Arrays.asList("android.hardware.wifi"); + + DeviceConfiguration device1 = DeviceConfiguration.builder() + .deviceType("pixel_7") + .cpuArch("x86_64") + .ramMb(4096) + .apiLevel("34") + .hardwareFeatures(features) + .build(); + + DeviceConfiguration device2 = DeviceConfiguration.builder() + .deviceType("pixel_7") + .cpuArch("x86_64") + .ramMb(4096) + .apiLevel("34") + .hardwareFeatures(features) + .build(); + + DeviceConfiguration device3 = DeviceConfiguration.builder() + .deviceType("pixel_5") + .cpuArch("x86_64") + .ramMb(4096) + .apiLevel("34") + .hardwareFeatures(features) + .build(); + + assertEquals(device1, device2); + assertNotEquals(device1, device3); + } + + @Test + void testDeviceConfiguration_HashCode() { + DeviceConfiguration device1 = DeviceConfiguration.builder() + .deviceType("pixel_7") + .cpuArch("x86_64") + .ramMb(4096) + .build(); + + DeviceConfiguration device2 = DeviceConfiguration.builder() + .deviceType("pixel_7") + .cpuArch("x86_64") + .ramMb(4096) + .build(); + + assertEquals(device1.hashCode(), device2.hashCode()); + } + + @Test + void testDeviceConfiguration_ToString() { + DeviceConfiguration device = DeviceConfiguration.builder() + .deviceType("pixel_7") + .cpuArch("x86_64") + .ramMb(4096) + .apiLevel("34") + .build(); + + String str = device.toString(); + assertNotNull(str); + assertTrue(str.contains("pixel_7")); + assertTrue(str.contains("x86_64")); + assertTrue(str.contains("4096")); + assertTrue(str.contains("34")); + } + + @Test + void testDeviceConfiguration_LowEndDevice() { + DeviceConfiguration device = DeviceConfiguration.builder() + .deviceType("low_end") + .cpuArch("x86") + .ramMb(512) + .internalStorageMb(1024) + .screenResolution("480x800") + .apiLevel("21") + .androidVersion("Android 5.0") + .build(); + + assertEquals(512, device.getRamMb()); + assertEquals(1024, device.getInternalStorageMb()); + assertEquals("Android 5.0", device.getAndroidVersion()); + } + + @Test + void testDeviceConfiguration_HighEndDevice() { + DeviceConfiguration device = DeviceConfiguration.builder() + .deviceType("pixel_8_pro") + .cpuArch("arm64-v8a") + .ramMb(12288) + .internalStorageMb(262144) + .screenResolution("1440x3120") + .apiLevel("35") + .androidVersion("Android 15") + .build(); + + assertEquals(12288, device.getRamMb()); + assertEquals(262144, device.getInternalStorageMb()); + assertEquals("Android 15", device.getAndroidVersion()); + } + + @Test + void testDeviceConfiguration_MultipleHardwareFeatures() { + List features = Arrays.asList( + "android.hardware.wifi", + "android.hardware.bluetooth", + "android.hardware.camera", + "android.hardware.gps", + "android.hardware.nfc", + "android.hardware.fingerprint" + ); + + DeviceConfiguration device = DeviceConfiguration.builder() + .hardwareFeatures(features) + .build(); + + assertEquals(6, device.getHardwareFeatures().size()); + assertTrue(device.getHardwareFeatures().contains("android.hardware.wifi")); + assertTrue(device.getHardwareFeatures().contains("android.hardware.fingerprint")); + } + + @Test + void testDeviceConfiguration_DifferentArchitectures() { + String[] architectures = {"x86", "x86_64", "arm64-v8a", "armeabi-v7a"}; + + for (String arch : architectures) { + DeviceConfiguration device = DeviceConfiguration.builder() + .cpuArch(arch) + .build(); + + assertEquals(arch, device.getCpuArch()); + } + } + + @Test + void testDeviceConfiguration_VariousResolutions() { + String[] resolutions = { + "480x800", // WVGA + "720x1280", // HD + "1080x1920", // Full HD + "1440x2560", // QHD + "1440x3120" // QHD+ + }; + + for (String resolution : resolutions) { + DeviceConfiguration device = DeviceConfiguration.builder() + .screenResolution(resolution) + .build(); + + assertEquals(resolution, device.getScreenResolution()); + } + } + + @Test + void testDeviceConfiguration_AllApiLevels() { + for (int apiLevel = 21; apiLevel <= 35; apiLevel++) { + DeviceConfiguration device = DeviceConfiguration.builder() + .apiLevel(String.valueOf(apiLevel)) + .build(); + + assertEquals(String.valueOf(apiLevel), device.getApiLevel()); + } + } + + @Test + void testDeviceConfiguration_NullValues() { + DeviceConfiguration device = DeviceConfiguration.builder() + .deviceType(null) + .cpuArch(null) + .screenResolution(null) + .hardwareFeatures(null) + .apiLevel(null) + .androidVersion(null) + .build(); + + assertNull(device.getDeviceType()); + assertNull(device.getCpuArch()); + assertNull(device.getScreenResolution()); + assertNull(device.getHardwareFeatures()); + assertNull(device.getApiLevel()); + assertNull(device.getAndroidVersion()); + } + + @Test + void testDeviceConfiguration_NegativeValues() { + DeviceConfiguration device = DeviceConfiguration.builder() + .ramMb(-1) + .internalStorageMb(-1) + .build(); + + assertEquals(-1, device.getRamMb()); + assertEquals(-1, device.getInternalStorageMb()); + } + + @Test + void testDeviceConfiguration_ZeroValues() { + DeviceConfiguration device = DeviceConfiguration.builder() + .ramMb(0) + .internalStorageMb(0) + .build(); + + assertEquals(0, device.getRamMb()); + assertEquals(0, device.getInternalStorageMb()); + } +} diff --git a/src/test/java/net/nicolamurtas/android/emulator/model/DownloadProgressTest.java b/src/test/java/net/nicolamurtas/android/emulator/model/DownloadProgressTest.java new file mode 100644 index 0000000..b980489 --- /dev/null +++ b/src/test/java/net/nicolamurtas/android/emulator/model/DownloadProgressTest.java @@ -0,0 +1,164 @@ +package net.nicolamurtas.android.emulator.model; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Unit tests for DownloadProgress value object. + * + * @author Nicola Murtas + * @version 3.0.0 + */ +class DownloadProgressTest { + + @Test + void testDownloadProgress_Creation() { + DownloadProgress progress = new DownloadProgress( + 50, + 5000000L, + 10000000L, + "platform-tools.zip", + "Downloading platform tools..." + ); + + assertEquals(50, progress.getPercentage()); + assertEquals(5000000L, progress.getBytesDownloaded()); + assertEquals(10000000L, progress.getTotalBytes()); + assertEquals("platform-tools.zip", progress.getCurrentFile()); + assertEquals("Downloading platform tools...", progress.getMessage()); + } + + @Test + void testDownloadProgress_ZeroProgress() { + DownloadProgress progress = new DownloadProgress( + 0, + 0L, + 10000000L, + "starting", + "Initializing download..." + ); + + assertEquals(0, progress.getPercentage()); + assertEquals(0L, progress.getBytesDownloaded()); + assertEquals(10000000L, progress.getTotalBytes()); + } + + @Test + void testDownloadProgress_CompleteProgress() { + DownloadProgress progress = new DownloadProgress( + 100, + 10000000L, + 10000000L, + "platform-tools.zip", + "Download complete" + ); + + assertEquals(100, progress.getPercentage()); + assertEquals(10000000L, progress.getBytesDownloaded()); + assertEquals(10000000L, progress.getTotalBytes()); + } + + @Test + void testDownloadProgress_Equality() { + DownloadProgress progress1 = new DownloadProgress( + 50, 5000000L, 10000000L, "file.zip", "Downloading..." + ); + DownloadProgress progress2 = new DownloadProgress( + 50, 5000000L, 10000000L, "file.zip", "Downloading..." + ); + DownloadProgress progress3 = new DownloadProgress( + 60, 6000000L, 10000000L, "file.zip", "Downloading..." + ); + + assertEquals(progress1, progress2); + assertNotEquals(progress1, progress3); + } + + @Test + void testDownloadProgress_HashCode() { + DownloadProgress progress1 = new DownloadProgress( + 50, 5000000L, 10000000L, "file.zip", "Downloading..." + ); + DownloadProgress progress2 = new DownloadProgress( + 50, 5000000L, 10000000L, "file.zip", "Downloading..." + ); + + assertEquals(progress1.hashCode(), progress2.hashCode()); + } + + @Test + void testDownloadProgress_ToString() { + DownloadProgress progress = new DownloadProgress( + 50, 5000000L, 10000000L, "file.zip", "Downloading..." + ); + + String str = progress.toString(); + assertNotNull(str); + assertTrue(str.contains("50")); + assertTrue(str.contains("5000000")); + assertTrue(str.contains("10000000")); + } + + @Test + void testDownloadProgress_NullMessage() { + DownloadProgress progress = new DownloadProgress( + 50, 5000000L, 10000000L, "file.zip", null + ); + + assertNull(progress.getMessage()); + } + + @Test + void testDownloadProgress_NullCurrentFile() { + DownloadProgress progress = new DownloadProgress( + 50, 5000000L, 10000000L, null, "Downloading..." + ); + + assertNull(progress.getCurrentFile()); + } + + @Test + void testDownloadProgress_EmptyStrings() { + DownloadProgress progress = new DownloadProgress( + 50, 5000000L, 10000000L, "", "" + ); + + assertEquals("", progress.getCurrentFile()); + assertEquals("", progress.getMessage()); + } + + @Test + void testDownloadProgress_LargeValues() { + DownloadProgress progress = new DownloadProgress( + 100, + Long.MAX_VALUE, + Long.MAX_VALUE, + "huge-file.zip", + "Downloading huge file" + ); + + assertEquals(100, progress.getPercentage()); + assertEquals(Long.MAX_VALUE, progress.getBytesDownloaded()); + assertEquals(Long.MAX_VALUE, progress.getTotalBytes()); + } + + @Test + void testDownloadProgress_NegativePercentage() { + // Even though it doesn't make sense, the value object should store whatever is given + DownloadProgress progress = new DownloadProgress( + -1, 0L, 10000000L, "file.zip", "Error" + ); + + assertEquals(-1, progress.getPercentage()); + } + + @Test + void testDownloadProgress_PercentageOverHundred() { + DownloadProgress progress = new DownloadProgress( + 150, 15000000L, 10000000L, "file.zip", "Over capacity" + ); + + assertEquals(150, progress.getPercentage()); + } +} diff --git a/src/test/java/net/nicolamurtas/android/emulator/model/EmulatorStatusTest.java b/src/test/java/net/nicolamurtas/android/emulator/model/EmulatorStatusTest.java new file mode 100644 index 0000000..541aeae --- /dev/null +++ b/src/test/java/net/nicolamurtas/android/emulator/model/EmulatorStatusTest.java @@ -0,0 +1,78 @@ +package net.nicolamurtas.android.emulator.model; + +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +/** + * Unit tests for EmulatorStatus enum. + * + * @author Nicola Murtas + * @version 3.0.0 + */ +class EmulatorStatusTest { + + @Test + void testAllStatusValuesExist() { + EmulatorStatus[] statuses = EmulatorStatus.values(); + assertEquals(5, statuses.length); + } + + @Test + void testStatusValueOf() { + assertEquals(EmulatorStatus.STOPPED, EmulatorStatus.valueOf("STOPPED")); + assertEquals(EmulatorStatus.STARTING, EmulatorStatus.valueOf("STARTING")); + assertEquals(EmulatorStatus.RUNNING, EmulatorStatus.valueOf("RUNNING")); + assertEquals(EmulatorStatus.STOPPING, EmulatorStatus.valueOf("STOPPING")); + assertEquals(EmulatorStatus.ERROR, EmulatorStatus.valueOf("ERROR")); + } + + @Test + void testStatusNames() { + assertEquals("STOPPED", EmulatorStatus.STOPPED.name()); + assertEquals("STARTING", EmulatorStatus.STARTING.name()); + assertEquals("RUNNING", EmulatorStatus.RUNNING.name()); + assertEquals("STOPPING", EmulatorStatus.STOPPING.name()); + assertEquals("ERROR", EmulatorStatus.ERROR.name()); + } + + @Test + void testStatusOrdinal() { + assertEquals(0, EmulatorStatus.STOPPED.ordinal()); + assertEquals(1, EmulatorStatus.STARTING.ordinal()); + assertEquals(2, EmulatorStatus.RUNNING.ordinal()); + assertEquals(3, EmulatorStatus.STOPPING.ordinal()); + assertEquals(4, EmulatorStatus.ERROR.ordinal()); + } + + @Test + void testInvalidValueOfThrowsException() { + assertThrows(IllegalArgumentException.class, () -> { + EmulatorStatus.valueOf("INVALID"); + }); + } + + @Test + void testEnumEquality() { + EmulatorStatus status1 = EmulatorStatus.RUNNING; + EmulatorStatus status2 = EmulatorStatus.RUNNING; + EmulatorStatus status3 = EmulatorStatus.STOPPED; + + assertEquals(status1, status2); + assertNotEquals(status1, status3); + assertSame(status1, status2); // Enums are singletons + } + + @Test + void testEnumInSwitchStatement() { + // Test that enum can be used in switch statements + String result = switch (EmulatorStatus.RUNNING) { + case STOPPED -> "not running"; + case STARTING -> "starting up"; + case RUNNING -> "active"; + case STOPPING -> "shutting down"; + case ERROR -> "failed"; + }; + + assertEquals("active", result); + } +} diff --git a/src/test/java/net/nicolamurtas/android/emulator/model/SdkConfigurationTest.java b/src/test/java/net/nicolamurtas/android/emulator/model/SdkConfigurationTest.java new file mode 100644 index 0000000..b9cf8f4 --- /dev/null +++ b/src/test/java/net/nicolamurtas/android/emulator/model/SdkConfigurationTest.java @@ -0,0 +1,217 @@ +package net.nicolamurtas.android.emulator.model; + +import org.junit.jupiter.api.Test; + +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Unit tests for SdkConfiguration domain object. + * + * @author Nicola Murtas + * @version 3.0.0 + */ +class SdkConfigurationTest { + + @Test + void testSdkConfiguration_Builder() { + Path sdkPath = Paths.get("/home/user/Android/sdk"); + List packages = Arrays.asList("platform-tools", "build-tools;33.0.0"); + + SdkConfiguration config = SdkConfiguration.builder() + .sdkPath(sdkPath) + .configured(true) + .platformToolsVersion("34.0.0") + .buildToolsVersion("33.0.0") + .installedPackages(packages) + .licensesAccepted(true) + .build(); + + assertEquals(sdkPath, config.getSdkPath()); + assertTrue(config.isConfigured()); + assertEquals("34.0.0", config.getPlatformToolsVersion()); + assertEquals("33.0.0", config.getBuildToolsVersion()); + assertEquals(packages, config.getInstalledPackages()); + assertTrue(config.isLicensesAccepted()); + } + + @Test + void testSdkConfiguration_DefaultValues() { + SdkConfiguration config = SdkConfiguration.builder().build(); + + assertNull(config.getSdkPath()); + assertFalse(config.isConfigured()); + assertNull(config.getPlatformToolsVersion()); + assertNull(config.getBuildToolsVersion()); + assertNull(config.getInstalledPackages()); + assertFalse(config.isLicensesAccepted()); + } + + @Test + void testSdkConfiguration_EmptyPackageList() { + SdkConfiguration config = SdkConfiguration.builder() + .installedPackages(Collections.emptyList()) + .build(); + + assertNotNull(config.getInstalledPackages()); + assertTrue(config.getInstalledPackages().isEmpty()); + } + + @Test + void testSdkConfiguration_SettersAndGetters() { + Path sdkPath = Paths.get("/opt/android-sdk"); + SdkConfiguration config = new SdkConfiguration(); + + config.setSdkPath(sdkPath); + config.setConfigured(true); + config.setPlatformToolsVersion("35.0.0"); + config.setBuildToolsVersion("34.0.0"); + config.setLicensesAccepted(true); + + assertEquals(sdkPath, config.getSdkPath()); + assertTrue(config.isConfigured()); + assertEquals("35.0.0", config.getPlatformToolsVersion()); + assertEquals("34.0.0", config.getBuildToolsVersion()); + assertTrue(config.isLicensesAccepted()); + } + + @Test + void testSdkConfiguration_Equality() { + Path sdkPath = Paths.get("/home/user/Android/sdk"); + List packages = Arrays.asList("platform-tools"); + + SdkConfiguration config1 = SdkConfiguration.builder() + .sdkPath(sdkPath) + .configured(true) + .platformToolsVersion("34.0.0") + .installedPackages(packages) + .build(); + + SdkConfiguration config2 = SdkConfiguration.builder() + .sdkPath(sdkPath) + .configured(true) + .platformToolsVersion("34.0.0") + .installedPackages(packages) + .build(); + + SdkConfiguration config3 = SdkConfiguration.builder() + .sdkPath(Paths.get("/different/path")) + .configured(true) + .platformToolsVersion("34.0.0") + .installedPackages(packages) + .build(); + + assertEquals(config1, config2); + assertNotEquals(config1, config3); + } + + @Test + void testSdkConfiguration_HashCode() { + Path sdkPath = Paths.get("/home/user/Android/sdk"); + + SdkConfiguration config1 = SdkConfiguration.builder() + .sdkPath(sdkPath) + .configured(true) + .build(); + + SdkConfiguration config2 = SdkConfiguration.builder() + .sdkPath(sdkPath) + .configured(true) + .build(); + + assertEquals(config1.hashCode(), config2.hashCode()); + } + + @Test + void testSdkConfiguration_ToString() { + SdkConfiguration config = SdkConfiguration.builder() + .sdkPath(Paths.get("/home/user/Android/sdk")) + .configured(true) + .platformToolsVersion("34.0.0") + .build(); + + String str = config.toString(); + assertNotNull(str); + assertTrue(str.contains("34.0.0")); + assertTrue(str.contains("true")); + } + + @Test + void testSdkConfiguration_NotConfigured() { + SdkConfiguration config = SdkConfiguration.builder() + .sdkPath(Paths.get("/home/user/Android/sdk")) + .configured(false) + .licensesAccepted(false) + .build(); + + assertFalse(config.isConfigured()); + assertFalse(config.isLicensesAccepted()); + } + + @Test + void testSdkConfiguration_MultiplePackages() { + List packages = Arrays.asList( + "platform-tools", + "build-tools;33.0.0", + "build-tools;34.0.0", + "platforms;android-33", + "platforms;android-34", + "system-images;android-33;google_apis;x86_64" + ); + + SdkConfiguration config = SdkConfiguration.builder() + .installedPackages(packages) + .build(); + + assertEquals(6, config.getInstalledPackages().size()); + assertTrue(config.getInstalledPackages().contains("platform-tools")); + assertTrue(config.getInstalledPackages().contains("system-images;android-33;google_apis;x86_64")); + } + + @Test + void testSdkConfiguration_WindowsPath() { + Path windowsPath = Paths.get("C:\\Users\\User\\AppData\\Local\\Android\\sdk"); + SdkConfiguration config = SdkConfiguration.builder() + .sdkPath(windowsPath) + .build(); + + assertEquals(windowsPath, config.getSdkPath()); + } + + @Test + void testSdkConfiguration_RelativePath() { + Path relativePath = Paths.get("../Android/sdk"); + SdkConfiguration config = SdkConfiguration.builder() + .sdkPath(relativePath) + .build(); + + assertEquals(relativePath, config.getSdkPath()); + } + + @Test + void testSdkConfiguration_NullVersions() { + SdkConfiguration config = SdkConfiguration.builder() + .platformToolsVersion(null) + .buildToolsVersion(null) + .build(); + + assertNull(config.getPlatformToolsVersion()); + assertNull(config.getBuildToolsVersion()); + } + + @Test + void testSdkConfiguration_EmptyVersionStrings() { + SdkConfiguration config = SdkConfiguration.builder() + .platformToolsVersion("") + .buildToolsVersion("") + .build(); + + assertEquals("", config.getPlatformToolsVersion()); + assertEquals("", config.getBuildToolsVersion()); + } +} diff --git a/src/test/java/net/nicolamurtas/android/emulator/service/ConfigServiceTest.java b/src/test/java/net/nicolamurtas/android/emulator/service/ConfigServiceTest.java new file mode 100644 index 0000000..437ddf8 --- /dev/null +++ b/src/test/java/net/nicolamurtas/android/emulator/service/ConfigServiceTest.java @@ -0,0 +1,238 @@ +package net.nicolamurtas.android.emulator.service; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Unit tests for ConfigService. + * + * @author Nicola Murtas + * @version 3.0.0 + */ +class ConfigServiceTest { + + private ConfigService configService; + private Path originalConfigFile; + + @BeforeEach + void setUp() throws IOException { + // Backup existing config file if it exists + originalConfigFile = Paths.get("android_emulator_config.properties"); + if (Files.exists(originalConfigFile)) { + Files.move(originalConfigFile, + Paths.get("android_emulator_config.properties.backup")); + } + } + + @AfterEach + void tearDown() throws IOException { + // Clean up test config file + Path configFile = Paths.get("android_emulator_config.properties"); + Files.deleteIfExists(configFile); + + // Restore original config file + Path backup = Paths.get("android_emulator_config.properties.backup"); + if (Files.exists(backup)) { + Files.move(backup, originalConfigFile); + } + } + + @Test + void testConfigService_CanBeInstantiated() { + assertDoesNotThrow(() -> { + configService = new ConfigService(); + assertNotNull(configService); + }); + } + + @Test + void testGetSdkPath_DefaultValue() { + configService = new ConfigService(); + Path sdkPath = configService.getSdkPath(); + assertNotNull(sdkPath); + // Should return default SDK path when not configured + assertTrue(sdkPath.toString().contains("Android")); + } + + @Test + void testSetSdkPath_AndGet(@TempDir Path tempDir) { + configService = new ConfigService(); + + Path testPath = tempDir.resolve("android-sdk"); + configService.setSdkPath(testPath); + configService.saveConfig(); + + // Create new instance to verify persistence + ConfigService newService = new ConfigService(); + assertEquals(testPath, newService.getSdkPath()); + } + + @Test + void testSaveConfig_CreatesFile() { + configService = new ConfigService(); + configService.setSdkPath(Paths.get("/test/path")); + configService.saveConfig(); + + Path configFile = Paths.get("android_emulator_config.properties"); + assertTrue(Files.exists(configFile)); + } + + @Test + void testGetValue_ExistingKey() { + configService = new ConfigService(); + configService.setValue("test.key", "test.value"); + + var value = configService.getValue("test.key"); + assertTrue(value.isPresent()); + assertEquals("test.value", value.get()); + } + + @Test + void testGetValue_NonExistingKey() { + configService = new ConfigService(); + + var value = configService.getValue("non.existing.key"); + assertFalse(value.isPresent()); + } + + @Test + void testSetValue_AndRetrieve() { + configService = new ConfigService(); + + configService.setValue("custom.property", "custom.value"); + var retrieved = configService.getValue("custom.property"); + + assertTrue(retrieved.isPresent()); + assertEquals("custom.value", retrieved.get()); + } + + @Test + void testRemoveValue() { + configService = new ConfigService(); + + configService.setValue("remove.me", "value"); + assertTrue(configService.getValue("remove.me").isPresent()); + + configService.removeValue("remove.me"); + assertFalse(configService.getValue("remove.me").isPresent()); + } + + @Test + void testIsSdkConfigured_NotConfigured() { + configService = new ConfigService(); + // With default path that doesn't exist, should return false + assertFalse(configService.isSdkConfigured()); + } + + @Test + void testIsSdkConfigured_WithPlatformTools(@TempDir Path tempDir) throws IOException { + configService = new ConfigService(); + + // Create minimal SDK structure with platform-tools + Path sdkPath = tempDir.resolve("android-sdk"); + Path platformTools = sdkPath.resolve("platform-tools"); + Files.createDirectories(platformTools); + + configService.setSdkPath(sdkPath); + assertTrue(configService.isSdkConfigured()); + } + + @Test + void testIsSdkConfigured_WithCmdlineTools(@TempDir Path tempDir) throws IOException { + configService = new ConfigService(); + + // Create minimal SDK structure with cmdline-tools + Path sdkPath = tempDir.resolve("android-sdk"); + Path cmdlineTools = sdkPath.resolve("cmdline-tools").resolve("latest"); + Files.createDirectories(cmdlineTools); + + configService.setSdkPath(sdkPath); + assertTrue(configService.isSdkConfigured()); + } + + @Test + void testSetValue_OverwriteExisting() { + configService = new ConfigService(); + + configService.setValue("key", "value1"); + assertEquals("value1", configService.getValue("key").get()); + + configService.setValue("key", "value2"); + assertEquals("value2", configService.getValue("key").get()); + } + + @Test + void testSaveAndLoad_Persistence(@TempDir Path tempDir) { + // First instance sets and saves values + configService = new ConfigService(); + configService.setSdkPath(tempDir.resolve("sdk")); + configService.setValue("test.property", "test.value"); + configService.saveConfig(); + + // Second instance loads from file + ConfigService newService = new ConfigService(); + assertEquals(tempDir.resolve("sdk"), newService.getSdkPath()); + assertEquals("test.value", newService.getValue("test.property").get()); + } + + @Test + void testGetSdkPath_EmptyString() { + configService = new ConfigService(); + configService.setValue("sdk.path", ""); + + // Empty string should return default path + Path sdkPath = configService.getSdkPath(); + assertNotNull(sdkPath); + assertTrue(sdkPath.toString().contains("Android")); + } + + @Test + void testMultipleValues() { + configService = new ConfigService(); + + configService.setValue("key1", "value1"); + configService.setValue("key2", "value2"); + configService.setValue("key3", "value3"); + + assertEquals("value1", configService.getValue("key1").get()); + assertEquals("value2", configService.getValue("key2").get()); + assertEquals("value3", configService.getValue("key3").get()); + } + + @Test + void testRemoveNonExistentValue() { + configService = new ConfigService(); + + // Should not throw when removing non-existent key + assertDoesNotThrow(() -> configService.removeValue("non.existent")); + } + + @Test + void testSdkPathWithSpaces(@TempDir Path tempDir) { + configService = new ConfigService(); + + Path pathWithSpaces = tempDir.resolve("path with spaces"); + configService.setSdkPath(pathWithSpaces); + + assertEquals(pathWithSpaces, configService.getSdkPath()); + } + + @Test + void testSdkPathWithSpecialCharacters(@TempDir Path tempDir) { + configService = new ConfigService(); + + Path pathWithSpecial = tempDir.resolve("path-with_special.chars"); + configService.setSdkPath(pathWithSpecial); + + assertEquals(pathWithSpecial, configService.getSdkPath()); + } +} diff --git a/src/test/java/net/nicolamurtas/android/emulator/util/AndroidVersionMapperTest.java b/src/test/java/net/nicolamurtas/android/emulator/util/AndroidVersionMapperTest.java new file mode 100644 index 0000000..6cfb38d --- /dev/null +++ b/src/test/java/net/nicolamurtas/android/emulator/util/AndroidVersionMapperTest.java @@ -0,0 +1,149 @@ +package net.nicolamurtas.android.emulator.util; + +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +/** + * Unit tests for AndroidVersionMapper utility class. + * + * @author Nicola Murtas + * @version 3.0.0 + */ +class AndroidVersionMapperTest { + + @Test + void testGetAndroidVersionName_ValidApiLevels() { + assertEquals("Android 16", AndroidVersionMapper.getAndroidVersionName("36")); + assertEquals("Android 15", AndroidVersionMapper.getAndroidVersionName("35")); + assertEquals("Android 14", AndroidVersionMapper.getAndroidVersionName("34")); + assertEquals("Android 13", AndroidVersionMapper.getAndroidVersionName("33")); + assertEquals("Android 12L", AndroidVersionMapper.getAndroidVersionName("32")); + assertEquals("Android 12", AndroidVersionMapper.getAndroidVersionName("31")); + assertEquals("Android 11", AndroidVersionMapper.getAndroidVersionName("30")); + assertEquals("Android 10", AndroidVersionMapper.getAndroidVersionName("29")); + assertEquals("Android 9", AndroidVersionMapper.getAndroidVersionName("28")); + assertEquals("Android 8.1", AndroidVersionMapper.getAndroidVersionName("27")); + assertEquals("Android 8.0", AndroidVersionMapper.getAndroidVersionName("26")); + assertEquals("Android 7.1", AndroidVersionMapper.getAndroidVersionName("25")); + assertEquals("Android 7.0", AndroidVersionMapper.getAndroidVersionName("24")); + assertEquals("Android 6.0", AndroidVersionMapper.getAndroidVersionName("23")); + assertEquals("Android 5.1", AndroidVersionMapper.getAndroidVersionName("22")); + assertEquals("Android 5.0", AndroidVersionMapper.getAndroidVersionName("21")); + } + + @Test + void testGetAndroidVersionName_UnknownApiLevel() { + assertEquals("Android API 99", AndroidVersionMapper.getAndroidVersionName("99")); + assertEquals("Android API 15", AndroidVersionMapper.getAndroidVersionName("15")); + assertEquals("Android API 1", AndroidVersionMapper.getAndroidVersionName("1")); + } + + @Test + void testGetAndroidVersionName_NullOrUnknown() { + assertEquals("Android (Unknown)", AndroidVersionMapper.getAndroidVersionName(null)); + assertEquals("Android (Unknown)", AndroidVersionMapper.getAndroidVersionName("Unknown")); + } + + @Test + void testGetAndroidVersionName_EmptyString() { + assertEquals("Android API ", AndroidVersionMapper.getAndroidVersionName("")); + } + + @Test + void testExtractApiLevelFromConfig_ValidConfig() { + String configContent = """ + avd.name=TestDevice + image.sysdir.1=system-images/android-35/google_apis/x86_64/ + hw.device.name=pixel_7 + """; + + assertEquals("35", AndroidVersionMapper.extractApiLevelFromConfig(configContent)); + } + + @Test + void testExtractApiLevelFromConfig_MultipleLines() { + String configContent = """ + avd.name=MyDevice + some.other.property=value + image.sysdir.1 = system-images/android-34/default/arm64-v8a/ + another.property=test + """; + + assertEquals("34", AndroidVersionMapper.extractApiLevelFromConfig(configContent)); + } + + @Test + void testExtractApiLevelFromConfig_DifferentApiLevels() { + String config30 = "image.sysdir.1=system-images/android-30/google_apis/x86_64/"; + assertEquals("30", AndroidVersionMapper.extractApiLevelFromConfig(config30)); + + String config33 = "image.sysdir.1=system-images/android-33/google_apis_playstore/x86_64/"; + assertEquals("33", AndroidVersionMapper.extractApiLevelFromConfig(config33)); + } + + @Test + void testExtractApiLevelFromConfig_NotFound() { + String configContent = """ + avd.name=TestDevice + hw.device.name=pixel_7 + some.property=value + """; + + assertEquals("Unknown", AndroidVersionMapper.extractApiLevelFromConfig(configContent)); + } + + @Test + void testExtractApiLevelFromConfig_NullContent() { + assertEquals("Unknown", AndroidVersionMapper.extractApiLevelFromConfig(null)); + } + + @Test + void testExtractApiLevelFromConfig_EmptyContent() { + assertEquals("Unknown", AndroidVersionMapper.extractApiLevelFromConfig("")); + } + + @Test + void testExtractApiLevelFromConfig_MalformedLine() { + String configContent = """ + image.sysdir.1 + image.sysdir.1= + image.sysdir.1=invalid + """; + + assertEquals("Unknown", AndroidVersionMapper.extractApiLevelFromConfig(configContent)); + } + + @Test + void testExtractApiLevelFromTarget_ValidFormats() { + assertEquals("35", AndroidVersionMapper.extractApiLevelFromTarget("Android 15.0 (API level 35)")); + assertEquals("34", AndroidVersionMapper.extractApiLevelFromTarget("Android 14 (API level 34)")); + assertEquals("30", AndroidVersionMapper.extractApiLevelFromTarget("Google APIs (API level 30)")); + } + + @Test + void testExtractApiLevelFromTarget_SimpleNumber() { + assertEquals("35", AndroidVersionMapper.extractApiLevelFromTarget("35")); + assertEquals("30", AndroidVersionMapper.extractApiLevelFromTarget("android 30 test")); + } + + @Test + void testExtractApiLevelFromTarget_NotFound() { + assertEquals("Unknown", AndroidVersionMapper.extractApiLevelFromTarget("No API level here")); + assertEquals("Unknown", AndroidVersionMapper.extractApiLevelFromTarget("Android Pie")); + } + + @Test + void testExtractApiLevelFromTarget_NullOrEmpty() { + assertEquals("Unknown", AndroidVersionMapper.extractApiLevelFromTarget(null)); + assertEquals("Unknown", AndroidVersionMapper.extractApiLevelFromTarget("")); + } + + @Test + void testExtractApiLevelFromTarget_EdgeCases() { + // API level at the end without closing parenthesis + assertEquals("Unknown", AndroidVersionMapper.extractApiLevelFromTarget("Android (API level")); + + // Multiple numbers + assertEquals("14", AndroidVersionMapper.extractApiLevelFromTarget("Android 14 version 2")); + } +} diff --git a/src/test/java/net/nicolamurtas/android/emulator/util/DeviceNameFormatterTest.java b/src/test/java/net/nicolamurtas/android/emulator/util/DeviceNameFormatterTest.java new file mode 100644 index 0000000..fa90138 --- /dev/null +++ b/src/test/java/net/nicolamurtas/android/emulator/util/DeviceNameFormatterTest.java @@ -0,0 +1,157 @@ +package net.nicolamurtas.android.emulator.util; + +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +/** + * Unit tests for DeviceNameFormatter utility class. + * + * @author Nicola Murtas + * @version 3.0.0 + */ +class DeviceNameFormatterTest { + + @Test + void testFormatDeviceName_WithUnderscore() { + assertEquals("Pixel 7", DeviceNameFormatter.formatDeviceName("pixel_7")); + assertEquals("Pixel 8", DeviceNameFormatter.formatDeviceName("pixel_8")); + assertEquals("Nexus 5x", DeviceNameFormatter.formatDeviceName("nexus_5x")); + } + + @Test + void testFormatDeviceName_MultipleUnderscores() { + assertEquals("My Custom Device", DeviceNameFormatter.formatDeviceName("my_custom_device")); + assertEquals("Galaxy S 23 Ultra", DeviceNameFormatter.formatDeviceName("galaxy_s_23_ultra")); + } + + @Test + void testFormatDeviceName_NoUnderscore() { + assertEquals("Pixel", DeviceNameFormatter.formatDeviceName("pixel")); + assertEquals("Nexus", DeviceNameFormatter.formatDeviceName("nexus")); + } + + @Test + void testFormatDeviceName_AlreadyCapitalized() { + assertEquals("Pixel 7", DeviceNameFormatter.formatDeviceName("Pixel_7")); + assertEquals("MY DEVICE", DeviceNameFormatter.formatDeviceName("MY_DEVICE")); + } + + @Test + void testFormatDeviceName_MixedCase() { + assertEquals("Google Pixel 7", DeviceNameFormatter.formatDeviceName("Google_Pixel_7")); + assertEquals("My Test Device", DeviceNameFormatter.formatDeviceName("my_Test_Device")); + } + + @Test + void testFormatDeviceName_NullInput() { + assertNull(DeviceNameFormatter.formatDeviceName(null)); + } + + @Test + void testFormatDeviceName_EmptyString() { + assertEquals("", DeviceNameFormatter.formatDeviceName("")); + } + + @Test + void testFormatDeviceName_WhitespaceOnly() { + assertEquals("", DeviceNameFormatter.formatDeviceName(" ")); + } + + @Test + void testFormatDeviceName_SingleCharacter() { + assertEquals("A", DeviceNameFormatter.formatDeviceName("a")); + assertEquals("X", DeviceNameFormatter.formatDeviceName("x")); + } + + @Test + void testFormatDeviceName_TrailingUnderscore() { + assertEquals("Pixel", DeviceNameFormatter.formatDeviceName("pixel_")); + assertEquals("Device", DeviceNameFormatter.formatDeviceName("device__")); + } + + @Test + void testFormatDeviceName_LeadingUnderscore() { + assertEquals("Pixel", DeviceNameFormatter.formatDeviceName("_pixel")); + assertEquals("Device", DeviceNameFormatter.formatDeviceName("__device")); + } + + @Test + void testIsValidAvdName_ValidNames() { + assertTrue(DeviceNameFormatter.isValidAvdName("MyDevice")); + assertTrue(DeviceNameFormatter.isValidAvdName("my_device")); + assertTrue(DeviceNameFormatter.isValidAvdName("my-device")); + assertTrue(DeviceNameFormatter.isValidAvdName("MyDevice123")); + assertTrue(DeviceNameFormatter.isValidAvdName("device_123")); + assertTrue(DeviceNameFormatter.isValidAvdName("a")); + assertTrue(DeviceNameFormatter.isValidAvdName("A1")); + assertTrue(DeviceNameFormatter.isValidAvdName("test-device_123")); + } + + @Test + void testIsValidAvdName_InvalidNames_WithSpaces() { + assertFalse(DeviceNameFormatter.isValidAvdName("My Device")); + assertFalse(DeviceNameFormatter.isValidAvdName("device name")); + assertFalse(DeviceNameFormatter.isValidAvdName(" device")); + assertFalse(DeviceNameFormatter.isValidAvdName("device ")); + assertFalse(DeviceNameFormatter.isValidAvdName("my device 123")); + } + + @Test + void testIsValidAvdName_InvalidNames_SpecialCharacters() { + assertFalse(DeviceNameFormatter.isValidAvdName("device!")); + assertFalse(DeviceNameFormatter.isValidAvdName("device@123")); + assertFalse(DeviceNameFormatter.isValidAvdName("device#name")); + assertFalse(DeviceNameFormatter.isValidAvdName("device$")); + assertFalse(DeviceNameFormatter.isValidAvdName("device%")); + assertFalse(DeviceNameFormatter.isValidAvdName("device&name")); + assertFalse(DeviceNameFormatter.isValidAvdName("device*")); + assertFalse(DeviceNameFormatter.isValidAvdName("device(name)")); + assertFalse(DeviceNameFormatter.isValidAvdName("device+name")); + assertFalse(DeviceNameFormatter.isValidAvdName("device=name")); + assertFalse(DeviceNameFormatter.isValidAvdName("device[name]")); + assertFalse(DeviceNameFormatter.isValidAvdName("device{name}")); + assertFalse(DeviceNameFormatter.isValidAvdName("device\\name")); + assertFalse(DeviceNameFormatter.isValidAvdName("device/name")); + assertFalse(DeviceNameFormatter.isValidAvdName("device:name")); + assertFalse(DeviceNameFormatter.isValidAvdName("device;name")); + assertFalse(DeviceNameFormatter.isValidAvdName("device\"name\"")); + assertFalse(DeviceNameFormatter.isValidAvdName("device'name'")); + assertFalse(DeviceNameFormatter.isValidAvdName("device")); + assertFalse(DeviceNameFormatter.isValidAvdName("device,name")); + assertFalse(DeviceNameFormatter.isValidAvdName("device.name")); + assertFalse(DeviceNameFormatter.isValidAvdName("device?name")); + } + + @Test + void testIsValidAvdName_NullOrEmpty() { + assertFalse(DeviceNameFormatter.isValidAvdName(null)); + assertFalse(DeviceNameFormatter.isValidAvdName("")); + } + + @Test + void testIsValidAvdName_OnlyValidCharacters() { + // Should only accept: letters, numbers, underscores, hyphens + assertTrue(DeviceNameFormatter.isValidAvdName("abcdefghijklmnopqrstuvwxyz")); + assertTrue(DeviceNameFormatter.isValidAvdName("ABCDEFGHIJKLMNOPQRSTUVWXYZ")); + assertTrue(DeviceNameFormatter.isValidAvdName("0123456789")); + assertTrue(DeviceNameFormatter.isValidAvdName("_-_-_")); + assertTrue(DeviceNameFormatter.isValidAvdName("Test_Device-123")); + } + + @Test + void testIsValidAvdName_EdgeCases() { + // Very long name (should still be valid as long as characters are valid) + String longName = "a".repeat(100); + assertTrue(DeviceNameFormatter.isValidAvdName(longName)); + + // Mix of all valid characters + assertTrue(DeviceNameFormatter.isValidAvdName("aA0_-")); + + // Starting with number (should be valid) + assertTrue(DeviceNameFormatter.isValidAvdName("123device")); + + // Starting with underscore or hyphen (should be valid) + assertTrue(DeviceNameFormatter.isValidAvdName("_device")); + assertTrue(DeviceNameFormatter.isValidAvdName("-device")); + } +} diff --git a/src/test/java/net/nicolamurtas/android/emulator/util/PlatformUtilsTest.java b/src/test/java/net/nicolamurtas/android/emulator/util/PlatformUtilsTest.java new file mode 100644 index 0000000..318c15c --- /dev/null +++ b/src/test/java/net/nicolamurtas/android/emulator/util/PlatformUtilsTest.java @@ -0,0 +1,264 @@ +package net.nicolamurtas.android.emulator.util; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Unit tests for PlatformUtils. + * + * @author Nicola Murtas + * @version 3.0.0 + */ +class PlatformUtilsTest { + + @Test + void testGetOperatingSystem() { + PlatformUtils.OperatingSystem os = PlatformUtils.getOperatingSystem(); + assertNotNull(os); + // Should be one of the known OSes + assertTrue(os == PlatformUtils.OperatingSystem.WINDOWS || + os == PlatformUtils.OperatingSystem.LINUX || + os == PlatformUtils.OperatingSystem.MACOS || + os == PlatformUtils.OperatingSystem.UNKNOWN); + } + + @Test + void testGetOperatingSystem_IsCached() { + PlatformUtils.OperatingSystem os1 = PlatformUtils.getOperatingSystem(); + PlatformUtils.OperatingSystem os2 = PlatformUtils.getOperatingSystem(); + assertSame(os1, os2, "Operating system should be cached"); + } + + @Test + void testIsWindows() { + boolean isWindows = PlatformUtils.isWindows(); + String osName = System.getProperty("os.name").toLowerCase(); + assertEquals(osName.contains("win"), isWindows); + } + + @Test + void testIsLinux() { + boolean isLinux = PlatformUtils.isLinux(); + String osName = System.getProperty("os.name").toLowerCase(); + assertEquals(osName.contains("nux") || osName.contains("nix"), isLinux); + } + + @Test + void testIsMacOS() { + boolean isMacOS = PlatformUtils.isMacOS(); + String osName = System.getProperty("os.name").toLowerCase(); + assertEquals(osName.contains("mac"), isMacOS); + } + + @Test + void testGetDefaultSdkPath() { + Path sdkPath = PlatformUtils.getDefaultSdkPath(); + assertNotNull(sdkPath); + + String userHome = System.getProperty("user.home"); + Path expected = Paths.get(userHome, "Android", "sdk"); + assertEquals(expected, sdkPath); + } + + @Test + void testGetExecutableExtension() { + String extension = PlatformUtils.getExecutableExtension(); + assertNotNull(extension); + + if (PlatformUtils.isWindows()) { + assertEquals(".bat", extension); + } else { + assertEquals("", extension); + } + } + + @Test + void testGetBinaryExtension() { + String extension = PlatformUtils.getBinaryExtension(); + assertNotNull(extension); + + if (PlatformUtils.isWindows()) { + assertEquals(".exe", extension); + } else { + assertEquals("", extension); + } + } + + @Test + void testMakeExecutable_NonExistentFile(@TempDir Path tempDir) { + Path nonExistent = tempDir.resolve("nonexistent.sh"); + + if (PlatformUtils.isWindows()) { + // Should be no-op on Windows + assertDoesNotThrow(() -> PlatformUtils.makeExecutable(nonExistent)); + } else { + // Should throw on Unix-like systems + assertThrows(IOException.class, () -> PlatformUtils.makeExecutable(nonExistent)); + } + } + + @Test + void testMakeExecutable_ExistingFile(@TempDir Path tempDir) throws IOException { + Path testFile = tempDir.resolve("test.sh"); + Files.writeString(testFile, "#!/bin/bash\necho test"); + + if (PlatformUtils.isWindows()) { + // Should be no-op on Windows + assertDoesNotThrow(() -> PlatformUtils.makeExecutable(testFile)); + } else { + // Should succeed on Unix-like systems + assertDoesNotThrow(() -> PlatformUtils.makeExecutable(testFile)); + assertTrue(Files.isExecutable(testFile)); + } + } + + @Test + void testMakeDirectoryExecutable_NonDirectory(@TempDir Path tempDir) throws IOException { + Path file = tempDir.resolve("file.txt"); + Files.writeString(file, "test"); + + if (PlatformUtils.isWindows()) { + // Should be no-op on Windows + assertDoesNotThrow(() -> PlatformUtils.makeDirectoryExecutable(file)); + } else { + // Should throw on Unix-like systems + assertThrows(IOException.class, () -> PlatformUtils.makeDirectoryExecutable(file)); + } + } + + @Test + void testMakeDirectoryExecutable_ValidDirectory(@TempDir Path tempDir) throws IOException { + // Create subdirectory with files + Path subdir = tempDir.resolve("scripts"); + Files.createDirectories(subdir); + + Path script1 = subdir.resolve("script1.sh"); + Path script2 = subdir.resolve("script2.sh"); + Files.writeString(script1, "#!/bin/bash\necho 1"); + Files.writeString(script2, "#!/bin/bash\necho 2"); + + assertDoesNotThrow(() -> PlatformUtils.makeDirectoryExecutable(subdir)); + + if (!PlatformUtils.isWindows()) { + assertTrue(Files.isExecutable(script1)); + assertTrue(Files.isExecutable(script2)); + } + } + + @Test + void testIsPathWritable_ExistingWritableDirectory(@TempDir Path tempDir) { + assertTrue(PlatformUtils.isPathWritable(tempDir)); + } + + @Test + void testIsPathWritable_NonExistentPath(@TempDir Path tempDir) { + Path nonExistent = tempDir.resolve("new-directory"); + assertTrue(PlatformUtils.isPathWritable(nonExistent)); + } + + @Test + void testIsPathWritable_ExistingFile(@TempDir Path tempDir) throws IOException { + Path file = tempDir.resolve("test.txt"); + Files.writeString(file, "test"); + assertTrue(PlatformUtils.isPathWritable(file)); + } + + @Test + void testGetSdkToolsDownloadUrl() { + String url = PlatformUtils.getSdkToolsDownloadUrl(); + assertNotNull(url); + assertTrue(url.startsWith("https://dl.google.com/android/repository/")); + assertTrue(url.endsWith(".zip")); + + // Verify it contains the correct OS identifier + if (PlatformUtils.isWindows()) { + assertTrue(url.contains("win")); + } else if (PlatformUtils.isMacOS()) { + assertTrue(url.contains("mac")); + } else if (PlatformUtils.isLinux()) { + assertTrue(url.contains("linux")); + } + } + + @Test + void testGetSdkToolsFileName() { + String filename = PlatformUtils.getSdkToolsFileName(); + assertNotNull(filename); + assertTrue(filename.startsWith("commandlinetools-")); + assertTrue(filename.endsWith(".zip")); + + // Verify it contains the correct OS identifier + if (PlatformUtils.isWindows()) { + assertEquals("commandlinetools-win.zip", filename); + } else if (PlatformUtils.isMacOS()) { + assertEquals("commandlinetools-mac.zip", filename); + } else if (PlatformUtils.isLinux()) { + assertEquals("commandlinetools-linux.zip", filename); + } + } + + @Test + void testOperatingSystemEnum() { + // Test that all enum values can be accessed + assertNotNull(PlatformUtils.OperatingSystem.WINDOWS); + assertNotNull(PlatformUtils.OperatingSystem.LINUX); + assertNotNull(PlatformUtils.OperatingSystem.MACOS); + assertNotNull(PlatformUtils.OperatingSystem.UNKNOWN); + + // Test valueOf + assertEquals(PlatformUtils.OperatingSystem.WINDOWS, + PlatformUtils.OperatingSystem.valueOf("WINDOWS")); + assertEquals(PlatformUtils.OperatingSystem.LINUX, + PlatformUtils.OperatingSystem.valueOf("LINUX")); + assertEquals(PlatformUtils.OperatingSystem.MACOS, + PlatformUtils.OperatingSystem.valueOf("MACOS")); + assertEquals(PlatformUtils.OperatingSystem.UNKNOWN, + PlatformUtils.OperatingSystem.valueOf("UNKNOWN")); + } + + @Test + void testOperatingSystemEnum_Values() { + PlatformUtils.OperatingSystem[] values = PlatformUtils.OperatingSystem.values(); + assertEquals(4, values.length); + } + + @Test + void testGetDefaultSdkPath_NotNull() { + Path sdkPath = PlatformUtils.getDefaultSdkPath(); + assertNotNull(sdkPath); + assertFalse(sdkPath.toString().isEmpty()); + } + + @Test + void testExtensions_Consistency() { + // Both extensions should be non-null + assertNotNull(PlatformUtils.getExecutableExtension()); + assertNotNull(PlatformUtils.getBinaryExtension()); + + // On the same OS, repeated calls should return same value + String ext1 = PlatformUtils.getExecutableExtension(); + String ext2 = PlatformUtils.getExecutableExtension(); + assertEquals(ext1, ext2); + } + + @Test + void testMakeDirectoryExecutable_EmptyDirectory(@TempDir Path tempDir) { + // Empty directory should not throw + assertDoesNotThrow(() -> PlatformUtils.makeDirectoryExecutable(tempDir)); + } + + @Test + void testIsPathWritable_SystemRoot() { + // Try to write to system root (should typically fail unless running as root/admin) + Path systemRoot = Paths.get("/"); + // This might succeed or fail depending on permissions, but shouldn't throw + assertDoesNotThrow(() -> PlatformUtils.isPathWritable(systemRoot)); + } +} diff --git a/src/test/java/net/nicolamurtas/android/emulator/util/ThemeUtilsTest.java b/src/test/java/net/nicolamurtas/android/emulator/util/ThemeUtilsTest.java new file mode 100644 index 0000000..3618bc0 --- /dev/null +++ b/src/test/java/net/nicolamurtas/android/emulator/util/ThemeUtilsTest.java @@ -0,0 +1,135 @@ +package net.nicolamurtas.android.emulator.util; + +import org.junit.jupiter.api.Test; +import java.awt.Color; +import static org.junit.jupiter.api.Assertions.*; + +/** + * Unit tests for ThemeUtils utility class. + * + * @author Nicola Murtas + * @version 3.0.0 + */ +class ThemeUtilsTest { + + @Test + void testIsDarkTheme_DoesNotThrow() { + // This test just verifies the method doesn't throw exceptions + // Actual result depends on UIManager state + assertDoesNotThrow(() -> ThemeUtils.isDarkTheme()); + } + + @Test + void testGetHeaderBackgroundColor_ReturnsNonNull() { + // Verify method returns a non-null color + Color headerColor = ThemeUtils.getHeaderBackgroundColor(); + assertNotNull(headerColor); + } + + @Test + void testGetHeaderBackgroundColor_DoesNotThrow() { + // Verify method doesn't throw exceptions + assertDoesNotThrow(() -> ThemeUtils.getHeaderBackgroundColor()); + } + + @Test + void testColors_SuccessColor() { + Color success = ThemeUtils.Colors.SUCCESS; + assertNotNull(success); + assertEquals(76, success.getRed()); + assertEquals(175, success.getGreen()); + assertEquals(80, success.getBlue()); + assertEquals(255, success.getAlpha()); + } + + @Test + void testColors_WarningColor() { + Color warning = ThemeUtils.Colors.WARNING; + assertNotNull(warning); + assertEquals(255, warning.getRed()); + assertEquals(152, warning.getGreen()); + assertEquals(0, warning.getBlue()); + assertEquals(255, warning.getAlpha()); + } + + @Test + void testColors_ErrorColor() { + Color error = ThemeUtils.Colors.ERROR; + assertNotNull(error); + assertEquals(244, error.getRed()); + assertEquals(67, error.getGreen()); + assertEquals(54, error.getBlue()); + assertEquals(255, error.getAlpha()); + } + + @Test + void testColors_InfoColor() { + Color info = ThemeUtils.Colors.INFO; + assertNotNull(info); + assertEquals(33, info.getRed()); + assertEquals(150, info.getGreen()); + assertEquals(243, info.getBlue()); + assertEquals(255, info.getAlpha()); + } + + @Test + void testColors_AllColorsAreOpaque() { + // All standard colors should be fully opaque (alpha = 255) + assertEquals(255, ThemeUtils.Colors.SUCCESS.getAlpha()); + assertEquals(255, ThemeUtils.Colors.WARNING.getAlpha()); + assertEquals(255, ThemeUtils.Colors.ERROR.getAlpha()); + assertEquals(255, ThemeUtils.Colors.INFO.getAlpha()); + } + + @Test + void testColors_MaterialDesignCompliance() { + // These colors follow Material Design color palette + // Green 500 + assertEquals(new Color(76, 175, 80), ThemeUtils.Colors.SUCCESS); + + // Orange 500 + assertEquals(new Color(255, 152, 0), ThemeUtils.Colors.WARNING); + + // Red 500 + assertEquals(new Color(244, 67, 54), ThemeUtils.Colors.ERROR); + + // Blue 500 + assertEquals(new Color(33, 150, 243), ThemeUtils.Colors.INFO); + } + + @Test + void testUtilityClassCannotBeInstantiated() { + // Verify constructor throws exception + try { + var constructor = ThemeUtils.class.getDeclaredConstructor(); + constructor.setAccessible(true); + assertThrows(java.lang.reflect.InvocationTargetException.class, constructor::newInstance); + } catch (NoSuchMethodException e) { + fail("ThemeUtils should have a private constructor"); + } + } + + @Test + void testColorsClassCannotBeInstantiated() { + // Verify Colors inner class constructor throws exception + try { + var constructor = ThemeUtils.Colors.class.getDeclaredConstructor(); + constructor.setAccessible(true); + assertThrows(java.lang.reflect.InvocationTargetException.class, constructor::newInstance); + } catch (NoSuchMethodException e) { + fail("ThemeUtils.Colors should have a private constructor"); + } + } + + @Test + void testHeaderBackgroundColor_ConsistentBetweenCalls() { + // Multiple calls should return consistent results + Color color1 = ThemeUtils.getHeaderBackgroundColor(); + Color color2 = ThemeUtils.getHeaderBackgroundColor(); + + // Colors should be equal (same RGB values) + assertEquals(color1.getRed(), color2.getRed()); + assertEquals(color1.getGreen(), color2.getGreen()); + assertEquals(color1.getBlue(), color2.getBlue()); + } +} diff --git a/src/test/java/net/nicolamurtas/android/emulator/view/AvdGridPanelTest.java b/src/test/java/net/nicolamurtas/android/emulator/view/AvdGridPanelTest.java new file mode 100644 index 0000000..087f93c --- /dev/null +++ b/src/test/java/net/nicolamurtas/android/emulator/view/AvdGridPanelTest.java @@ -0,0 +1,187 @@ +package net.nicolamurtas.android.emulator.view; + +import net.nicolamurtas.android.emulator.service.EmulatorService; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; + +import java.awt.GraphicsEnvironment; +import java.util.Collections; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Unit tests for AvdGridPanel. + * + * @author Nicola Murtas + * @version 3.0.0 + */ +class AvdGridPanelTest { + + @Test + @EnabledIfEnvironmentVariable(named = "DISPLAY", matches = ".*") + void testAvdGridPanel_CanBeInstantiated() { + assertDoesNotThrow(() -> { + AvdGridPanel panel = new AvdGridPanel(); + assertNotNull(panel); + }); + } + + @Test + void testAvdGridPanel_HeadlessEnvironment() { + if (GraphicsEnvironment.isHeadless()) { + assertThrows(java.awt.HeadlessException.class, () -> { + new AvdGridPanel(); + }); + } else { + assertDoesNotThrow(() -> { + new AvdGridPanel(); + }); + } + } + + @Test + @EnabledIfEnvironmentVariable(named = "DISPLAY", matches = ".*") + void testAvdGridPanel_UpdateAvdList_EmptyList() { + AvdGridPanel panel = new AvdGridPanel(); + + assertDoesNotThrow(() -> { + panel.updateAvdList(Collections.emptyList()); + }); + } + + @Test + @EnabledIfEnvironmentVariable(named = "DISPLAY", matches = ".*") + void testAvdGridPanel_UpdateAvdList_Null() { + AvdGridPanel panel = new AvdGridPanel(); + + assertDoesNotThrow(() -> { + panel.updateAvdList(null); + }); + } + + @Test + @EnabledIfEnvironmentVariable(named = "DISPLAY", matches = ".*") + void testAvdGridPanel_SetOnCreateAvd() { + AvdGridPanel panel = new AvdGridPanel(); + + boolean[] called = {false}; + panel.setOnCreateAvd(() -> called[0] = true); + + assertNotNull(panel); + } + + @Test + @EnabledIfEnvironmentVariable(named = "DISPLAY", matches = ".*") + void testAvdGridPanel_SetOnRefresh() { + AvdGridPanel panel = new AvdGridPanel(); + + boolean[] called = {false}; + panel.setOnRefresh(() -> called[0] = true); + + assertNotNull(panel); + } + + @Test + @EnabledIfEnvironmentVariable(named = "DISPLAY", matches = ".*") + void testAvdGridPanel_SetOnStartEmulator() { + AvdGridPanel panel = new AvdGridPanel(); + + String[] capturedName = {null}; + panel.setOnStartEmulator(name -> capturedName[0] = name); + + assertNotNull(panel); + } + + @Test + @EnabledIfEnvironmentVariable(named = "DISPLAY", matches = ".*") + void testAvdGridPanel_SetOnStopEmulator() { + AvdGridPanel panel = new AvdGridPanel(); + + String[] capturedName = {null}; + panel.setOnStopEmulator(name -> capturedName[0] = name); + + assertNotNull(panel); + } + + @Test + @EnabledIfEnvironmentVariable(named = "DISPLAY", matches = ".*") + void testAvdGridPanel_SetOnRenameAvd() { + AvdGridPanel panel = new AvdGridPanel(); + + String[] capturedName = {null}; + panel.setOnRenameAvd(name -> capturedName[0] = name); + + assertNotNull(panel); + } + + @Test + @EnabledIfEnvironmentVariable(named = "DISPLAY", matches = ".*") + void testAvdGridPanel_SetOnDeleteAvd() { + AvdGridPanel panel = new AvdGridPanel(); + + String[] capturedName = {null}; + panel.setOnDeleteAvd(name -> capturedName[0] = name); + + assertNotNull(panel); + } + + @Test + @EnabledIfEnvironmentVariable(named = "DISPLAY", matches = ".*") + void testAvdGridPanel_SetEmulatorService() { + AvdGridPanel panel = new AvdGridPanel(); + + assertDoesNotThrow(() -> { + panel.setEmulatorService(null); + }); + } + + @Test + @EnabledIfEnvironmentVariable(named = "DISPLAY", matches = ".*") + void testAvdGridPanel_GetAllAvds_Empty() { + AvdGridPanel panel = new AvdGridPanel(); + + List avds = panel.getAllAvds(); + assertNotNull(avds); + assertTrue(avds.isEmpty()); + } + + @Test + @EnabledIfEnvironmentVariable(named = "DISPLAY", matches = ".*") + void testAvdGridPanel_SetAllCallbacks_Null() { + AvdGridPanel panel = new AvdGridPanel(); + + assertDoesNotThrow(() -> { + panel.setOnCreateAvd(null); + panel.setOnRefresh(null); + panel.setOnStartEmulator(null); + panel.setOnStopEmulator(null); + panel.setOnRenameAvd(null); + panel.setOnDeleteAvd(null); + }); + } + + @Test + @EnabledIfEnvironmentVariable(named = "DISPLAY", matches = ".*") + void testAvdGridPanel_UpdateAvdList_Multiple() { + AvdGridPanel panel = new AvdGridPanel(); + + assertDoesNotThrow(() -> { + panel.updateAvdList(Collections.emptyList()); + panel.updateAvdList(Collections.emptyList()); + panel.updateAvdList(Collections.emptyList()); + }); + } + + @Test + @EnabledIfEnvironmentVariable(named = "DISPLAY", matches = ".*") + void testAvdGridPanel_MultipleCallbackSettings() { + AvdGridPanel panel = new AvdGridPanel(); + + assertDoesNotThrow(() -> { + panel.setOnCreateAvd(() -> {}); + panel.setOnCreateAvd(() -> {}); + panel.setOnCreateAvd(null); + }); + } +} diff --git a/src/test/java/net/nicolamurtas/android/emulator/view/LogPanelTest.java b/src/test/java/net/nicolamurtas/android/emulator/view/LogPanelTest.java new file mode 100644 index 0000000..621359f --- /dev/null +++ b/src/test/java/net/nicolamurtas/android/emulator/view/LogPanelTest.java @@ -0,0 +1,126 @@ +package net.nicolamurtas.android.emulator.view; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; + +import java.awt.GraphicsEnvironment; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Unit tests for LogPanel. + * + * @author Nicola Murtas + * @version 3.0.0 + */ +class LogPanelTest { + + @Test + @EnabledIfEnvironmentVariable(named = "DISPLAY", matches = ".*") + void testLogPanel_CanBeInstantiated() { + assertDoesNotThrow(() -> { + LogPanel logPanel = new LogPanel(); + assertNotNull(logPanel); + }); + } + + @Test + void testLogPanel_HeadlessEnvironment() { + if (GraphicsEnvironment.isHeadless()) { + assertThrows(java.awt.HeadlessException.class, () -> { + new LogPanel(); + }); + } else { + assertDoesNotThrow(() -> { + new LogPanel(); + }); + } + } + + @Test + @EnabledIfEnvironmentVariable(named = "DISPLAY", matches = ".*") + void testLogPanel_AddLog() { + LogPanel logPanel = new LogPanel(); + assertDoesNotThrow(() -> { + logPanel.addLog("Test message"); + logPanel.addLog("Another message"); + }); + } + + @Test + @EnabledIfEnvironmentVariable(named = "DISPLAY", matches = ".*") + void testLogPanel_AddLog_NullMessage() { + LogPanel logPanel = new LogPanel(); + assertDoesNotThrow(() -> logPanel.addLog(null)); + } + + @Test + @EnabledIfEnvironmentVariable(named = "DISPLAY", matches = ".*") + void testLogPanel_AddLog_EmptyMessage() { + LogPanel logPanel = new LogPanel(); + assertDoesNotThrow(() -> logPanel.addLog("")); + } + + @Test + @EnabledIfEnvironmentVariable(named = "DISPLAY", matches = ".*") + void testLogPanel_ClearLogs() { + LogPanel logPanel = new LogPanel(); + logPanel.addLog("Message 1"); + logPanel.addLog("Message 2"); + + assertDoesNotThrow(() -> logPanel.clearLogs()); + } + + @Test + @EnabledIfEnvironmentVariable(named = "DISPLAY", matches = ".*") + void testLogPanel_SetOnClear() { + LogPanel logPanel = new LogPanel(); + + boolean[] called = {false}; + logPanel.setOnClear(() -> called[0] = true); + + // Trigger clear manually + assertDoesNotThrow(() -> logPanel.clearLogs()); + } + + @Test + @EnabledIfEnvironmentVariable(named = "DISPLAY", matches = ".*") + void testLogPanel_MultipleMessages() { + LogPanel logPanel = new LogPanel(); + + assertDoesNotThrow(() -> { + for (int i = 0; i < 100; i++) { + logPanel.addLog("Message " + i); + } + }); + } + + @Test + @EnabledIfEnvironmentVariable(named = "DISPLAY", matches = ".*") + void testLogPanel_LongMessage() { + LogPanel logPanel = new LogPanel(); + + String longMessage = "A".repeat(1000); + assertDoesNotThrow(() -> logPanel.addLog(longMessage)); + } + + @Test + @EnabledIfEnvironmentVariable(named = "DISPLAY", matches = ".*") + void testLogPanel_ClearEmpty() { + LogPanel logPanel = new LogPanel(); + // Clearing empty logs should not throw + assertDoesNotThrow(() -> logPanel.clearLogs()); + } + + @Test + @EnabledIfEnvironmentVariable(named = "DISPLAY", matches = ".*") + void testLogPanel_SpecialCharacters() { + LogPanel logPanel = new LogPanel(); + + assertDoesNotThrow(() -> { + logPanel.addLog("Message with Γ©mojis πŸš€"); + logPanel.addLog("Message with tabs\t\tand\nnewlines"); + logPanel.addLog("Message with special chars: @#$%^&*()"); + }); + } +} diff --git a/src/test/java/net/nicolamurtas/android/emulator/view/MainViewTest.java b/src/test/java/net/nicolamurtas/android/emulator/view/MainViewTest.java new file mode 100644 index 0000000..daa9f65 --- /dev/null +++ b/src/test/java/net/nicolamurtas/android/emulator/view/MainViewTest.java @@ -0,0 +1,150 @@ +package net.nicolamurtas.android.emulator.view; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; + +import java.awt.GraphicsEnvironment; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Unit tests for MainView. + * + * Note: Most tests are conditional on having a display available, + * as MainView is a Swing component that requires a graphics environment. + * + * @author Nicola Murtas + * @version 3.0.0 + */ +class MainViewTest { + + @Test + @EnabledIfEnvironmentVariable(named = "DISPLAY", matches = ".*") + void testMainView_CanBeInstantiatedWhenSdkConfigured() { + assertDoesNotThrow(() -> { + MainView view = new MainView(true); + assertNotNull(view); + assertNotNull(view.getSdkConfigPanel()); + assertNotNull(view.getAvdGridPanel()); + assertNotNull(view.getLogPanel()); + }); + } + + @Test + @EnabledIfEnvironmentVariable(named = "DISPLAY", matches = ".*") + void testMainView_CanBeInstantiatedWhenSdkNotConfigured() { + assertDoesNotThrow(() -> { + MainView view = new MainView(false); + assertNotNull(view); + assertNotNull(view.getSdkConfigPanel()); + assertNotNull(view.getAvdGridPanel()); + assertNotNull(view.getLogPanel()); + }); + } + + @Test + @EnabledIfEnvironmentVariable(named = "DISPLAY", matches = ".*") + void testMainView_ComponentsNotNull() { + MainView view = new MainView(true); + + assertNotNull(view.getSdkConfigPanel(), "SDK config panel should not be null"); + assertNotNull(view.getAvdGridPanel(), "AVD grid panel should not be null"); + assertNotNull(view.getLogPanel(), "Log panel should not be null"); + } + + @Test + @EnabledIfEnvironmentVariable(named = "DISPLAY", matches = ".*") + void testMainView_LogFunctionality() { + MainView view = new MainView(true); + + assertDoesNotThrow(() -> { + view.log("Test message 1"); + view.log("Test message 2"); + view.log("Test message 3"); + }); + } + + @Test + @EnabledIfEnvironmentVariable(named = "DISPLAY", matches = ".*") + void testMainView_ProgressBarFunctionality() { + MainView view = new MainView(true); + + assertDoesNotThrow(() -> { + view.showProgress(true); + view.updateProgress(50, "Test progress"); + view.showProgress(false); + }); + } + + @Test + void testMainView_HeadlessEnvironmentHandling() { + if (GraphicsEnvironment.isHeadless()) { + assertThrows(java.awt.HeadlessException.class, () -> { + new MainView(true); + }); + } else { + // If not headless, should work fine + assertDoesNotThrow(() -> { + new MainView(true); + }); + } + } + + @Test + @EnabledIfEnvironmentVariable(named = "DISPLAY", matches = ".*") + void testMainView_LogEmptyMessage() { + MainView view = new MainView(true); + assertDoesNotThrow(() -> view.log("")); + } + + @Test + @EnabledIfEnvironmentVariable(named = "DISPLAY", matches = ".*") + void testMainView_LogNullMessage() { + MainView view = new MainView(true); + assertDoesNotThrow(() -> view.log(null)); + } + + @Test + @EnabledIfEnvironmentVariable(named = "DISPLAY", matches = ".*") + void testMainView_ProgressWithZeroValue() { + MainView view = new MainView(true); + assertDoesNotThrow(() -> { + view.showProgress(true); + view.updateProgress(0, "Starting"); + }); + } + + @Test + @EnabledIfEnvironmentVariable(named = "DISPLAY", matches = ".*") + void testMainView_ProgressWithHundredValue() { + MainView view = new MainView(true); + assertDoesNotThrow(() -> { + view.showProgress(true); + view.updateProgress(100, "Complete"); + }); + } + + @Test + @EnabledIfEnvironmentVariable(named = "DISPLAY", matches = ".*") + void testMainView_MultipleLogCalls() { + MainView view = new MainView(true); + assertDoesNotThrow(() -> { + for (int i = 0; i < 100; i++) { + view.log("Message " + i); + } + }); + } + + @Test + @EnabledIfEnvironmentVariable(named = "DISPLAY", matches = ".*") + void testMainView_ProgressUpdatesSequentially() { + MainView view = new MainView(true); + assertDoesNotThrow(() -> { + view.showProgress(true); + for (int i = 0; i <= 100; i += 10) { + view.updateProgress(i, "Progress: " + i + "%"); + } + view.showProgress(false); + }); + } +} diff --git a/src/test/java/net/nicolamurtas/android/emulator/view/SdkConfigPanelTest.java b/src/test/java/net/nicolamurtas/android/emulator/view/SdkConfigPanelTest.java new file mode 100644 index 0000000..ebe7bf6 --- /dev/null +++ b/src/test/java/net/nicolamurtas/android/emulator/view/SdkConfigPanelTest.java @@ -0,0 +1,181 @@ +package net.nicolamurtas.android.emulator.view; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; + +import java.awt.GraphicsEnvironment; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Unit tests for SdkConfigPanel. + * + * @author Nicola Murtas + * @version 3.0.0 + */ +class SdkConfigPanelTest { + + @Test + @EnabledIfEnvironmentVariable(named = "DISPLAY", matches = ".*") + void testSdkConfigPanel_CanBeInstantiatedConfigured() { + assertDoesNotThrow(() -> { + SdkConfigPanel panel = new SdkConfigPanel(true); + assertNotNull(panel); + }); + } + + @Test + @EnabledIfEnvironmentVariable(named = "DISPLAY", matches = ".*") + void testSdkConfigPanel_CanBeInstantiatedNotConfigured() { + assertDoesNotThrow(() -> { + SdkConfigPanel panel = new SdkConfigPanel(false); + assertNotNull(panel); + }); + } + + @Test + void testSdkConfigPanel_HeadlessEnvironment() { + if (GraphicsEnvironment.isHeadless()) { + assertThrows(java.awt.HeadlessException.class, () -> { + new SdkConfigPanel(true); + }); + } else { + assertDoesNotThrow(() -> { + new SdkConfigPanel(true); + }); + } + } + + @Test + @EnabledIfEnvironmentVariable(named = "DISPLAY", matches = ".*") + void testSdkConfigPanel_SetSdkPath() { + SdkConfigPanel panel = new SdkConfigPanel(false); + + assertDoesNotThrow(() -> { + panel.setSdkPath("/home/user/Android/sdk"); + panel.setSdkPath("C:\\Android\\sdk"); + panel.setSdkPath(""); + }); + } + + @Test + @EnabledIfEnvironmentVariable(named = "DISPLAY", matches = ".*") + void testSdkConfigPanel_GetSdkPath() { + SdkConfigPanel panel = new SdkConfigPanel(false); + panel.setSdkPath("/test/path"); + + String path = panel.getSdkPath(); + assertNotNull(path); + assertEquals("/test/path", path); + } + + @Test + @EnabledIfEnvironmentVariable(named = "DISPLAY", matches = ".*") + void testSdkConfigPanel_SetConfigured() { + SdkConfigPanel panel = new SdkConfigPanel(false); + + assertDoesNotThrow(() -> { + panel.setConfigured(true); + panel.setConfigured(false); + }); + } + + @Test + @EnabledIfEnvironmentVariable(named = "DISPLAY", matches = ".*") + void testSdkConfigPanel_SetOnBrowse() { + SdkConfigPanel panel = new SdkConfigPanel(false); + + boolean[] called = {false}; + panel.setOnBrowse(() -> called[0] = true); + + // We can't easily trigger the button click, but we can verify the setter works + assertNotNull(panel); + } + + @Test + @EnabledIfEnvironmentVariable(named = "DISPLAY", matches = ".*") + void testSdkConfigPanel_SetOnDownload() { + SdkConfigPanel panel = new SdkConfigPanel(false); + + boolean[] called = {false}; + panel.setOnDownload(() -> called[0] = true); + + assertNotNull(panel); + } + + @Test + @EnabledIfEnvironmentVariable(named = "DISPLAY", matches = ".*") + void testSdkConfigPanel_SetOnVerify() { + SdkConfigPanel panel = new SdkConfigPanel(false); + + boolean[] called = {false}; + panel.setOnVerify(() -> called[0] = true); + + assertNotNull(panel); + } + + @Test + @EnabledIfEnvironmentVariable(named = "DISPLAY", matches = ".*") + void testSdkConfigPanel_SetSdkPath_Null() { + SdkConfigPanel panel = new SdkConfigPanel(false); + + assertDoesNotThrow(() -> panel.setSdkPath(null)); + } + + @Test + @EnabledIfEnvironmentVariable(named = "DISPLAY", matches = ".*") + void testSdkConfigPanel_GetSdkPath_Empty() { + SdkConfigPanel panel = new SdkConfigPanel(false); + panel.setSdkPath(""); + + String path = panel.getSdkPath(); + assertNotNull(path); + assertTrue(path.isEmpty() || path.isBlank()); + } + + @Test + @EnabledIfEnvironmentVariable(named = "DISPLAY", matches = ".*") + void testSdkConfigPanel_MultipleStateChanges() { + SdkConfigPanel panel = new SdkConfigPanel(false); + + assertDoesNotThrow(() -> { + panel.setConfigured(true); + panel.setSdkPath("/path1"); + panel.setConfigured(false); + panel.setSdkPath("/path2"); + panel.setConfigured(true); + }); + } + + @Test + @EnabledIfEnvironmentVariable(named = "DISPLAY", matches = ".*") + void testSdkConfigPanel_PathWithSpaces() { + SdkConfigPanel panel = new SdkConfigPanel(false); + String pathWithSpaces = "/home/user/My Documents/Android SDK"; + + panel.setSdkPath(pathWithSpaces); + assertEquals(pathWithSpaces, panel.getSdkPath()); + } + + @Test + @EnabledIfEnvironmentVariable(named = "DISPLAY", matches = ".*") + void testSdkConfigPanel_PathWithSpecialChars() { + SdkConfigPanel panel = new SdkConfigPanel(false); + String specialPath = "/home/user/sdk-test_123"; + + panel.setSdkPath(specialPath); + assertEquals(specialPath, panel.getSdkPath()); + } + + @Test + @EnabledIfEnvironmentVariable(named = "DISPLAY", matches = ".*") + void testSdkConfigPanel_SetCallbacks_Null() { + SdkConfigPanel panel = new SdkConfigPanel(false); + + assertDoesNotThrow(() -> { + panel.setOnBrowse(null); + panel.setOnDownload(null); + panel.setOnVerify(null); + }); + } +}