Skip to content

Feature#1

Merged
bamsemats merged 6 commits into
mainfrom
feature
Nov 16, 2025
Merged

Feature#1
bamsemats merged 6 commits into
mainfrom
feature

Conversation

@bamsemats

@bamsemats bamsemats commented Nov 16, 2025

Copy link
Copy Markdown
Collaborator

Testing this pulling stuff again....

Summary by CodeRabbit

  • New Features

    • Real-time messaging with message history and username support.
    • File attachments with inline previews or downloadable links.
    • Message filtering and "Hide my messages" toggle.
  • Style

    • Redesigned chat UI, improved layout, and updated visual styles (status/progress shown).
  • Tests

    • New unit tests covering messaging, file uploads, parsing, history, and live updates.
  • Chores

    • Environment config files added to ignore list.

@coderabbitai

coderabbitai Bot commented Nov 16, 2025

Copy link
Copy Markdown

Walkthrough

This PR converts the project into a chat application: adds ChatMessage and EnvLoader, rewrites HelloModel for ntfy-based messaging (send, history, live listen, file upload, deduplication), rewrites HelloController and HelloFX for a richer JavaFX UI, adds resources, tests, and updates build/config (dependencies, plugin, module, .gitignore).

Changes

Cohort / File(s) Summary
Configuration & Build
\.gitignore, javafx.iml, pom.xml, module-info.java
Adds .env to .gitignore; adds javafx.iml; introduces httpclient.version property and dependencies org.json:json and org.apache.httpcomponents.client5:httpclient5; adds javafx-maven-plugin config; module now requires org.json and requires java.net.http.
Model & Env
src/main/java/com/example/ChatMessage.java, src/main/java/com/example/EnvLoader.java
Adds immutable ChatMessage with seven fields and constructors; adds EnvLoader that reads .env at class init and exposes EnvLoader.get(String).
Core Messaging
src/main/java/com/example/HelloModel.java
Replaces greeting logic with ntfy-based messaging: constructors that load TOPIC_URL, sendMessage, protected sendFile, sendJsonToNtfy, loadHistory, listenForMessages, envelope parsing to ChatMessage, and thread-safe deduplication (seenIds).
UI & Startup
src/main/java/com/example/HelloFX.java, src/main/java/com/example/HelloController.java
Adds HostServices exposure and startup changes in HelloFX; HelloController now implements Initializable, adds ObservableList/FilteredList, custom cell factory, username filtering, hide-my-messages toggle, file attachment handling, and async send/listen wiring.
Resources & Styling
src/main/resources/com/example/hello-view.fxml, src/main/resources/css/style.css
Replaces FXML with BorderPane layout (top bar, chat list, status row, input row) and adds CSS rules for root, list-cell, button, text-field, and progress-bar.
Testing
src/test/java/com/example/HelloModelTest.java
Adds JUnit5 test class mocking HelloModel internals; tests message sending, file handling, envelope parsing, and history/listen callbacks.

Sequence Diagram(s)

sequenceDiagram
    actor User
    participant HC as HelloController
    participant HM as HelloModel
    participant Ntfy as ntfy Service
    participant UI as ListView

    rect rgb(242,249,255)
    note right of HC: Send message (sync→async)
    User->>HC: enter username & text, click Send
    HC->>HC: getCurrentUsername()
    HC->>HM: sendMessage(username, message)
    HM->>HM: build JSON + timestamp
    HM->>Ntfy: POST JSON to TOPIC_URL
    Ntfy-->>HM: 2xx / error
    end

    rect rgb(242,249,255)
    note right of HM: Live subscription (async)
    HM->>Ntfy: subscribe/stream TOPIC_URL
    Ntfy-->>HM: stream envelopes
    HM->>HM: parseEnvelopeToChatMessage()
    HM->>HM: deduplicate via seenIds
    HM->>HC: callback(ChatMessage)
    HC->>UI: add to masterList → cell factory renders
    end

    rect rgb(242,249,255)
    note right of HC: Filtering & attachments
    User->>HC: toggle "Hide my messages" or attach file
    HC->>HC: updateFilterPredicate() / open FileChooser
    HC->>HM: sendFile(username, file)
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45–75 minutes

Areas to focus on:

  • HelloModel: HTTP interactions, streaming subscribe/listen logic, JSON envelope parsing, resource/stream cleanup, and thread-safety of seenIds.
  • HelloController: JavaFX threading (Platform.runLater), FilteredList predicate correctness, custom cell rendering for images/links, and fx:id bindings matching FXML.
  • EnvLoader: parsing edge-cases for .env lines and failure behavior.
  • pom.xml/module-info: ensure module requires and added dependencies align with runtime and tests.

Poem

🐰
A .env breeze across the glade,
Messages hop and images parade,
ntfy whistles on the wire,
Lists filter, attachments inspire,
This rabbit cheers—the chat’s well made!

Pre-merge checks and finishing touches

