diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a56e0b3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,49 @@ +# Compiled class files +*.class + +# Log files +*.log + +# Package files +*.jar +*.war +*.nar +*.ear +*.zip +*.tar.gz +*.rar + +# Maven +target/ +pom.xml.tag +pom.xml.releaseBackup +pom.xml.versionsBackup +pom.xml.next +release.properties +dependency-reduced-pom.xml +buildNumber.properties +.mvn/timing.properties +.mvn/wrapper/maven-wrapper.jar + +# Gradle +.gradle/ +build/ + +# IDE +.idea/ +*.iml +*.ipr +*.iws +.project +.classpath +.settings/ +.vscode/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Local storage +.ironnotify/ diff --git a/README.md b/README.md index 68d1861..ea0cbb9 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,358 @@ -# ironnotify-java -IronNotify SDK for Java - Event notifications and alerts +# IronNotify SDK for Java + +Event notifications and alerts SDK for Java applications. Send notifications, receive real-time updates, and manage notification state. + +[![Maven Central](https://img.shields.io/maven-central/v/com.ironservices/notify.svg)](https://search.maven.org/artifact/com.ironservices/notify) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) + +## Installation + +### Maven + +```xml + + com.ironservices + notify + 1.0.0 + +``` + +### Gradle + +```groovy +implementation 'com.ironservices:notify:1.0.0' +``` + +## Quick Start + +### Send a Simple Notification + +```java +import com.ironservices.notify.*; +import java.util.Map; + +public class Main { + public static void main(String[] args) { + // Initialize + IronNotify.init("ak_live_xxxxx"); + + // Send a simple notification + SendResult result = IronNotify.notify( + "order.created", + "New Order Received", + "Order #1234 has been placed", + SeverityLevel.SUCCESS, + Map.of("order_id", "1234", "amount", 99.99) + ); + + if (result.isSuccess()) { + System.out.println("Notification sent: " + result.getNotificationId()); + } + + // Shutdown when done + IronNotify.shutdown(); + } +} +``` + +### Fluent Event Builder + +```java +import com.ironservices.notify.*; +import java.time.Duration; + +public class Main { + public static void main(String[] args) { + IronNotify.init("ak_live_xxxxx"); + + // Build complex notifications with the fluent API + SendResult result = IronNotify.event("payment.failed") + .withTitle("Payment Failed") + .withMessage("Payment could not be processed") + .withSeverity(SeverityLevel.ERROR) + .withMetadata("order_id", "1234") + .withMetadata("reason", "Card declined") + .withAction("Retry Payment", "/orders/1234/retry", null, "primary") + .withAction(EventBuilder.action("Contact Support") + .handler("open_support") + .build()) + .forUser("user-123") + .withDeduplicationKey("payment-failed-1234") + .expiresIn(Duration.ofHours(24)) + .send(); + + if (result.isQueued()) { + System.out.println("Notification queued for later"); + } + + IronNotify.shutdown(); + } +} +``` + +### Async Support + +```java +import com.ironservices.notify.*; +import java.util.concurrent.CompletableFuture; + +public class Main { + public static void main(String[] args) { + IronNotify.init("ak_live_xxxxx"); + + // Async notification + CompletableFuture future = IronNotify.notifyAsync( + "user.signup", + "New User Registered" + ); + + future.thenAccept(result -> { + System.out.println("Notification sent: " + result.isSuccess()); + }); + + // Async event builder + IronNotify.event("order.shipped") + .withTitle("Order Shipped") + .forUser("user-123") + .sendAsync() + .thenAccept(result -> System.out.println("Done!")); + + // Wait for async operations + future.join(); + IronNotify.shutdown(); + } +} +``` + +### Using the Client Directly + +```java +import com.ironservices.notify.*; + +public class Main { + public static void main(String[] args) { + NotifyClient client = new NotifyClient( + NotifyOptions.builder() + .apiKey("ak_live_xxxxx") + .debug(true) + .build() + ); + + // Send notification + SendResult result = client.notify("event.type", "Title"); + + // Use event builder + result = client.event("event.type") + .withTitle("Title") + .send(); + + // Clean up + client.close(); + } +} +``` + +## Configuration + +```java +import com.ironservices.notify.*; +import java.time.Duration; + +NotifyClient client = new NotifyClient( + NotifyOptions.builder() + .apiKey("ak_live_xxxxx") + .apiBaseUrl("https://api.ironnotify.com") + .webSocketUrl("wss://ws.ironnotify.com") + .debug(false) + .enableOfflineQueue(true) + .maxOfflineQueueSize(100) + .autoReconnect(true) + .maxReconnectAttempts(5) + .reconnectDelay(Duration.ofSeconds(1)) + .httpTimeout(Duration.ofSeconds(30)) + .build() +); +``` + +### Configuration Options + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `apiKey` | String | required | Your API key (ak_live_xxx or ak_test_xxx) | +| `apiBaseUrl` | String | https://api.ironnotify.com | API base URL | +| `webSocketUrl` | String | wss://ws.ironnotify.com | WebSocket URL | +| `debug` | boolean | false | Enable debug logging | +| `enableOfflineQueue` | boolean | true | Queue notifications when offline | +| `maxOfflineQueueSize` | int | 100 | Max offline queue size | +| `autoReconnect` | boolean | true | Auto-reconnect WebSocket | +| `maxReconnectAttempts` | int | 5 | Max reconnection attempts | +| `reconnectDelay` | Duration | 1s | Base reconnection delay | +| `httpTimeout` | Duration | 30s | HTTP request timeout | + +## Severity Levels + +```java +SeverityLevel.INFO // "info" +SeverityLevel.SUCCESS // "success" +SeverityLevel.WARNING // "warning" +SeverityLevel.ERROR // "error" +SeverityLevel.CRITICAL // "critical" +``` + +## Actions + +```java +// Simple action with URL +IronNotify.event("order.shipped") + .withTitle("Order Shipped") + .withAction("Track Package", "https://tracking.example.com/123") + .send(); + +// Action with handler +IronNotify.event("order.shipped") + .withTitle("Order Shipped") + .withAction("View Order", null, "view_order", "default") + .send(); + +// Using action builder +IronNotify.event("order.shipped") + .withTitle("Order Shipped") + .withAction(EventBuilder.action("Track Package") + .url("https://tracking.example.com/123") + .style("primary") + .build()) + .send(); +``` + +## Deduplication + +Prevent duplicate notifications: + +```java +IronNotify.event("reminder") + .withTitle("Daily Reminder") + .withDeduplicationKey("daily-reminder-2024-01-15") + .send(); +``` + +## Grouping + +Group related notifications: + +```java +IronNotify.event("comment.new") + .withTitle("New Comment") + .withGroupKey("post-123-comments") + .send(); +``` + +## Expiration + +```java +import java.time.Duration; +import java.time.Instant; + +// Expires in 1 hour +IronNotify.event("flash_sale") + .withTitle("Flash Sale!") + .expiresIn(Duration.ofHours(1)) + .send(); + +// Expires at specific time +IronNotify.event("event_reminder") + .withTitle("Event Tomorrow") + .expiresAt(Instant.now().plus(Duration.ofDays(1))) + .send(); +``` + +## Managing Notifications + +### Get Notifications + +```java +import java.util.List; +import java.io.IOException; + +// Get all notifications +List notifications = client.getNotifications(); + +// With options +List unread = client.getNotifications(10, 0, true); + +// Async +client.getNotificationsAsync(10, 0, false) + .thenAccept(list -> System.out.println("Got " + list.size() + " notifications")); +``` + +### Mark as Read + +```java +// Mark single notification +client.markAsRead("notification-id"); + +// Mark all as read +client.markAllAsRead(); +``` + +### Get Unread Count + +```java +int count = client.getUnreadCount(); +System.out.printf("You have %d unread notifications%n", count); +``` + +## Real-Time Notifications + +```java +NotifyClient client = new NotifyClient(new NotifyOptions("ak_live_xxxxx")); + +client.onNotification(notification -> { + System.out.println("New notification: " + notification.getTitle()); +}); + +client.onUnreadCountChange(count -> { + System.out.println("Unread count: " + count); +}); + +client.onConnectionStateChange(state -> { + System.out.println("Connection state: " + state); +}); + +client.connect(); +client.subscribeToUser("user-123"); +client.subscribeToApp(); +``` + +## Offline Support + +Notifications are automatically queued when offline: + +```java +// This will be queued if offline +IronNotify.notify("event", "Title"); + +// Manually flush the queue +IronNotify.flush(); + +// Or async +IronNotify.flushAsync().join(); +``` + +## Requirements + +- Java 11+ +- OkHttp 4.x +- Gson 2.x + +## Thread Safety + +The client is thread-safe and can be used from multiple threads concurrently. + +## Links + +- [Documentation](https://www.ironnotify.com/docs) +- [Dashboard](https://www.ironnotify.com) + +## License + +MIT License - see [LICENSE](LICENSE) for details. diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..2cac911 --- /dev/null +++ b/pom.xml @@ -0,0 +1,101 @@ + + + 4.0.0 + + com.ironservices + notify + 1.0.0 + jar + + IronNotify Java SDK + Event notifications and alerts SDK for Java applications + https://github.com/IronServices/ironnotify-java + + + + MIT License + https://opensource.org/licenses/MIT + + + + + + IronServices + support@ironservices.com + IronServices + https://www.ironservices.com + + + + + scm:git:git://github.com/IronServices/ironnotify-java.git + scm:git:ssh://github.com:IronServices/ironnotify-java.git + https://github.com/IronServices/ironnotify-java/tree/main + + + + 11 + 11 + UTF-8 + + + + + com.squareup.okhttp3 + okhttp + 4.12.0 + + + com.google.code.gson + gson + 2.10.1 + + + org.java-websocket + Java-WebSocket + 1.5.4 + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.11.0 + + 11 + 11 + + + + org.apache.maven.plugins + maven-source-plugin + 3.3.0 + + + attach-sources + + jar + + + + + + org.apache.maven.plugins + maven-javadoc-plugin + 3.6.0 + + + attach-javadocs + + jar + + + + + + + diff --git a/src/main/java/com/ironservices/notify/ConnectionState.java b/src/main/java/com/ironservices/notify/ConnectionState.java new file mode 100644 index 0000000..f99c065 --- /dev/null +++ b/src/main/java/com/ironservices/notify/ConnectionState.java @@ -0,0 +1,26 @@ +package com.ironservices.notify; + +/** + * WebSocket connection state. + */ +public enum ConnectionState { + DISCONNECTED("disconnected"), + CONNECTING("connecting"), + CONNECTED("connected"), + RECONNECTING("reconnecting"); + + private final String value; + + ConnectionState(String value) { + this.value = value; + } + + public String getValue() { + return value; + } + + @Override + public String toString() { + return value; + } +} diff --git a/src/main/java/com/ironservices/notify/EventBuilder.java b/src/main/java/com/ironservices/notify/EventBuilder.java new file mode 100644 index 0000000..b20150b --- /dev/null +++ b/src/main/java/com/ironservices/notify/EventBuilder.java @@ -0,0 +1,215 @@ +package com.ironservices.notify; + +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +/** + * Builder for creating notifications with a fluent API. + */ +public class EventBuilder { + private final NotifyClient client; + private final String eventType; + private String title; + private String message; + private SeverityLevel severity = SeverityLevel.INFO; + private Map metadata = new HashMap<>(); + private List actions = new ArrayList<>(); + private String userId; + private String groupKey; + private String deduplicationKey; + private Instant expiresAt; + + EventBuilder(NotifyClient client, String eventType) { + this.client = client; + this.eventType = eventType; + } + + /** + * Sets the notification title. + */ + public EventBuilder withTitle(String title) { + this.title = title; + return this; + } + + /** + * Sets the notification message. + */ + public EventBuilder withMessage(String message) { + this.message = message; + return this; + } + + /** + * Sets the severity level. + */ + public EventBuilder withSeverity(SeverityLevel severity) { + this.severity = severity; + return this; + } + + /** + * Adds a metadata entry. + */ + public EventBuilder withMetadata(String key, Object value) { + this.metadata.put(key, value); + return this; + } + + /** + * Adds multiple metadata entries. + */ + public EventBuilder withMetadata(Map metadata) { + this.metadata.putAll(metadata); + return this; + } + + /** + * Adds an action button with a URL. + */ + public EventBuilder withAction(String label, String url) { + this.actions.add(new NotificationAction(label, url, null, "default")); + return this; + } + + /** + * Adds an action button with options. + */ + public EventBuilder withAction(String label, String url, String action, String style) { + this.actions.add(new NotificationAction(label, url, action, style)); + return this; + } + + /** + * Adds an action using the ActionBuilder. + */ + public EventBuilder withAction(NotificationAction action) { + this.actions.add(action); + return this; + } + + /** + * Sets the target user ID. + */ + public EventBuilder forUser(String userId) { + this.userId = userId; + return this; + } + + /** + * Sets the group key for grouping related notifications. + */ + public EventBuilder withGroupKey(String groupKey) { + this.groupKey = groupKey; + return this; + } + + /** + * Sets the deduplication key. + */ + public EventBuilder withDeduplicationKey(String key) { + this.deduplicationKey = key; + return this; + } + + /** + * Sets the expiration time from now. + */ + public EventBuilder expiresIn(Duration duration) { + this.expiresAt = Instant.now().plus(duration); + return this; + } + + /** + * Sets the expiration time. + */ + public EventBuilder expiresAt(Instant instant) { + this.expiresAt = instant; + return this; + } + + /** + * Builds the notification payload. + */ + public NotificationPayload build() { + if (title == null || title.isEmpty()) { + throw new IllegalStateException("Notification title is required"); + } + + NotificationPayload payload = new NotificationPayload(eventType, title); + payload.setMessage(message); + payload.setSeverity(severity); + payload.setUserId(userId); + payload.setGroupKey(groupKey); + payload.setDeduplicationKey(deduplicationKey); + payload.setExpiresAt(expiresAt); + + if (!metadata.isEmpty()) { + payload.setMetadata(metadata); + } + + if (!actions.isEmpty()) { + payload.setActions(actions); + } + + return payload; + } + + /** + * Sends the notification synchronously. + */ + public SendResult send() { + NotificationPayload payload = build(); + return client.sendPayload(payload); + } + + /** + * Sends the notification asynchronously. + */ + public CompletableFuture sendAsync() { + NotificationPayload payload = build(); + return client.sendPayloadAsync(payload); + } + + /** + * Creates an action builder for complex actions. + */ + public static ActionBuilder action(String label) { + return new ActionBuilder(label); + } + + /** + * Builder for notification actions. + */ + public static class ActionBuilder { + private final NotificationAction action; + + ActionBuilder(String label) { + this.action = new NotificationAction(label); + } + + public ActionBuilder url(String url) { + action.setUrl(url); + return this; + } + + public ActionBuilder handler(String actionHandler) { + action.setAction(actionHandler); + return this; + } + + public ActionBuilder style(String style) { + action.setStyle(style); + return this; + } + + public NotificationAction build() { + return action; + } + } +} diff --git a/src/main/java/com/ironservices/notify/IronNotify.java b/src/main/java/com/ironservices/notify/IronNotify.java new file mode 100644 index 0000000..44a758c --- /dev/null +++ b/src/main/java/com/ironservices/notify/IronNotify.java @@ -0,0 +1,173 @@ +package com.ironservices.notify; + +import java.io.IOException; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +/** + * Static facade for the IronNotify SDK. + * Provides convenient static methods that delegate to the global client. + */ +public final class IronNotify { + + private IronNotify() { + // Utility class + } + + /** + * Initializes the SDK with an API key. + */ + public static void init(String apiKey) { + NotifyClient.init(apiKey); + } + + /** + * Initializes the SDK with options. + */ + public static void init(NotifyOptions options) { + NotifyClient.init(options); + } + + /** + * Sends a notification. + */ + public static SendResult notify(String eventType, String title) { + return NotifyClient.get().notify(eventType, title); + } + + /** + * Sends a notification with options. + */ + public static SendResult notify(String eventType, String title, String message, + SeverityLevel severity, Map metadata) { + return NotifyClient.get().notify(eventType, title, message, severity, metadata); + } + + /** + * Sends a notification asynchronously. + */ + public static CompletableFuture notifyAsync(String eventType, String title) { + return NotifyClient.get().notifyAsync(eventType, title); + } + + /** + * Sends a notification asynchronously with options. + */ + public static CompletableFuture notifyAsync(String eventType, String title, String message, + SeverityLevel severity, Map metadata) { + return NotifyClient.get().notifyAsync(eventType, title, message, severity, metadata); + } + + /** + * Creates an event builder. + */ + public static EventBuilder event(String eventType) { + return NotifyClient.get().event(eventType); + } + + /** + * Gets notifications. + */ + public static List getNotifications() throws IOException { + return NotifyClient.get().getNotifications(); + } + + /** + * Gets notifications with options. + */ + public static List getNotifications(Integer limit, Integer offset, boolean unreadOnly) throws IOException { + return NotifyClient.get().getNotifications(limit, offset, unreadOnly); + } + + /** + * Gets notifications asynchronously. + */ + public static CompletableFuture> getNotificationsAsync() { + return NotifyClient.get().getNotificationsAsync(); + } + + /** + * Gets notifications asynchronously with options. + */ + public static CompletableFuture> getNotificationsAsync(Integer limit, Integer offset, boolean unreadOnly) { + return NotifyClient.get().getNotificationsAsync(limit, offset, unreadOnly); + } + + /** + * Gets the unread count. + */ + public static int getUnreadCount() throws IOException { + return NotifyClient.get().getUnreadCount(); + } + + /** + * Gets the unread count asynchronously. + */ + public static CompletableFuture getUnreadCountAsync() { + return NotifyClient.get().getUnreadCountAsync(); + } + + /** + * Marks a notification as read. + */ + public static boolean markAsRead(String notificationId) throws IOException { + return NotifyClient.get().markAsRead(notificationId); + } + + /** + * Marks all notifications as read. + */ + public static boolean markAllAsRead() throws IOException { + return NotifyClient.get().markAllAsRead(); + } + + /** + * Connects to real-time notifications. + */ + public static void connect() { + NotifyClient.get().connect(); + } + + /** + * Disconnects from real-time notifications. + */ + public static void disconnect() { + NotifyClient.get().disconnect(); + } + + /** + * Subscribes to a user's notifications. + */ + public static void subscribeToUser(String userId) { + NotifyClient.get().subscribeToUser(userId); + } + + /** + * Subscribes to app-wide notifications. + */ + public static void subscribeToApp() { + NotifyClient.get().subscribeToApp(); + } + + /** + * Flushes the offline queue. + */ + public static void flush() { + NotifyClient.get().flush(); + } + + /** + * Flushes the offline queue asynchronously. + */ + public static CompletableFuture flushAsync() { + return NotifyClient.get().flushAsync(); + } + + /** + * Shuts down the SDK. + */ + public static void shutdown() { + NotifyClient.shutdown(); + } +} diff --git a/src/main/java/com/ironservices/notify/Notification.java b/src/main/java/com/ironservices/notify/Notification.java new file mode 100644 index 0000000..0724c14 --- /dev/null +++ b/src/main/java/com/ironservices/notify/Notification.java @@ -0,0 +1,119 @@ +package com.ironservices.notify; + +import java.time.Instant; +import java.util.List; +import java.util.Map; + +/** + * A notification received from the server. + */ +public class Notification { + private String id; + private String eventType; + private String title; + private String message; + private SeverityLevel severity; + private Map metadata; + private List actions; + private String userId; + private String groupKey; + private boolean read; + private Instant createdAt; + private Instant expiresAt; + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getEventType() { + return eventType; + } + + public void setEventType(String eventType) { + this.eventType = eventType; + } + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } + + public String getMessage() { + return message; + } + + public void setMessage(String message) { + this.message = message; + } + + public SeverityLevel getSeverity() { + return severity; + } + + public void setSeverity(SeverityLevel severity) { + this.severity = severity; + } + + public Map getMetadata() { + return metadata; + } + + public void setMetadata(Map metadata) { + this.metadata = metadata; + } + + public List getActions() { + return actions; + } + + public void setActions(List actions) { + this.actions = actions; + } + + public String getUserId() { + return userId; + } + + public void setUserId(String userId) { + this.userId = userId; + } + + public String getGroupKey() { + return groupKey; + } + + public void setGroupKey(String groupKey) { + this.groupKey = groupKey; + } + + public boolean isRead() { + return read; + } + + public void setRead(boolean read) { + this.read = read; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(Instant createdAt) { + this.createdAt = createdAt; + } + + public Instant getExpiresAt() { + return expiresAt; + } + + public void setExpiresAt(Instant expiresAt) { + this.expiresAt = expiresAt; + } +} diff --git a/src/main/java/com/ironservices/notify/NotificationAction.java b/src/main/java/com/ironservices/notify/NotificationAction.java new file mode 100644 index 0000000..bbe30e4 --- /dev/null +++ b/src/main/java/com/ironservices/notify/NotificationAction.java @@ -0,0 +1,63 @@ +package com.ironservices.notify; + +/** + * Action button on a notification. + */ +public class NotificationAction { + private String label; + private String url; + private String action; + private String style; + + public NotificationAction() { + this.style = "default"; + } + + public NotificationAction(String label) { + this.label = label; + this.style = "default"; + } + + public NotificationAction(String label, String url, String action, String style) { + this.label = label; + this.url = url; + this.action = action; + this.style = style != null ? style : "default"; + } + + public String getLabel() { + return label; + } + + public NotificationAction setLabel(String label) { + this.label = label; + return this; + } + + public String getUrl() { + return url; + } + + public NotificationAction setUrl(String url) { + this.url = url; + return this; + } + + public String getAction() { + return action; + } + + public NotificationAction setAction(String action) { + this.action = action; + return this; + } + + public String getStyle() { + return style; + } + + public NotificationAction setStyle(String style) { + this.style = style; + return this; + } +} diff --git a/src/main/java/com/ironservices/notify/NotificationPayload.java b/src/main/java/com/ironservices/notify/NotificationPayload.java new file mode 100644 index 0000000..1eba754 --- /dev/null +++ b/src/main/java/com/ironservices/notify/NotificationPayload.java @@ -0,0 +1,121 @@ +package com.ironservices.notify; + +import java.time.Instant; +import java.util.List; +import java.util.Map; + +/** + * Payload for creating a notification. + */ +public class NotificationPayload { + private String eventType; + private String title; + private String message; + private SeverityLevel severity; + private Map metadata; + private List actions; + private String userId; + private String groupKey; + private String deduplicationKey; + private Instant expiresAt; + + public NotificationPayload() { + this.severity = SeverityLevel.INFO; + } + + public NotificationPayload(String eventType, String title) { + this.eventType = eventType; + this.title = title; + this.severity = SeverityLevel.INFO; + } + + public String getEventType() { + return eventType; + } + + public NotificationPayload setEventType(String eventType) { + this.eventType = eventType; + return this; + } + + public String getTitle() { + return title; + } + + public NotificationPayload setTitle(String title) { + this.title = title; + return this; + } + + public String getMessage() { + return message; + } + + public NotificationPayload setMessage(String message) { + this.message = message; + return this; + } + + public SeverityLevel getSeverity() { + return severity; + } + + public NotificationPayload setSeverity(SeverityLevel severity) { + this.severity = severity; + return this; + } + + public Map getMetadata() { + return metadata; + } + + public NotificationPayload setMetadata(Map metadata) { + this.metadata = metadata; + return this; + } + + public List getActions() { + return actions; + } + + public NotificationPayload setActions(List actions) { + this.actions = actions; + return this; + } + + public String getUserId() { + return userId; + } + + public NotificationPayload setUserId(String userId) { + this.userId = userId; + return this; + } + + public String getGroupKey() { + return groupKey; + } + + public NotificationPayload setGroupKey(String groupKey) { + this.groupKey = groupKey; + return this; + } + + public String getDeduplicationKey() { + return deduplicationKey; + } + + public NotificationPayload setDeduplicationKey(String deduplicationKey) { + this.deduplicationKey = deduplicationKey; + return this; + } + + public Instant getExpiresAt() { + return expiresAt; + } + + public NotificationPayload setExpiresAt(Instant expiresAt) { + this.expiresAt = expiresAt; + return this; + } +} diff --git a/src/main/java/com/ironservices/notify/NotifyClient.java b/src/main/java/com/ironservices/notify/NotifyClient.java new file mode 100644 index 0000000..0262808 --- /dev/null +++ b/src/main/java/com/ironservices/notify/NotifyClient.java @@ -0,0 +1,347 @@ +package com.ironservices.notify; + +import java.io.IOException; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.function.Consumer; + +/** + * IronNotify client for sending and receiving notifications. + */ +public class NotifyClient implements AutoCloseable { + private final NotifyOptions options; + private final Transport transport; + private final OfflineQueue queue; + private volatile boolean isOnline = true; + private volatile ConnectionState connectionState = ConnectionState.DISCONNECTED; + + private Consumer onNotification; + private Consumer onUnreadCountChange; + private Consumer onConnectionStateChange; + + private static NotifyClient globalClient; + private static final Object globalLock = new Object(); + + /** + * Creates a new NotifyClient with the given options. + */ + public NotifyClient(NotifyOptions options) { + if (options.getApiKey() == null || options.getApiKey().isEmpty()) { + throw new IllegalArgumentException("API key is required"); + } + + this.options = options; + this.transport = new Transport( + options.getApiBaseUrl(), + options.getApiKey(), + options.getHttpTimeout(), + options.isDebug() + ); + + if (options.isEnableOfflineQueue()) { + this.queue = new OfflineQueue(options.getMaxOfflineQueueSize(), options.isDebug()); + } else { + this.queue = null; + } + + if (options.isDebug()) { + System.out.println("[IronNotify] Client initialized"); + } + } + + /** + * Initializes the global client. + */ + public static void init(String apiKey) { + init(new NotifyOptions(apiKey)); + } + + /** + * Initializes the global client with options. + */ + public static void init(NotifyOptions options) { + synchronized (globalLock) { + if (globalClient != null) { + globalClient.close(); + } + globalClient = new NotifyClient(options); + } + } + + /** + * Gets the global client. + */ + public static NotifyClient get() { + synchronized (globalLock) { + if (globalClient == null) { + throw new IllegalStateException("IronNotify not initialized. Call init() first."); + } + return globalClient; + } + } + + /** + * Sends a notification. + */ + public SendResult notify(String eventType, String title) { + return notify(eventType, title, null, SeverityLevel.INFO, null); + } + + /** + * Sends a notification with options. + */ + public SendResult notify(String eventType, String title, String message, + SeverityLevel severity, Map metadata) { + NotificationPayload payload = new NotificationPayload(eventType, title); + payload.setMessage(message); + payload.setSeverity(severity); + payload.setMetadata(metadata); + return sendPayload(payload); + } + + /** + * Sends a notification asynchronously. + */ + public CompletableFuture notifyAsync(String eventType, String title) { + return notifyAsync(eventType, title, null, SeverityLevel.INFO, null); + } + + /** + * Sends a notification asynchronously with options. + */ + public CompletableFuture notifyAsync(String eventType, String title, String message, + SeverityLevel severity, Map metadata) { + NotificationPayload payload = new NotificationPayload(eventType, title); + payload.setMessage(message); + payload.setSeverity(severity); + payload.setMetadata(metadata); + return sendPayloadAsync(payload); + } + + /** + * Creates an event builder. + */ + public EventBuilder event(String eventType) { + return new EventBuilder(this, eventType); + } + + /** + * Sends a notification payload. + */ + public SendResult sendPayload(NotificationPayload payload) { + SendResult result = transport.send(payload); + + if (!result.isSuccess() && options.isEnableOfflineQueue() && queue != null) { + queue.add(payload); + isOnline = false; + return SendResult.queued(result.getError()); + } + + return result; + } + + /** + * Sends a notification payload asynchronously. + */ + public CompletableFuture sendPayloadAsync(NotificationPayload payload) { + return transport.sendAsync(payload).thenApply(result -> { + if (!result.isSuccess() && options.isEnableOfflineQueue() && queue != null) { + queue.add(payload); + isOnline = false; + return SendResult.queued(result.getError()); + } + return result; + }); + } + + /** + * Gets notifications. + */ + public List getNotifications() throws IOException { + return getNotifications(null, null, false); + } + + /** + * Gets notifications with options. + */ + public List getNotifications(Integer limit, Integer offset, boolean unreadOnly) throws IOException { + return transport.getNotifications(limit, offset, unreadOnly); + } + + /** + * Gets notifications asynchronously. + */ + public CompletableFuture> getNotificationsAsync() { + return getNotificationsAsync(null, null, false); + } + + /** + * Gets notifications asynchronously with options. + */ + public CompletableFuture> getNotificationsAsync(Integer limit, Integer offset, boolean unreadOnly) { + return transport.getNotificationsAsync(limit, offset, unreadOnly); + } + + /** + * Gets the unread notification count. + */ + public int getUnreadCount() throws IOException { + return transport.getUnreadCount(); + } + + /** + * Gets the unread notification count asynchronously. + */ + public CompletableFuture getUnreadCountAsync() { + return transport.getUnreadCountAsync(); + } + + /** + * Marks a notification as read. + */ + public boolean markAsRead(String notificationId) throws IOException { + return transport.markAsRead(notificationId); + } + + /** + * Marks a notification as read asynchronously. + */ + public CompletableFuture markAsReadAsync(String notificationId) { + return transport.markAsReadAsync(notificationId); + } + + /** + * Marks all notifications as read. + */ + public boolean markAllAsRead() throws IOException { + return transport.markAllAsRead(); + } + + /** + * Marks all notifications as read asynchronously. + */ + public CompletableFuture markAllAsReadAsync() { + return transport.markAllAsReadAsync(); + } + + /** + * Sets the notification handler. + */ + public void onNotification(Consumer handler) { + this.onNotification = handler; + } + + /** + * Sets the unread count change handler. + */ + public void onUnreadCountChange(Consumer handler) { + this.onUnreadCountChange = handler; + } + + /** + * Sets the connection state change handler. + */ + public void onConnectionStateChange(Consumer handler) { + this.onConnectionStateChange = handler; + } + + /** + * Gets the current connection state. + */ + public ConnectionState getConnectionState() { + return connectionState; + } + + /** + * Connects to real-time notifications. + */ + public void connect() { + setConnectionState(ConnectionState.CONNECTED); + if (options.isDebug()) { + System.out.println("[IronNotify] Connected (WebSocket not implemented)"); + } + } + + /** + * Disconnects from real-time notifications. + */ + public void disconnect() { + setConnectionState(ConnectionState.DISCONNECTED); + } + + /** + * Subscribes to a user's notifications. + */ + public void subscribeToUser(String userId) { + if (options.isDebug()) { + System.out.println("[IronNotify] Subscribed to user: " + userId); + } + } + + /** + * Subscribes to app-wide notifications. + */ + public void subscribeToApp() { + if (options.isDebug()) { + System.out.println("[IronNotify] Subscribed to app notifications"); + } + } + + /** + * Flushes the offline queue. + */ + public void flush() { + if (queue == null || queue.isEmpty()) { + return; + } + + if (!transport.isOnline()) { + return; + } + + isOnline = true; + List notifications = queue.getAll(); + + for (int i = notifications.size() - 1; i >= 0; i--) { + SendResult result = transport.send(notifications.get(i)); + if (result.isSuccess()) { + queue.remove(i); + } else { + break; + } + } + } + + /** + * Flushes the offline queue asynchronously. + */ + public CompletableFuture flushAsync() { + return CompletableFuture.runAsync(this::flush); + } + + private void setConnectionState(ConnectionState state) { + this.connectionState = state; + if (onConnectionStateChange != null) { + onConnectionStateChange.accept(state); + } + } + + @Override + public void close() { + disconnect(); + transport.close(); + } + + /** + * Closes the global client. + */ + public static void shutdown() { + synchronized (globalLock) { + if (globalClient != null) { + globalClient.close(); + globalClient = null; + } + } + } +} diff --git a/src/main/java/com/ironservices/notify/NotifyOptions.java b/src/main/java/com/ironservices/notify/NotifyOptions.java new file mode 100644 index 0000000..730823c --- /dev/null +++ b/src/main/java/com/ironservices/notify/NotifyOptions.java @@ -0,0 +1,187 @@ +package com.ironservices.notify; + +import java.time.Duration; + +/** + * Configuration options for the IronNotify client. + */ +public class NotifyOptions { + private String apiKey; + private String apiBaseUrl = "https://api.ironnotify.com"; + private String webSocketUrl = "wss://ws.ironnotify.com"; + private boolean debug = false; + private boolean enableOfflineQueue = true; + private int maxOfflineQueueSize = 100; + private boolean autoReconnect = true; + private int maxReconnectAttempts = 5; + private Duration reconnectDelay = Duration.ofSeconds(1); + private Duration httpTimeout = Duration.ofSeconds(30); + + public NotifyOptions() { + } + + public NotifyOptions(String apiKey) { + this.apiKey = apiKey; + } + + public String getApiKey() { + return apiKey; + } + + public NotifyOptions setApiKey(String apiKey) { + this.apiKey = apiKey; + return this; + } + + public String getApiBaseUrl() { + return apiBaseUrl; + } + + public NotifyOptions setApiBaseUrl(String apiBaseUrl) { + this.apiBaseUrl = apiBaseUrl; + return this; + } + + public String getWebSocketUrl() { + return webSocketUrl; + } + + public NotifyOptions setWebSocketUrl(String webSocketUrl) { + this.webSocketUrl = webSocketUrl; + return this; + } + + public boolean isDebug() { + return debug; + } + + public NotifyOptions setDebug(boolean debug) { + this.debug = debug; + return this; + } + + public boolean isEnableOfflineQueue() { + return enableOfflineQueue; + } + + public NotifyOptions setEnableOfflineQueue(boolean enableOfflineQueue) { + this.enableOfflineQueue = enableOfflineQueue; + return this; + } + + public int getMaxOfflineQueueSize() { + return maxOfflineQueueSize; + } + + public NotifyOptions setMaxOfflineQueueSize(int maxOfflineQueueSize) { + this.maxOfflineQueueSize = maxOfflineQueueSize; + return this; + } + + public boolean isAutoReconnect() { + return autoReconnect; + } + + public NotifyOptions setAutoReconnect(boolean autoReconnect) { + this.autoReconnect = autoReconnect; + return this; + } + + public int getMaxReconnectAttempts() { + return maxReconnectAttempts; + } + + public NotifyOptions setMaxReconnectAttempts(int maxReconnectAttempts) { + this.maxReconnectAttempts = maxReconnectAttempts; + return this; + } + + public Duration getReconnectDelay() { + return reconnectDelay; + } + + public NotifyOptions setReconnectDelay(Duration reconnectDelay) { + this.reconnectDelay = reconnectDelay; + return this; + } + + public Duration getHttpTimeout() { + return httpTimeout; + } + + public NotifyOptions setHttpTimeout(Duration httpTimeout) { + this.httpTimeout = httpTimeout; + return this; + } + + /** + * Creates a builder for NotifyOptions. + */ + public static Builder builder() { + return new Builder(); + } + + /** + * Builder for NotifyOptions. + */ + public static class Builder { + private final NotifyOptions options = new NotifyOptions(); + + public Builder apiKey(String apiKey) { + options.setApiKey(apiKey); + return this; + } + + public Builder apiBaseUrl(String apiBaseUrl) { + options.setApiBaseUrl(apiBaseUrl); + return this; + } + + public Builder webSocketUrl(String webSocketUrl) { + options.setWebSocketUrl(webSocketUrl); + return this; + } + + public Builder debug(boolean debug) { + options.setDebug(debug); + return this; + } + + public Builder enableOfflineQueue(boolean enable) { + options.setEnableOfflineQueue(enable); + return this; + } + + public Builder maxOfflineQueueSize(int size) { + options.setMaxOfflineQueueSize(size); + return this; + } + + public Builder autoReconnect(boolean autoReconnect) { + options.setAutoReconnect(autoReconnect); + return this; + } + + public Builder maxReconnectAttempts(int attempts) { + options.setMaxReconnectAttempts(attempts); + return this; + } + + public Builder reconnectDelay(Duration delay) { + options.setReconnectDelay(delay); + return this; + } + + public Builder httpTimeout(Duration timeout) { + options.setHttpTimeout(timeout); + return this; + } + + public NotifyOptions build() { + if (options.getApiKey() == null || options.getApiKey().isEmpty()) { + throw new IllegalArgumentException("API key is required"); + } + return options; + } + } +} diff --git a/src/main/java/com/ironservices/notify/OfflineQueue.java b/src/main/java/com/ironservices/notify/OfflineQueue.java new file mode 100644 index 0000000..a8a2e37 --- /dev/null +++ b/src/main/java/com/ironservices/notify/OfflineQueue.java @@ -0,0 +1,111 @@ +package com.ironservices.notify; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.reflect.TypeToken; + +import java.io.*; +import java.lang.reflect.Type; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; + +/** + * Offline queue for storing notifications when offline. + */ +class OfflineQueue { + private final int maxSize; + private final boolean debug; + private final List queue; + private final Path storagePath; + private final Gson gson; + private final Object lock = new Object(); + + OfflineQueue(int maxSize, boolean debug) { + this.maxSize = maxSize; + this.debug = debug; + this.queue = new ArrayList<>(); + this.storagePath = Paths.get(System.getProperty("user.home"), ".ironnotify", "offline_queue.json"); + this.gson = new GsonBuilder().create(); + loadFromStorage(); + } + + void add(NotificationPayload payload) { + synchronized (lock) { + if (queue.size() >= maxSize) { + queue.remove(0); + if (debug) { + System.out.println("[IronNotify] Offline queue full, dropping oldest notification"); + } + } + + queue.add(payload); + saveToStorage(); + + if (debug) { + System.out.println("[IronNotify] Notification queued for later: " + payload.getEventType()); + } + } + } + + List getAll() { + synchronized (lock) { + return new ArrayList<>(queue); + } + } + + void remove(int index) { + synchronized (lock) { + if (index >= 0 && index < queue.size()) { + queue.remove(index); + saveToStorage(); + } + } + } + + void clear() { + synchronized (lock) { + queue.clear(); + saveToStorage(); + } + } + + int size() { + synchronized (lock) { + return queue.size(); + } + } + + boolean isEmpty() { + synchronized (lock) { + return queue.isEmpty(); + } + } + + private void loadFromStorage() { + try { + if (Files.exists(storagePath)) { + String json = new String(Files.readAllBytes(storagePath)); + Type listType = new TypeToken>(){}.getType(); + List loaded = gson.fromJson(json, listType); + if (loaded != null) { + queue.addAll(loaded); + } + } + } catch (Exception e) { + // Ignore errors loading from storage + } + } + + private void saveToStorage() { + try { + Files.createDirectories(storagePath.getParent()); + String json = gson.toJson(queue); + Files.write(storagePath, json.getBytes()); + } catch (Exception e) { + // Ignore errors saving to storage + } + } +} diff --git a/src/main/java/com/ironservices/notify/SendResult.java b/src/main/java/com/ironservices/notify/SendResult.java new file mode 100644 index 0000000..097b6c7 --- /dev/null +++ b/src/main/java/com/ironservices/notify/SendResult.java @@ -0,0 +1,46 @@ +package com.ironservices.notify; + +/** + * Result of sending a notification. + */ +public class SendResult { + private final boolean success; + private final String notificationId; + private final String error; + private final boolean queued; + + private SendResult(boolean success, String notificationId, String error, boolean queued) { + this.success = success; + this.notificationId = notificationId; + this.error = error; + this.queued = queued; + } + + public static SendResult success(String notificationId) { + return new SendResult(true, notificationId, null, false); + } + + public static SendResult failure(String error) { + return new SendResult(false, null, error, false); + } + + public static SendResult queued(String error) { + return new SendResult(false, null, error, true); + } + + public boolean isSuccess() { + return success; + } + + public String getNotificationId() { + return notificationId; + } + + public String getError() { + return error; + } + + public boolean isQueued() { + return queued; + } +} diff --git a/src/main/java/com/ironservices/notify/SeverityLevel.java b/src/main/java/com/ironservices/notify/SeverityLevel.java new file mode 100644 index 0000000..4d595d0 --- /dev/null +++ b/src/main/java/com/ironservices/notify/SeverityLevel.java @@ -0,0 +1,38 @@ +package com.ironservices.notify; + +import com.google.gson.annotations.SerializedName; + +/** + * Severity level for notifications. + */ +public enum SeverityLevel { + @SerializedName("info") + INFO("info"), + + @SerializedName("success") + SUCCESS("success"), + + @SerializedName("warning") + WARNING("warning"), + + @SerializedName("error") + ERROR("error"), + + @SerializedName("critical") + CRITICAL("critical"); + + private final String value; + + SeverityLevel(String value) { + this.value = value; + } + + public String getValue() { + return value; + } + + @Override + public String toString() { + return value; + } +} diff --git a/src/main/java/com/ironservices/notify/Transport.java b/src/main/java/com/ironservices/notify/Transport.java new file mode 100644 index 0000000..588b4f7 --- /dev/null +++ b/src/main/java/com/ironservices/notify/Transport.java @@ -0,0 +1,235 @@ +package com.ironservices.notify; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.reflect.TypeToken; +import okhttp3.*; + +import java.io.IOException; +import java.lang.reflect.Type; +import java.time.Duration; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; + +/** + * HTTP transport for IronNotify API. + */ +class Transport { + private static final MediaType JSON = MediaType.get("application/json; charset=utf-8"); + + private final String baseUrl; + private final String apiKey; + private final boolean debug; + private final OkHttpClient client; + private final Gson gson; + + Transport(String baseUrl, String apiKey, Duration timeout, boolean debug) { + this.baseUrl = baseUrl; + this.apiKey = apiKey; + this.debug = debug; + this.client = new OkHttpClient.Builder() + .connectTimeout(timeout.toMillis(), TimeUnit.MILLISECONDS) + .readTimeout(timeout.toMillis(), TimeUnit.MILLISECONDS) + .writeTimeout(timeout.toMillis(), TimeUnit.MILLISECONDS) + .build(); + this.gson = new GsonBuilder() + .setFieldNamingStrategy(field -> { + String name = field.getName(); + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < name.length(); i++) { + char c = name.charAt(i); + if (Character.isUpperCase(c)) { + if (i > 0) { + sb.append('_'); + } + sb.append(Character.toLowerCase(c)); + } else { + sb.append(c); + } + } + return sb.toString(); + }) + .create(); + } + + SendResult send(NotificationPayload payload) { + try { + String json = gson.toJson(payload); + + if (debug) { + System.out.println("[IronNotify] Sending notification: " + payload.getEventType()); + } + + Request request = new Request.Builder() + .url(baseUrl + "/api/v1/notify") + .header("Authorization", "Bearer " + apiKey) + .post(RequestBody.create(json, JSON)) + .build(); + + try (Response response = client.newCall(request).execute()) { + String body = response.body() != null ? response.body().string() : ""; + + if (response.isSuccessful()) { + SendResponse result = gson.fromJson(body, SendResponse.class); + return SendResult.success(result != null ? result.notificationId : null); + } + + ErrorResponse error = gson.fromJson(body, ErrorResponse.class); + return SendResult.failure(error != null && error.error != null + ? error.error + : "HTTP " + response.code() + ": " + body); + } + } catch (IOException e) { + return SendResult.failure(e.getMessage()); + } + } + + CompletableFuture sendAsync(NotificationPayload payload) { + return CompletableFuture.supplyAsync(() -> send(payload)); + } + + List getNotifications(Integer limit, Integer offset, boolean unreadOnly) throws IOException { + HttpUrl.Builder urlBuilder = HttpUrl.parse(baseUrl + "/api/v1/notifications").newBuilder(); + + if (limit != null && limit > 0) { + urlBuilder.addQueryParameter("limit", String.valueOf(limit)); + } + if (offset != null && offset > 0) { + urlBuilder.addQueryParameter("offset", String.valueOf(offset)); + } + if (unreadOnly) { + urlBuilder.addQueryParameter("unread_only", "true"); + } + + Request request = new Request.Builder() + .url(urlBuilder.build()) + .header("Authorization", "Bearer " + apiKey) + .get() + .build(); + + try (Response response = client.newCall(request).execute()) { + if (!response.isSuccessful()) { + throw new IOException("HTTP " + response.code()); + } + + String body = response.body() != null ? response.body().string() : "[]"; + Type listType = new TypeToken>(){}.getType(); + return gson.fromJson(body, listType); + } + } + + CompletableFuture> getNotificationsAsync(Integer limit, Integer offset, boolean unreadOnly) { + return CompletableFuture.supplyAsync(() -> { + try { + return getNotifications(limit, offset, unreadOnly); + } catch (IOException e) { + throw new RuntimeException(e); + } + }); + } + + int getUnreadCount() throws IOException { + Request request = new Request.Builder() + .url(baseUrl + "/api/v1/notifications/unread-count") + .header("Authorization", "Bearer " + apiKey) + .get() + .build(); + + try (Response response = client.newCall(request).execute()) { + if (!response.isSuccessful()) { + throw new IOException("HTTP " + response.code()); + } + + String body = response.body() != null ? response.body().string() : "{}"; + CountResponse result = gson.fromJson(body, CountResponse.class); + return result != null ? result.count : 0; + } + } + + CompletableFuture getUnreadCountAsync() { + return CompletableFuture.supplyAsync(() -> { + try { + return getUnreadCount(); + } catch (IOException e) { + throw new RuntimeException(e); + } + }); + } + + boolean markAsRead(String notificationId) throws IOException { + Request request = new Request.Builder() + .url(baseUrl + "/api/v1/notifications/" + notificationId + "/read") + .header("Authorization", "Bearer " + apiKey) + .post(RequestBody.create("", JSON)) + .build(); + + try (Response response = client.newCall(request).execute()) { + return response.isSuccessful(); + } + } + + CompletableFuture markAsReadAsync(String notificationId) { + return CompletableFuture.supplyAsync(() -> { + try { + return markAsRead(notificationId); + } catch (IOException e) { + return false; + } + }); + } + + boolean markAllAsRead() throws IOException { + Request request = new Request.Builder() + .url(baseUrl + "/api/v1/notifications/read-all") + .header("Authorization", "Bearer " + apiKey) + .post(RequestBody.create("", JSON)) + .build(); + + try (Response response = client.newCall(request).execute()) { + return response.isSuccessful(); + } + } + + CompletableFuture markAllAsReadAsync() { + return CompletableFuture.supplyAsync(() -> { + try { + return markAllAsRead(); + } catch (IOException e) { + return false; + } + }); + } + + boolean isOnline() { + try { + Request request = new Request.Builder() + .url(baseUrl + "/health") + .get() + .build(); + + try (Response response = client.newCall(request).execute()) { + return response.isSuccessful(); + } + } catch (IOException e) { + return false; + } + } + + void close() { + client.dispatcher().executorService().shutdown(); + client.connectionPool().evictAll(); + } + + private static class SendResponse { + String notificationId; + } + + private static class ErrorResponse { + String error; + } + + private static class CountResponse { + int count; + } +}