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 @@
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/test/java/com/example/HelloModelTest.java b/src/test/java/com/example/HelloModelTest.java
new file mode 100644
index 00000000..006229c8
--- /dev/null
+++ b/src/test/java/com/example/HelloModelTest.java
@@ -0,0 +1,90 @@
+package com.example;
+
+import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo;
+import com.github.tomakehurst.wiremock.junit5.WireMockTest;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.io.IOException;
+
+import static com.github.tomakehurst.wiremock.client.WireMock.*;
+import static org.assertj.core.api.Assertions.assertThat;
+
+@WireMockTest
+class HelloModelTest {
+
+ @Test
+ @DisplayName("GIVEN a model with messageToSend WHEN calling sendMessage THEN send method on connection should be called")
+ void sendMessageCallsConnectionWithMessageToSend() throws IOException {
+
+ var spy = new NtfyConnectionSpy();
+ var model = new HelloModel(spy);
+ model.setMessageToSend("Hello World");
+
+ model.sendMessage();
+
+ assertThat(spy.message).isEqualTo("Hello World");
+ }
+
+ @Test
+ @DisplayName("GIVEN a fake Ntfy server WHEN calling sendMessage THEN an HTTP POST request should be sent with correct body")
+ void sendMessageToFakeServer(WireMockRuntimeInfo wmRuntimeInfo) throws IOException {
+
+ var con = new NtfyConnectionImpl("http://localhost:" + wmRuntimeInfo.getHttpPort());
+ var model = new HelloModel(con);
+ model.setMessageToSend("Hello World");
+ stubFor(post("/mytopic").willReturn(ok()));
+
+ model.sendMessage();
+
+ verify(postRequestedFor(urlEqualTo("/mytopic"))
+ .withRequestBody(matching("Hello World")));
+ }
+
+ @Test
+ @DisplayName("GIVEN a stubbed connection WHEN receiving message THEN it should appear in the model's messages list")
+ void receiveMessageAddsMessagesToList() throws InterruptedException {
+ var stub = new NtfyConnectionStub();
+ var model = new HelloModel(stub);
+
+ model.connectToTopic();
+
+ stub.simulateIncomingMessage(new NtfyMessageDto("1", System.currentTimeMillis(), "message", "mytopic", "Hello world"));
+ Thread.sleep(50);
+
+ assertThat(model.getMessages())
+ .extracting(NtfyMessageDto::message)
+ .containsExactly("Hello world");
+ }
+
+ @Test
+ @DisplayName("GIVEN a model with messages WHEN connecting to a new topic THEN old messages are cleared")
+ void connectToTopicClearsMessages() {
+ var stub = new NtfyConnectionStub();
+ var model = new HelloModel(stub);
+
+ model.connectToTopic();
+
+ stub.simulateIncomingMessage(new NtfyMessageDto("1", System.currentTimeMillis(), "message", "mytopic", "Old message"));
+ assertThat(model.getMessages()).hasSize(1);
+
+ model.setTopic("newtopic");
+ model.connectToTopic();
+
+ assertThat(model.getMessages()).isEmpty();
+ }
+
+ @Test
+ @DisplayName("GIVEN an active subscription WHEN disconnecting THEN subscription should be closed")
+ void disconnectClosesSubscription() {
+ var stub = new NtfyConnectionStub();
+ var model = new HelloModel(stub);
+
+ model.connectToTopic();
+ model.disconnect();
+
+ stub.simulateIncomingMessage(new NtfyMessageDto("1", System.currentTimeMillis(), "message", "mytopic", "Should not appear"));
+
+ assertThat(model.getMessages()).isEmpty();
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/com/example/NtfyConnectionSpy.java b/src/test/java/com/example/NtfyConnectionSpy.java
new file mode 100644
index 00000000..a79ea7ce
--- /dev/null
+++ b/src/test/java/com/example/NtfyConnectionSpy.java
@@ -0,0 +1,61 @@
+package com.example;
+
+import java.util.function.Consumer;
+
+/**
+ * Spy implementation of NtfyConnection used in tests.
+ * Records sent messages for verification.
+ */
+public class NtfyConnectionSpy implements NtfyConnection {
+
+ /** Last sent message. */
+ String message;
+ /** Last topic used. */
+ String topic;
+
+ /**
+ * Records the topic and message that were sent.
+ *
+ * @param topic the topic the message was sent to
+ * @param message the message that was sent
+ */
+ @Override
+ public void send(String topic, String message) {
+ this.topic = topic;
+ this.message = message;
+ }
+
+ /**
+ * Records the topic and returns a subscription for testing.
+ *
+ * @param topic the topic to subscribe to
+ * @param messageHandler the handler for incoming messages (not invoked in Spy)
+ * @return a Subscription object that can be closed and queried
+ */
+ @Override
+ public Subscription receive(String topic, Consumer messageHandler) {
+ this.topic = topic;
+
+ return new Subscription() {
+ private boolean active = true;
+
+ /**
+ * Closes the subscription.
+ */
+ @Override
+ public void close() {
+ active = false;
+ }
+
+ /**
+ * Checks whether the subscription is still open.
+ *
+ * @return true if open, false if closed
+ */
+ @Override
+ public boolean isOpen() {
+ return active;
+ }
+ };
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/com/example/NtfyConnectionStub.java b/src/test/java/com/example/NtfyConnectionStub.java
new file mode 100644
index 00000000..1f49c8bd
--- /dev/null
+++ b/src/test/java/com/example/NtfyConnectionStub.java
@@ -0,0 +1,71 @@
+package com.example;
+
+import java.util.function.Consumer;
+
+/**
+ * Stub implementation of NtfyConnection used in tests.
+ * Allows simulating incoming messages and tracking subscription state.
+ */
+public class NtfyConnectionStub implements NtfyConnection {
+
+ /** Handler to call when simulating incoming messages. */
+ private Consumer messageHandler;
+ /** Flag indicating if the subscription is active. */
+ private boolean subscriptionActive = false;
+
+ /**
+ * Does nothing in this stub; included for interface completeness.
+ *
+ * @param topic the topic the message would be sent to
+ * @param message the message content
+ */
+ @Override
+ public void send(String topic, String message) {
+ }
+
+ /**
+ * Simulates subscribing to a topic.
+ * Stores the handler for later invocation via {@link #simulateIncomingMessage}.
+ *
+ * @param topic the topic to subscribe to
+ * @param messageHandler the callback for received messages
+ * @return a Subscription object to control the simulated subscription
+ */
+ @Override
+ public Subscription receive(String topic, Consumer messageHandler) {
+ this.messageHandler = messageHandler;
+ subscriptionActive = true;
+
+ return new Subscription() {
+ /**
+ * Closes the simulated subscription.
+ */
+ @Override
+ public void close() {
+ subscriptionActive = false;
+ }
+
+ /**
+ * Checks whether the simulated subscription is active.
+ *
+ * @return true if active, false if closed
+ */
+ @Override
+ public boolean isOpen() {
+ return subscriptionActive;
+ }
+ };
+ }
+
+ /**
+ * Simulates an incoming message by invoking the registered handler
+ * if the subscription is active.
+ *
+ * @param message the message to simulate
+ */
+ public void simulateIncomingMessage(NtfyMessageDto message) {
+ if (messageHandler != null && subscriptionActive) {
+ messageHandler.accept(message);
+ }
+ }
+}
\ No newline at end of file