diff --git a/.env b/.env new file mode 100644 index 00000000..af739aa1 --- /dev/null +++ b/.env @@ -0,0 +1,2 @@ +NTFY_BASE_URL=https://ntfy.fungover.org +NTFY_TOPIC=mytopic \ No newline at end of file diff --git a/pom.xml b/pom.xml index c40f667e..59eca387 100644 --- a/pom.xml +++ b/pom.xml @@ -1,11 +1,12 @@ + xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 + http://maven.apache.org/xsd/maven-4.0.0.xsd"> 4.0.0 com.example - javafx + javafx-chat 1.0-SNAPSHOT @@ -16,6 +17,7 @@ 5.20.0 25 + org.junit.jupiter @@ -35,6 +37,7 @@ ${mockito.version} test + org.openjfx javafx-controls @@ -45,7 +48,41 @@ javafx-fxml ${javafx.version} + + org.openjfx + javafx-swing + ${javafx.version} + + + + com.fasterxml.jackson.core + jackson-databind + 2.17.2 + + + com.fasterxml.jackson.core + jackson-core + 2.17.2 + + + com.fasterxml.jackson.core + jackson-annotations + 2.17.2 + + + io.github.cdimascio + dotenv-java + 3.2.0 + + + + org.wiremock + wiremock + 4.0.0-beta.15 + test + + @@ -55,7 +92,7 @@ com.example.HelloFX - + javafx true diff --git a/src/main/java/com/example/FakeNtfyConnection.java b/src/main/java/com/example/FakeNtfyConnection.java new file mode 100644 index 00000000..373930ed --- /dev/null +++ b/src/main/java/com/example/FakeNtfyConnection.java @@ -0,0 +1,107 @@ +package com.example; + +import java.io.File; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.function.Consumer; + +/** + * A fake implementation of {@link NtfyConnection} for testing purposes. + * Stores sent messages and files in memory and simulates incoming messages. + */ +public class FakeNtfyConnection implements NtfyConnection { + + private final List sentMessages = new ArrayList<>(); + private final List sentFiles = new ArrayList<>(); + private Consumer messageHandler; + private boolean shouldSucceed = true; + + /** + * Sends a text message. + * Stores the message in memory and returns success based on {@link #shouldSucceed}. + * + * @param message the message to send + * @return true if operation succeeds, false otherwise + */ + @Override + public boolean send(String message) { + sentMessages.add(Objects.requireNonNull(message)); + return shouldSucceed; + } + + /** + * Sends a file. + * Stores the file in memory if it exists and returns success based on {@link #shouldSucceed}. + * + * @param file the file to send + * @return true if file exists and operation succeeds, false otherwise + */ + @Override + public boolean sendFile(File file) { + if (file != null && file.exists()) { + sentFiles.add(file); + return shouldSucceed; + } + return false; + } + + /** + * Registers a message handler to receive incoming messages. + * Only the last registered handler will be active. + * + * @param handler the consumer that handles incoming messages + */ + @Override + public void receive(Consumer handler) { + this.messageHandler = Objects.requireNonNull(handler); + } + + /** + * Simulates an incoming message. + * Calls the registered handler if present. + * + * @param message the message to simulate + */ + public void simulateIncomingMessage(NtfyMessageDto message) { + if (messageHandler != null) { + messageHandler.accept(message); + } + } + + /** + * Returns a copy of all sent messages for verification. + * + * @return list of sent messages + */ + public List getSentMessages() { + return new ArrayList<>(sentMessages); + } + + /** + * Returns a copy of all sent files for verification. + * + * @return list of sent files + */ + public List getSentFiles() { + return new ArrayList<>(sentFiles); + } + + /** + * Configures whether operations should succeed. + * + * @param shouldSucceed true for success, false for failure + */ + public void setShouldSucceed(boolean shouldSucceed) { + this.shouldSucceed = shouldSucceed; + } + + /** + * Clears all stored messages, files, and the message handler. + */ + public void clear() { + sentMessages.clear(); + sentFiles.clear(); + messageHandler = null; + } +} diff --git a/src/main/java/com/example/HelloController.java b/src/main/java/com/example/HelloController.java index fdd160a0..5d7ecf2f 100644 --- a/src/main/java/com/example/HelloController.java +++ b/src/main/java/com/example/HelloController.java @@ -1,22 +1,383 @@ package com.example; +import javafx.animation.KeyFrame; +import javafx.animation.Timeline; +import javafx.application.Platform; import javafx.fxml.FXML; -import javafx.scene.control.Label; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.Scene; +import javafx.scene.control.*; +import javafx.scene.image.Image; +import javafx.scene.image.ImageView; +import javafx.scene.layout.HBox; +import javafx.scene.layout.VBox; +import javafx.scene.layout.StackPane; +import javafx.stage.FileChooser; +import javafx.stage.Stage; + +import java.io.File; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; -/** - * Controller layer: mediates between the view (FXML) and the model. - */ public class HelloController { + @FXML private ListView messageView; + @FXML private TextField inputField; + @FXML private Label messageLabel; + private final HelloModel model = new HelloModel(); + private Stage primaryStage; + private File selectedFile; + private final String myTopic = "MY_TOPIC"; + private final Set sentMessageIds = ConcurrentHashMap.newKeySet(); + private final Set pendingMessageTexts = ConcurrentHashMap.newKeySet(); + private final Set pendingFileNames = ConcurrentHashMap.newKeySet(); - @FXML - private Label messageLabel; + /** + * Sets the primary stage for this controller + * @param stage the primary JavaFX stage to use for dialog windows + */ + public void setPrimaryStage(Stage stage) { + this.primaryStage = stage; + } @FXML private void initialize() { - if (messageLabel != null) { - messageLabel.setText(model.getGreeting()); + try { + messageView.setItems(model.getMessages()); + inputField.setOnAction(event -> sendMessage()); + model.getMessages().addListener((javafx.collections.ListChangeListener.Change change) -> { + while (change.next()) { + if (change.wasAdded()) { + Platform.runLater(() -> { + messageView.scrollTo(messageView.getItems().size() - 1); + messageView.refresh(); + }); + } + } + }); + + showWelcomeMessage(); + + messageView.setCellFactory(lv -> new ListCell<>() { + /** + * Updates the list cell content for each message + * @param item the NtfyMessageDto to display, contains message data + * @param empty indicates if the cell is empty and should be cleared + */ + @Override + protected void updateItem(NtfyMessageDto item, boolean empty) { + super.updateItem(item, empty); + if (empty || item == null) { + setText(null); + setGraphic(null); + return; + } + boolean isMyMessage = isMyMessage(item); + + VBox messageContainer = new VBox(2); + messageContainer.setAlignment(Pos.CENTER); + messageContainer.setPadding(new Insets(5, 10, 5, 10)); + + Label senderLabel = new Label(isMyMessage ? "You:" : "Incoming:"); + senderLabel.setStyle("-fx-font-size: 11px; -fx-text-fill: #00FF41; -fx-font-weight: bold;"); + + HBox contentBox = new HBox(5); + contentBox.setAlignment(Pos.CENTER); + + if (item.hasAttachment()) { + ImageView icon = createIconForAttachment(item); + icon.setFitWidth(20); + icon.setFitHeight(20); + + Label messageLabel = new Label("📎 " + item.getAttachmentName()); + messageLabel.setWrapText(true); + messageLabel.setMaxWidth(400); + messageLabel.setStyle( + "-fx-background-color: " + (isMyMessage ? "#003B00" : "#002800") + ";" + + "-fx-text-fill: #00FF41;" + + "-fx-padding: 8px 12px;" + + "-fx-background-radius: 15px;" + + "-fx-font-size: 14px;" + + "-fx-border-color: " + (isMyMessage ? "#008F11" : "#006400") + ";" + + "-fx-border-width: 1px;" + + "-fx-border-radius: 15px;" + ); + + contentBox.getChildren().addAll(icon, messageLabel); + } else { + String displayText = getDisplayText(item); + Label messageLabel = new Label(displayText); + messageLabel.setWrapText(true); + messageLabel.setMaxWidth(400); + messageLabel.setStyle( + "-fx-background-color: " + (isMyMessage ? "#003B00" : "#002800") + ";" + + "-fx-text-fill: #00FF41;" + + "-fx-padding: 8px 12px;" + + "-fx-background-radius: 15px;" + + "-fx-font-size: 14px;" + + "-fx-border-color: " + (isMyMessage ? "#008F11" : "#006400") + ";" + + "-fx-border-width: 1px;" + + "-fx-border-radius: 15px;" + ); + contentBox.getChildren().add(messageLabel); + } + + Label timeLabel = new Label(item.getFormattedTime()); + timeLabel.setStyle("-fx-font-size: 10px; -fx-text-fill: #00AA00;"); + + messageContainer.getChildren().addAll(senderLabel, contentBox, timeLabel); + + setGraphic(messageContainer); + setText(null); + setAlignment(Pos.CENTER); + } + + private String getDisplayText(NtfyMessageDto item) { + if (item.message() != null && !item.message().isBlank()) { + return item.message(); + } else { + return "(No message)"; + } + } + }); + } catch (Exception e) { + showAlert("Error during initialization: " + e.getMessage()); + e.printStackTrace(); } } -} + + /** + * Simplified method to determine if a message is from the current user + * @param item the message DTO to check ownership of + * @return true if the message was sent by current user, false otherwise + */ + private boolean isMyMessage(NtfyMessageDto item) { + if (sentMessageIds.contains(item.id())) {return true;} + if (item.message() != null && pendingMessageTexts.contains(item.message())) {return true;} + if (item.hasAttachment() && item.getAttachmentName() != null && pendingFileNames.contains(item.getAttachmentName())) {return true;} + return myTopic.equals(item.topic());} + + @FXML + private void onSend() { sendMessage(); } + + @FXML + private void sendMessage() { + try { + if (selectedFile != null) { + sendFileMessage(); + return; + } + + sendTextMessage(); + } catch (Exception e) { + handleSendError(e); + } + } + + /** + * Handles sending file messages with proper cleanup and tracking + */ + private void sendFileMessage() { + String fileName = selectedFile.getName(); + pendingFileNames.add(fileName); + Platform.runLater(() -> messageView.refresh()); + + boolean ok = model.sendFile(selectedFile); + messageLabel.setText(ok ? "File sent" : "File error"); + + scheduleCleanup(() -> { + pendingFileNames.remove(fileName); + trackRealFileId(fileName); + }, 2000); + + selectedFile = null; + } + + /** + * Handles sending text messages with validation and cleanup + */ + private void sendTextMessage() { + String text = inputField.getText().trim(); + if (text.isEmpty()) { + messageLabel.setText("Write something before sending."); + return; + } + + pendingMessageTexts.add(text); + Platform.runLater(() -> messageView.refresh()); + model.sendMessage(text); + inputField.clear(); + messageLabel.setText("Message sent"); + + scheduleCleanup(() -> { + pendingMessageTexts.remove(text); + trackRealMessageId(text); + }, 2000); + } + + /** + * Schedules cleanup tasks to run after a delay + * @param cleanupTask the task to execute + * @param delayMillis delay in milliseconds + */ + private void scheduleCleanup(Runnable cleanupTask, long delayMillis) { + Timeline cleanupTimeline = new Timeline( + new KeyFrame(javafx.util.Duration.millis(delayMillis), e -> cleanupTask.run()) + ); + cleanupTimeline.play(); + } + + /** + * Handles send errors with consistent error reporting + * @param e the exception that occurred + */ + private void handleSendError(Exception e) { + String errorMessage = "Send error: " + e.getMessage(); + messageLabel.setText(errorMessage); + System.err.println("❌ SEND ERROR: " + errorMessage); + e.printStackTrace(); + } + + /** + * Find the real message ID for a sent message by matching text content + * @param expectedText the text content to search for in received messages + */ + private void trackRealMessageId(String expectedText) { + for (NtfyMessageDto message : model.getMessages()) { + if (expectedText.equals(message.message()) && + !sentMessageIds.contains(message.id())) { + sentMessageIds.add(message.id()); + Platform.runLater(() -> messageView.refresh()); + break; + } + } + } + + /** + * Find the real message ID for a sent file + */ + private void trackRealFileId(String expectedFileName) { + for (NtfyMessageDto message : model.getMessages()) { + if (message.hasAttachment() && expectedFileName.equals(message.getAttachmentName()) && + !sentMessageIds.contains(message.id())) { + sentMessageIds.add(message.id()); + Platform.runLater(() -> messageView.refresh()); + break; + } + } + } + + private void showWelcomeMessage() { + Platform.runLater(() -> { + Alert welcomeAlert = new Alert(Alert.AlertType.INFORMATION); + welcomeAlert.setTitle("Matrix Binary Chat"); + welcomeAlert.setHeaderText(null); + + String welcomeText = "Welcome to the Matrix Binary Chat!\n\n" + + "💡 Tips:\n" + + "• Double tap picture icon to open pictures\n" + + "• Files and pictures are automatically downloaded to 'downloads' folder\n" + + "• Chat in real-time with binary encryption\n\n" + + "This message will auto-close in 12 seconds..."; + + Label contentLabel = new Label(welcomeText); + contentLabel.setStyle("-fx-font-family: 'Consolas'; -fx-font-size: 12px; -fx-text-fill: #00FF41; -fx-background-color: #001100; -fx-padding: 10px;"); + contentLabel.setWrapText(true); + + welcomeAlert.getDialogPane().setContent(contentLabel); + welcomeAlert.getDialogPane().setStyle( + "-fx-background-color: #001100; " + + "-fx-border-color: #00FF41; " + + "-fx-border-width: 2px; " + + "-fx-border-radius: 5px;" + ); + welcomeAlert.getDialogPane().lookupButton(ButtonType.OK).setStyle( + "-fx-background-color: #003B00; " + + "-fx-text-fill: #00FF41; " + + "-fx-border-color: #00FF41; " + + "-fx-font-family: 'Consolas';" + ); + welcomeAlert.setGraphic(null); + Timeline autoCloseTimeline = new Timeline( + new KeyFrame(javafx.util.Duration.seconds(12), e -> welcomeAlert.close()) + ); + autoCloseTimeline.play(); + welcomeAlert.show(); + }); + } + + /** + * Creates an appropriate icon view for different attachment types + * @param item the message DTO containing attachment information + * @return ImageView configured with appropriate icon and click handlers + */ + private ImageView createIconForAttachment(NtfyMessageDto item) { + ImageView iconView = new ImageView(); + try { + String type = item.getAttachmentContentType(); + File file = new File("downloads", item.getAttachmentName()); + + if (type != null && type.startsWith("image/")) { + iconView.setImage(new Image(getClass().getResourceAsStream("/icons/image.png"))); + + if (file.exists()) { + iconView.setOnMouseClicked(e -> { + if (e.getClickCount() == 2) { + ImageView fullImage = new ImageView(new Image(file.toURI().toString())); + fullImage.setPreserveRatio(true); + fullImage.setFitWidth(600); + fullImage.setFitHeight(600); + + Stage stage = new Stage(); + stage.setTitle(item.getAttachmentName()); + stage.setScene(new Scene(new StackPane(fullImage), 600, 600)); + stage.show(); + } + }); + } + } else if ("application/pdf".equals(type)) { + iconView.setImage(new Image(getClass().getResourceAsStream("/icons/pdf.png"))); + } else if ("application/zip".equals(type)) { + iconView.setImage(new Image(getClass().getResourceAsStream("/icons/zip.png"))); + } else { + iconView.setImage(new Image(getClass().getResourceAsStream("/icons/file.png"))); + } + } catch (Exception e) { + System.err.println("❌ ICON ERROR: " + e.getMessage()); + e.printStackTrace(); + try { + iconView.setImage(new Image(getClass().getResourceAsStream("/icons/file.png"))); + } catch (Exception ignored) {} + } + return iconView; + } + + @FXML + private void attachFile() { + try { + FileChooser chooser = new FileChooser(); + selectedFile = chooser.showOpenDialog(primaryStage); + if (selectedFile != null) messageLabel.setText("Selected file: " + selectedFile.getName()); + } catch (Exception e) { + showAlert("File selection error: " + e.getMessage()); + System.err.println("❌ FILE CHOOSER ERROR: "); + e.printStackTrace(); + } + } + + /** + * Shows an error alert dialog with the specified message + * @param msg the error message to display in the alert dialog + */ + private void showAlert(String msg) { + Platform.runLater(() -> { + Alert alert = new Alert(Alert.AlertType.ERROR); + alert.setTitle("Error"); + alert.setHeaderText(null); + alert.setContentText(msg); + alert.showAndWait(); + }); + } +} \ 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..c0e21efd 100644 --- a/src/main/java/com/example/HelloFX.java +++ b/src/main/java/com/example/HelloFX.java @@ -2,24 +2,41 @@ import javafx.application.Application; import javafx.fxml.FXMLLoader; -import javafx.scene.Parent; import javafx.scene.Scene; +import javafx.scene.layout.StackPane; import javafx.stage.Stage; public class HelloFX extends Application { + /** + * Main entry point for the JavaFX application + * @param stage the primary stage for this application, onto which + * the application scene can be set + * @throws Exception if loading the FXML file or initializing the scene fails + */ @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"); + FXMLLoader loader = new FXMLLoader(getClass().getResource("/hello-view.fxml")); + StackPane root = loader.load(); + + MatrixRain rain = new MatrixRain(700, 600); + rain.setOpacity(0.15); + root.getChildren().add(0, rain); + + HelloController ctrl = loader.getController(); + ctrl.setPrimaryStage(stage); + Scene scene = new Scene(root, 700, 600); + stage.setTitle("Matrix Binary Chat"); stage.setScene(scene); stage.show(); + rain.startAnimation(); } + /** + * Application main method + * @param args the command line arguments passed to the 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..97a56de5 100644 --- a/src/main/java/com/example/HelloModel.java +++ b/src/main/java/com/example/HelloModel.java @@ -1,15 +1,153 @@ package com.example; -/** - * Model layer: encapsulates application data and business logic. - */ +import javafx.application.Platform; +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; +import javafx.scene.control.Alert; +import javafx.scene.image.Image; + +import java.io.*; +import java.net.URL; +import java.util.Objects; +import java.util.concurrent.Executor; +import java.util.function.Consumer; + public class HelloModel { + + private final NtfyConnection connection; + private final ObservableList messages = FXCollections.observableArrayList(); + private final Executor uiExecutor; + private final Consumer errorHandler; + + /** + * Default constructor that creates a new NtfyConnection instance + */ + public HelloModel() { + this(new NtfyConnectionImpl(), Platform::runLater, HelloModel::showPlatformAlert); + } + + /** + * Constructor that accepts a custom NtfyConnection instance + * @param connection the NtfyConnection implementation to use for messaging + * @throws NullPointerException if connection is null + */ + public HelloModel(NtfyConnection connection) { + this(connection, Platform::runLater, HelloModel::showPlatformAlert); + } + + /** + * Constructor for testing with custom connection and executor + * @param connection the NtfyConnection implementation to use for messaging + * @param uiExecutor the executor to use for UI operations + * @param errorHandler the error handler for displaying errors + * @throws NullPointerException if connection or uiExecutor is null + */ + public HelloModel(NtfyConnection connection, Executor uiExecutor, Consumer errorHandler) { + this.connection = Objects.requireNonNull(connection); + this.uiExecutor = Objects.requireNonNull(uiExecutor); + this.errorHandler = Objects.requireNonNull(errorHandler); + receiveMessages(); + } + + /** + * Default platform alert implementation + */ + private static void showPlatformAlert(String message) { + Platform.runLater(() -> { + Alert alert = new Alert(Alert.AlertType.ERROR); + alert.setContentText(message); + alert.showAndWait(); + }); + } + + /** + * Gets the observable list of messages for UI binding + * @return ObservableList containing all received and sent messages + */ + public ObservableList getMessages() { + return messages; + } + + /** + * Sends a text message through the Ntfy connection + * @param text the message text to send + */ + public void sendMessage(String text) { + if (!connection.send(text)) showError("Could not send message"); + } + + /** + * Sends a file through the Ntfy connection + * @param file the file to send, must exist and not be null + * @return true if file was sent successfully, false otherwise + */ + public boolean sendFile(File file) { + if (file == null || !file.exists()) return false; + try { + boolean ok = connection.sendFile(file); + if (!ok) showError("Could not send file"); + return ok; + } catch (Exception e) { + showError("Error sending file: " + e.getMessage()); + return false; + } + } + + /** + * Starts receiving messages from the Ntfy connection + * Automatically filters system events and handles attachments + */ + public void receiveMessages() { + connection.receive(msg -> uiExecutor.execute(() -> { + // Filtrera bort system-event + if (!"message".equals(msg.event())) return; + + messages.add(msg); + if (msg.hasAttachment()) { + try { + saveAttachmentAutomatically(msg); + } catch (IOException e) { + showError("Error downloading file: " + e.getMessage()); + } + } + })); + } + + /** + * Automatically saves attachments from messages to the downloads folder + * @param item the message DTO containing attachment information + * @throws IOException if the download or file creation fails + */ + private void saveAttachmentAutomatically(NtfyMessageDto item) throws IOException { + if (item.getAttachmentUrl() == null) return; + File downloads = new File("downloads"); + if (!downloads.exists()) downloads.mkdirs(); + File dest = new File(downloads, item.getAttachmentName()); + downloadFile(item.getAttachmentUrl(), dest); + } + + /** + * Downloads a file from a URL to a local destination + * @param urlString the URL string of the file to download + * @param dest the destination file where the download will be saved + * @throws IOException if the network connection or file writing fails + */ + private void downloadFile(String urlString, File dest) throws IOException { + try (InputStream in = new URL(urlString).openStream(); + OutputStream out = new FileOutputStream(dest)) { + byte[] buffer = new byte[8192]; + int bytesRead; + while ((bytesRead = in.read(buffer)) != -1) { + out.write(buffer, 0, bytesRead); + } + } + } + /** - * Returns a greeting based on the current Java and JavaFX versions. + * Shows an error using the configured error handler + * @param msg the error message to display */ - public String getGreeting() { - String javaVersion = System.getProperty("java.version"); - String javafxVersion = System.getProperty("javafx.version"); - return "Hello, JavaFX " + javafxVersion + ", running on Java " + javaVersion + "."; + private void showError(String msg) { + errorHandler.accept(msg); } -} +} \ No newline at end of file diff --git a/src/main/java/com/example/MatrixRain.java b/src/main/java/com/example/MatrixRain.java new file mode 100644 index 00000000..81196734 --- /dev/null +++ b/src/main/java/com/example/MatrixRain.java @@ -0,0 +1,57 @@ +package com.example; + +import javafx.animation.AnimationTimer; +import javafx.scene.canvas.Canvas; +import javafx.scene.canvas.GraphicsContext; +import javafx.scene.paint.Color; + +import java.util.Random; + +public class MatrixRain extends Canvas { + private final int width; + private final int height; + private final char[] chars = "01".toCharArray(); + private final int fontSize = 16; + private final int columns; + private final int[] drops; + private final Random random = new Random(); + + + /** + * Creates a MatrixRain effect canvas with the specified dimensions + * @param width the width of the canvas in pixels + * @param height the height of the canvas in pixels + */ + public MatrixRain(int width, int height) { + super(width, height); + this.width = width; + this.height = height; + this.columns = width / fontSize; + this.drops = new int[columns]; + for (int i = 0; i < columns; i++) drops[i] = 1; + } + + /** + * Starts the Matrix digital rain animation effect + * Creates falling binary digits (0s and 1s) with trailing effects + */ + public void startAnimation() { + GraphicsContext gc = getGraphicsContext2D(); + new AnimationTimer() { + @Override + public void handle(long now) { + gc.setFill(Color.rgb(0, 0, 0, 0.1)); + gc.fillRect(0, 0, width, height); + gc.setFill(Color.LIME); + gc.setFont(javafx.scene.text.Font.font(fontSize)); + + for (int i = 0; i < columns; i++) { + char c = chars[random.nextInt(chars.length)]; + gc.fillText(String.valueOf(c), i * fontSize, drops[i] * fontSize); + if (drops[i] * fontSize > height && random.nextDouble() > 0.975) drops[i] = 0; + drops[i]++; + } + } + }.start(); + } +} \ 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..c74ce531 --- /dev/null +++ b/src/main/java/com/example/NtfyConnection.java @@ -0,0 +1,26 @@ +package com.example; + +import java.io.File; +import java.util.function.Consumer; + +public interface NtfyConnection { + /** + * Sends a text message to the NTFY topic + * @param message the text message to send + * @return true if the message was sent successfully, false otherwise + */ + boolean send(String message); + + /** + * Starts receiving messages from the NTFY topic + * @param messageHandler the consumer callback that will process incoming messages + */ + void receive(Consumer messageHandler); + + /** + * Sends a file attachment to the NTFY topic + * @param file the file to send as an attachment + * @return true if the file was sent successfully, false otherwise + */ + boolean sendFile(File file); +} \ 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..4846322f --- /dev/null +++ b/src/main/java/com/example/NtfyConnectionImpl.java @@ -0,0 +1,150 @@ +package com.example; + +import com.fasterxml.jackson.databind.DeserializationFeature; +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.*; +import java.nio.file.Files; +import java.time.Duration; +import java.util.Objects; +import java.util.function.Consumer; + +public class NtfyConnectionImpl implements NtfyConnection { + + private final HttpClient http; + private final ObjectMapper mapper = new ObjectMapper(); + private final String hostName; + private final String topic; + + /** + * Default constructor that loads configuration from .env file + * Initializes HTTP client and JSON mapper with required settings + */ + public NtfyConnectionImpl() { + Dotenv dotenv = Dotenv.load(); + + this.hostName = Objects.requireNonNull(dotenv.get("NTFY_BASE_URL"), "Missing NTFY_BASE_URL in .env"); + this.topic = Objects.requireNonNull(dotenv.get("NTFY_TOPIC"), "Missing NTFY_TOPIC in .env"); + + this.http = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(5)) + .version(HttpClient.Version.HTTP_1_1) + .build(); + + mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + } + + /** + * Checks if an HTTP response indicates success + * @param resp the HTTP response to check + * @return true if status code is in 200-299 range, false otherwise + */ + private boolean isSuccess(HttpResponse resp) { + int c = resp.statusCode(); + return c >= 200 && c < 300; + } + + @Override + /** + * Sends a text message to the NTFY topic + * @param message the text message to send to the NTFY topic + * @return true if the message was sent successfully, false if an error occurred + */ + public boolean send(String message) { + + System.out.println("📤 RAW SENT: " + message); + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(hostName + "/" + topic)) + .timeout(Duration.ofSeconds(10)) + .POST(HttpRequest.BodyPublishers.ofString(message)) + .build(); + + try { + http.send(request, HttpResponse.BodyHandlers.ofString()); + return true; + } catch (Exception e) { + System.err.println("❌ SEND ERROR: " + e.getMessage()); + return false; + } + } + + @Override + /** + * Starts receiving messages from the NTFY topic asynchronously + * @param handler the consumer callback that processes incoming NTFY messages + */ + public void receive(Consumer handler) { + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(hostName + "/" + topic + "/json")) + .GET() + .build(); + + http.sendAsync(request, HttpResponse.BodyHandlers.ofLines()) + .thenAccept(resp -> { + if (!isSuccess(resp)) { + System.err.println("❌ SUBSCRIBE FAILED: " + resp.statusCode()); + return; + } + + resp.body().forEach(line -> { + if (line.isBlank()) return; + + System.out.println("📥 RAW RECEIVED: " + line); + + try { + NtfyMessageDto msg = mapper.readValue(line, NtfyMessageDto.class); + handler.accept(msg); + } catch (Exception e) { + System.err.println("❌ JSON ERROR: " + e.getMessage()); + } + }); + }) + .exceptionally(err -> { + System.err.println("❌ RECEIVE ERROR: " + err.getMessage()); + return null; + }); + } + + + @Override + /** + * Sends a file attachment to the NTFY topic + * @param file the file to send as an attachment to the NTFY topic + * @return true if the file was sent successfully, false if the file is missing or an error occurred + */ + public boolean sendFile(File file) { + if (file == null || !file.exists()) { + System.err.println("❌ File missing"); + return false; + } + + try { + byte[] data = Files.readAllBytes(file.toPath()); + String type = Files.probeContentType(file.toPath()); + if (type == null) type = "application/octet-stream"; + + System.out.println("📤 RAW SENT (FILE): " + file.getName() + + " (" + data.length + " bytes)"); + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(hostName + "/" + topic)) + .header("Content-Type", type) + .header("Filename", file.getName()) + .POST(HttpRequest.BodyPublishers.ofByteArray(data)) + .build(); + + http.send(request, HttpResponse.BodyHandlers.ofString()); + return true; + + } catch (Exception e) { + System.err.println("❌ FILE SEND ERROR: " + e.getMessage()); + 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..76d31f5d --- /dev/null +++ b/src/main/java/com/example/NtfyMessageDto.java @@ -0,0 +1,68 @@ +package com.example; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.time.Instant; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; + +/** + * Data Transfer Object for Ntfy messages + * + * @param id Unique message identifier + * @param time Unix timestamp in seconds + * @param event Type of event (message, open, keepalive) + * @param topic Topic the message was sent to + * @param message The message content + * @param title Message title (optional) + * @param attachment File attachment (optional) + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public record NtfyMessageDto( + String id, + long time, + String event, + String topic, + String message, + String title, + @JsonProperty("attachment") Attachment attachment +) { + + public boolean hasAttachment() {return attachment != null;} + public String getAttachmentUrl() {return attachment != null ? attachment.url() : null;} + public String getAttachmentName() {return attachment != null ? attachment.name() : null;} + public String getAttachmentContentType() {return attachment != null ? attachment.type() : null;} + + /** + * Formats the timestamp to readable time + * @return Formatted time string (HH:mm:ss) + */ + public String getFormattedTime() { + try { return Instant.ofEpochSecond(time).atZone(ZoneId.systemDefault()).format(DateTimeFormatter.ofPattern("HH:mm:ss")); + } catch (Exception e) {return "Invalid time";}} + + @Override + public String toString() { + return "NtfyMessageDto{" + + "id='" + id + '\'' + + ", time=" + time + + ", event='" + event + '\'' + + ", topic='" + topic + '\'' + + ", message='" + message + '\'' + + ", title='" + title + '\'' + + ", attachment=" + attachment + + '}'; + } +} + +/** + * Represents a file attachment + * + * @param name File name + * @param url Download URL + * @param type Content type + * @param size File size in bytes + */ +@JsonIgnoreProperties(ignoreUnknown = true) +record Attachment(String name, String url, String type, long size) {} \ No newline at end of file diff --git a/src/main/java/module-info.java b/src/main/java/module-info.java index 71574a27..b1c084b6 100644 --- a/src/main/java/module-info.java +++ b/src/main/java/module-info.java @@ -1,7 +1,15 @@ module hellofx { requires javafx.controls; requires javafx.fxml; + requires io.github.cdimascio.dotenv.java; + requires java.net.http; + requires javafx.graphics; + requires java.desktop; + + requires com.fasterxml.jackson.databind; + requires javafx.swing; + + opens com.example to javafx.fxml, com.fasterxml.jackson.databind; - 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 deleted file mode 100644 index 20a7dc82..00000000 --- a/src/main/resources/com/example/hello-view.fxml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - diff --git a/src/main/resources/hello-view.fxml b/src/main/resources/hello-view.fxml new file mode 100644 index 00000000..5e583b5f --- /dev/null +++ b/src/main/resources/hello-view.fxml @@ -0,0 +1,18 @@ + + + + + + + +