diff --git a/.gitignore b/.gitignore index 6ac465db..5a54815d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ target/ /.idea/ +.env \ No newline at end of file diff --git a/mvnw b/mvnw old mode 100644 new mode 100755 diff --git a/pom.xml b/pom.xml index c40f667e..507a44b4 100644 --- a/pom.xml +++ b/pom.xml @@ -45,6 +45,22 @@ javafx-fxml ${javafx.version} + + io.github.cdimascio + dotenv-java + 3.2.0 + + + tools.jackson.core + jackson-databind + 3.0.1 + + + org.wiremock + wiremock + 4.0.0-beta.15 + test + diff --git a/src/main/java/com/example/HelloController.java b/src/main/java/com/example/HelloController.java index fdd160a0..d5d99d70 100644 --- a/src/main/java/com/example/HelloController.java +++ b/src/main/java/com/example/HelloController.java @@ -1,22 +1,99 @@ package com.example; +import javafx.event.ActionEvent; import javafx.fxml.FXML; import javafx.scene.control.Label; +import javafx.scene.control.ListView; +import javafx.scene.control.TextField; + +import java.io.IOException; /** * Controller layer: mediates between the view (FXML) and the model. + * Handles user interaction, initializes UI bindings, and forwards actions + * (sending messages, switching topics) to the model. */ public class HelloController { - private final HelloModel model = new HelloModel(); + /** The application model injected by the framework or calling code. */ + private final HelloModel model; + + /** + * Default constructor used by the JavaFX runtime. + * Creates a model with a production {@link NtfyConnectionImpl}. + */ + public HelloController() { + this(new HelloModel(new NtfyConnectionImpl())); + } + + /** + * Constructor primarily intended for testing or dependency injection. + * + * @param model the model instance this controller should use + */ + public HelloController(HelloModel model) { + this.model = model; + } + + @FXML + public ListView messageView; @FXML private Label messageLabel; + @FXML + private Label statusLabel; + + @FXML + private TextField inputField; + + @FXML + private TextField topicField; + + /** + * Called automatically by JavaFX after FXML fields are injected. + * Sets up UI bindings, listens for connection status changes, + * and triggers the initial topic connection. + */ @FXML private void initialize() { if (messageLabel != null) { messageLabel.setText(model.getGreeting()); } + + // Bind input fields to model state + inputField.textProperty().bindBidirectional(model.messageToSendProperty()); + topicField.textProperty().bindBidirectional(model.topicProperty()); + messageView.setItems(model.getMessages()); + + // Update connection status indicator + model.connectedProperty().addListener((obs, wasConnected, isConnected) -> { + statusLabel.setText(isConnected ? "🟢 Connected" : "🔴 Disconnected"); + }); + + model.connectToTopic(); + } + + /** + * Handles clicking the "Send" button. + * Sends the message via the model and clears the input field. + */ + public void sendMessage(ActionEvent actionEvent) { + if (!inputField.getText().trim().isEmpty()) { + try { + model.sendMessage(); + inputField.clear(); + } catch (IOException e) { + System.err.println("Failed to send message: " + e.getMessage()); + } + } + } + + /** + * Handles clicking the "Connect" button. + * Reconnects the model to the current topic. + */ + public void connectToTopic(ActionEvent actionEvent) { + model.connectToTopic(); } -} +} \ No newline at end of file diff --git a/src/main/java/com/example/HelloFX.java b/src/main/java/com/example/HelloFX.java index 96bdc5ca..4b7ea8af 100644 --- a/src/main/java/com/example/HelloFX.java +++ b/src/main/java/com/example/HelloFX.java @@ -6,20 +6,29 @@ import javafx.scene.Scene; import javafx.stage.Stage; +/** + * JavaFX application entry point. + * Loads the FXML view and displays the main window. + */ public class HelloFX extends Application { + /** + * Initializes and shows the primary JavaFX stage. + */ @Override public void start(Stage stage) throws Exception { FXMLLoader fxmlLoader = new FXMLLoader(HelloFX.class.getResource("hello-view.fxml")); Parent root = fxmlLoader.load(); - Scene scene = new Scene(root, 640, 480); - stage.setTitle("Hello MVC"); + Scene scene = new Scene(root, 740, 480); + stage.setTitle("JavaFX Chat App \uD83D\uDCAC"); stage.setScene(scene); stage.show(); } + /** + * Launches the JavaFX application. + */ public static void main(String[] args) { launch(); } - } \ No newline at end of file diff --git a/src/main/java/com/example/HelloModel.java b/src/main/java/com/example/HelloModel.java index 385cfd10..87482faf 100644 --- a/src/main/java/com/example/HelloModel.java +++ b/src/main/java/com/example/HelloModel.java @@ -1,9 +1,87 @@ package com.example; +import javafx.application.Platform; +import javafx.beans.property.ReadOnlyBooleanProperty; +import javafx.beans.property.ReadOnlyBooleanWrapper; +import javafx.beans.property.SimpleStringProperty; +import javafx.beans.property.StringProperty; +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; + +import java.io.IOException; + /** * Model layer: encapsulates application data and business logic. + * Manages the active topic subscription, incoming messages, and + * message sending through an {@link NtfyConnection}. */ public class HelloModel { + + /** Underlying connection for sending and receiving messages. */ + private final NtfyConnection connection; + /** Observable list of received messages for UI binding. */ + private final ObservableList messages = FXCollections.observableArrayList(); + /** Text the user intends to send. */ + private final StringProperty messageToSend = new SimpleStringProperty(); + /** Currently selected topic. */ + private final StringProperty topic = new SimpleStringProperty("mytopic"); + /** Handle for the active subscription, if any. */ + private NtfyConnection.Subscription currentSubscription; + /** Indicates whether the model is currently connected to a topic. */ + private final ReadOnlyBooleanWrapper connected = new ReadOnlyBooleanWrapper(false); + + /** + * Creates a new model using the provided {@link NtfyConnection}. + * + * @param connection the message connection backend + */ + public HelloModel(NtfyConnection connection) { + this.connection = connection; + } + + /** @return observable list of received messages */ + public ObservableList getMessages() { + return messages; + } + + public StringProperty messageToSendProperty() { + return messageToSend; + } + + public String getMessageToSend() { + return messageToSend.get(); + } + + public void setMessageToSend(String message) { + messageToSend.set(message); + } + + public String getTopic() { + return topic.get(); + } + + public StringProperty topicProperty() { + return topic; + } + + public void setTopic(String topic) { + this.topic.set(topic); + } + + /** + * Read-only property indicating whether a subscription is active. + */ + public ReadOnlyBooleanProperty connectedProperty() { + return connected.getReadOnlyProperty(); + } + + /** + * @return true if a subscription is active and open + */ + public boolean isConnected() { + return connected.get(); + } + /** * Returns a greeting based on the current Java and JavaFX versions. */ @@ -12,4 +90,73 @@ public String getGreeting() { String javafxVersion = System.getProperty("javafx.version"); return "Hello, JavaFX " + javafxVersion + ", running on Java " + javaVersion + "."; } -} + + /** + * Sends the value of {@link #messageToSend} to the current topic. + * + * @throws IOException if sending through the connection fails + */ + public void sendMessage() throws IOException { + connection.send(topic.get(), messageToSend.get()); + messageToSend.set(""); + } + + /** + * Connects to the current topic by creating a new subscription. + * Any previous subscription is closed first. + * Old messages are preserved if subscription creation fails. + * Incoming messages are added to {@link #messages} on the JavaFX thread. + */ + public void connectToTopic() { + disconnect(); + + // Make a backup of current messages in case subscription fails + var oldMessages = FXCollections.observableArrayList(messages); + // Clear messages for the new topic + messages.clear(); + + try { + // Start receiving new messages asynchronously + currentSubscription = connection.receive(topic.get(), + m -> runOnFx(() -> messages.add(m))); + // Mark as connected + connected.set(true); + } catch (Exception e) { + // Restore old messages if connection failed + messages.setAll(oldMessages); + connected.set(false); + System.err.println("Failed to connect to topic: " + e.getMessage()); + } + } + + + /** + * Stops the active subscription, if one exists, and updates connection state. + */ + public void disconnect() { + if (currentSubscription != null) { + try { + if (currentSubscription.isOpen()) { + currentSubscription.close(); + } + } catch (IOException e) { + System.err.println("Error closing subscription: " + e.getMessage()); + } + currentSubscription = null; + connected.set(false); + } + } + + /** + * Ensures that the given task runs on the JavaFX thread. + * Falls back to direct execution if JavaFX is not initialized (e.g. in tests). + */ + private static void runOnFx(Runnable task) { + try { + if (Platform.isFxApplicationThread()) task.run(); + else Platform.runLater(task); + } catch (IllegalStateException notInitialized) { + task.run(); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/example/NtfyConnection.java b/src/main/java/com/example/NtfyConnection.java new file mode 100644 index 00000000..64a210d6 --- /dev/null +++ b/src/main/java/com/example/NtfyConnection.java @@ -0,0 +1,55 @@ +package com.example; + +import java.io.Closeable; +import java.io.IOException; +import java.util.function.Consumer; + +/** + * Represents a connection to a Ntfy-compatible notification service. + * Implementations of this interface provide basic operations for: + * * Sending messages to a specific topic + * * Subscribing to incoming messages from a topic + */ +public interface NtfyConnection { + + /** + * Sends a message to the given topic. + * + * @param topic the topic to publish to (must not be null or blank) + * @param message the message content to send + * @throws IOException if the message cannot be delivered due to + * network errors or underlying I/O issues + */ + void send(String topic, String message) throws IOException; + + /** + * Subscribes to a topic and receives messages asynchronously. + * + * @param topic the topic to subscribe to + * @param messageHandler callback invoked for every received message on a background thread + * @return a {@link Subscription} that controls the active message stream + */ + Subscription receive(String topic, Consumer messageHandler); + + /** + * Controls an active topic subscription. + * Encapsulates the logic to stop an active message stream. + */ + interface Subscription extends Closeable { + + /** + * Closes this subscription and stops receiving messages. + * + * @throws IOException if closing fails + */ + @Override + void close() throws IOException; + + /** + * Checks whether the subscription is still active. + * + * @return true if active, false otherwise + */ + boolean isOpen(); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/NtfyConnectionImpl.java b/src/main/java/com/example/NtfyConnectionImpl.java new file mode 100644 index 00000000..7b15ee39 --- /dev/null +++ b/src/main/java/com/example/NtfyConnectionImpl.java @@ -0,0 +1,129 @@ +package com.example; + +import io.github.cdimascio.dotenv.Dotenv; +import tools.jackson.databind.ObjectMapper; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.Objects; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Consumer; + +/** + * Implementation of {@link NtfyConnection} that communicates with a Ntfy server + * using Java's built-in {@link HttpClient}. + * Supports sending messages to a topic and subscribing to a topic to receive streaming + * JSON messages in real time. + */ +public class NtfyConnectionImpl implements NtfyConnection { + + private final HttpClient http = HttpClient.newHttpClient(); + private final String hostName; + private final ObjectMapper mapper = new ObjectMapper(); + + /** + * Creates a connection using a hostname loaded from a .env file. + * Expects the variable HOST_NAME to be present. + */ + public NtfyConnectionImpl() { + Dotenv dotenv = Dotenv.load(); + hostName = Objects.requireNonNull(dotenv.get("HOST_NAME")); + } + + /** + * Creates a connection using the given hostname. + * + * @param hostName Base URL of the Ntfy server + */ + public NtfyConnectionImpl(String hostName) { + this.hostName = hostName; + } + + /** + * Sends a message to the given topic. + * + * @param topic The topic to publish to. + * @param message Message body to send. + * @throws IOException If sending fails or the thread is interrupted. + */ + @Override + public void send(String topic, String message) throws IOException { + HttpRequest httpRequest = HttpRequest.newBuilder() + .POST(HttpRequest.BodyPublishers.ofString(message)) + .header("Cache-Control", "no-cache") + .uri(URI.create(hostName + "/" + topic)) + .build(); + try { + http.send(httpRequest, HttpResponse.BodyHandlers.discarding()); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException("Interrupted while sending message", e); + } + } + + /** + * Subscribes to a topic and receives incoming messages as a JSON stream. + * Each valid message is deserialized into {@link NtfyMessageDto} and passed + * to the given message handler. + *

+ * The subscription remains active until {@link Subscription#close()} is called. + * + * @param topic Topic to subscribe to. + * @param messageHandler Callback invoked for each received message. + * @return A {@link Subscription} that can be closed to stop listening. + */ + @Override + public Subscription receive(String topic, Consumer messageHandler) { + HttpRequest httpRequest = HttpRequest.newBuilder() + .GET() + .uri(URI.create(hostName + "/" + topic + "/json")) + .build(); + + AtomicBoolean active = new AtomicBoolean(true); + + CompletableFuture future = http.sendAsync(httpRequest, HttpResponse.BodyHandlers.ofLines()) + .exceptionally(throwable -> { + System.err.println("Failed to receive messages: " + throwable.getMessage()); + return null; + }) + .thenAccept(response -> { + if (response == null) return; + response.body() + .filter(s -> active.get()) + .map(s -> { + try { + return mapper.readValue(s, NtfyMessageDto.class); + } catch (Exception e) { + System.err.println("Failed to parse message: " + e.getMessage()); + return null; + } + }) + .filter(Objects::nonNull) + .filter(message -> "message".equals(message.event())) + .forEach(messageHandler); + }); + + return new Subscription() { + /** + * Stops the subscription and cancels the streaming request. + */ + @Override + public void close() throws IOException { + active.set(false); + future.cancel(true); + } + + /** + * Indicates whether the subscription is still active. + */ + @Override + public boolean isOpen() { + return active.get() && !future.isDone(); + } + }; + } +} \ No newline at end of file diff --git a/src/main/java/com/example/NtfyMessageDto.java b/src/main/java/com/example/NtfyMessageDto.java new file mode 100644 index 00000000..ec8b6ff5 --- /dev/null +++ b/src/main/java/com/example/NtfyMessageDto.java @@ -0,0 +1,16 @@ +package com.example; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +/** + * DTO for messages received from a Ntfy server. + * Unknown JSON fields are ignored during deserialization. + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public record NtfyMessageDto(String id, long time, String event, String topic, String message) { + + @Override + public String toString() { + return message; + } +} \ No newline at end of file diff --git a/src/main/java/module-info.java b/src/main/java/module-info.java index 71574a27..649be1ab 100644 --- a/src/main/java/module-info.java +++ b/src/main/java/module-info.java @@ -1,6 +1,9 @@ module hellofx { requires javafx.controls; requires javafx.fxml; + requires io.github.cdimascio.dotenv.java; + requires java.net.http; + requires tools.jackson.databind; opens com.example to javafx.fxml; exports com.example; diff --git a/src/main/resources/com/example/hello-view.fxml b/src/main/resources/com/example/hello-view.fxml index 20a7dc82..a386033a 100644 --- a/src/main/resources/com/example/hello-view.fxml +++ b/src/main/resources/com/example/hello-view.fxml @@ -1,9 +1,45 @@ - - - - - - - + + + + + + + + + + + +