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
[](https://github.com/NmurtasDev/AndroidEmulatorManager/actions/workflows/maven.yml)
+[](https://github.com/NmurtasDev/AndroidEmulatorManager/actions/workflows/code-quality.yml)
+[](https://codecov.io/gh/NmurtasDev/AndroidEmulatorManager)
[](https://github.com/NmurtasDev/AndroidEmulatorManager/actions/workflows/codeql.yml)
[](https://opensource.org/licenses/MIT)
[](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);
+ });
+ }
+}