diff --git a/.gitignore b/.gitignore index 6ac465db..244268f9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ target/ /.idea/ +.env diff --git a/mvnw b/mvnw old mode 100644 new mode 100755 diff --git a/pom.xml b/pom.xml index c40f667e..f2b3aecd 100644 --- a/pom.xml +++ b/pom.xml @@ -40,6 +40,16 @@ javafx-controls ${javafx.version} + + com.fasterxml.jackson.core + jackson-databind + 2.19.0 + + + io.github.cdimascio + dotenv-java + 3.2.0 + org.openjfx javafx-fxml @@ -63,6 +73,15 @@ true + + org.apache.maven.plugins + maven-compiler-plugin + + 25 + 25 + --enable-preview + + diff --git a/src/main/java/com/example/HelloController.java b/src/main/java/com/example/HelloController.java index fdd160a0..8ea5159c 100644 --- a/src/main/java/com/example/HelloController.java +++ b/src/main/java/com/example/HelloController.java @@ -1,22 +1,213 @@ package com.example; +import javafx.beans.binding.Bindings; + import javafx.fxml.FXML; -import javafx.scene.control.Label; + +import javafx.scene.control.*; + +import javafx.stage.FileChooser; +import javafx.stage.Stage; + +import java.io.File; + + /** - * Controller layer: mediates between the view (FXML) and the model. + * Controller layer: Manages the user interface and mediates communication between the View (FXML) and the Model (HelloModel). + * Handles user interactions such as sending messages, attaching files, and displaying chat content. */ public class HelloController { - private final HelloModel model = new HelloModel(); + private static final String HOST_NAME = System.getenv("HOST_NAME"); + + static { + if (HOST_NAME == null || HOST_NAME.isBlank()) { + throw new IllegalStateException( + "Environment variable HOST_NAME must be set to the server URL." + ); + } + } + + + + + + /** + * The model instance that holds application data and business logic. + * Initializes connection to the specified Ntfy server. + */ + private final HelloModel model = new HelloModel(new NtfyConnectionImpl(HOST_NAME)); + + + /** + * The ListView component displaying the list of chat messages. + */ @FXML - private Label messageLabel; + public ListView chatListView; + /** + * The label displaying the current fixed topic being used. + */ + @FXML + public Label topicLabel; + + /** + * The label indicating the name of the file currently attached for sending. + */ + @FXML + public Label attachedFileLabel; + + + /** + * The button used to send the message or the attached file. + */ + @FXML + private Button sendButton; + + @FXML + private TextField messageInput; + + @FXML + private Button attachFile; + + + /** + * Initializes the controller. This method is called automatically by the FXML loader. + * It sets up bindings between the view components and the model and configures the chat list view. + */ @FXML private void initialize() { - if (messageLabel != null) { - messageLabel.setText(model.getGreeting()); + if (sendButton != null) { + sendButton.setText(model.getGreeting()); + + // Bindning: Knappen är inaktiverad ENDAST om BÅDE meddelandet är tomt OCH fil inte är bifogad + sendButton.disableProperty().bind( + Bindings.createBooleanBinding(() -> { + boolean isMessageEmpty = model.messageToSendProperty().get() == null || + model.messageToSendProperty().get().trim().isEmpty(); + boolean isFileNotAttached = model.fileToSendProperty().get() == null; + + return isMessageEmpty && isFileNotAttached; + }, + model.messageToSendProperty(), + model.fileToSendProperty()) + ); + } + + if (topicLabel != null) { + // Visar den fasta topicen + topicLabel.textProperty().bind( + Bindings.concat("Fixed Topic: ", model.currentTopicProperty()) + ); + } + + // Hanterar visning av bifogad fil + if (attachedFileLabel != null) { + model.fileToSendProperty().addListener((obs, oldFile, newFile) -> { + if (newFile != null) { + attachedFileLabel.setText("Attached file: " + newFile.getName()); + attachedFileLabel.setStyle("-fx-font-style: italic;" + + " -fx-font-size: 12px;" + + " -fx-text-fill: #008000;"); + } else { + attachedFileLabel.setText("No file attached"); + attachedFileLabel.setStyle("-fx-font-style: italic; " + + "-fx-font-size: 12px; " + + "-fx-text-fill: #333;"); + } + }); + attachedFileLabel.setText("No file attached"); + } + + if (messageInput!=null){ + messageInput.textProperty().bindBidirectional(model.messageToSendProperty()); + } + + if(chatListView!=null){ + chatListView.setItems(model.getMessages()); + // Använd den enkla CellFactoryn + chatListView.setCellFactory(param -> new SimpleMessageCell()); + } + } + + /** + * A simple custom ListCell implementation for the chatListView. + * It displays the message text or a placeholder for file uploads, and indicates if the message was sent locally. + */ + private static class SimpleMessageCell extends ListCell { + + @Override + protected void updateItem(NtfyMessageDto item, boolean empty) { + super.updateItem(item, empty); + + if (empty || item == null) { + setText(null); + setGraphic(null); + setStyle(null); + } else { + // Hämta meddelandet eller visa filstatus om meddelandet är tomt + String displayMessage = item.message() != null && !item.message().trim().isEmpty() + ? item.message() + : ("file".equals(item.event())) ? item.topic() + " Uploaded" : ""; + + // Lägg till prefix för att visa om det är skickat lokalt + String prefix = item.isLocal() ? "(Sent) " : ""; + + setText(prefix + displayMessage); + setGraphic(null); + + // Mycket enkel stil utan bubblor/färger. Använd standard utseende. + setStyle(null); + } + } + } + + + /** + * Handles the action of the send button. + * If a file is attached, it calls the model to send the file; otherwise, it calls the model to send the text message. + */ + @FXML + protected void sendMessage() { + if (model.fileToSendProperty().get() != null) { + // Om en fil är bifogad, skicka filen och rensa bilagan i modellen + model.sendFile(); + } else { + // Annars, skicka textmeddelandet + model.sendMessage(); + } + + if (messageInput!=null){ + messageInput.requestFocus(); + } + } + + /** + * Handles the action of the attach file button. + * Opens a FileChooser dialog and sets the selected file in the model. + */ + @FXML + protected void attachFile() { + // Hämta scenen från en av kontrollerna + + if (attachFile == null || attachFile.getScene() == null || attachFile.getScene().getWindow() == null) { + System.err.println("Cannot open file chooser: UI not ready."); + return; + } + Stage stage = (Stage) attachFile.getScene().getWindow(); + + + + + FileChooser fileChooser = new FileChooser(); + fileChooser.setTitle("Choose file to attach"); + + File selectedFile = fileChooser.showOpenDialog(stage); + + if (selectedFile != null) { + model.setFileToSend(selectedFile); } } -} +} \ 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..cfc280e1 100644 --- a/src/main/java/com/example/HelloFX.java +++ b/src/main/java/com/example/HelloFX.java @@ -1,5 +1,6 @@ package com.example; + import javafx.application.Application; import javafx.fxml.FXMLLoader; import javafx.scene.Parent; @@ -10,6 +11,9 @@ public class HelloFX extends Application { @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); diff --git a/src/main/java/com/example/HelloModel.java b/src/main/java/com/example/HelloModel.java index 385cfd10..23cf46ee 100644 --- a/src/main/java/com/example/HelloModel.java +++ b/src/main/java/com/example/HelloModel.java @@ -1,15 +1,173 @@ package com.example; + +import javafx.application.Platform; + +import javafx.beans.property.ObjectProperty; +import javafx.beans.property.SimpleObjectProperty; +import javafx.beans.property.SimpleStringProperty; +import javafx.beans.property.StringProperty; +import javafx.collections.FXCollections; + +import javafx.collections.ObservableList; + +import java.io.File; + +import java.util.concurrent.CompletableFuture; + + /** * Model layer: encapsulates application data and business logic. + * Manages the current message, topic, list of received messages, and connection to Ntfy. */ public class HelloModel { + private final NtfyConnection connection; + private final StringProperty messageToSend = new SimpleStringProperty(""); + private final StringProperty currentTopic = new SimpleStringProperty("mytopic"); + private final ObservableList messages = FXCollections.observableArrayList(); + private final ObjectProperty fileToSend = new SimpleObjectProperty<>(null); // Ny egenskap för filbilaga + /** - * Returns a greeting based on the current Java and JavaFX versions. + * Initializes the model and establishes connection to the specified Ntfy server. + * @param connection The Ntfy connection implementation to use (e.g., NtfyConnectionImpl or a Spy). + */ + public HelloModel(NtfyConnection connection) { + this.connection = connection; + connection.connect(currentTopic.get(), this::receiveMessage); + } + /** + * Returns a standard greeting string. + * @return The greeting string. */ public String getGreeting() { - String javaVersion = System.getProperty("java.version"); - String javafxVersion = System.getProperty("javafx.version"); - return "Hello, JavaFX " + javafxVersion + ", running on Java " + javaVersion + "."; + return "Skicka meddelande"; + } + + // NY METOD: Hanterar att köra koden på JavaFX-tråden ELLER direkt i testmiljö + private static void runOnFx(Runnable task) { + try { + if (Platform.isFxApplicationThread()) task.run(); + else Platform.runLater(task); + } catch (IllegalStateException notInitialized) { + task.run(); + } + } + + /** + * Sends the current text message asynchronously. + * The network call is moved off the FX thread. UI updates happen via runOnFx. + */ + public void sendMessage() { + final String message = messageToSend.get(); + if (message == null || message.trim().isEmpty()) { + System.err.println("Cannot send empty message."); + return; + } + + // 1. Markera meddelandet som lokalt skickat och lägg till i listan (UI-uppdatering) + final NtfyMessageDto localSentDto = new NtfyMessageDto(message, currentTopic.get(), "text", true); + runOnFx(() -> { + messages.add(localSentDto); + messageToSend.set(""); // Rensa inmatningsfältet + }); + + // 2. Utför nätverksanropet på en bakgrundstråd + CompletableFuture.runAsync(() -> { + // Nätverksanropet sker här i bakgrunden + try { + connection.send(message, currentTopic.get()); + } catch (Exception e) { + System.err.println("Failed to send message: " + e.getMessage()); + + } + }); + } + + /** + * Sends the currently attached file to the Ntfy server. + * The file's name and a temporary message are added to the local list before sending. + * The file attachment is cleared after sending. + */ + public void sendFile() { + File file = fileToSend.get(); + if (file == null) { + System.err.println("Cannot send file: no file selected."); + return; + } + final NtfyMessageDto localSentDto = new NtfyMessageDto(file.getName(), currentTopic.get(), "file", true); + runOnFx(() -> { + messages.add(localSentDto); + fileToSend.set(null); // Rensa filbilagan i UI + }); + + + // 2. Utför nätverksanropet på en bakgrundstråd + CompletableFuture.runAsync(() -> { + // Nätverksanropet sker här i bakgrunden + try { + connection.sendFile(file, currentTopic.get()); + } catch( Exception e){ + System.err.println("Failed to send file: " + e.getMessage()); + } + }); + } + + /** + * Property for the file currently attached to be sent. + * @return The ObjectProperty containing the File object, or null if no file is attached. + */ + public ObjectProperty fileToSendProperty() { + return fileToSend; + } + + /** + * Sets the file to be sent with the next message. + * @param file The file to attach. Set to null to clear the attachment. + */ + public void setFileToSend(File file) { + this.fileToSend.set(file); + } + + + /** + * Handles an incoming message from the Ntfy connection and adds it to the message list on the FX thread. + * @param message The received NtfyMessageDto. + */ + private void receiveMessage(NtfyMessageDto message) { + + runOnFx(() -> messages.add(message)); + } + + /** + * Returns the observable list of messages received and sent. + * @return The ObservableList of NtfyMessageDto objects. + */ + public ObservableList getMessages() { + return messages; + } + + /** + * Property for the message currently being composed to send. + * @return The StringProperty holding the message content. + */ + public StringProperty messageToSendProperty() { + return messageToSend; + } + + /** + * Property for the current Ntfy topic being subscribed to. + * @return The StringProperty holding the current topic name. + */ + public StringProperty currentTopicProperty() { + return currentTopic; + } + + + /** + * Sets the message content to send. Used by the controller for bidirectional binding. + * @param message The new message content. + */ + public void setMessageToSend(String message) { + this.messageToSend.set(message); } } diff --git a/src/main/java/com/example/NtfyConnection.java b/src/main/java/com/example/NtfyConnection.java new file mode 100644 index 00000000..1aa42e92 --- /dev/null +++ b/src/main/java/com/example/NtfyConnection.java @@ -0,0 +1,22 @@ +package com.example; + +import java.io.File; + +import java.util.function.Consumer; + +public interface NtfyConnection { + + String getTopic(); + + boolean send(String message, String topic); + + boolean sendFile(File file, String topic); + + void connect(String topic, Consumer messageHandler); + + + + public void receive(Consumer messageHandler); + + +} diff --git a/src/main/java/com/example/NtfyConnectionImpl.java b/src/main/java/com/example/NtfyConnectionImpl.java new file mode 100644 index 00000000..8c5cfd2b --- /dev/null +++ b/src/main/java/com/example/NtfyConnectionImpl.java @@ -0,0 +1,175 @@ +package com.example; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.github.cdimascio.dotenv.Dotenv; + +import java.io.File; +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.nio.file.Files; +import java.util.Objects; +import java.util.concurrent.CompletableFuture; +import java.util.function.Consumer; + + +/** + * Implementation of NtfyConnection using Java's built-in HttpClient for both sending messages and subscribing to a topic. + * Manages the HTTP connection logic for text messages, file uploads. + */ +public class NtfyConnectionImpl implements NtfyConnection { + + private final HttpClient http = HttpClient.newHttpClient(); + private final String hostName; + private final ObjectMapper mapper = new ObjectMapper(); + + + private String currentTopic = "mytopic"; + + private CompletableFuture currentSubscription = CompletableFuture.completedFuture(null); + + + public NtfyConnectionImpl() { + + Dotenv dotenv = Dotenv.load(); + + this.hostName = Objects.requireNonNull(dotenv.get("HOST_NAME")); + } + + /** + * Creates a new connection implementation. + * hostName The base URL of the Ntfy server. + */ + public NtfyConnectionImpl(String hostName) { + this.hostName = hostName; + } + + @Override + public String getTopic() { + return currentTopic; + } + + + /** + * Establishes a connection to the Ntfy topic to receive messages in real-time. + * This method runs asynchronously in a dedicated thread. + */ + @Override + public void connect(String newTopic, Consumer messageHandler) { + + currentSubscription.cancel(true); + System.out.println("Cancelled subscription on " + currentTopic); + + + currentTopic = newTopic; + + HttpRequest httpRequest = HttpRequest.newBuilder() + .GET() + .uri(URI.create(hostName + "/" + currentTopic + "/json")) + .build(); + + + currentSubscription = http.sendAsync(httpRequest, HttpResponse.BodyHandlers.ofLines()) + .thenAccept(response -> response.body() + .map(s -> { + try { + return mapper.readValue(s, NtfyMessageDto.class); + } catch (JsonProcessingException e) { + return null; + } + }) + .filter(Objects::nonNull) + .filter(message -> message.event().equals("message")) + .forEach(messageHandler)) + .exceptionally(e -> { + + if (!(e instanceof java.util.concurrent.CancellationException)) { + System.out.println("Failure in receiving on: " + currentTopic + ": " + e.getMessage()); + } + return null; + }); + } + + + /** + * Sends a text message to the specified Ntfy topic. + * @param message The text content of the message. + * @param topic The Ntfy topic to send the message to. + */ + @Override + public boolean send(String message, String topic) { + + HttpRequest httpRequest = HttpRequest.newBuilder() + .POST(HttpRequest.BodyPublishers.ofString(message)) + .header("Cache", "no") + .uri(URI.create(hostName + "/" + topic)) + .build(); + + try { + + HttpResponse response = http.send(httpRequest, HttpResponse.BodyHandlers.discarding()); + + return response.statusCode() >= 200 && response.statusCode() < 300; + } catch (IOException e) { + System.out.println("Error sending message to " + topic + ": " + e.getMessage()); + } catch (InterruptedException e) { + System.out.println("Interrupted sending message to " + topic + ": " + e.getMessage()); + Thread.currentThread().interrupt(); + } + return false; + } + + @Override + public void receive(Consumer messageHandler) { + // Återanvänder connect-logiken + connect(currentTopic, messageHandler); + } + + + private String cleanHeaderValue(String value) { + + return value.replaceAll("[^\\w.\\-]", "-"); + } + + + @Override + public boolean sendFile(File file, String topic) { + if (!file.exists() || !file.isFile()) { + System.out.println("File not valid: " + file.getAbsolutePath()); + return false; + } + + try { + + String contentType = Files.probeContentType(file.toPath()); + if (contentType == null) { + contentType = "application/octet-stream"; + } + + + String cleanedFilename = cleanHeaderValue(file.getName()); + + HttpRequest httpRequest = HttpRequest.newBuilder() + .POST(HttpRequest.BodyPublishers.ofFile(file.toPath())) + .header("Content-Type", contentType) + .header("Filename", cleanedFilename) // Använder det rensade filnamnet + .uri(URI.create(hostName + "/" + topic)) + .build(); + + + HttpResponse response = http.send(httpRequest, HttpResponse.BodyHandlers.discarding()); + return response.statusCode() >= 200 && response.statusCode() < 300; + + } catch (IOException e) { + System.out.println("Could not transfer file or read file: " + e.getMessage()); + return false; + } catch (InterruptedException e) { + System.out.println("Interrupted file transfer: " + e.getMessage()); + Thread.currentThread().interrupt(); + return false; + } + } +} diff --git a/src/main/java/com/example/NtfyMessageDto.java b/src/main/java/com/example/NtfyMessageDto.java new file mode 100644 index 00000000..592300be --- /dev/null +++ b/src/main/java/com/example/NtfyMessageDto.java @@ -0,0 +1,36 @@ +package com.example; + + + + +public record NtfyMessageDto(String id, long time, String event, String topic, String type, String message, boolean isLocal) { + + // Sekundär konstruktor för mottagna meddelanden (isLocal = false) + public NtfyMessageDto(String id, long time, String event, String topic, String type, String message) { + this(id, time, event, topic, type, message, false); + } + + /** + * Konstruktor för att skapa ett lokalt skickat meddelande (som visas i listan innan bekräftelse). + * @param message Den huvudsakliga meddelandetexten eller filnamnet. + * @param topic Den aktuella topicen. + * @param type Typ av innehåll ("text" eller "file"). + * @param isLocal Alltid true för lokalt skickade meddelanden. + */ + public NtfyMessageDto(String message, String topic, String type, boolean isLocal) { + // Fix: Vi använder 0L för att explicit kasta 0 som en long + this(null, 0L, "message", topic, type, message, isLocal); + } + + // Kort konstruktor för att bara skicka en meddelandetext (kanske inte används men fixas för konsekvens) + public NtfyMessageDto(String message) { + this(null, 0L, "message", null, "text", message, true); + } + + @Override + public String toString(){ + return message; + } + +} + diff --git a/src/main/java/module-info.java b/src/main/java/module-info.java index 71574a27..d9d383d6 100644 --- a/src/main/java/module-info.java +++ b/src/main/java/module-info.java @@ -2,6 +2,10 @@ requires javafx.controls; requires javafx.fxml; + requires java.net.http; + requires com.fasterxml.jackson.databind; + requires io.github.cdimascio.dotenv.java; + opens com.example to javafx.fxml; exports com.example; } \ No newline at end of file diff --git a/src/main/resources/com/example/hello-view.fxml b/src/main/resources/com/example/hello-view.fxml index 20a7dc82..c392392e 100644 --- a/src/main/resources/com/example/hello-view.fxml +++ b/src/main/resources/com/example/hello-view.fxml @@ -1,9 +1,67 @@ - - - - - - - + + + + + +
+ +
+ + + + + +