package org.asamk.signal.jsonrpc; import org.asamk.signal.manager.Manager; import org.asamk.signal.manager.MultiAccountManager; import org.asamk.signal.manager.ProvisioningManager; import org.asamk.signal.manager.RegistrationManager; import org.asamk.signal.manager.api.CallInfo; import org.asamk.signal.manager.api.CallOffer; import org.asamk.signal.manager.api.Configuration; import org.asamk.signal.manager.api.Device; import org.asamk.signal.manager.api.DeviceLinkUrl; import org.asamk.signal.manager.api.Group; import org.asamk.signal.manager.api.GroupId; import org.asamk.signal.manager.api.GroupInviteLinkUrl; import org.asamk.signal.manager.api.Identity; import org.asamk.signal.manager.api.IdentityVerificationCode; import org.asamk.signal.manager.api.Message; import org.asamk.signal.manager.api.MessageEnvelope; import org.asamk.signal.manager.api.Pair; import org.asamk.signal.manager.api.ReceiveConfig; import org.asamk.signal.manager.api.Recipient; import org.asamk.signal.manager.api.RecipientIdentifier; import org.asamk.signal.manager.api.SendGroupMessageResults; import org.asamk.signal.manager.api.SendMessageResult; import org.asamk.signal.manager.api.SendMessageResults; import org.asamk.signal.manager.api.StickerPack; import org.asamk.signal.manager.api.StickerPackId; import org.asamk.signal.manager.api.StickerPackUrl; import org.asamk.signal.manager.api.TurnServer; import org.asamk.signal.manager.api.TypingAction; import org.asamk.signal.manager.api.UpdateGroup; import org.asamk.signal.manager.api.UpdateProfile; import org.asamk.signal.manager.api.UserStatus; import org.asamk.signal.manager.api.UsernameLinkUrl; import org.asamk.signal.manager.api.UsernameStatus; import org.asamk.signal.output.JsonWriter; import org.junit.jupiter.api.Test; import java.io.File; import java.io.InputStream; import java.net.URI; import java.time.Duration; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.List; import java.util.Map; import java.util.Optional; import java.util.Queue; import java.util.Set; import java.util.concurrent.ConcurrentLinkedQueue; import java.util.concurrent.atomic.AtomicInteger; import java.util.function.Consumer; import static org.junit.jupiter.api.Assertions.assertEquals; /** * Tests for the subscribeCallEvents / unsubscribeCallEvents JSON-RPC commands * introduced in commit d1e93dd. */ class SubscribeCallEventsTest { /** * Feeds pre-configured JSON-RPC lines to the handler, then returns null to end. */ private static class LineFeeder { private final Queue lines = new ConcurrentLinkedQueue<>(); void addLine(String line) { lines.add(line); } String getLine() { return lines.poll(); } } /** * Captures JSON-RPC responses written by the handler. */ private static class CapturingJsonWriter implements JsonWriter { final List written = Collections.synchronizedList(new ArrayList<>()); @Override public void write(final Object object) { written.add(object); } } /** * Minimal Manager stub that tracks call event listener add/remove calls. */ private static class StubManager implements Manager { final List listeners = new ArrayList<>(); final AtomicInteger addCount = new AtomicInteger(0); final AtomicInteger removeCount = new AtomicInteger(0); final String selfNumber; StubManager(String selfNumber) { this.selfNumber = selfNumber; } @Override public void addCallEventListener(CallEventListener listener) { addCount.incrementAndGet(); listeners.add(listener); } @Override public void removeCallEventListener(CallEventListener listener) { removeCount.incrementAndGet(); listeners.remove(listener); } @Override public String getSelfNumber() { return selfNumber; } // --- Stubs for remaining Manager interface methods --- @Override public Map getUserStatus(Set n) { return Map.of(); } @Override public Map getUsernameStatus(Set u) { return Map.of(); } @Override public void updateAccountAttributes(String d, Boolean u, Boolean dn, Boolean ns) { } @Override public Configuration getConfiguration() { return null; } @Override public void updateConfiguration(Configuration c) { } @Override public void updateProfile(UpdateProfile u) { } @Override public String getUsername() { return null; } @Override public UsernameLinkUrl getUsernameLink() { return null; } @Override public void setUsername(String u) { } @Override public void deleteUsername() { } @Override public void startChangeNumber(String n, boolean v, String c) { } @Override public void finishChangeNumber(String n, String v, String p) { } @Override public void unregister() { } @Override public void deleteAccount() { } @Override public void submitRateLimitRecaptchaChallenge(String c, String cap) { } @Override public List getLinkedDevices() { return List.of(); } @Override public void updateLinkedDevice(int d, String n) { } @Override public void removeLinkedDevices(int d) { } @Override public void addDeviceLink(DeviceLinkUrl u) { } @Override public void setRegistrationLockPin(Optional p) { } @Override public List getGroups() { return List.of(); } @Override public List getGroups(Collection g) { return List.of(); } @Override public SendGroupMessageResults quitGroup(GroupId g, Set a) { return null; } @Override public void deleteGroup(GroupId g) { } @Override public Pair createGroup( String n, Set m, String a ) { return null; } @Override public SendGroupMessageResults updateGroup(GroupId g, UpdateGroup u) { return null; } @Override public Pair joinGroup(GroupInviteLinkUrl u) { return null; } @Override public SendMessageResults sendTypingMessage(TypingAction a, Set r) { return null; } @Override public SendMessageResults sendReadReceipt(RecipientIdentifier.Single s, List m) { return null; } @Override public SendMessageResults sendViewedReceipt(RecipientIdentifier.Single s, List m) { return null; } @Override public SendMessageResults sendMessage(Message m, Set r, boolean n) { return null; } @Override public SendMessageResults sendEditMessage(Message m, Set r, long t) { return null; } @Override public SendMessageResults sendRemoteDeleteMessage(long t, Set r) { return null; } @Override public SendMessageResults sendMessageReaction( String e, boolean rm, RecipientIdentifier.Single a, long t, Set r, boolean n, boolean s ) { return null; } @Override public SendMessageResults sendAdminDelete( RecipientIdentifier.Single a, long t, Set r, boolean n, boolean s ) { return null; } @Override public SendMessageResults sendPinMessage( int d, RecipientIdentifier.Single a, long t, Set r, boolean n, boolean s ) { return null; } @Override public SendMessageResults sendUnpinMessage( RecipientIdentifier.Single a, long t, Set r, boolean n, boolean s ) { return null; } @Override public SendMessageResults sendPaymentNotificationMessage(byte[] r, String n, RecipientIdentifier.Single re) { return null; } @Override public void sendEndSessionMessage(Set r) { } @Override public SendMessageResults sendMessageRequestResponse( MessageEnvelope.Sync.MessageRequestResponse.Type t, Set r ) { return null; } @Override public SendMessageResults sendPollCreateMessage( String q, boolean a, List o, Set r, boolean n ) { return null; } @Override public SendMessageResults sendPollVoteMessage( RecipientIdentifier.Single a, long t, List o, int v, Set r, boolean n ) { return null; } @Override public SendMessageResults sendPollTerminateMessage(long t, Set r, boolean n) { return null; } @Override public void hideRecipient(RecipientIdentifier.Single r) { } @Override public void deleteRecipient(RecipientIdentifier.Single r) { } @Override public void deleteContact(RecipientIdentifier.Single r) { } @Override public void setContactName(RecipientIdentifier.Single r, String g, String f, String ng, String nf, String n) { } @Override public void setContactsBlocked(Collection r, boolean b) { } @Override public void setGroupsBlocked(Collection g, boolean b) { } @Override public void setExpirationTimer(RecipientIdentifier.Single r, int t) { } @Override public StickerPackUrl uploadStickerPack(File p) { return null; } @Override public void installStickerPack(StickerPackUrl u) { } @Override public List getStickerPacks() { return List.of(); } @Override public void requestAllSyncData() { } @Override public void addReceiveHandler(ReceiveMessageHandler h, boolean w) { } @Override public void removeReceiveHandler(ReceiveMessageHandler h) { } @Override public boolean isReceiving() { return false; } @Override public void receiveMessages(Optional t, Optional m, ReceiveMessageHandler h) { } @Override public void stopReceiveMessages() { } @Override public void setReceiveConfig(ReceiveConfig r) { } @Override public boolean isContactBlocked(RecipientIdentifier.Single r) { return false; } @Override public void sendContacts() { } @Override public List getRecipients( boolean o, Optional b, Collection a, Optional n ) { return List.of(); } @Override public String getContactOrProfileName(RecipientIdentifier.Single r) { return null; } @Override public Group getGroup(GroupId g) { return null; } @Override public List getIdentities() { return List.of(); } @Override public List getIdentities(RecipientIdentifier.Single r) { return List.of(); } @Override public boolean trustIdentityVerified(RecipientIdentifier.Single r, IdentityVerificationCode v) { return false; } @Override public boolean trustIdentityAllKeys(RecipientIdentifier.Single r) { return false; } @Override public void addAddressChangedListener(Runnable l) { } @Override public void addClosedListener(Runnable l) { } @Override public void addUnidentifiedKeepAlive(String token) { } @Override public void removeUnidentifiedKeepAlive(String token) { } @Override public InputStream retrieveAttachment(String id) { return null; } @Override public InputStream retrieveContactAvatar(RecipientIdentifier.Single r) { return null; } @Override public InputStream retrieveProfileAvatar(RecipientIdentifier.Single r) { return null; } @Override public InputStream retrieveGroupAvatar(GroupId g) { return null; } @Override public InputStream retrieveSticker(StickerPackId s, int i) { return null; } @Override public CallInfo startCall(RecipientIdentifier.Single r) { return null; } @Override public CallInfo acceptCall(long c) { return null; } @Override public void hangupCall(long c) { } @Override public SendMessageResult rejectCall(long c) { return null; } @Override public List listActiveCalls() { return List.of(); } @Override public void sendCallOffer(RecipientIdentifier.Single r, CallOffer o) { } @Override public void sendCallAnswer(RecipientIdentifier.Single r, long c, byte[] a) { } @Override public void sendIceUpdate(RecipientIdentifier.Single r, long c, List i) { } @Override public void sendHangup(RecipientIdentifier.Single r, long c, MessageEnvelope.Call.Hangup.Type t) { } @Override public void sendBusy(RecipientIdentifier.Single r, long c) { } @Override public List getTurnServerInfo() { return List.of(); } @Override public void close() { } } /** * Minimal MultiAccountManager stub for multi-account mode tests. */ private static class StubMultiAccountManager implements MultiAccountManager { final List managers; final List> addedHandlers = new ArrayList<>(); StubMultiAccountManager(List managers) { this.managers = new ArrayList<>(managers); } @Override public List getAccountNumbers() { return managers.stream().map(Manager::getSelfNumber).toList(); } @Override public List getManagers() { return managers; } @Override public void addOnManagerAddedHandler(Consumer handler) { addedHandlers.add(handler); } @Override public void addOnManagerRemovedHandler(Consumer handler) { } @Override public Manager getManager(String phoneNumber) { return managers.stream().filter(m -> phoneNumber.equals(m.getSelfNumber())).findFirst().orElse(null); } @Override public URI getNewProvisioningDeviceLinkUri() { return null; } @Override public ProvisioningManager getProvisioningManagerFor(URI u) { return null; } @Override public RegistrationManager getNewRegistrationManager(String a) { return null; } @Override public void close() { } } private static String jsonRpcCall(int id, String method) { return "{\"jsonrpc\":\"2.0\",\"id\":" + id + ",\"method\":\"" + method + "\"}"; } private static String jsonRpcCall(int id, String method, String params) { return "{\"jsonrpc\":\"2.0\",\"id\":" + id + ",\"method\":\"" + method + "\",\"params\":" + params + "}"; } // --- Single-account mode tests --- @Test void callEventsNotSubscribedByDefault() { var manager = new StubManager("+15551234567"); var feeder = new LineFeeder(); var writer = new CapturingJsonWriter(); // Send no subscribeCallEvents, just end the connection var handler = new SignalJsonRpcDispatcherHandler(writer, feeder::getLine, true); handler.handleConnection(manager); // No listeners should have been added assertEquals(0, manager.addCount.get(), "call events should not be auto-subscribed"); } @Test void subscribeCallEventsAddsListener() { var manager = new StubManager("+15551234567"); var feeder = new LineFeeder(); var writer = new CapturingJsonWriter(); feeder.addLine(jsonRpcCall(1, "subscribeCallEvents")); // null terminates the read loop var handler = new SignalJsonRpcDispatcherHandler(writer, feeder::getLine, true); handler.handleConnection(manager); assertEquals(1, manager.addCount.get(), "subscribeCallEvents should add one listener"); // Cleanup in finally block should remove it assertEquals(1, manager.removeCount.get(), "cleanup should remove the listener"); assertEquals(0, manager.listeners.size(), "no listeners should remain after cleanup"); } @Test void subscribeCallEventsCanBeCalledMultipleTimes() { var manager = new StubManager("+15551234567"); var feeder = new LineFeeder(); var writer = new CapturingJsonWriter(); feeder.addLine(jsonRpcCall(1, "subscribeCallEvents")); feeder.addLine(jsonRpcCall(2, "subscribeCallEvents")); var handler = new SignalJsonRpcDispatcherHandler(writer, feeder::getLine, true); handler.handleConnection(manager); // The implementation allows multiple subscriptions, so two calls add two listeners assertEquals(2, manager.addCount.get(), "multiple subscribeCallEvents should add multiple listeners"); } @Test void unsubscribeCallEventsRemovesListener() { var manager = new StubManager("+15551234567"); var feeder = new LineFeeder(); var writer = new CapturingJsonWriter(); feeder.addLine(jsonRpcCall(1, "subscribeCallEvents")); feeder.addLine(jsonRpcCall(2, "unsubscribeCallEvents", "{\"subscription\":0}")); var handler = new SignalJsonRpcDispatcherHandler(writer, feeder::getLine, true); handler.handleConnection(manager); assertEquals(1, manager.addCount.get(), "should have subscribed once"); // removeCount: 1 from explicit unsubscribe. The finally block's unsubscribeAllCallEvents // iterates an empty list so adds 0 more. assertEquals(1, manager.removeCount.get(), "should have unsubscribed once"); assertEquals(0, manager.listeners.size()); } @Test void unsubscribeWithoutSubscribeIsNoOp() { var manager = new StubManager("+15551234567"); var feeder = new LineFeeder(); var writer = new CapturingJsonWriter(); feeder.addLine(jsonRpcCall(1, "unsubscribeCallEvents")); var handler = new SignalJsonRpcDispatcherHandler(writer, feeder::getLine, true); handler.handleConnection(manager); assertEquals(0, manager.addCount.get()); assertEquals(0, manager.removeCount.get()); } // --- Multi-account mode tests --- @Test void multiAccountSubscribeCallEventsSubscribesAllManagers() { var manager1 = new StubManager("+15551111111"); var manager2 = new StubManager("+15552222222"); var multi = new StubMultiAccountManager(List.of(manager1, manager2)); var feeder = new LineFeeder(); var writer = new CapturingJsonWriter(); feeder.addLine(jsonRpcCall(1, "subscribeCallEvents")); var handler = new SignalJsonRpcDispatcherHandler(writer, feeder::getLine, true); handler.handleConnection(multi); assertEquals(1, manager1.addCount.get(), "manager1 should have one listener"); assertEquals(1, manager2.addCount.get(), "manager2 should have one listener"); // Registers onManagerAdded handlers for receive, call events, and keep-alive assertEquals(3, multi.addedHandlers.size(), "should register onManagerAdded handlers"); } @Test void multiAccountUnsubscribeCallEventsCleansUpAll() { var manager1 = new StubManager("+15551111111"); var manager2 = new StubManager("+15552222222"); var multi = new StubMultiAccountManager(List.of(manager1, manager2)); var feeder = new LineFeeder(); var writer = new CapturingJsonWriter(); feeder.addLine(jsonRpcCall(1, "subscribeCallEvents")); feeder.addLine(jsonRpcCall(2, "unsubscribeCallEvents", "{\"subscription\":0}")); var handler = new SignalJsonRpcDispatcherHandler(writer, feeder::getLine, true); handler.handleConnection(multi); assertEquals(1, manager1.addCount.get()); assertEquals(1, manager2.addCount.get()); assertEquals(1, manager1.removeCount.get(), "manager1 listener should be removed"); assertEquals(1, manager2.removeCount.get(), "manager2 listener should be removed"); } @Test void multiAccountCallEventsNotSubscribedByDefault() { var manager1 = new StubManager("+15551111111"); var multi = new StubMultiAccountManager(List.of(manager1)); var feeder = new LineFeeder(); var writer = new CapturingJsonWriter(); var handler = new SignalJsonRpcDispatcherHandler(writer, feeder::getLine, true); handler.handleConnection(multi); assertEquals(0, manager1.addCount.get(), "call events should not be auto-subscribed in multi mode"); } }