diff --git a/.gitignore b/.gitignore index 6ac465db..97c21425 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ target/ /.idea/ +.env +*.iml diff --git a/pom.xml b/pom.xml index c40f667e..3b7e09b2 100644 --- a/pom.xml +++ b/pom.xml @@ -11,15 +11,22 @@ 25 UTF-8 - 6.0.0 + 5.9.2 3.27.6 5.20.0 25 + 4.0.16-alpha org.junit.jupiter - junit-jupiter + junit-jupiter-api + ${junit.jupiter.version} + test + + + org.junit.jupiter + junit-jupiter-engine ${junit.jupiter.version} test @@ -35,6 +42,18 @@ ${mockito.version} test + + org.testfx + testfx-junit5 + ${testfx.version} + test + + + org.testfx + testfx-core + ${testfx.version} + test + org.openjfx javafx-controls @@ -45,8 +64,45 @@ javafx-fxml ${javafx.version} + + org.openjfx + javafx-base + ${javafx.version} + + + com.fasterxml.jackson.core + jackson-databind + 2.17.0 + + + io.github.cdimascio + dotenv-java + 3.1.0 + + + org.jetbrains + annotations + 24.0.1 + + + org.testng + testng + RELEASE + compile + + + + src/main/resources + + **/*.fxml + **/*.css + **/*.jpg + **/*.png + + + org.openjfx @@ -63,6 +119,17 @@ true + + org.apache.maven.plugins + maven-surefire-plugin + 3.2.5 + + + **/*Test.java + **/*IT.java + + + diff --git a/src/main/java/com/example/ChatNetworkClient.java b/src/main/java/com/example/ChatNetworkClient.java new file mode 100644 index 00000000..f65f0106 --- /dev/null +++ b/src/main/java/com/example/ChatNetworkClient.java @@ -0,0 +1,19 @@ +package com.example; + +import java.io.Closeable; +import java.io.IOException; +import java.util.function.Consumer; + +public interface ChatNetworkClient { + void send(String baseUrl, NtfyMessage message) throws Exception; + + //Returnerar mitt egna Subscription-interface + Subscription subscribe(String baseUrl, String topic, Consumer messageHandler); + + //Inner interface för Subscription + interface Subscription extends Closeable { + @Override + void close() throws IOException; + boolean isOpen(); + } +} diff --git a/src/main/java/com/example/HelloController.java b/src/main/java/com/example/HelloController.java index fdd160a0..ff939345 100644 --- a/src/main/java/com/example/HelloController.java +++ b/src/main/java/com/example/HelloController.java @@ -1,22 +1,155 @@ package com.example; +import javafx.beans.binding.Bindings; import javafx.fxml.FXML; import javafx.scene.control.Label; +import javafx.scene.control.ListView; +import javafx.scene.control.TextField; +import javafx.scene.control.Button; + +import java.io.IOException; +import java.util.UUID; /** * Controller layer: mediates between the view (FXML) and the model. */ public class HelloController { - private final HelloModel model = new HelloModel(); + private final HelloModel model; + private final ChatNetworkClient httpClient; // Abstraktion (Dependency Inversion) + private final String hostName; + private ChatNetworkClient.Subscription subscription; + private long subscriptionStartTime; - @FXML - private Label messageLabel; + @FXML private Label messageLabel; + + //Nya FXML-komponenter: + @FXML private ListView messageListView; //Visar meddelanden från HelloModel + @FXML private TextField messageTextField; //Används för att skriva nya meddelanden + @FXML private Button sendButton; //Används för att skicka meddelanden + @FXML private Label connectionStatusLabel; // Status för anslutning + + //Konstruktor för Dependency Injection + public HelloController(HelloModel model, ChatNetworkClient httpClient, String hostName) { + this.model = model; + this.httpClient = httpClient; + this.hostName = hostName; + } @FXML private void initialize() { if (messageLabel != null) { messageLabel.setText(model.getGreeting()); } + //Binder listView till meddelandelistan i modellen + messageListView.setItems(model.getMessages()); + + //Binder anslutningsstatus till etiketten + connectionStatusLabel.textProperty().bind( + Bindings.when(model.connectedProperty()) + .then("Ansluten: ja") + .otherwise("Ansluten: nej") + ); + } + + @FXML + private void sendMessage() { + String messageText = messageTextField.getText(); + if (!messageText.isEmpty()) { + try { + NtfyMessage message = createMessage(messageText); + httpClient.send(hostName, message); + messageTextField.clear(); + } catch (Exception e) { + handleSendError(e); + } + } + } + + private NtfyMessage createMessage(String text) { + return new NtfyMessage( + UUID.randomUUID().toString(), + System.currentTimeMillis(), + "message", + "mytopic", + text + ); + } + + private void handleSendError(Exception e) { + System.err.println("Fel vid sändning: " + e.getMessage()); + } + + + @FXML + private void subscribeToTopic() { + unsubscribeFromCurrentTopic(); // Stäng den gamla prenumerationen + model.getMessages().clear(); // Rensa gamla meddelanden + startNewSubscription(); // Starta ny prenumeration + model.setConnected(true); // Uppdatera anslutningsstatus + } + + private void unsubscribeFromCurrentTopic() { + if (subscription != null) { + try { + subscription.close(); + } catch (IOException e) { + System.err.println("Kunde inte stänga gammal prenumeration: " + e.getMessage()); + } + } + } + + private void startNewSubscription() { + subscriptionStartTime = System.currentTimeMillis(); // Spara starttiden + subscription = httpClient.subscribe( + hostName, + "mytopic", + this::handleIncomingMessage + ); + } + + private void handleIncomingMessage(NtfyMessage message) { + System.out.println("Mottaget meddelande: " + message.id() + ", tid: " + message.time()); + long messageTimeSeconds = message.time(); + long currentTimeSeconds = System.currentTimeMillis() / 1000; + + // Hoppa över gamla meddelanden + if (messageTimeSeconds < (subscriptionStartTime / 1000)) { + System.out.println("Hoppar över (gammalt meddelande)."); + return; + } + + // Hoppa över dubbletter + if (isDuplicateMessage(message)) { + System.out.println("Hoppar över (dublett)."); + return; + } + + // Lägg till meddelandet i modellen + System.out.println("Lägger till i modellen: " + message.message()); + model.addMessage(message); + } + + private boolean isDuplicateMessage(NtfyMessage message) { + return model.getMessages().stream() + .anyMatch(existingMessage -> existingMessage.id().equals(message.id())); + } + + + @FXML + private void unsubscribeFromTopic() { + if (subscription != null) { + closeSubscription(); + model.setConnected(false); + subscription = null; // Rensa referensen + } + } + + private void closeSubscription() { + try { + subscription.close(); + } catch (IOException e) { + System.err.println("Fel vid avprenumeration: " + e.getMessage()); + } } } diff --git a/src/main/java/com/example/HelloFX.java b/src/main/java/com/example/HelloFX.java index 96bdc5ca..0b764cbb 100644 --- a/src/main/java/com/example/HelloFX.java +++ b/src/main/java/com/example/HelloFX.java @@ -1,19 +1,48 @@ package com.example; +import io.github.cdimascio.dotenv.Dotenv; import javafx.application.Application; import javafx.fxml.FXMLLoader; import javafx.scene.Parent; import javafx.scene.Scene; import javafx.stage.Stage; +import java.util.Objects; + public class HelloFX extends Application { @Override public void start(Stage stage) throws Exception { - FXMLLoader fxmlLoader = new FXMLLoader(HelloFX.class.getResource("hello-view.fxml")); + Dotenv dotenv = Dotenv.load(); //Ladda .env-filen + + String hostName = dotenv.get("HOST_NAME"); + + //Validering (valfritt) + if (hostName == null) { + throw new IllegalStateException( + "Kunde inte läsa HOST_NAME från .env-filen. " + + "Kontrollera att filen finns i projektets rotmapp!" + ); + } + + System.out.println("Ansluter till ntfy på: " + hostName); //Debug-logg + + HelloModel model = new HelloModel(); + ChatNetworkClient httpClient = new NtfyHttpClient(); + + FXMLLoader fxmlLoader = new FXMLLoader( + HelloFX.class.getResource("/com/example/hello-view.fxml") + ); + fxmlLoader.setControllerFactory(c -> new HelloController(model, httpClient, hostName)); + Parent root = fxmlLoader.load(); Scene scene = new Scene(root, 640, 480); - stage.setTitle("Hello MVC"); + + scene.getStylesheets().add( + Objects.requireNonNull(getClass().getResource("/com/example/styles.css")).toExternalForm() + ); + + stage.setTitle("Chat App"); stage.setScene(scene); stage.show(); } diff --git a/src/main/java/com/example/HelloModel.java b/src/main/java/com/example/HelloModel.java index 385cfd10..fb611a3d 100644 --- a/src/main/java/com/example/HelloModel.java +++ b/src/main/java/com/example/HelloModel.java @@ -1,5 +1,12 @@ package com.example; +import javafx.application.Platform; +import javafx.beans.property.BooleanProperty; +import javafx.beans.property.ReadOnlyBooleanProperty; +import javafx.beans.property.SimpleBooleanProperty; +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; + /** * Model layer: encapsulates application data and business logic. */ @@ -7,9 +14,53 @@ public class HelloModel { /** * Returns a greeting based on the current Java and JavaFX versions. */ + + private final ObservableList messages = FXCollections.observableArrayList(); + private final BooleanProperty connected = new SimpleBooleanProperty(false); + private boolean isTesting = false; // <-- Lägg till denna rad + public String getGreeting() { String javaVersion = System.getProperty("java.version"); String javafxVersion = System.getProperty("javafx.version"); return "Hello, JavaFX " + javafxVersion + ", running on Java " + javaVersion + "."; } + + public ObservableList getMessages() { + return messages; + } + + + public ReadOnlyBooleanProperty connectedProperty() { + return connected; + } + + public void setTesting(boolean testing) { // <-- Lägg till denna metod + this.isTesting = testing; + } + + // Ersätt den gamla runOnFX-metoden med denna: + private void runOnFX(Runnable task) { + if (isTesting) { // <-- Ny logik för tester + task.run(); + } else if (Platform.isFxApplicationThread()) { + task.run(); + } else { + try { + Platform.runLater(task); + } catch (IllegalThreadStateException notInitialized) { + task.run(); // Fallback för enhetstester (om JavaFX inte är initierat) + } + } + + } + + public void addMessage(NtfyMessage message) { + runOnFX(() -> messages.add(message)); + } + + public void setConnected(boolean connected) { + runOnFX(() -> this.connected.set(connected)); + } + + } diff --git a/src/main/java/com/example/NtfyHttpClient.java b/src/main/java/com/example/NtfyHttpClient.java new file mode 100644 index 00000000..1461d5a6 --- /dev/null +++ b/src/main/java/com/example/NtfyHttpClient.java @@ -0,0 +1,97 @@ +package com.example; + +import com.fasterxml.jackson.databind.ObjectMapper; +import javafx.application.Platform; + +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.concurrent.atomic.AtomicBoolean; +import java.util.function.Consumer; + +public class NtfyHttpClient implements ChatNetworkClient{ + private final HttpClient http; //Singleton (återanvänds för alla anrop) + private final ObjectMapper mapper; + + public NtfyHttpClient() { + this.http = HttpClient.newHttpClient(); //Implicit Singleton + this.mapper = new ObjectMapper(); + } + + @Override + public void send(String baseUrl, NtfyMessage message) throws Exception{ + //Builder Pattern för HttpRequest + HttpRequest request = HttpRequest.newBuilder() + .POST(HttpRequest.BodyPublishers.ofString(message.message())) + .header("Content-Type", "application/json") + .uri(URI.create(baseUrl + "/" + message.topic())) + .build(); + + //Debug-loggning + System.out.println("Skickar till: " + baseUrl + "/" + message.topic()); + System.out.println("Meddelande: " + message.message()); + + //Synkront anrop (blockerande) + HttpResponse response = http.send( + request, + HttpResponse.BodyHandlers.discarding() + ); + //validera statuskoden + if (response.statusCode() >= 400) { + throw new IOException("Fel vid sändning: HTTP " + response.statusCode()); + } + System.out.println("Meddelandet skickat! Statuskod: " + response.statusCode()); + } + + public Subscription subscribe(String baseUrl, String topic, Consumer messageHandler) { + AtomicBoolean isOpen = new AtomicBoolean(true); //Spåra prenumerationens status + + http.sendAsync( + HttpRequest.newBuilder() + .GET() + .uri(URI.create(baseUrl + "/" + topic + "/json")) + .build(), + HttpResponse.BodyHandlers.ofLines() + ).thenAccept(response -> { + System.out.println("Prenumeration startad för topic: " + topic); //Debug-loggning + + //Logga varje rad som mottas från servern + response.body() + .peek(line -> System.out.println("Mottagen rad från servern: " + line)) + .takeWhile(line -> isOpen.get()) //Avbryt strömmen om isOpen = false + .map(line-> { + try { + NtfyMessage message = mapper.readValue(line, NtfyMessage.class); + System.out.println("Parsat meddelande: " + message); //Debug-loggning + return message; + } catch (IOException e) { + throw new RuntimeException(e); + } + }) + .filter(message -> message.event().equals("message")) + .forEach(message -> { + if (isOpen.get()) { //Endast om prenumerationen är öppen + Platform.runLater(() -> messageHandler.accept(message)); + } + }); + }).exceptionally(error -> { + System.err.println("Fel vid prenumeration: " + error.getMessage()); + return null; + }); + + //Command Pattern: Returnera ett Subscription-objekt + return new Subscription() { + @Override + public void close() throws IOException { + isOpen.set(false); //Stäng strömmen + } + + @Override + public boolean isOpen() { + return isOpen.get(); + } + }; + } +} diff --git a/src/main/java/com/example/NtfyMessage.java b/src/main/java/com/example/NtfyMessage.java new file mode 100644 index 00000000..bb8a6a40 --- /dev/null +++ b/src/main/java/com/example/NtfyMessage.java @@ -0,0 +1,19 @@ +package com.example; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import org.jetbrains.annotations.NotNull; + +@JsonIgnoreProperties(ignoreUnknown = true) +public record NtfyMessage( + String id, + Long time, + String event, + String topic, + String message) { + + @Override + @NotNull + public String toString(){ + return message != null ? message : String.format("NtfyMessage[event=%s]", event); + } +} diff --git a/src/main/java/com/example/Subscription.java b/src/main/java/com/example/Subscription.java new file mode 100644 index 00000000..73328460 --- /dev/null +++ b/src/main/java/com/example/Subscription.java @@ -0,0 +1,11 @@ +package com.example; + +import java.io.Closeable; +import java.io.IOException; + +public interface Subscription extends Closeable { + + @Override + void close() throws IOException; + boolean isOpen(); +} diff --git a/src/main/java/module-info.java b/src/main/java/module-info.java index 71574a27..4562bb52 100644 --- a/src/main/java/module-info.java +++ b/src/main/java/module-info.java @@ -1,6 +1,11 @@ module hellofx { requires javafx.controls; requires javafx.fxml; + requires com.fasterxml.jackson.annotation; + requires java.net.http; + requires io.github.cdimascio.dotenv.java; + requires com.fasterxml.jackson.databind; + requires org.jetbrains.annotations; opens com.example to javafx.fxml; exports com.example; diff --git a/src/main/resources/com/example/background.jpg b/src/main/resources/com/example/background.jpg new file mode 100644 index 00000000..e3778815 Binary files /dev/null and b/src/main/resources/com/example/background.jpg differ diff --git a/src/main/resources/com/example/hello-view.fxml b/src/main/resources/com/example/hello-view.fxml index 20a7dc82..5d388fec 100644 --- a/src/main/resources/com/example/hello-view.fxml +++ b/src/main/resources/com/example/hello-view.fxml @@ -1,9 +1,37 @@ - - - - - - - + + + + + + + + + + +