diff --git a/.gitignore b/.gitignore index 6ac465db..244268f9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ target/ /.idea/ +.env diff --git a/pom.xml b/pom.xml index c40f667e..9ee23793 100644 --- a/pom.xml +++ b/pom.xml @@ -45,6 +45,16 @@ javafx-fxml ${javafx.version} + + io.github.cdimascio + dotenv-java + 3.2.0 + + + tools.jackson.core + jackson-databind + 3.0.1 + diff --git a/src/main/java/com/example/HelloController.java b/src/main/java/com/example/HelloController.java index fdd160a0..0a03e770 100644 --- a/src/main/java/com/example/HelloController.java +++ b/src/main/java/com/example/HelloController.java @@ -1,22 +1,73 @@ 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 javafx.stage.FileChooser; +import javafx.stage.Stage; + +import java.io.File; /** * Controller layer: mediates between the view (FXML) and the model. */ public class HelloController { - private final HelloModel model = new HelloModel(); + private final NtfyConnection connection = new NtfyConnectionImpl(); + private final HelloModel model = new HelloModel(connection); + + public ListView messageView; + + @FXML + private Label topic; @FXML - private Label messageLabel; + TextField input; + + File attachment = null; @FXML private void initialize() { - if (messageLabel != null) { - messageLabel.setText(model.getGreeting()); + topic.textProperty().bind(connection.topicProperty()); + messageView.setItems(model.getFormatedMessages()); + model.receiveMessage(); + } + + /** + * Send a message, file if one has been attached else a string from the input field + * @param actionEvent + */ + public void sendMessage(ActionEvent actionEvent) { + if(attachment != null) { + connection.sendFile(attachment); + attachment = null; + } + else{ + model.sendMessage(input.getText().trim()); } + input.clear(); + } + + /** + * Opens a filechooser and adds selected file as attachement to be sent + * @param actionEvent + */ + public void sendFile(ActionEvent actionEvent){ + FileChooser fileChooser = new FileChooser(); + fileChooser.setTitle("Attach a file"); + + Stage stage = (Stage) topic.getScene().getWindow(); + + + attachment = fileChooser.showOpenDialog(stage); + if(attachment != null) { + input.setText(attachment.getAbsolutePath()); + } + } + + public void changeTopic(ActionEvent actionEvent) { + model.changeTopic(); } } diff --git a/src/main/java/com/example/HelloFX.java b/src/main/java/com/example/HelloFX.java index 96bdc5ca..f37f5cb9 100644 --- a/src/main/java/com/example/HelloFX.java +++ b/src/main/java/com/example/HelloFX.java @@ -10,6 +10,7 @@ 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..41a1bb14 100644 --- a/src/main/java/com/example/HelloModel.java +++ b/src/main/java/com/example/HelloModel.java @@ -1,15 +1,133 @@ package com.example; +import javafx.application.Platform; +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; +import javafx.scene.control.*; +import javafx.scene.image.Image; +import javafx.scene.image.ImageView; +import javafx.scene.layout.GridPane; + +import java.io.IOException; +import java.net.URL; +import java.text.DateFormat; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.regex.Pattern; + /** * Model layer: encapsulates application data and business logic. */ public class HelloModel { + + private final NtfyConnection connection; + + private final ObservableList messageHistory = FXCollections.observableArrayList(); + private final ObservableList formatedMessages = FXCollections.observableArrayList(); + + + public HelloModel(NtfyConnection connection) { + this.connection = connection; + } + + + public ObservableList getFormatedMessages() { + return formatedMessages; + } + + public ObservableList getMessageHistory() { + return messageHistory; + } + + /** + * Sends a string as message + * @param message String to send + */ + public void sendMessage(String message) { + connection.send(message); + } + /** - * Returns a greeting based on the current Java and JavaFX versions. + * Clears the display before opening a new connection to a topic and displaying it */ - public String getGreeting() { - String javaVersion = System.getProperty("java.version"); - String javafxVersion = System.getProperty("javafx.version"); - return "Hello, JavaFX " + javafxVersion + ", running on Java " + javaVersion + "."; + public void receiveMessage() { + formatedMessages.clear(); + connection.recieve(m -> Platform.runLater(() -> logMessage(m))); + } + + + /** + * Adds the message to internal message history and the values to be displayed formated in the display list + * If the message is an attachment it displays it as an image or hyperlink depending on content + * @param message received message from NTFY server + */ + public void logMessage(NtfyMessage message) { + messageHistory.add(message); + + if(message.attachment() != null) { + try { + URL url = new URL(message.attachment().get("url")); + + if(Pattern.matches("^image\\/\\w+",message.attachment().get("type"))) {//check if the attachment is an image + ImageView image = new ImageView(new Image(url.toExternalForm())); + image.setPreserveRatio(true); + image.setFitHeight ( 250 ); + image.setFitWidth ( 250 ); + + formatedMessages.addFirst(image); + } + + else{ + Hyperlink hyperlink = new Hyperlink(url.toExternalForm()); + formatedMessages.addFirst(hyperlink); + } + + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + + Date timeStamp = new Date(message.time()*1000); + DateFormat dateFormat = new SimpleDateFormat("HH:mm:ss"); + String stringMessage = dateFormat.format(timeStamp) + " : " + message.message(); + formatedMessages.addFirst(stringMessage); + } + + /** + * Opens as dialog with text input + * If a value is entered and the add button pressed, change the topic per the input + */ + public void changeTopic() { + Dialog dialog = new Dialog(); + dialog.setTitle("Add Topic"); + + ButtonType addTopicButton = new ButtonType("Add Topic", ButtonBar.ButtonData.OK_DONE); + dialog.getDialogPane().getButtonTypes().addAll(addTopicButton, ButtonType.CANCEL); + + TextField newTopic = new TextField(); + newTopic.setPromptText("Topic"); + + GridPane gridPane = new GridPane(); + gridPane.setHgap(10); + gridPane.setVgap(10); + + gridPane.add(newTopic, 0, 0); + + dialog.getDialogPane().setContent(gridPane); + + Platform.runLater(() -> newTopic.requestFocus()); + + dialog.setResultConverter(pressedButton -> { + if (pressedButton == addTopicButton) { + if(!newTopic.getText().isBlank()){ + connection.setTopic(newTopic.getText().trim()); + receiveMessage(); + } + } + return null; + }); + + dialog.show(); } -} +} \ 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..f5a0e5cf --- /dev/null +++ b/src/main/java/com/example/NtfyConnection.java @@ -0,0 +1,23 @@ +package com.example; + +import javafx.beans.property.SimpleStringProperty; + +import java.io.File; +import java.util.function.Consumer; + +public interface NtfyConnection { + + SimpleStringProperty topic = new SimpleStringProperty(); + + public SimpleStringProperty topicProperty(); + + public String getTopic(); + + public void setTopic(String topic); + + public boolean send(String message); + + public void recieve(Consumer messageHandler); + + public boolean sendFile(File attachment); +} diff --git a/src/main/java/com/example/NtfyConnectionImpl.java b/src/main/java/com/example/NtfyConnectionImpl.java new file mode 100644 index 00000000..fcf135b4 --- /dev/null +++ b/src/main/java/com/example/NtfyConnectionImpl.java @@ -0,0 +1,141 @@ +package com.example; + +import io.github.cdimascio.dotenv.Dotenv; +import javafx.beans.property.SimpleStringProperty; +import tools.jackson.databind.ObjectMapper; + +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.nio.file.Paths; +import java.util.Objects; +import java.util.function.Consumer; + +public class NtfyConnectionImpl implements NtfyConnection { + + private final HttpClient httpClient = HttpClient.newHttpClient(); + private final String HOSTNAME; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + private SimpleStringProperty topic = new SimpleStringProperty("JUV25D"); //Default value simplifies code + + public NtfyConnectionImpl() { + Dotenv dotenv = Dotenv.load(); + HOSTNAME = Objects.requireNonNull(dotenv.get("HOSTNAME")); + } + + public NtfyConnectionImpl(String HOSTNAME) { + this.HOSTNAME = HOSTNAME; + } + + @Override + public SimpleStringProperty topicProperty() { + return topic; + } + + + @Override + public String getTopic() { + return topic.getValue(); + } + + + @Override + public void setTopic(String topic) { + if (topic != null && !topic.isEmpty()) { + this.topic.setValue(topic); + } + } + + /** + * Sends a string as message + * + * @param message String to send + */ + @Override + public boolean send(String message) { + if (message == null || message.isBlank()) return false; //only send messages that isn't null and has text + + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(HOSTNAME + "/" + topic.getValue())) + .POST(HttpRequest.BodyPublishers.ofString(message)) + .build(); + + try { + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + + if (response.statusCode() >= 200 && response.statusCode() < 300) { + return true; + } else { + System.out.println("Error sending message " + response.statusCode()); + return false; + } + } catch (IOException e) { + System.err.println("Error sending message"); + } catch (InterruptedException e) { + System.err.println("Interrupted sending message"); + } + return false; + } + + + /** + * Converts a file to bytes and sends it with correct header + * + * @param attachment File to be sent + */ + public boolean sendFile(File attachment) { + if (attachment == null || !attachment.exists()) return false; + + try { + byte[] attachmentAsBytes = Files.readAllBytes(Paths.get(attachment.getAbsolutePath())); + + String contentType = Files.probeContentType(attachment.toPath()); + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(HOSTNAME + "/" + topic.getValue())) + .header("Content-Type", contentType) + .header("Filename", attachment.getName()) + .POST(HttpRequest.BodyPublishers.ofByteArray(attachmentAsBytes)) + .build(); + + + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + + if (response.statusCode() >= 200 && response.statusCode() < 300) { + return true; + } else { + System.out.println("Error sending attachment " + response.statusCode()); + return false; + } + } catch (IOException e) { + System.err.println("Error sending attachment"); + } catch (InterruptedException e) { + System.err.println("Interrupted sending attachment"); + } + return false; + } + + + @Override + public void recieve(Consumer messageHandler) { + HttpRequest httpRequest = HttpRequest.newBuilder() + .GET() + .uri(URI.create(HOSTNAME + "/" + topic.getValue() + "/json?since=all")) + .build(); + + httpClient.sendAsync(httpRequest, HttpResponse.BodyHandlers.ofLines()) + .thenAccept(response -> response.body() + .map(s -> objectMapper.readValue(s, NtfyMessage.class)) + .filter(m -> m.event().equals("message")) + .forEach(messageHandler)); + } +} + + diff --git a/src/main/java/com/example/NtfyMessage.java b/src/main/java/com/example/NtfyMessage.java new file mode 100644 index 00000000..e8d1a5ef --- /dev/null +++ b/src/main/java/com/example/NtfyMessage.java @@ -0,0 +1,9 @@ +package com.example; + + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +import java.util.HashMap; + +@JsonIgnoreProperties(ignoreUnknown = true) +public record NtfyMessage(String id, long time, String event, String topic, String message, HashMap attachment){} diff --git a/src/main/java/module-info.java b/src/main/java/module-info.java index 71574a27..ad7ed70a 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 io.github.cdimascio.dotenv.java; + requires java.net.http; + requires tools.jackson.databind; + requires javafx.graphics; + requires javafx.base; 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..49277535 100644 --- a/src/main/resources/com/example/hello-view.fxml +++ b/src/main/resources/com/example/hello-view.fxml @@ -1,9 +1,30 @@ - - - - - - + + + + +
+ + + + + +
+ + + +