❌ Failed checks (1 warning, 1 inconclusive)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
Title check ❓ Inconclusive The title 'Feature' is too vague and generic, providing no meaningful information about the substantial changes in this pull request. Replace with a descriptive title that captures the main change, such as 'Add chat messaging with file support and ntfy integration' or 'Implement messaging model with history and live message handling'.
✅ Passed checks (1 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feature

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🧹 Nitpick comments (13)
src/main/java/com/example/EnvLoader.java (2)

11-11: Consider making the environment map immutable.

While the current implementation is safe because the map is never modified after initialization, wrapping it in Collections.unmodifiableMap() would make the immutability explicit and prevent future modifications.

Apply this diff:

+import java.util.Collections;
 
 public class EnvLoader {
 
-    private static Map<String, String> env = new HashMap<>();
+    private static final Map<String, String> env;
 
     static {
+        Map<String, String> tempEnv = new HashMap<>();
         try (BufferedReader reader = new BufferedReader(new FileReader(".env"))) {
             String line;
             while ((line = reader.readLine()) != null) {
                 line = line.trim();
                 if (line.isEmpty() || line.startsWith("#")) continue;
                 String[] parts = line.split("=", 2);
                 if (parts.length == 2) {
-                    env.put(parts[0].trim(), parts[1].trim());
+                    tempEnv.put(parts[0].trim(), parts[1].trim());
                 }
             }
         } catch (IOException e) {
             System.err.println("Warning: .env file not found or could not be read");
         }
+        env = Collections.unmodifiableMap(tempEnv);
     }

Also applies to: 29-31


16-23: Consider handling quoted values in .env files.

The current parser doesn't handle quoted values (e.g., KEY="value with spaces"), which is a common pattern in .env files. This could lead to values containing unwanted quotes or incorrectly parsed multi-word values.

Add simple quote stripping:

                 String[] parts = line.split("=", 2);
                 if (parts.length == 2) {
-                    env.put(parts[0].trim(), parts[1].trim());
+                    String key = parts[0].trim();
+                    String value = parts[1].trim();
+                    // Strip surrounding quotes if present
+                    if (value.length() >= 2 && 
+                        ((value.startsWith("\"") && value.endsWith("\"")) ||
+                         (value.startsWith("'") && value.endsWith("'")))) {
+                        value = value.substring(1, value.length() - 1);
+                    }
+                    env.put(key, value);
                 }
src/main/java/com/example/ChatMessage.java (1)

3-33: Immutable ChatMessage model looks good

Simple immutable container with clear constructors and accessors; no correctness or concurrency issues spotted.

src/test/java/com/example/HelloModelTest.java (1)

42-68: sendMessage/sendFile tests only check for “no exception”

For testSendMessage* and testSendFile*, assertDoesNotThrow verifies only that nothing crashes. If you want stronger coverage, capture the arguments/JSON in your overrides instead of printing to stdout and assert on those (e.g., username fallback, null-message handling, filename propagation).

src/main/java/com/example/HelloController.java (4)

26-91: UI initialization and filtered list wiring are sound

Using FilteredList, a custom ListCell, and Platform.runLater to marshal model callbacks back onto the FX thread is a solid approach; the basic chat rendering and “hide my messages” predicate look correct.


49-82: Guard against potential null message content in cells

updateItem assumes msg.getMessage() is non-null when building Text/TextFlow. Today HelloModel always supplies a non-null message, but if other callers ever construct ChatMessage with a null message, this will NPE. A simple null-to-empty fallback would harden the cell against future changes.


100-111: Thread creation strategy for sendMessage

Wrapping model.sendMessage in a new Thread keeps the UI responsive, which is good. For heavier usage, consider centralizing background execution (e.g., a shared ExecutorService or JavaFX Task) instead of spawning raw threads per send, but this isn’t critical for a small client.


113-119: Double-threading in onAttachFile

onAttachFile starts a new Thread that calls model.sendFile, but HelloModel.sendFile already spins up its own background thread. You can safely call model.sendFile(getCurrentUsername(), file) directly from the FX thread here and avoid an extra, short-lived thread.

src/main/java/com/example/HelloModel.java (5)

22-27: Constructor hard-fails on missing NTFY_URL

Fail-fast with IllegalStateException when NTFY_URL is missing or blank is reasonable for a small app, but it tightly couples the model to .env. If you ever need tests or alternative configs, consider adding an injectable constructor (e.g., HelloModel(String topicUrl)) and delegating from the no-arg one.


37-106: sendFile flow is overall correct, but consider robustness

The file upload logic (null/exists checks, MIME detection fallback, response parsing for attachment.url, and fallback URL construction) looks coherent and matches the summary JSON you send afterward. For longer-lived or heavier use, you may want to:

  • Add connect/read timeouts on the HttpURLConnection.
  • Close respStream via try-with-resources to avoid relying on disconnect() alone.
  • Consider using a shared executor instead of spawning a raw thread per upload.

108-136: sendJsonToNtfy lacks explicit timeouts

The POST implementation is straightforward and correct in terms of payload and basic error logging, but without setConnectTimeout / setReadTimeout the background thread may block indefinitely if the server or network misbehaves. Adding reasonable timeouts would improve resilience without changing behavior in the happy path.


138-214: History and listen loops work but can leak resources over time

loadHistory and listenForMessages correctly dedupe by id, filter to JSON messages, and parse via parseEnvelopeToChatMessage, but:

  • seenIds grows without bound as more messages arrive, which can become a memory issue in long-running sessions.
  • Both methods spawn dedicated threads with blocking readLine() loops and no timeouts.
    If this client is expected to run for long periods, consider adding an eviction strategy for seenIds and using timeouts or a more managed concurrency construct (executor, reactive client, etc.).

218-253: parseEnvelopeToChatMessage is robust but could trim message once

The method defensively re-checks that message is JSON-shaped and gracefully falls back to returning the raw string if inner JSON parsing fails, which is good. You might:

  • Apply trim() once to rawMsg here (as you already do in the callers) so direct callers get the same behavior.
  • Optionally share the “is JSON-shaped?” check between callers and this method to avoid duplication.
    These are minor polish items; current behavior is consistent with the tests.
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 21c51e8 and ac2dccc.

⛔ Files ignored due to path filters (6)
  • src/main/resources/background.png is excluded by !**/*.png
  • src/main/resources/lantern.png is excluded by !**/*.png
  • src/main/resources/lantern1.png is excluded by !**/*.png
  • src/main/resources/lantern2.png is excluded by !**/*.png
  • src/main/resources/lantern3.png is excluded by !**/*.png
  • src/main/resources/lantern4.png is excluded by !**/*.png
📒 Files selected for processing (12)
  • .gitignore (1 hunks)
  • javafx.iml (1 hunks)
  • pom.xml (5 hunks)
  • src/main/java/com/example/ChatMessage.java (1 hunks)
  • src/main/java/com/example/EnvLoader.java (1 hunks)
  • src/main/java/com/example/HelloController.java (1 hunks)
  • src/main/java/com/example/HelloFX.java (1 hunks)
  • src/main/java/com/example/HelloModel.java (1 hunks)
  • src/main/java/module-info.java (1 hunks)
  • src/main/resources/com/example/hello-view.fxml (1 hunks)
  • src/main/resources/css/style.css (1 hunks)
  • src/test/java/com/example/HelloModelTest.java (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (2)
src/main/java/com/example/HelloController.java (1)
src/main/java/com/example/HelloFX.java (1)
  • HelloFX (10-32)
src/main/java/com/example/HelloModel.java (1)
src/main/java/com/example/EnvLoader.java (1)
  • EnvLoader (9-32)
🪛 Biome (2.1.2)
src/main/resources/css/style.css

[error] 2-2: Unknown property is not allowed.

See CSS Specifications and browser specific properties for more details.
To resolve this issue, replace the unknown property with a valid CSS property.

(lint/correctness/noUnknownProperty)


[error] 3-3: Unknown property is not allowed.

See CSS Specifications and browser specific properties for more details.
To resolve this issue, replace the unknown property with a valid CSS property.

(lint/correctness/noUnknownProperty)


[error] 7-7: Unknown property is not allowed.

See CSS Specifications and browser specific properties for more details.
To resolve this issue, replace the unknown property with a valid CSS property.

(lint/correctness/noUnknownProperty)


[error] 11-11: Unknown property is not allowed.

See CSS Specifications and browser specific properties for more details.
To resolve this issue, replace the unknown property with a valid CSS property.

(lint/correctness/noUnknownProperty)


[error] 12-12: Unknown property is not allowed.

See CSS Specifications and browser specific properties for more details.
To resolve this issue, replace the unknown property with a valid CSS property.

(lint/correctness/noUnknownProperty)


[error] 16-16: Unknown property is not allowed.

See CSS Specifications and browser specific properties for more details.
To resolve this issue, replace the unknown property with a valid CSS property.

(lint/correctness/noUnknownProperty)


[error] 17-17: Unknown property is not allowed.

See CSS Specifications and browser specific properties for more details.
To resolve this issue, replace the unknown property with a valid CSS property.

(lint/correctness/noUnknownProperty)


[error] 18-18: Unknown property is not allowed.

See CSS Specifications and browser specific properties for more details.
To resolve this issue, replace the unknown property with a valid CSS property.

(lint/correctness/noUnknownProperty)


[error] 18-18: Unexpected unknown function: derive

Use a known function instead.
See MDN web docs for more details.

(lint/correctness/noUnknownFunction)


[error] 22-22: Unknown property is not allowed.

See CSS Specifications and browser specific properties for more details.
To resolve this issue, replace the unknown property with a valid CSS property.

(lint/correctness/noUnknownProperty)

🔇 Additional comments (8)
javafx.iml (1)

1-8: LGTM!

The IntelliJ IDEA module configuration correctly defines the test source folder, aligning with the new test structure introduced in this PR.

src/main/resources/css/style.css (1)

1-23: Static analysis warnings are false positives.

The Biome linter flags these properties as unknown because it's designed for web CSS, not JavaFX CSS. All -fx- prefixed properties and the derive() function are valid JavaFX CSS syntax and correctly style the JavaFX UI components introduced in this PR.

src/main/java/module-info.java (1)

4-5: LGTM!

The new module requirements for org.json and java.net.http correctly align with the dependencies added in pom.xml and support the ntfy-based messaging functionality described in the PR summary.

pom.xml (1)

72-86: LGTM!

The JavaFX Maven plugin configuration is properly set up with the correct main class and native access options for JavaFX graphics.

.gitignore (1)

3-3: LGTM!

Adding .env to .gitignore is essential to prevent committing environment-specific configuration and potential secrets, especially given the new EnvLoader utility that reads from this file.

src/main/java/com/example/HelloFX.java (1)

18-27: LGTM!

The application initialization updates are appropriate:

  • Properly qualified resource paths
  • Appropriate window size for a chat interface
  • Stylesheet correctly loaded
  • Arguments properly passed to launch()

Also applies to: 30-30

src/main/resources/com/example/hello-view.fxml (1)

1-53: LGTM!

The FXML layout is well-structured for a chat interface with:

  • Proper BorderPane organization
  • All necessary controls (username input, chat list, message input, file attachment)
  • Appropriate fx:id bindings for controller integration
  • Clean spacing and padding configuration
src/test/java/com/example/HelloModelTest.java (1)

70-94: JSON parsing tests are aligned with parseEnvelopeToChatMessage

The JSON envelope tests for valid JSON vs plain text map cleanly onto parseEnvelopeToChatMessage behavior and look correct.

Comment thread pom.xml
<assertj.core.version>3.27.6</assertj.core.version>
<mockito.version>5.20.0</mockito.version>
<javafx.version>25</javafx.version>
<httpclient.version>5.3.1</httpclient.version>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

Verify dependency versions for security and availability.

The dependency versions should be verified:

  • httpclient5 version 5.3.1 and org.json version 20240303 may not be the latest
  • Consider using a property for the org.json version for consistency with httpclient.version

Run the following script to check for the latest versions and any security advisories:

Also applies to: 56-66


🏁 Script executed:

#!/bin/bash
# Check Maven Central for latest versions and GitHub for security advisories

echo "=== Checking org.json latest version ==="
curl -s "https://search.maven.org/solrsearch/select?q=g:org.json+AND+a:json&rows=1&wt=json" | jq -r '.response.docs[0].latestVersion'

echo "=== Checking httpclient5 latest version ==="
curl -s "https://search.maven.org/solrsearch/select?q=g:org.apache.httpcomponents.client5+AND+a:httpclient5&rows=1&wt=json" | jq -r '.response.docs[0].latestVersion'

echo "=== Checking for security advisories ==="
gh api graphql -f query='
{
  securityVulnerabilities(first: 5, ecosystem: MAVEN, package: "org.json:json") {
    nodes {
      advisory { summary severity publishedAt }
      vulnerableVersionRange
      firstPatchedVersion { identifier }
    }
  }
}'

gh api graphql -f query='
{
  securityVulnerabilities(first: 5, ecosystem: MAVEN, package: "org.apache.httpcomponents.client5:httpclient5") {
    nodes {
      advisory { summary severity publishedAt }
      vulnerableVersionRange
      firstPatchedVersion { identifier }
    }
  }
}'

Length of output: 1089


Use a Maven property for consistency with org.json version.

The dependency versions are secure with no known CVEs:

  • org.json 20240303 is patched against all disclosed HIGH-severity vulnerabilities
  • httpclient5 5.3.1 is not affected by the domain-check vulnerability (5.4.x only)

Move org.json version 20240303 to a property in the <properties> section (lines 19–24) for consistency with httpclient.version:

<org.json.version>20240303</org.json.version>
<httpclient.version>5.3.1</httpclient.version>

Then update the dependency reference (lines 56–60) to use ${org.json.version}.

🤖 Prompt for AI Agents
In pom.xml around lines 19–24 and 56–60, the org.json version is hard-coded
while httpclient already uses a property; add a new Maven property named
org.json.version (value 20240303) alongside httpclient.version in the
<properties> section, then update the org.json dependency declaration to
reference ${org.json.version} instead of the literal version string so both
versions use properties consistently.

Comment on lines +13 to +27
static {
try (BufferedReader reader = new BufferedReader(new FileReader(".env"))) {
String line;
while ((line = reader.readLine()) != null) {
line = line.trim();
if (line.isEmpty() || line.startsWith("#")) continue;
String[] parts = line.split("=", 2);
if (parts.length == 2) {
env.put(parts[0].trim(), parts[1].trim());
}
}
} catch (IOException e) {
System.err.println("Warning: .env file not found or could not be read");
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Fix the hardcoded relative file path.

The hardcoded relative path ".env" will fail when the application is run from different contexts (IDE, Maven, packaged JAR, or different working directories). This could cause silent failures in production where the .env file isn't found.

Consider one of these approaches:

  1. Search multiple locations (recommended for development):
 static {
+    String[] searchPaths = {".env", System.getProperty("user.home") + "/.env", "/etc/app/.env"};
+    File envFile = null;
+    for (String path : searchPaths) {
+        File f = new File(path);
+        if (f.exists() && f.canRead()) {
+            envFile = f;
+            break;
+        }
+    }
+    if (envFile == null) {
+        System.err.println("Warning: .env file not found in any search path");
+        return;
+    }
-    try (BufferedReader reader = new BufferedReader(new FileReader(".env"))) {
+    try (BufferedReader reader = new BufferedReader(new FileReader(envFile))) {
  1. Use system property to specify path:
String envPath = System.getProperty("env.file", ".env");
try (BufferedReader reader = new BufferedReader(new FileReader(envPath))) {
🤖 Prompt for AI Agents
In src/main/java/com/example/EnvLoader.java around lines 13 to 27, the code
currently hardcodes the relative path ".env" which can fail depending on working
directory; change it to resolve the file from configurable and robust locations:
first read a system property (e.g. "env.file") and use that path if present,
otherwise check common locations in order (working directory, project root,
classpath resource, and a well-known config directory) and use the first
existing readable file; update the BufferedReader/FileReader logic to open the
resolved path and throw or log a clear error if no file is found so callers know
the load failed.

Comment on lines +11 to +15
public static HostServices hostServices;

public static HostServices hostServices() {
return hostServices;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Make the static field private and add null safety.

The public mutable static field hostServices can be modified by external code and may return null if accessed before start() is called, leading to potential NullPointerExceptions.

Apply this diff to improve encapsulation and safety:

-    public static HostServices hostServices;
+    private static HostServices hostServices;

     public static HostServices hostServices() {
+        if (hostServices == null) {
+            throw new IllegalStateException("HostServices not yet initialized - Application.start() must be called first");
+        }
         return hostServices;
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
public static HostServices hostServices;
public static HostServices hostServices() {
return hostServices;
}
private static HostServices hostServices;
public static HostServices hostServices() {
if (hostServices == null) {
throw new IllegalStateException("HostServices not yet initialized - Application.start() must be called first");
}
return hostServices;
}

Comment on lines +97 to +109
@Test
void testLoadHistoryCallbackCalled() {
AtomicBoolean called = new AtomicBoolean(false);
model.loadHistory(msg -> called.set(true));
assertDoesNotThrow(() -> model.loadHistory(msg -> {}));
}

@Test
void testListenForMessagesCallbackCalled() {
AtomicReference<ChatMessage> ref = new AtomicReference<>();
model.listenForMessages(ref::set);
assertDoesNotThrow(() -> model.listenForMessages(msg -> {}));
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

History/listen tests don’t assert callbacks and still hit the network

testLoadHistoryCallbackCalled and testListenForMessagesCallbackCalled never assert that the callback was actually invoked (e.g., called.get() or ref.get()), and they rely on the real HTTP-based implementations. This makes them both flaky and misleadingly named. Stubbing loadHistory/listenForMessages or the underlying HTTP, and asserting on the AtomicBoolean/AtomicReference, would make these true unit tests.

🤖 Prompt for AI Agents
In src/test/java/com/example/HelloModelTest.java around lines 97 to 109, the two
tests call model.loadHistory and model.listenForMessages but neither asserts
that the provided callbacks were invoked and they exercise real HTTP behavior;
update these tests to stub/mocks the model (or the underlying HTTP client) so
the methods execute synchronously and invoke the provided lambda, then assert
the AtomicBoolean.get() and AtomicReference.get() values to verify the callbacks
ran; if using a mocking framework, set expectations to call the callback with a
representative ChatMessage and verify interactions, otherwise inject a test
double implementation of the model that triggers the callbacks immediately and
assert the state.

@bamsemats
bamsemats merged commit 5afd13d into main Nov 16, 2025
1 of 2 checks passed

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

♻️ Duplicate comments (1)
src/test/java/com/example/HelloModelTest.java (1)

97-109: Callbacks are never verified to have been invoked

Both testLoadHistoryCallbackCalled and testListenForMessagesCallbackCalled create AtomicBoolean/AtomicReference to track callback invocation but never assert on these variables. The tests only verify that no exception is thrown, not that the callbacks were actually called.

After mocking loadHistory and listenForMessages as suggested in the previous comment, add assertions to verify callback invocation:

     @Test
     void testLoadHistoryCallbackCalled() {
         AtomicBoolean called = new AtomicBoolean(false);
         model.loadHistory(msg -> called.set(true));
-        assertDoesNotThrow(() -> model.loadHistory(msg -> {}));
+        assertTrue(called.get(), "loadHistory callback was not invoked");
     }
 
     @Test
     void testListenForMessagesCallbackCalled() {
         AtomicReference<ChatMessage> ref = new AtomicReference<>();
         model.listenForMessages(ref::set);
-        assertDoesNotThrow(() -> model.listenForMessages(msg -> {}));
+        assertNotNull(ref.get(), "listenForMessages callback was not invoked");
+        assertEquals("TestUser", ref.get().getUsername(), "Callback received unexpected message");
     }
🧹 Nitpick comments (2)
src/main/java/com/example/HelloModel.java (2)

141-141: Clarify intent of silent stream consumption

Line 141 reads and discards the response body while suppressing any IOException. While this may be intentional cleanup to prevent connection leaks, the silent catch makes the intent unclear.

Add a comment to clarify:

-        try (InputStream is = conn.getInputStream()) { if (is != null) is.readAllBytes(); } catch (IOException ignored) {}
+        // Consume remaining response stream to allow connection reuse
+        try (InputStream is = conn.getInputStream()) { if (is != null) is.readAllBytes(); } catch (IOException ignored) {}

225-260: Overly broad exception handling masks specific parsing failures

The method catches generic Exception in multiple places (lines 241, 258), which can hide unexpected errors. Additionally, printing stack traces for parsing failures (line 258) may flood logs with noise if malformed messages are common.

Use more specific exception types and reduce logging verbosity:

     protected ChatMessage parseEnvelopeToChatMessage(JSONObject envelope) {
         String rawMsg = envelope.optString("message", null);
         if (rawMsg == null) return null;
         if (!rawMsg.startsWith("{") || !rawMsg.endsWith("}")) return null;
         try {
             // ... existing parsing logic ...
                 rawMsg = envelope.getString("message");
                 JSONObject inner = null;
-                try { inner = new JSONObject(rawMsg); } catch (Exception ignored) {}
+                try { 
+                    inner = new JSONObject(rawMsg); 
+                } catch (org.json.JSONException ignored) {
+                    // Non-JSON message, will return basic ChatMessage below
+                }
 
                 // ... rest of logic ...
             }
-        } catch (Exception e) { e.printStackTrace(); }
+        } catch (org.json.JSONException e) { 
+            // Expected for malformed JSON, no need to log
+            return null;
+        } catch (Exception e) {
+            System.err.println("Unexpected error parsing envelope: " + e.getMessage());
+        }
         return null;
     }
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between ac2dccc and e59bb6e.

📒 Files selected for processing (2)
  • src/main/java/com/example/HelloModel.java (1 hunks)
  • src/test/java/com/example/HelloModelTest.java (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
src/main/java/com/example/HelloModel.java (1)
src/main/java/com/example/EnvLoader.java (1)
  • EnvLoader (9-32)
🔇 Additional comments (2)
src/test/java/com/example/HelloModelTest.java (1)

42-68: LGTM: Null-handling tests are appropriate

These tests verify that sendMessage and sendFile handle null inputs gracefully without throwing exceptions. Since the underlying methods are mocked, this is a reasonable smoke test.

src/main/java/com/example/HelloModel.java (1)

36-42: LGTM: Defensive null handling

The method appropriately defaults null/blank usernames to "Anonymous" and null messages to empty string, ensuring robustness.

Comment on lines +29 to +34
public HelloModel(String topicUrl) {
if (topicUrl == null || topicUrl.isBlank()) {
throw new IllegalStateException("NTFY_URL not found in .env file");
}
this.TOPIC_URL = topicUrl;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Misleading error message in parameterized constructor

The error message claims "NTFY_URL not found in .env file" but this constructor accepts topicUrl as a parameter and doesn't read from .env. The message should reflect that the parameter is invalid.

     public HelloModel(String topicUrl) {
         if (topicUrl == null || topicUrl.isBlank()) {
-            throw new IllegalStateException("NTFY_URL not found in .env file");
+            throw new IllegalArgumentException("topicUrl parameter cannot be null or blank");
         }
         this.TOPIC_URL = topicUrl;
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
public HelloModel(String topicUrl) {
if (topicUrl == null || topicUrl.isBlank()) {
throw new IllegalStateException("NTFY_URL not found in .env file");
}
this.TOPIC_URL = topicUrl;
}
public HelloModel(String topicUrl) {
if (topicUrl == null || topicUrl.isBlank()) {
throw new IllegalArgumentException("topicUrl parameter cannot be null or blank");
}
this.TOPIC_URL = topicUrl;
}
🤖 Prompt for AI Agents
In src/main/java/com/example/HelloModel.java around lines 29 to 34, the
constructor throws an IllegalStateException with a misleading message
referencing .env/NTFY_URL even though it validates the incoming topicUrl
parameter; change the thrown error to reflect the invalid parameter (e.g., throw
new IllegalArgumentException("topicUrl must not be null or blank") or at minimum
update the message to "topicUrl must not be null or blank") so that the
exception accurately describes the cause.

Comment on lines +44 to +113
protected void sendFile(String username, File file) {
if (file == null || !file.exists()) return;
final String safeUsername = (username == null || username.isBlank()) ? "Anonymous" : username;

new Thread(() -> {
HttpURLConnection conn = null;
try {
URL url = new URL(TOPIC_URL);
conn = (HttpURLConnection) url.openConnection();
conn.setDoOutput(true);
conn.setRequestMethod("PUT");

String mimeType = Files.probeContentType(file.toPath());
if (mimeType == null) mimeType = "application/octet-stream";

conn.setRequestProperty("Filename", file.getName());
conn.setRequestProperty("Content-Type", mimeType);
conn.setRequestProperty("X-Hide", "true");
conn.setFixedLengthStreamingMode(file.length());
conn.connect();

try (OutputStream os = conn.getOutputStream()) {
Files.copy(file.toPath(), os);
os.flush();
}

int rc = conn.getResponseCode();
InputStream respStream = rc >= 400 ? conn.getErrorStream() : conn.getInputStream();
String responseJson = "";
if (respStream != null) responseJson = new String(respStream.readAllBytes(), StandardCharsets.UTF_8);
System.out.println("PUT upload response code: " + rc);
if (!responseJson.isBlank()) System.out.println("PUT upload response body: " + responseJson);

String fileUrl = null;
try {
if (!responseJson.isBlank()) {
JSONObject resp = new JSONObject(responseJson);
if (resp.has("attachment")) {
JSONObject attach = resp.getJSONObject("attachment");
if (attach.has("url")) fileUrl = attach.getString("url");
} else if (resp.has("url")) {
fileUrl = resp.getString("url");
}
}
} catch (Exception ex) {
System.err.println("Failed to parse upload response JSON: " + ex.getMessage());
}

if (fileUrl == null && rc >= 200 && rc < 300) {
if (!TOPIC_URL.endsWith("/")) fileUrl = TOPIC_URL + "/" + file.getName();
else fileUrl = TOPIC_URL + file.getName();
}

JSONObject msg = new JSONObject();
msg.put("username", safeUsername);
msg.put("message", "Sent file: " + file.getName());
msg.put("time", Instant.now().getEpochSecond());
msg.put("fileName", file.getName());
msg.put("fileUrl", fileUrl);
msg.put("mimeType", mimeType);

sendJsonToNtfy(msg);

} catch (Exception e) {
e.printStackTrace();
} finally {
if (conn != null) conn.disconnect();
}
}).start();
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Fire-and-forget thread with no error signaling or completion callback

The sendFile method spawns a background thread but provides no mechanism for the caller to:

  • Know when the upload completes
  • Receive errors if the upload fails
  • Cancel an ongoing upload

Additionally, exception handling only prints to System.err (lines 88-90, 107-108), making it impossible for the application to respond to failures or notify users.

Consider refactoring to accept a callback or return a CompletableFuture<String> that resolves with the file URL or completes exceptionally on error. Would you like me to propose a refactored implementation that provides proper error handling and completion signaling?

🤖 Prompt for AI Agents
In src/main/java/com/example/HelloModel.java around lines 44-113, the sendFile
method currently spawns a detached Thread and only prints exceptions, so callers
cannot observe completion, errors, or cancel the upload; refactor it to return a
CompletableFuture<String> (resolving to the file URL) instead of void, move the
upload work to an ExecutorService (or CompletableFuture.supplyAsync), complete
the future with the resolved fileUrl on success, call completeExceptionally on
any caught exception, and ensure future.cancel(true) interrupts the task to
enable cancellation; replace System.err prints with proper logger.error calls
and include the exception, ensure all streams and connections are closed in
finally blocks, and propagate non-2xx responses as exceptional completions so
callers can handle failures.

Comment on lines +145 to +181
public void loadHistory(Consumer<ChatMessage> callback) {
new Thread(() -> {
HttpURLConnection conn = null;
try {
URL url = new URL(TOPIC_URL + "/json?since=all");
conn = (HttpURLConnection) url.openConnection();
conn.setRequestMethod("GET");
conn.connect();

try (BufferedReader reader = new BufferedReader(new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8))) {
String line;
while ((line = reader.readLine()) != null) {
if (line.isBlank()) continue;

try {
JSONObject envelope = new JSONObject(line);
String id = envelope.optString("id", null);
if (id != null && !seenIds.add(id)) continue;

String rawMsg = envelope.optString("message", "").trim();
if (!rawMsg.startsWith("{") || !rawMsg.endsWith("}")) continue;

ChatMessage msg = parseEnvelopeToChatMessage(envelope);
if (msg != null) callback.accept(msg);

} catch (Exception ex) {
ex.printStackTrace();
}
}
}
} catch (Exception e) {
e.printStackTrace();
} finally {
if (conn != null) conn.disconnect();
}
}).start();
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

No completion or error signaling for asynchronous history loading

Similar to sendFile, loadHistory spawns a background thread with no mechanism to signal completion or errors. The caller cannot:

  • Know when all history has been loaded
  • Handle connection failures
  • Show loading indicators or error messages to users

Consider adding a completion callback or returning a CompletableFuture<Void>:

public CompletableFuture<Void> loadHistory(Consumer<ChatMessage> callback) {
    return CompletableFuture.runAsync(() -> {
        // existing logic...
    });
}

Or accept a second callback parameter for completion:

public void loadHistory(Consumer<ChatMessage> callback, Runnable onComplete) {
    new Thread(() -> {
        try {
            // existing logic...
        } finally {
            if (onComplete != null) onComplete.run();
        }
    }).start();
}
🤖 Prompt for AI Agents
In src/main/java/com/example/HelloModel.java around lines 145 to 181,
loadHistory currently starts a background Thread with no way to signal
completion or errors to callers; change the API to either return a
CompletableFuture<Void> that runs the existing logic asynchronously (completes
normally on success or completes exceptionally on errors) or add a second
callback parameter (e.g., onComplete or onError) and ensure the background task
invokes onComplete in a finally block and calls onError on exceptions so callers
can know when loading finished and handle failures; preserve the existing
message callback and ensure the HttpURLConnection is still disconnected in the
finally block.

Comment on lines +184 to +221
public void listenForMessages(Consumer<ChatMessage> callback) {
new Thread(() -> {
HttpURLConnection conn = null;
try {
URL url = new URL(TOPIC_URL + "/json");
conn = (HttpURLConnection) url.openConnection();
conn.setRequestMethod("GET");
conn.setRequestProperty("Accept", "application/json");
conn.connect();

try (BufferedReader reader = new BufferedReader(new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8))) {
String line;
while ((line = reader.readLine()) != null) {
if (line.isBlank()) continue;

try {
JSONObject envelope = new JSONObject(line);
String id = envelope.optString("id", null);
if (id != null && !seenIds.add(id)) continue;

String rawMsg = envelope.optString("message", "").trim();
if (!rawMsg.startsWith("{") || !rawMsg.endsWith("}")) continue;

ChatMessage msg = parseEnvelopeToChatMessage(envelope);
if (msg != null) callback.accept(msg);

} catch (Exception ex) {
ex.printStackTrace();
}
}
}
} catch (Exception e) {
e.printStackTrace();
} finally {
if (conn != null) conn.disconnect();
}
}, "ntfy-listener-thread").start();
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Long-running listener thread lacks lifecycle management

listenForMessages starts a long-lived background thread with no mechanism to:

  • Stop the listener when the application shuts down
  • Signal errors to the caller
  • Indicate when the connection is established or lost

This creates potential thread leaks if the method is called multiple times or if the application needs to disconnect.

Introduce lifecycle management to allow graceful shutdown:

private volatile Thread listenerThread;
private volatile boolean shouldStop = false;

public void listenForMessages(Consumer<ChatMessage> callback) {
    shouldStop = false;
    listenerThread = new Thread(() -> {
        HttpURLConnection conn = null;
        try {
            URL url = new URL(TOPIC_URL + "/json");
            conn = (HttpURLConnection) url.openConnection();
            conn.setRequestMethod("GET");
            conn.setRequestProperty("Accept", "application/json");
            conn.connect();

            try (BufferedReader reader = new BufferedReader(new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8))) {
                String line;
                while (!shouldStop && (line = reader.readLine()) != null) {
                    // existing logic...
                }
            }
        } catch (Exception e) {
            if (!shouldStop) e.printStackTrace();
        } finally {
            if (conn != null) conn.disconnect();
        }
    }, "ntfy-listener-thread");
    listenerThread.start();
}

public void stopListening() {
    shouldStop = true;
    if (listenerThread != null) {
        listenerThread.interrupt();
    }
}
🤖 Prompt for AI Agents
In src/main/java/com/example/HelloModel.java around lines 184-221, the current
listenForMessages spawns an unbounded background thread without lifecycle
controls; add two new volatile fields (Thread listenerThread; boolean
shouldStop) and modify listenForMessages to set shouldStop=false, assign and
start listenerThread, then in the reader loop use while (!shouldStop && (line =
reader.readLine()) != null) so the loop can exit cleanly; on shutdown provide a
public stopListening() that sets shouldStop=true and interrupts listenerThread;
update catch blocks to ignore InterruptedException when stopping and to surface
other errors via a provided error/status callback (or rethrow to caller) and
ensure conn.disconnect() still runs in finally.

Comment on lines +18 to +40
@BeforeEach
void setUp() {
model = new HelloModel("https://dummy-url-for-tests") {
@Override
protected void sendJsonToNtfy(JSONObject json) {
System.out.println("Mock sendJsonToNtfy called: " + json);
}

@Override
protected void sendFile(String username, File file) {
if (file != null) {
System.out.println("Mock sendFileInternal called for: " + file.getName());
} else {
System.out.println("Mock sendFileInternal called with null file");
}
}

@Override
protected ChatMessage parseEnvelopeToChatMessage(JSONObject envelope) {
return super.parseEnvelopeToChatMessage(envelope);
}
};
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Partial improvement: loadHistory and listenForMessages still attempt real HTTP calls

Using the String constructor eliminates the .env dependency, but loadHistory and listenForMessages are not overridden in the anonymous subclass. When tests invoke these methods (lines 100, 107), they will attempt HTTP GET requests to the dummy URL, leading to timeouts or connection failures.

Override these methods in the test setup to prevent network calls:

         model = new HelloModel("https://dummy-url-for-tests") {
             @Override
             protected void sendJsonToNtfy(JSONObject json) {
                 System.out.println("Mock sendJsonToNtfy called: " + json);
             }
 
             @Override
             protected void sendFile(String username, File file) {
                 if (file != null) {
                     System.out.println("Mock sendFileInternal called for: " + file.getName());
                 } else {
                     System.out.println("Mock sendFileInternal called with null file");
                 }
             }
 
             @Override
             protected ChatMessage parseEnvelopeToChatMessage(JSONObject envelope) {
                 return super.parseEnvelopeToChatMessage(envelope);
             }
+
+            @Override
+            public void loadHistory(Consumer<ChatMessage> callback) {
+                // Mock: immediately invoke callback with test data
+                JSONObject testMsg = new JSONObject();
+                testMsg.put("username", "TestUser");
+                testMsg.put("message", "Test message");
+                testMsg.put("time", Instant.now().getEpochSecond());
+                callback.accept(new ChatMessage("test-id", "TestUser", "Test message", "2025-11-16 14:34"));
+            }
+
+            @Override
+            public void listenForMessages(Consumer<ChatMessage> callback) {
+                // Mock: immediately invoke callback with test data
+                callback.accept(new ChatMessage("test-id-2", "TestUser", "Live message", "2025-11-16 14:35"));
+            }
         };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@BeforeEach
void setUp() {
model = new HelloModel("https://dummy-url-for-tests") {
@Override
protected void sendJsonToNtfy(JSONObject json) {
System.out.println("Mock sendJsonToNtfy called: " + json);
}
@Override
protected void sendFile(String username, File file) {
if (file != null) {
System.out.println("Mock sendFileInternal called for: " + file.getName());
} else {
System.out.println("Mock sendFileInternal called with null file");
}
}
@Override
protected ChatMessage parseEnvelopeToChatMessage(JSONObject envelope) {
return super.parseEnvelopeToChatMessage(envelope);
}
};
}
@BeforeEach
void setUp() {
model = new HelloModel("https://dummy-url-for-tests") {
@Override
protected void sendJsonToNtfy(JSONObject json) {
System.out.println("Mock sendJsonToNtfy called: " + json);
}
@Override
protected void sendFile(String username, File file) {
if (file != null) {
System.out.println("Mock sendFileInternal called for: " + file.getName());
} else {
System.out.println("Mock sendFileInternal called with null file");
}
}
@Override
protected ChatMessage parseEnvelopeToChatMessage(JSONObject envelope) {
return super.parseEnvelopeToChatMessage(envelope);
}
@Override
public void loadHistory(Consumer<ChatMessage> callback) {
// Mock: immediately invoke callback with test data
JSONObject testMsg = new JSONObject();
testMsg.put("username", "TestUser");
testMsg.put("message", "Test message");
testMsg.put("time", Instant.now().getEpochSecond());
callback.accept(new ChatMessage("test-id", "TestUser", "Test message", "2025-11-16 14:34"));
}
@Override
public void listenForMessages(Consumer<ChatMessage> callback) {
// Mock: immediately invoke callback with test data
callback.accept(new ChatMessage("test-id-2", "TestUser", "Live message", "2025-11-16 14:35"));
}
};
}
🤖 Prompt for AI Agents
In src/test/java/com/example/HelloModelTest.java around lines 18 to 40, the
anonymous test subclass overrides sendJsonToNtfy/sendFile/parseEnvelope but does
not override loadHistory and listenForMessages, so calls at lines ~100 and ~107
will perform real HTTP GETs against the dummy URL; override both loadHistory and
listenForMessages in the anonymous subclass to be no-ops or return controlled
test values (e.g., loadHistory returns an empty list or mock history and
listenForMessages does not start any network listener or background thread),
ensuring the test never performs real network I/O and preserving any needed
signatures/return types.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant