From 9b6c098137f45d357fccec90bacd731486fe0b10 Mon Sep 17 00:00:00 2001 From: AsamK Date: Tue, 6 Oct 2026 22:34:26 +0200 Subject: [PATCH 1/4] Update libsignal-service --- gradle/libs.versions.toml | 2 +- .../signal/manager/RegistrationManager.java | 6 +- ...dException.java => TwoFactorRequired.java} | 4 +- .../signal/manager/config/LiveConfig.java | 3 - .../signal/manager/config/StagingConfig.java | 3 - .../signal/manager/helper/AccountHelper.java | 4 +- .../signal/manager/helper/ProfileHelper.java | 2 +- .../signal/manager/helper/SendHelper.java | 3 +- .../internal/RegistrationManagerImpl.java | 17 +-- .../signal/manager/util/ProfileUtils.java | 14 ++- .../signal/commands/RegisterCommand.java | 6 +- .../dbus/DbusRegistrationManagerImpl.java | 4 +- .../signal-cli/reachability-metadata.json | 110 ++++++++++++------ 13 files changed, 106 insertions(+), 72 deletions(-) rename lib/src/main/java/org/asamk/signal/manager/api/{TotpRequiredException.java => TwoFactorRequired.java} (62%) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 037a126b..b9c5bd5c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -4,7 +4,7 @@ coroutines = "1.11.0" junit = "6.1.3" micronaut-json-schema = "2.3.1" micronaut-core = "5.2.14" -signal-service = "2.15.3_unofficial_153" +signal-service = "2.15.3_unofficial_154" [libraries] bouncycastle = "org.bouncycastle:bcprov-jdk18on:1.86" diff --git a/lib/src/main/java/org/asamk/signal/manager/RegistrationManager.java b/lib/src/main/java/org/asamk/signal/manager/RegistrationManager.java index 7e89dcc3..fe88f2c3 100644 --- a/lib/src/main/java/org/asamk/signal/manager/RegistrationManager.java +++ b/lib/src/main/java/org/asamk/signal/manager/RegistrationManager.java @@ -6,7 +6,7 @@ import org.asamk.signal.manager.api.NonNormalizedPhoneNumberException; import org.asamk.signal.manager.api.PinLockMissingException; import org.asamk.signal.manager.api.PinLockedException; import org.asamk.signal.manager.api.RateLimitException; -import org.asamk.signal.manager.api.TotpRequiredException; +import org.asamk.signal.manager.api.TwoFactorRequired; import org.asamk.signal.manager.api.VerificationMethodNotAvailableException; import java.io.Closeable; @@ -18,7 +18,7 @@ public interface RegistrationManager extends Closeable { boolean voiceVerification, String captcha, final boolean forceRegister - ) throws IOException, CaptchaRequiredException, NonNormalizedPhoneNumberException, RateLimitException, TotpRequiredException, VerificationMethodNotAvailableException; + ) throws IOException, CaptchaRequiredException, NonNormalizedPhoneNumberException, RateLimitException, TwoFactorRequired, VerificationMethodNotAvailableException; void verifyAccount( String verificationCode, @@ -29,7 +29,7 @@ public interface RegistrationManager extends Closeable { String recoveryKey, boolean forceRegister, Integer totp - ) throws IOException, RateLimitException, TotpRequiredException; + ) throws IOException, RateLimitException, TwoFactorRequired; void deleteLocalAccountData() throws IOException; diff --git a/lib/src/main/java/org/asamk/signal/manager/api/TotpRequiredException.java b/lib/src/main/java/org/asamk/signal/manager/api/TwoFactorRequired.java similarity index 62% rename from lib/src/main/java/org/asamk/signal/manager/api/TotpRequiredException.java rename to lib/src/main/java/org/asamk/signal/manager/api/TwoFactorRequired.java index a85e43ba..ec5cac43 100644 --- a/lib/src/main/java/org/asamk/signal/manager/api/TotpRequiredException.java +++ b/lib/src/main/java/org/asamk/signal/manager/api/TwoFactorRequired.java @@ -2,9 +2,9 @@ package org.asamk.signal.manager.api; import java.io.IOException; -public class TotpRequiredException extends IOException { +public class TwoFactorRequired extends IOException { - public TotpRequiredException() { + public TwoFactorRequired() { super("A TOTP token is required or the supplied token is incorrect"); } } diff --git a/lib/src/main/java/org/asamk/signal/manager/config/LiveConfig.java b/lib/src/main/java/org/asamk/signal/manager/config/LiveConfig.java index 13f2ece8..56163a4a 100644 --- a/lib/src/main/java/org/asamk/signal/manager/config/LiveConfig.java +++ b/lib/src/main/java/org/asamk/signal/manager/config/LiveConfig.java @@ -3,7 +3,6 @@ package org.asamk.signal.manager.config; import org.signal.libsignal.net.Network.Environment; import org.signal.libsignal.protocol.InvalidKeyException; import org.signal.libsignal.protocol.ecc.ECPublicKey; -import org.signal.network.config.HttpProxy; import org.signal.network.config.SignalCdnUrl; import org.signal.network.config.SignalCdsiUrl; import org.signal.network.config.SignalProxy; @@ -44,7 +43,6 @@ class LiveConfig { private static final Optional dns = Optional.empty(); private static final Optional proxy = Optional.empty(); - private static final Optional systemProxy = Optional.empty(); private static final byte[] zkGroupServerPublicParams = Base64.getDecoder() .decode("AMhf5ywVwITZMsff/eCyudZx9JDmkkkbV6PInzG4p8x3VqVJSFiMvnvlEKWuRob/1eaIetR31IYeAbm0NdOuHH8Qi+Rexi1wLlpzIo1gstHWBfZzy1+qHRV5A4TqPp15YzBPm0WSggW6PbSn+F4lf57VCnHF7p8SvzAA2ZZJPYJURt8X7bbg+H3i+PEjH9DXItNEqs2sNcug37xZQDLm7X36nOoGPs54XsEGzPdEV+itQNGUFEjY6X9Uv+Acuks7NpyGvCoKxGwgKgE5XyJ+nNKlyHHOLb6N1NuHyBrZrgtY/JYJHRooo5CEqYKBqdFnmbTVGEkCvJKxLnjwKWf+fEPoWeQFj5ObDjcKMZf2Jm2Ae69x+ikU5gBXsRmoF94GXTLfN0/vLt98KDPnxwAQL9j5V1jGOY8jQl6MLxEs56cwXN0dqCnImzVH3TZT1cJ8SW1BRX6qIVxEzjsSGx3yxF3suAilPMqGRp4ffyopjMD1JXiKR2RwLKzizUe5e8XyGOy9fplzhw3jVzTRyUZTRSZKkMLWcQ/gv0E4aONNqs4P+NameAZYOD12qRkxosQQP5uux6B2nRyZ7sAV54DgFyLiRcq1FvwKw2EPQdk4HDoePrO/RNUbyNddnM/mMgj4FW65xCoT1LmjrIjsv/Ggdlx46ueczhMgtBunx1/w8k8V+l8LVZ8gAT6wkU5J+DPQalQguMg12Jzug3q4TbdHiGCmD9EunCwOmsLuLJkz6EcSYXtrlDEnAM+hicw7iergYLLlMXpfTdGxJCWJmP4zqUFeTTmsmhsjGBt7NiEB/9pFFEB3pSbf4iiUukw63Eo8Aqnf4iwob6X1QviCWuc8t0LUlT9vALgh/f2DPVOOmR0RW6bgRvc7DSF20V/omg+YBw=="); @@ -72,7 +70,6 @@ class LiveConfig { interceptors, dns, proxy, - systemProxy, zkGroupServerPublicParams, genericServerPublicParams, backupServerPublicParams, diff --git a/lib/src/main/java/org/asamk/signal/manager/config/StagingConfig.java b/lib/src/main/java/org/asamk/signal/manager/config/StagingConfig.java index e6b1673b..10f9c684 100644 --- a/lib/src/main/java/org/asamk/signal/manager/config/StagingConfig.java +++ b/lib/src/main/java/org/asamk/signal/manager/config/StagingConfig.java @@ -3,7 +3,6 @@ package org.asamk.signal.manager.config; import org.signal.libsignal.net.Network; import org.signal.libsignal.protocol.InvalidKeyException; import org.signal.libsignal.protocol.ecc.ECPublicKey; -import org.signal.network.config.HttpProxy; import org.signal.network.config.SignalCdnUrl; import org.signal.network.config.SignalCdsiUrl; import org.signal.network.config.SignalProxy; @@ -44,7 +43,6 @@ class StagingConfig { private static final Optional dns = Optional.empty(); private static final Optional proxy = Optional.empty(); - private static final Optional systemProxy = Optional.empty(); private static final byte[] zkGroupServerPublicParams = Base64.getDecoder() .decode("ABSY21VckQcbSXVNCGRYJcfWHiAMZmpTtTELcDmxgdFbtp/bWsSxZdMKzfCp8rvIs8ocCU3B37fT3r4Mi5qAemeGeR2X+/YmOGR5ofui7tD5mDQfstAI9i+4WpMtIe8KC3wU5w3Inq3uNWVmoGtpKndsNfwJrCg0Hd9zmObhypUnSkfYn2ooMOOnBpfdanRtrvetZUayDMSC5iSRcXKpdlukrpzzsCIvEwjwQlJYVPOQPj4V0F4UXXBdHSLK05uoPBCQG8G9rYIGedYsClJXnbrgGYG3eMTG5hnx4X4ntARBgELuMWWUEEfSK0mjXg+/2lPmWcTZWR9nkqgQQP0tbzuiPm74H2wMO4u1Wafe+UwyIlIT9L7KLS19Aw8r4sPrXZSSsOZ6s7M1+rTJN0bI5CKY2PX29y5Ok3jSWufIKcgKOnWoP67d5b2du2ZVJjpjfibNIHbT/cegy/sBLoFwtHogVYUewANUAXIaMPyCLRArsKhfJ5wBtTminG/PAvuBdJ70Z/bXVPf8TVsR292zQ65xwvWTejROW6AZX6aqucUjlENAErBme1YHmOSpU6tr6doJ66dPzVAWIanmO/5mgjNEDeK7DDqQdB1xd03HT2Qs2TxY3kCK8aAb/0iM0HQiXjxZ9HIgYhbtvGEnDKW5ILSUydqH/KBhW4Pb0jZWnqN/YgbWDKeJxnDbYcUob5ZY5Lt5ZCMKuaGUvCJRrCtuugSMaqjowCGRempsDdJEt+cMaalhZ6gczklJB/IbdwENW9KeVFPoFNFzhxWUIS5ML9riVYhAtE6JE5jX0xiHNVIIPthb458cfA8daR0nYfYAUKogQArm0iBezOO+mPk5vCNWI+wwkyFCqNDXz/qxl1gAntuCJtSfq9OC3NkdhQlgYQ=="); @@ -72,7 +70,6 @@ class StagingConfig { interceptors, dns, proxy, - systemProxy, zkGroupServerPublicParams, genericServerPublicParams, backupServerPublicParams, diff --git a/lib/src/main/java/org/asamk/signal/manager/helper/AccountHelper.java b/lib/src/main/java/org/asamk/signal/manager/helper/AccountHelper.java index 210a5906..06cc46cb 100644 --- a/lib/src/main/java/org/asamk/signal/manager/helper/AccountHelper.java +++ b/lib/src/main/java/org/asamk/signal/manager/helper/AccountHelper.java @@ -560,9 +560,9 @@ public class AccountHelper { final List devices = handleResponseExceptionSuspend(cont -> dependencies.getLinkDeviceApi() .getDevices(cont)); final var deviceId = account.getDeviceId(); - final var device = devices.stream().filter(d -> d.id == deviceId).findFirst(); + final var device = devices.stream().filter(d -> d.getId() == deviceId).findFirst(); if (device.isPresent()) { - account.setEncryptedDeviceName(device.get().name); + account.setEncryptedDeviceName(device.get().getName()); } } diff --git a/lib/src/main/java/org/asamk/signal/manager/helper/ProfileHelper.java b/lib/src/main/java/org/asamk/signal/manager/helper/ProfileHelper.java index 6abe6677..e9ad8943 100644 --- a/lib/src/main/java/org/asamk/signal/manager/helper/ProfileHelper.java +++ b/lib/src/main/java/org/asamk/signal/manager/helper/ProfileHelper.java @@ -365,7 +365,7 @@ public final class ProfileHelper { } if (recipientId.equals(account.getSelfRecipientId())) { - final var isUnrestricted = encryptedProfile.isUnrestrictedUnidentifiedAccess(); + final var isUnrestricted = encryptedProfile.getUnrestrictedUnidentifiedAccess(); if (account.isUnrestrictedUnidentifiedAccess() != isUnrestricted) { account.setUnrestrictedUnidentifiedAccess(isUnrestricted); } diff --git a/lib/src/main/java/org/asamk/signal/manager/helper/SendHelper.java b/lib/src/main/java/org/asamk/signal/manager/helper/SendHelper.java index bd1188af..fd898bec 100644 --- a/lib/src/main/java/org/asamk/signal/manager/helper/SendHelper.java +++ b/lib/src/main/java/org/asamk/signal/manager/helper/SendHelper.java @@ -555,7 +555,8 @@ public class SendHelper { recipients, unidentifiedAccess, groupSendEndorsements, - message), + message, + null), recipientIds, groupInfo, false); diff --git a/lib/src/main/java/org/asamk/signal/manager/internal/RegistrationManagerImpl.java b/lib/src/main/java/org/asamk/signal/manager/internal/RegistrationManagerImpl.java index 4a6edbb6..77b004ee 100644 --- a/lib/src/main/java/org/asamk/signal/manager/internal/RegistrationManagerImpl.java +++ b/lib/src/main/java/org/asamk/signal/manager/internal/RegistrationManagerImpl.java @@ -25,7 +25,7 @@ import org.asamk.signal.manager.api.NonNormalizedPhoneNumberException; import org.asamk.signal.manager.api.PinLockMissingException; import org.asamk.signal.manager.api.PinLockedException; import org.asamk.signal.manager.api.RateLimitException; -import org.asamk.signal.manager.api.TotpRequiredException; +import org.asamk.signal.manager.api.TwoFactorRequired; import org.asamk.signal.manager.api.UpdateProfile; import org.asamk.signal.manager.api.VerificationMethodNotAvailableException; import org.asamk.signal.manager.config.ServiceConfig; @@ -123,7 +123,7 @@ public class RegistrationManagerImpl implements RegistrationManager { boolean voiceVerification, String captcha, final boolean forceRegister - ) throws IOException, CaptchaRequiredException, NonNormalizedPhoneNumberException, RateLimitException, TotpRequiredException, VerificationMethodNotAvailableException { + ) throws IOException, CaptchaRequiredException, NonNormalizedPhoneNumberException, RateLimitException, TwoFactorRequired, VerificationMethodNotAvailableException { if (account.isRegistered() && account.getServiceEnvironment() != null && account.getServiceEnvironment() != serviceEnvironmentConfig.type()) { @@ -212,7 +212,7 @@ public class RegistrationManagerImpl implements RegistrationManager { final String recoveryKey, final boolean forceRegister, final Integer totp - ) throws IOException, RateLimitException, TotpRequiredException { + ) throws IOException, RateLimitException, TwoFactorRequired { if (account.getAci() == null) { throw new IOException("Recovery-key registration requires an ACI account identifier"); } @@ -228,13 +228,13 @@ public class RegistrationManagerImpl implements RegistrationManager { private boolean attemptRecoverAccount( final Integer totp - ) throws IOException, RateLimitException, TotpRequiredException { + ) throws IOException, RateLimitException, TwoFactorRequired { final var accountEntropyPool = account.getAccountEntropyPool(); try { recoverAccount(totp, false, accountEntropyPool); logger.info("Reregistered existing account using its ACI and Account Entropy Pool."); return true; - } catch (TotpRequiredException | RateLimitException e) { + } catch (TwoFactorRequired | RateLimitException e) { throw e; } catch (RecoveryRequestFailedException e) { logger.debug("Failed to reregister account using its ACI and Account Entropy Pool", e); @@ -246,7 +246,7 @@ public class RegistrationManagerImpl implements RegistrationManager { final Integer totp, final boolean includeRegistrationLock, final AccountEntropyPool accountEntropyPool - ) throws IOException, RateLimitException, TotpRequiredException { + ) throws IOException, RateLimitException, TwoFactorRequired { if (account.getPniIdentityKeyPair() == null) { account.setPniIdentityKeyPair(KeyUtils.generateIdentityKeyPair()); } @@ -273,6 +273,7 @@ public class RegistrationManagerImpl implements RegistrationManager { true, account.getAci(), totp, + null, cont)); } catch (BadRequestException e) { switch (e.getError()) { @@ -283,7 +284,7 @@ public class RegistrationManagerImpl implements RegistrationManager { recoverAccount(totp, true, accountEntropyPool); return; } - case RegisterAccountError.TotpMissingOrIncorrect ignored -> throw new TotpRequiredException(); + case RegisterAccountError.TwoFactorRequired ignored -> throw new TwoFactorRequired(); case RegisterAccountError.RegistrationRecoveryPasswordIncorrect ignored -> throw new RecoveryRequestFailedException("Account key or recovery key is incorrect", e); case RegisterAccountError.RateLimited ignored -> throw new RateLimitException(null); @@ -416,7 +417,7 @@ public class RegistrationManagerImpl implements RegistrationManager { account.finishRegistration(aci, pni, masterKey, pin, aciPreKeys, pniPreKeys); accountFileUpdater.updateAccountIdentifiers(account.getNumber(), aci); - finishManagerRegistration(response.isStorageCapable()); + finishManagerRegistration(response.getStorageCapable()); } private void finishManagerRegistration(final boolean storageCapable) throws IOException { diff --git a/lib/src/main/java/org/asamk/signal/manager/util/ProfileUtils.java b/lib/src/main/java/org/asamk/signal/manager/util/ProfileUtils.java index 0b5b418a..4f6c3126 100644 --- a/lib/src/main/java/org/asamk/signal/manager/util/ProfileUtils.java +++ b/lib/src/main/java/org/asamk/signal/manager/util/ProfileUtils.java @@ -65,7 +65,7 @@ public class ProfileUtils { final SignalServiceProfile encryptedProfile, final ProfileCipher profileCipher ) { - if (encryptedProfile.isUnrestrictedUnidentifiedAccess()) { + if (encryptedProfile.getUnrestrictedUnidentifiedAccess()) { return Profile.UnidentifiedAccessMode.UNRESTRICTED; } @@ -81,11 +81,13 @@ public class ProfileUtils { public static HashSet getCapabilities(final SignalServiceProfile encryptedProfile) { final var capabilities = new HashSet(); - if (encryptedProfile.getCapabilities().isStorage()) { - capabilities.add(Profile.Capability.storage); - } - if (encryptedProfile.getCapabilities().isStorageServiceEncryptionV2()) { - capabilities.add(Profile.Capability.storageServiceEncryptionV2Capability); + if (encryptedProfile.getCapabilities() != null) { + if (encryptedProfile.getCapabilities().getStorage()) { + capabilities.add(Profile.Capability.storage); + } + if (encryptedProfile.getCapabilities().getStorageServiceEncryptionV2()) { + capabilities.add(Profile.Capability.storageServiceEncryptionV2Capability); + } } return capabilities; diff --git a/src/main/java/org/asamk/signal/commands/RegisterCommand.java b/src/main/java/org/asamk/signal/commands/RegisterCommand.java index 50543963..5d7bf212 100644 --- a/src/main/java/org/asamk/signal/commands/RegisterCommand.java +++ b/src/main/java/org/asamk/signal/commands/RegisterCommand.java @@ -15,7 +15,7 @@ import org.asamk.signal.manager.RegistrationManager; import org.asamk.signal.manager.api.CaptchaRequiredException; import org.asamk.signal.manager.api.NonNormalizedPhoneNumberException; import org.asamk.signal.manager.api.RateLimitException; -import org.asamk.signal.manager.api.TotpRequiredException; +import org.asamk.signal.manager.api.TwoFactorRequired; import org.asamk.signal.manager.api.VerificationMethodNotAvailableException; import org.asamk.signal.output.JsonWriter; import org.asamk.signal.util.CommandUtil; @@ -111,7 +111,7 @@ public class RegisterCommand implements RegistrationCommand, JsonRpcRegistration } catch (RateLimitException e) { final var message = CommandUtil.getRateLimitMessage(e); throw new RateLimitErrorException(message, e); - } catch (TotpRequiredException e) { + } catch (TwoFactorRequired e) { throw new UserErrorException("A TOTP token is required; rerun register with --totp TOKEN"); } catch (IOException e) { throw new IOErrorException("Failed to register: %s (%s)".formatted(e.getMessage(), @@ -130,7 +130,7 @@ public class RegisterCommand implements RegistrationCommand, JsonRpcRegistration throw new UserErrorException(message); } catch (NonNormalizedPhoneNumberException e) { throw new UserErrorException("Failed to register: " + e.getMessage(), e); - } catch (TotpRequiredException e) { + } catch (TwoFactorRequired e) { throw new UserErrorException( "A TOTP token is required; rerun register with --recovery-key RECOVERY-KEY --totp TOKEN"); } catch (IOException e) { diff --git a/src/main/java/org/asamk/signal/dbus/DbusRegistrationManagerImpl.java b/src/main/java/org/asamk/signal/dbus/DbusRegistrationManagerImpl.java index ee1a04f8..91b801f1 100644 --- a/src/main/java/org/asamk/signal/dbus/DbusRegistrationManagerImpl.java +++ b/src/main/java/org/asamk/signal/dbus/DbusRegistrationManagerImpl.java @@ -5,7 +5,7 @@ import org.asamk.signal.manager.RegistrationManager; import org.asamk.signal.manager.api.CaptchaRequiredException; import org.asamk.signal.manager.api.IncorrectPinException; import org.asamk.signal.manager.api.PinLockedException; -import org.asamk.signal.manager.api.TotpRequiredException; +import org.asamk.signal.manager.api.TwoFactorRequired; import org.freedesktop.dbus.connections.impl.DBusConnection; import java.io.IOException; @@ -59,7 +59,7 @@ public class DbusRegistrationManagerImpl implements RegistrationManager { final String recoveryKey, final boolean forceRegister, final Integer totp - ) throws IOException, TotpRequiredException { + ) throws IOException, TwoFactorRequired { throw new IOException("Recovery-key registration is not supported over D-Bus"); } diff --git a/src/main/resources/META-INF/native-image/org.asamk/signal-cli/reachability-metadata.json b/src/main/resources/META-INF/native-image/org.asamk/signal-cli/reachability-metadata.json index ffa1ee0e..afb0b520 100644 --- a/src/main/resources/META-INF/native-image/org.asamk/signal-cli/reachability-metadata.json +++ b/src/main/resources/META-INF/native-image/org.asamk/signal-cli/reachability-metadata.json @@ -1415,6 +1415,9 @@ } ] }, + { + "type": "java.math.BigDecimal" + }, { "type": "java.math.BigInteger" }, @@ -1771,6 +1774,9 @@ { "type": "kotlin.Any" }, + { + "type": "kotlin.Array" + }, { "type": "kotlin.Boolean" }, @@ -1946,42 +1952,7 @@ }, { "type": "kotlin.reflect.jvm.internal.impl.resolve.scopes.DescriptorKindFilter", - "allPublicFields": true, - "fields": [ - { - "name": "ALL" - }, - { - "name": "CALLABLES" - }, - { - "name": "CLASSIFIERS" - }, - { - "name": "Companion" - }, - { - "name": "FUNCTIONS" - }, - { - "name": "NON_SINGLETON_CLASSIFIERS" - }, - { - "name": "PACKAGES" - }, - { - "name": "SINGLETON_CLASSIFIERS" - }, - { - "name": "TYPE_ALIASES" - }, - { - "name": "VALUES" - }, - { - "name": "VARIABLES" - } - ] + "allPublicFields": true }, { "type": "kotlinx.coroutines.AwaitAll" @@ -2513,6 +2484,10 @@ "type": "org.asamk.signal.commands.ListAccountsCommand$JsonAccount", "allDeclaredFields": true, "methods": [ + { + "name": "aci", + "parameterTypes": [] + }, { "name": "number", "parameterTypes": [] @@ -5118,6 +5093,15 @@ } ] }, + { + "type": "org.bouncycastle.jcajce.provider.asymmetric.XMSS$Mappings", + "methods": [ + { + "name": "", + "parameterTypes": [] + } + ] + }, { "type": "org.bouncycastle.jcajce.provider.asymmetric.edec.SignatureSpi$Ed25519", "methods": [ @@ -7184,6 +7168,9 @@ "type": "org.signal.libsignal.zkgroup.profiles.ExpiringProfileKeyCredential", "allDeclaredFields": true }, + { + "type": "org.signal.libsignal.zkgroup.profiles.ExpiringProfileKeyCredentialResponse" + }, { "type": "org.signal.libsignal.zkgroup.profiles.ProfileKey", "allDeclaredFields": true @@ -8321,6 +8308,13 @@ { "name": "", "parameterTypes": [] + }, + { + "name": "", + "parameterTypes": [ + "int", + "int" + ] } ] }, @@ -8463,6 +8457,12 @@ "allDeclaredMethods": true, "allDeclaredConstructors": true }, + { + "type": "org.whispersystems.signalservice.api.profiles.SignalServiceProfile$Companion" + }, + { + "type": "org.whispersystems.signalservice.api.profiles.SignalServiceProfile$RequestType" + }, { "type": "org.whispersystems.signalservice.api.profiles.SignalServiceProfileWrite", "allDeclaredFields": true, @@ -8471,7 +8471,13 @@ }, { "type": "org.whispersystems.signalservice.api.provisioning.ProvisioningMessage", - "allDeclaredFields": true + "allDeclaredFields": true, + "methods": [ + { + "name": "getBody", + "parameterTypes": [] + } + ] }, { "type": "org.whispersystems.signalservice.api.push.ServiceId", @@ -8919,6 +8925,9 @@ "allDeclaredMethods": true, "allDeclaredConstructors": true }, + { + "type": "org.whispersystems.signalservice.internal.push.AuthCredentials$Companion" + }, { "type": "org.whispersystems.signalservice.internal.push.ByteArrayDeserializerBase64", "methods": [ @@ -8928,6 +8937,15 @@ } ] }, + { + "type": "org.whispersystems.signalservice.internal.push.ByteArraySerializerBase64NoPadding", + "methods": [ + { + "name": "", + "parameterTypes": [] + } + ] + }, { "type": "org.whispersystems.signalservice.internal.push.CdsiAuthResponse", "allDeclaredFields": true, @@ -8935,6 +8953,13 @@ { "name": "", "parameterTypes": [] + }, + { + "name": "", + "parameterTypes": [ + "java.lang.String", + "java.lang.String" + ] } ] }, @@ -9096,6 +9121,14 @@ "name": "", "parameterTypes": [] }, + { + "name": "", + "parameterTypes": [ + "long", + "org.signal.libsignal.protocol.kem.KEMPublicKey", + "byte[]" + ] + }, { "name": "getKeyId", "parameterTypes": [] @@ -9182,6 +9215,9 @@ "allDeclaredMethods": true, "allDeclaredConstructors": true }, + { + "type": "org.whispersystems.signalservice.internal.push.MismatchedDevices$Companion" + }, { "type": "org.whispersystems.signalservice.internal.push.OneTimePreKeyCounts", "allDeclaredFields": true, From dec7b8207a8805a2f57a933966e3481bd120fb5a Mon Sep 17 00:00:00 2001 From: Robert Nisipeanu Date: Tue, 22 Sep 2026 22:15:50 +0300 Subject: [PATCH 2/4] Preserve multi-device call signaling with legacy tunnel compatibility --- docs/CALL_TUNNEL.md | 23 +++ .../signal/manager/helper/CallManager.java | 104 ++++++++--- .../helper/IncomingMessageHandler.java | 14 +- .../helper/CallManagerSignalingTest.java | 176 ++++++++++++++++++ 4 files changed, 276 insertions(+), 41 deletions(-) create mode 100644 lib/src/test/java/org/asamk/signal/manager/helper/CallManagerSignalingTest.java diff --git a/docs/CALL_TUNNEL.md b/docs/CALL_TUNNEL.md index b51bc5dd..017d524a 100644 --- a/docs/CALL_TUNNEL.md +++ b/docs/CALL_TUNNEL.md @@ -31,6 +31,29 @@ to JSON-RPC clients, which use them to connect audio via platform APIs. --- +## Multi-device compatibility + +A tunnel supporting device-aware hangup/busy handling includes +`"signalingVersion": 2` in its `ready` event. The separate +`{"type":"signalingCapabilities","version":2}` event is also accepted for +compatibility with tunnels advertising capabilities separately. + +For this capability, signal-cli forwards incoming `receivedHangup` messages with +`senderDeviceId`, numeric `hangupType` (0 normal, 1 accepted elsewhere, 2 declined +elsewhere, 3 busy elsewhere, 4 permission needed), and `deviceId` identifying the +accepted/declined device. `receivedBusy` carries `senderDeviceId`. RingRTC decides +which device connection should end. + +Outgoing signaling events may include `receiverDeviceId`: an integer targets +one device, explicit null broadcasts, and an absent field retains the legacy +call target. A `sendHangup` also carries `deviceId` for the accepted/declined +device; this is distinct from its delivery target. + +An older tunnel keeps the existing direct hangup/busy cleanup and notification +filtering. An older CLI ignores the added capability and target fields. Either +project can upgrade independently; complete multi-device behavior requires both. +The account's local device-ID fix is independent of this protocol extension. + ## Spawning the Tunnel For each call, signal-cli: diff --git a/lib/src/main/java/org/asamk/signal/manager/helper/CallManager.java b/lib/src/main/java/org/asamk/signal/manager/helper/CallManager.java index de97899e..29b9ba49 100644 --- a/lib/src/main/java/org/asamk/signal/manager/helper/CallManager.java +++ b/lib/src/main/java/org/asamk/signal/manager/helper/CallManager.java @@ -276,8 +276,12 @@ public class CallManager implements AutoCloseable { answerMsg.put("receiverIdentityKey", java.util.Base64.getEncoder().encodeToString(localIdentityKey)); sendControlMessage(state, writeJson(answerMsg)); - state.deviceId = deviceId; - state.state = CallInfo.State.CONNECTING; + if (!state.multiDeviceSignaling) { + state.deviceId = deviceId; + } + if (state.state != CallInfo.State.CONNECTED && state.state != CallInfo.State.RECONNECTING) { + state.state = CallInfo.State.CONNECTING; + } fireCallEvent(state, null); logger.debug("Received answer for call {}", callIdUnsigned(callId)); @@ -300,18 +304,45 @@ public class CallManager implements AutoCloseable { logger.debug("Forwarded ICE candidate to tunnel for call {}", callIdUnsigned(callId)); } - public void handleIncomingHangup(final long callId) { - if (callEventListeners.isEmpty() && !activeCalls.containsKey(callId)) { + public void handleIncomingHangup(final RecipientId sender, final long callId, final int senderDeviceId, + final HangupMessage.Type type, final int deviceId) { + var state = activeCalls.get(callId); + if (state == null || !state.recipientId.equals(sender)) { return; } - endCall(callId, "remote_hangup"); + if (!state.multiDeviceSignaling) { + if (type == null || type == HangupMessage.Type.NORMAL) { + endCall(callId, "remote_hangup"); + } + return; + } + var message = mapper.createObjectNode(); + message.put("type", "receivedHangup"); + message.put("senderDeviceId", senderDeviceId); + message.put("hangupType", type == null ? 0 : switch (type) { + case NORMAL -> 0; + case ACCEPTED -> 1; + case DECLINED -> 2; + case BUSY -> 3; + case NEED_PERMISSION -> 4; + }); + message.put("deviceId", deviceId); + sendControlMessage(state, writeJson(message)); } - public void handleIncomingBusy(final long callId) { - if (callEventListeners.isEmpty() && !activeCalls.containsKey(callId)) { + public void handleIncomingBusy(final RecipientId sender, final long callId, final int senderDeviceId) { + var state = activeCalls.get(callId); + if (state == null || !state.recipientId.equals(sender)) { return; } - endCall(callId, "remote_busy"); + if (!state.multiDeviceSignaling) { + endCall(callId, "remote_busy"); + return; + } + var message = mapper.createObjectNode(); + message.put("type", "receivedBusy"); + message.put("senderDeviceId", senderDeviceId); + sendControlMessage(state, writeJson(message)); } // --- Internal helpers --- @@ -483,6 +514,7 @@ public class CallManager implements AutoCloseable { switch (type) { case "ready" -> { + state.multiDeviceSignaling = json.path("signalingVersion").asInt(1) >= 2; if (json.has("inputDeviceName")) { state.inputDeviceName = json.get("inputDeviceName").asText(); } @@ -494,15 +526,18 @@ public class CallManager implements AutoCloseable { state.inputDeviceName, state.outputDeviceName); } + case "signalingCapabilities" -> { + state.multiDeviceSignaling = json.path("version").asInt(1) >= 2; + } case "sendOffer" -> { var opaqueB64 = json.get("opaque").asText(); var opaque = java.util.Base64.getDecoder().decode(opaqueB64); - logSendMessageResult(sendOfferViaSignal(state, opaque)); + logSendMessageResult(sendOfferViaSignal(state, opaque, receiverDeviceId(state, json))); } case "sendAnswer" -> { var opaqueB64 = json.get("opaque").asText(); var opaque = java.util.Base64.getDecoder().decode(opaqueB64); - logSendMessageResult(sendAnswerViaSignal(state, opaque)); + logSendMessageResult(sendAnswerViaSignal(state, opaque, receiverDeviceId(state, json))); } case "sendIce" -> { var candidatesArr = json.get("candidates"); @@ -510,7 +545,7 @@ public class CallManager implements AutoCloseable { for (var c : candidatesArr) { opaqueList.add(java.util.Base64.getDecoder().decode(c.get("opaque").asText())); } - logSendMessageResult(sendIceViaSignal(state, opaqueList)); + logSendMessageResult(sendIceViaSignal(state, opaqueList, receiverDeviceId(state, json))); } case "sendHangup" -> { // RingRTC wants us to send a hangup message via Signal protocol. @@ -518,16 +553,14 @@ public class CallManager implements AutoCloseable { var hangupType = json.has("hangupType") ? json.get("hangupType").asText("normal") : "normal"; - // Skip multi-device hangup types — signal-cli is single-device, - // and sending these to the remote peer causes it to terminate the call. - if (hangupType.contains("onanotherdevice")) { - logger.debug("Ignoring multi-device hangup type: {}", hangupType); - } else { - logSendMessageResult(sendHangupViaSignal(state, hangupType)); + if (!state.multiDeviceSignaling && hangupType.contains("onanotherdevice")) { + break; } + logSendMessageResult(sendHangupViaSignal(state, hangupType, + json.path("deviceId").asInt(0), receiverDeviceId(state, json))); } case "sendBusy" -> { - logSendMessageResult(sendBusyViaSignal(state)); + logSendMessageResult(sendBusyViaSignal(state, receiverDeviceId(state, json))); } case "stateChange" -> { var ringrtcState = json.get("state").asText(); @@ -616,45 +649,55 @@ public class CallManager implements AutoCloseable { } } - private SendMessageResult sendOfferViaSignal(CallState state, byte[] opaque) { + private static Integer receiverDeviceId(CallState state, com.fasterxml.jackson.databind.JsonNode event) { + var value = event.get("receiverDeviceId"); + return value == null ? state.deviceId : value.isNull() ? null : value.intValue(); + } + + private SendMessageResult sendOfferViaSignal(CallState state, byte[] opaque, Integer receiverDeviceId) { var offerMessage = new OfferMessage(state.callId, OfferMessage.Type.AUDIO_CALL, opaque); - var callMessage = SignalServiceCallMessage.forOffer(offerMessage, state.deviceId); + var callMessage = SignalServiceCallMessage.forOffer(offerMessage, receiverDeviceId); final var result = context.getSendHelper().sendCallMessage(callMessage, state.recipientId); logger.debug("Sent offer via Signal for call {}", callIdUnsigned(state.callId)); return result; } - private SendMessageResult sendAnswerViaSignal(CallState state, byte[] opaque) { + private SendMessageResult sendAnswerViaSignal(CallState state, byte[] opaque, Integer receiverDeviceId) { var answerMessage = new AnswerMessage(state.callId, opaque); - var callMessage = SignalServiceCallMessage.forAnswer(answerMessage, state.deviceId); + var callMessage = SignalServiceCallMessage.forAnswer(answerMessage, receiverDeviceId); final var result = context.getSendHelper().sendCallMessage(callMessage, state.recipientId); logger.debug("Sent answer via Signal for call {}", callIdUnsigned(state.callId)); return result; } - private SendMessageResult sendIceViaSignal(CallState state, List opaqueList) { + private SendMessageResult sendIceViaSignal(CallState state, List opaqueList, Integer receiverDeviceId) { var iceUpdates = opaqueList.stream().map(opaque -> new IceUpdateMessage(state.callId, opaque)).toList(); - var callMessage = SignalServiceCallMessage.forIceUpdates(iceUpdates, state.deviceId); + var callMessage = SignalServiceCallMessage.forIceUpdates(iceUpdates, receiverDeviceId); final var result = context.getSendHelper().sendCallMessage(callMessage, state.recipientId); logger.debug("Sent {} ICE candidates via Signal for call {}", opaqueList.size(), callIdUnsigned(state.callId)); return result; } - private SendMessageResult sendBusyViaSignal(CallState state) { + private SendMessageResult sendBusyViaSignal(CallState state, Integer receiverDeviceId) { var busyMessage = new BusyMessage(state.callId); - var callMessage = SignalServiceCallMessage.forBusy(busyMessage, state.deviceId); + var callMessage = SignalServiceCallMessage.forBusy(busyMessage, receiverDeviceId); return context.getSendHelper().sendCallMessage(callMessage, state.recipientId); } - private SendMessageResult sendHangupViaSignal(CallState state, String hangupType) { + private static SignalServiceCallMessage hangupMessage(long callId, String hangupType, int deviceId, Integer receiverDeviceId) { var type = switch (hangupType) { case "accepted", "acceptedonanotherdevice" -> HangupMessage.Type.ACCEPTED; case "declined", "declinedonanotherdevice" -> HangupMessage.Type.DECLINED; case "busy", "busyonanotherdevice" -> HangupMessage.Type.BUSY; + case "needpermission" -> HangupMessage.Type.NEED_PERMISSION; default -> HangupMessage.Type.NORMAL; }; - var hangupMessage = new HangupMessage(state.callId, type, 0); - var callMessage = SignalServiceCallMessage.forHangup(hangupMessage, state.deviceId); + var hangupMessage = new HangupMessage(callId, type, deviceId); + return SignalServiceCallMessage.forHangup(hangupMessage, receiverDeviceId); + } + + private SendMessageResult sendHangupViaSignal(CallState state, String hangupType, int deviceId, Integer receiverDeviceId) { + var callMessage = hangupMessage(state.callId, hangupType, deviceId, receiverDeviceId); final var result = context.getSendHelper().sendCallMessage(callMessage, state.recipientId); logger.debug("Sent hangup ({}) via Signal for call {}", hangupType, callIdUnsigned(state.callId)); return result; @@ -700,7 +743,7 @@ public class CallManager implements AutoCloseable { } } - private void endCall(final long callId, final String reason) { + void endCall(final long callId, final String reason) { var state = activeCalls.remove(callId); dependencies.getAuthenticatedSignalWebSocket().removeKeepAliveToken("call" + callId); dependencies.getUnauthenticatedSignalWebSocket().removeKeepAliveToken("call" + callId); @@ -775,6 +818,7 @@ public class CallManager implements AutoCloseable { volatile CallInfo.State state; final RecipientId recipientId; volatile Integer deviceId; + volatile boolean multiDeviceSignaling; final boolean isOutgoing; volatile String inputDeviceName; volatile String outputDeviceName; diff --git a/lib/src/main/java/org/asamk/signal/manager/helper/IncomingMessageHandler.java b/lib/src/main/java/org/asamk/signal/manager/helper/IncomingMessageHandler.java index 7af7b28c..b03b0675 100644 --- a/lib/src/main/java/org/asamk/signal/manager/helper/IncomingMessageHandler.java +++ b/lib/src/main/java/org/asamk/signal/manager/helper/IncomingMessageHandler.java @@ -463,17 +463,9 @@ public final class IncomingMessageHandler { } }); - callMessage.getHangupMessage().ifPresent(hangup -> { - // Only NORMAL hangups actually end the call. ACCEPTED/DECLINED/BUSY - // are multi-device notifications irrelevant for single-device signal-cli. - var hangupType = hangup.getType(); - if (hangupType == org.whispersystems.signalservice.api.messages.calls.HangupMessage.Type.NORMAL - || hangupType == null) { - callManager.handleIncomingHangup(hangup.getId()); - } - }); - - callMessage.getBusyMessage().ifPresent(busy -> callManager.handleIncomingBusy(busy.getId())); + callMessage.getHangupMessage().ifPresent(hangup -> + callManager.handleIncomingHangup(sender, hangup.getId(), deviceId, hangup.getType(), hangup.getDeviceId())); + callMessage.getBusyMessage().ifPresent(busy -> callManager.handleIncomingBusy(sender, busy.getId(), deviceId)); } private boolean handlePniSignatureMessage( diff --git a/lib/src/test/java/org/asamk/signal/manager/helper/CallManagerSignalingTest.java b/lib/src/test/java/org/asamk/signal/manager/helper/CallManagerSignalingTest.java new file mode 100644 index 00000000..3a16f349 --- /dev/null +++ b/lib/src/test/java/org/asamk/signal/manager/helper/CallManagerSignalingTest.java @@ -0,0 +1,176 @@ +package org.asamk.signal.manager.helper; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.asamk.signal.manager.Settings; +import org.asamk.signal.manager.api.CallInfo; +import org.asamk.signal.manager.api.ServiceEnvironment; +import org.asamk.signal.manager.storage.SignalAccount; +import org.asamk.signal.manager.storage.recipients.RecipientId; +import org.asamk.signal.manager.storage.recipients.TestRecipientId; +import org.asamk.signal.manager.util.KeyUtils; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.signal.core.models.ServiceId.ACI; +import org.whispersystems.signalservice.api.messages.calls.HangupMessage; +import org.whispersystems.signalservice.api.messages.calls.SignalServiceCallMessage; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.io.PrintWriter; +import java.io.StringWriter; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +class CallManagerSignalingTest { + private static final ObjectMapper MAPPER = new ObjectMapper(); + @TempDir + Path directory; + private SignalAccount account; + private RecordingCallManager manager; + private CallManager.CallState state; + private Map calls; + private final StringWriter output = new StringWriter(); + + @BeforeEach + @SuppressWarnings("unchecked") + void setUp() throws Exception { + account = SignalAccount.create(directory.toFile(), "account", "+12025550100", + ACI.parseOrThrow("11111111-1111-4111-8111-111111111111"), ServiceEnvironment.STAGING, + KeyUtils.generateIdentityKeyPair(), KeyUtils.generateIdentityKeyPair(), KeyUtils.createProfileKey(), + Settings.DEFAULT); + manager = new RecordingCallManager(new Context(account, null, null, null, null, null)); + var recipient = account.getRecipientStore().resolveRecipient( + ACI.parseOrThrow("22222222-2222-4222-8222-222222222222")); + state = new CallManager.CallState(-1L, CallInfo.State.CONNECTED, recipient, null, true); + state.controlWriter = new PrintWriter(output, true); + var field = CallManager.class.getDeclaredField("activeCalls"); + field.setAccessible(true); + calls = (Map) field.get(manager); + calls.put(state.callId, state); + } + + @AfterEach + void close() throws Exception { + if (calls != null) calls.clear(); + if (manager != null) manager.close(); + if (account != null) account.close(); + } + + private void events(String json) throws Exception { + var method = CallManager.class.getDeclaredMethod("readControlEvents", CallManager.CallState.class, InputStream.class); + method.setAccessible(true); + method.invoke(manager, state, new ByteArrayInputStream((json + "\n").getBytes(StandardCharsets.UTF_8))); + } + + @Test + void readyNegotiatesBeforeCallEvents() throws Exception { + events("{\"type\":\"ready\",\"signalingVersion\":2}"); + manager.handleIncomingHangup(state.recipientId, state.callId, 2, HangupMessage.Type.NORMAL, 0); + var message = MAPPER.readTree(output.toString()); + assertEquals("receivedHangup", message.path("type").asText()); + assertEquals(2, message.path("senderDeviceId").asInt()); + assertEquals(CallInfo.State.CONNECTED, state.state); + assertTrue(calls.containsKey(state.callId)); + } + + @Test + void supportsSeparateCapabilityEvent() throws Exception { + events("{\"type\":\"ready\"}\n{\"type\":\"signalingCapabilities\",\"version\":2}"); + manager.handleIncomingBusy(state.recipientId, state.callId, 7); + var message = MAPPER.readTree(output.toString()); + assertEquals("receivedBusy", message.path("type").asText()); + assertEquals(7, message.path("senderDeviceId").asInt()); + assertEquals(CallInfo.State.CONNECTED, state.state); + } + + @Test + void oldTunnelDelegatesNormalHangupToLegacyCleanup() throws Exception { + events("{\"type\":\"ready\"}"); + manager.handleIncomingHangup(state.recipientId, state.callId, 1, HangupMessage.Type.NORMAL, 0); + assertEquals(state.callId, manager.endedCall); + assertEquals("remote_hangup", manager.endReason); + assertEquals("", output.toString()); + } + + @Test + void oldTunnelDelegatesBusyToLegacyCleanup() { + manager.handleIncomingBusy(state.recipientId, state.callId, 1); + assertEquals(state.callId, manager.endedCall); + assertEquals("remote_busy", manager.endReason); + } + + @Test + void oldTunnelIgnoresOtherDeviceNotification() { + manager.handleIncomingHangup(state.recipientId, state.callId, 2, HangupMessage.Type.ACCEPTED, 1); + assertEquals("", output.toString()); + assertEquals(CallInfo.State.CONNECTED, state.state); + } + + @Test + void ignoresWrongSenderAndUnknownCall() throws Exception { + events("{\"type\":\"ready\",\"signalingVersion\":2}"); + RecipientId stranger = TestRecipientId.createTestId(99999L); + manager.handleIncomingHangup(stranger, state.callId, 2, HangupMessage.Type.NORMAL, 0); + manager.handleIncomingBusy(stranger, state.callId, 2); + manager.handleIncomingHangup(state.recipientId, 42, 2, HangupMessage.Type.NORMAL, 0); + manager.handleIncomingBusy(state.recipientId, 42, 2); + assertEquals("", output.toString()); + assertEquals(CallInfo.State.CONNECTED, state.state); + } + + @Test + void preservesBroadcastAndExplicitTargetsWithLegacyFallback() throws Exception { + var method = CallManager.class.getDeclaredMethod("receiverDeviceId", CallManager.CallState.class, JsonNode.class); + method.setAccessible(true); + state.deviceId = 3; + assertEquals(3, method.invoke(null, state, MAPPER.readTree("{}"))); + assertNull(method.invoke(null, state, MAPPER.readTree("{\"receiverDeviceId\":null}"))); + assertEquals(7, method.invoke(null, state, MAPPER.readTree("{\"receiverDeviceId\":7}"))); + } + + @Test + void acceptedNotificationPreservesAnsweredDeviceAndBroadcast() throws Exception { + var method = CallManager.class.getDeclaredMethod("hangupMessage", long.class, String.class, int.class, Integer.class); + method.setAccessible(true); + var message = (SignalServiceCallMessage) method.invoke(null, -1L, "acceptedonanotherdevice", 1, null); + assertTrue(message.getDestinationDeviceId().isEmpty()); + var hangup = message.getHangupMessage().orElseThrow(); + assertEquals(HangupMessage.Type.ACCEPTED, hangup.getType()); + assertEquals(1, hangup.getDeviceId()); + assertEquals(-1L, hangup.getId()); + } + + @ParameterizedTest + @EnumSource(value = CallInfo.State.class, names = {"CONNECTED", "RECONNECTING"}) + void lateAnswerDoesNotReplaceEstablishedState(CallInfo.State established) throws Exception { + events("{\"type\":\"ready\",\"signalingVersion\":2}"); + state.state = established; + manager.handleIncomingAnswer(state.callId, 2, new byte[]{1}); + assertEquals(established, state.state); + assertNull(state.deviceId); + assertEquals(2, MAPPER.readTree(output.toString()).path("senderDeviceId").asInt()); + } + + private static class RecordingCallManager extends CallManager { + long endedCall; + String endReason; + + RecordingCallManager(Context context) { + super(context); + } + + @Override + void endCall(long callId, String reason) { + endedCall = callId; + endReason = reason; + } + } +} From 9525dd3895904006641590caf65c8761e8eab744 Mon Sep 17 00:00:00 2001 From: Robert Nisipeanu Date: Tue, 22 Sep 2026 22:25:28 +0300 Subject: [PATCH 3/4] Retain early remote notifications until tunnel negotiation --- docs/CALL_TUNNEL.md | 6 ++- .../signal/manager/helper/CallManager.java | 41 +++++++++++++++---- .../helper/CallManagerSignalingTest.java | 40 ++++++++++++++++++ 3 files changed, 79 insertions(+), 8 deletions(-) diff --git a/docs/CALL_TUNNEL.md b/docs/CALL_TUNNEL.md index 017d524a..898a2312 100644 --- a/docs/CALL_TUNNEL.md +++ b/docs/CALL_TUNNEL.md @@ -52,7 +52,11 @@ device; this is distinct from its delivery target. An older tunnel keeps the existing direct hangup/busy cleanup and notification filtering. An older CLI ignores the added capability and target fields. Either project can upgrade independently; complete multi-device behavior requires both. -The account's local device-ID fix is independent of this protocol extension. +Linked accounts also require the local device-ID correction included in this branch. +Notifications received before capability negotiation are retained until the version +is known. An unversioned `ready` alone does not settle negotiation: a subsequent +capability event enables version 2; a first legacy signaling/state event selects +legacy behavior. ## Spawning the Tunnel diff --git a/lib/src/main/java/org/asamk/signal/manager/helper/CallManager.java b/lib/src/main/java/org/asamk/signal/manager/helper/CallManager.java index 29b9ba49..77eca18c 100644 --- a/lib/src/main/java/org/asamk/signal/manager/helper/CallManager.java +++ b/lib/src/main/java/org/asamk/signal/manager/helper/CallManager.java @@ -276,7 +276,7 @@ public class CallManager implements AutoCloseable { answerMsg.put("receiverIdentityKey", java.util.Base64.getEncoder().encodeToString(localIdentityKey)); sendControlMessage(state, writeJson(answerMsg)); - if (!state.multiDeviceSignaling) { + if (!Boolean.TRUE.equals(state.multiDeviceSignaling)) { state.deviceId = deviceId; } if (state.state != CallInfo.State.CONNECTED && state.state != CallInfo.State.RECONNECTING) { @@ -310,7 +310,13 @@ public class CallManager implements AutoCloseable { if (state == null || !state.recipientId.equals(sender)) { return; } - if (!state.multiDeviceSignaling) { + synchronized (state) { + if (state.multiDeviceSignaling == null && type != null && type != HangupMessage.Type.NORMAL) { + state.pendingRemoteNotifications.add(() -> handleIncomingHangup(sender, callId, senderDeviceId, type, deviceId)); + return; + } + } + if (!Boolean.TRUE.equals(state.multiDeviceSignaling)) { if (type == null || type == HangupMessage.Type.NORMAL) { endCall(callId, "remote_hangup"); } @@ -335,7 +341,7 @@ public class CallManager implements AutoCloseable { if (state == null || !state.recipientId.equals(sender)) { return; } - if (!state.multiDeviceSignaling) { + if (!Boolean.TRUE.equals(state.multiDeviceSignaling)) { endCall(callId, "remote_busy"); return; } @@ -500,6 +506,17 @@ public class CallManager implements AutoCloseable { return writeJson(config); } + private void setSignalingVersion(CallState state, int version) { + synchronized (state) { + state.multiDeviceSignaling = version >= 2; + var pending = List.copyOf(state.pendingRemoteNotifications); + state.pendingRemoteNotifications.clear(); + for (var notification : pending) { + notification.run(); + } + } + } + private void readControlEvents(CallState state, java.io.InputStream inputStream) { try (var reader = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8))) { String line; @@ -512,9 +529,17 @@ public class CallManager implements AutoCloseable { var json = mapper.readTree(line); var type = json.has("type") ? json.get("type").asText() : ""; + if (state.multiDeviceSignaling == null && switch (type) { + case "sendOffer", "sendAnswer", "sendIce", "sendHangup", "sendBusy", "stateChange" -> true; + default -> false; + }) { + setSignalingVersion(state, 1); + } switch (type) { case "ready" -> { - state.multiDeviceSignaling = json.path("signalingVersion").asInt(1) >= 2; + if (json.has("signalingVersion")) { + setSignalingVersion(state, json.path("signalingVersion").asInt(1)); + } if (json.has("inputDeviceName")) { state.inputDeviceName = json.get("inputDeviceName").asText(); } @@ -527,7 +552,7 @@ public class CallManager implements AutoCloseable { state.outputDeviceName); } case "signalingCapabilities" -> { - state.multiDeviceSignaling = json.path("version").asInt(1) >= 2; + setSignalingVersion(state, json.path("version").asInt(1)); } case "sendOffer" -> { var opaqueB64 = json.get("opaque").asText(); @@ -553,7 +578,7 @@ public class CallManager implements AutoCloseable { var hangupType = json.has("hangupType") ? json.get("hangupType").asText("normal") : "normal"; - if (!state.multiDeviceSignaling && hangupType.contains("onanotherdevice")) { + if (!Boolean.TRUE.equals(state.multiDeviceSignaling) && hangupType.contains("onanotherdevice")) { break; } logSendMessageResult(sendHangupViaSignal(state, hangupType, @@ -818,7 +843,9 @@ public class CallManager implements AutoCloseable { volatile CallInfo.State state; final RecipientId recipientId; volatile Integer deviceId; - volatile boolean multiDeviceSignaling; + // Unknown until capabilities or the first legacy call event arrive. + volatile Boolean multiDeviceSignaling; + final List pendingRemoteNotifications = new ArrayList<>(); final boolean isOutgoing; volatile String inputDeviceName; volatile String outputDeviceName; diff --git a/lib/src/test/java/org/asamk/signal/manager/helper/CallManagerSignalingTest.java b/lib/src/test/java/org/asamk/signal/manager/helper/CallManagerSignalingTest.java index 3a16f349..e43c3d9e 100644 --- a/lib/src/test/java/org/asamk/signal/manager/helper/CallManagerSignalingTest.java +++ b/lib/src/test/java/org/asamk/signal/manager/helper/CallManagerSignalingTest.java @@ -70,6 +70,46 @@ class CallManagerSignalingTest { method.invoke(manager, state, new ByteArrayInputStream((json + "\n").getBytes(StandardCharsets.UTF_8))); } + @Test + void replaysAcceptedNotificationReceivedBeforeReady() throws Exception { + manager.handleIncomingHangup(state.recipientId, state.callId, 1, HangupMessage.Type.ACCEPTED, 2); + assertEquals("", output.toString()); + events("{\"type\":\"ready\",\"signalingVersion\":2}"); + var message = MAPPER.readTree(output.toString()); + assertEquals("receivedHangup", message.path("type").asText()); + assertEquals(1, message.path("hangupType").asInt()); + assertEquals(2, message.path("deviceId").asInt()); + assertTrue(state.pendingRemoteNotifications.isEmpty()); + } + + @Test + void separateCapabilitiesDoNotLoseEarlyNotification() throws Exception { + manager.handleIncomingHangup(state.recipientId, state.callId, 1, HangupMessage.Type.DECLINED, 2); + events("{\"type\":\"ready\"}"); + assertEquals("", output.toString()); + events("{\"type\":\"signalingCapabilities\",\"version\":2}"); + assertEquals(2, MAPPER.readTree(output.toString()).path("hangupType").asInt()); + } + + @Test + void legacyCallEventResolvesPendingNotification() throws Exception { + manager.handleIncomingHangup(state.recipientId, state.callId, 1, HangupMessage.Type.ACCEPTED, 2); + events("{\"type\":\"ready\"}\n{\"type\":\"stateChange\",\"state\":\"Connected\"}"); + assertEquals(Boolean.FALSE, state.multiDeviceSignaling); + assertTrue(state.pendingRemoteNotifications.isEmpty()); + assertEquals("", output.toString()); + assertEquals(CallInfo.State.CONNECTED, state.state); + } + + @Test + void doesNotReplayNotificationsAfterCallRemoval() throws Exception { + manager.handleIncomingHangup(state.recipientId, state.callId, 1, HangupMessage.Type.ACCEPTED, 2); + calls.clear(); + events("{\"type\":\"ready\",\"signalingVersion\":2}"); + assertEquals("", output.toString()); + assertTrue(state.pendingRemoteNotifications.isEmpty()); + } + @Test void readyNegotiatesBeforeCallEvents() throws Exception { events("{\"type\":\"ready\",\"signalingVersion\":2}"); From 56597397c4547df5671fbe09a722f1f4482ccfb6 Mon Sep 17 00:00:00 2001 From: Robert Nisipeanu Date: Tue, 22 Sep 2026 22:34:48 +0300 Subject: [PATCH 4/4] Serialize remote call notifications with call removal --- .../signal/manager/helper/CallManager.java | 31 ++++++++-- .../helper/CallManagerSignalingTest.java | 60 ++++++++++++++++++- 2 files changed, 85 insertions(+), 6 deletions(-) diff --git a/lib/src/main/java/org/asamk/signal/manager/helper/CallManager.java b/lib/src/main/java/org/asamk/signal/manager/helper/CallManager.java index 77eca18c..eb767692 100644 --- a/lib/src/main/java/org/asamk/signal/manager/helper/CallManager.java +++ b/lib/src/main/java/org/asamk/signal/manager/helper/CallManager.java @@ -311,6 +311,9 @@ public class CallManager implements AutoCloseable { return; } synchronized (state) { + if (activeCalls.get(callId) != state) { + return; + } if (state.multiDeviceSignaling == null && type != null && type != HangupMessage.Type.NORMAL) { state.pendingRemoteNotifications.add(() -> handleIncomingHangup(sender, callId, senderDeviceId, type, deviceId)); return; @@ -333,7 +336,7 @@ public class CallManager implements AutoCloseable { case NEED_PERMISSION -> 4; }); message.put("deviceId", deviceId); - sendControlMessage(state, writeJson(message)); + sendRemoteControlMessage(state, message); } public void handleIncomingBusy(final RecipientId sender, final long callId, final int senderDeviceId) { @@ -348,7 +351,15 @@ public class CallManager implements AutoCloseable { var message = mapper.createObjectNode(); message.put("type", "receivedBusy"); message.put("senderDeviceId", senderDeviceId); - sendControlMessage(state, writeJson(message)); + sendRemoteControlMessage(state, message); + } + + private void sendRemoteControlMessage(CallState state, ObjectNode message) { + synchronized (state) { + if (activeCalls.get(state.callId) == state) { + sendControlMessage(state, writeJson(message)); + } + } } // --- Internal helpers --- @@ -768,13 +779,23 @@ public class CallManager implements AutoCloseable { } } + CallState removeCall(final long callId) { + var state = activeCalls.get(callId); + if (state == null) return null; + synchronized (state) { + if (!activeCalls.remove(callId, state)) return null; + state.pendingRemoteNotifications.clear(); + state.state = CallInfo.State.ENDED; + return state; + } + } + void endCall(final long callId, final String reason) { - var state = activeCalls.remove(callId); + var state = removeCall(callId); + if (state == null) return; dependencies.getAuthenticatedSignalWebSocket().removeKeepAliveToken("call" + callId); dependencies.getUnauthenticatedSignalWebSocket().removeKeepAliveToken("call" + callId); - if (state == null) return; - state.state = CallInfo.State.ENDED; fireCallEvent(state, reason); logger.debug("Call {} ended: {}", callIdUnsigned(callId), reason); diff --git a/lib/src/test/java/org/asamk/signal/manager/helper/CallManagerSignalingTest.java b/lib/src/test/java/org/asamk/signal/manager/helper/CallManagerSignalingTest.java index e43c3d9e..aeb2f20f 100644 --- a/lib/src/test/java/org/asamk/signal/manager/helper/CallManagerSignalingTest.java +++ b/lib/src/test/java/org/asamk/signal/manager/helper/CallManagerSignalingTest.java @@ -26,6 +26,10 @@ import java.io.StringWriter; import java.nio.charset.StandardCharsets; import java.nio.file.Path; import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; import static org.junit.jupiter.api.Assertions.*; @@ -104,12 +108,66 @@ class CallManagerSignalingTest { @Test void doesNotReplayNotificationsAfterCallRemoval() throws Exception { manager.handleIncomingHangup(state.recipientId, state.callId, 1, HangupMessage.Type.ACCEPTED, 2); - calls.clear(); + manager.removeCall(state.callId); events("{\"type\":\"ready\",\"signalingVersion\":2}"); assertEquals("", output.toString()); assertTrue(state.pendingRemoteNotifications.isEmpty()); } + @Test + void callRemovalWaitsForInFlightNotification() throws Exception { + var entered = new CountDownLatch(1); + var release = new CountDownLatch(1); + var registeredAtWrite = new AtomicBoolean(); + var failure = new AtomicReference(); + state.controlWriter = new PrintWriter(output, true) { + @Override + public void println(String line) { + entered.countDown(); + try { + if (!release.await(5, TimeUnit.SECONDS)) throw new AssertionError("write gate timed out"); + registeredAtWrite.set(calls.containsKey(state.callId)); + super.println(line); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new AssertionError(e); + } + } + }; + manager.handleIncomingHangup(state.recipientId, state.callId, 1, HangupMessage.Type.ACCEPTED, 2); + var negotiation = new Thread(() -> { + try { + events("{\"type\":\"ready\",\"signalingVersion\":2}"); + } catch (Throwable e) { + failure.set(e); + } + }); + var cancellation = new Thread(() -> manager.removeCall(state.callId)); + negotiation.start(); + try { + assertTrue(entered.await(5, TimeUnit.SECONDS)); + cancellation.start(); + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); + while (cancellation.getState() != Thread.State.BLOCKED && cancellation.isAlive() + && System.nanoTime() < deadline) { + Thread.onSpinWait(); + } + assertEquals(Thread.State.BLOCKED, cancellation.getState()); + assertTrue(calls.containsKey(state.callId)); + } finally { + release.countDown(); + negotiation.join(5000); + cancellation.join(5000); + } + assertFalse(negotiation.isAlive()); + assertFalse(cancellation.isAlive()); + assertNull(failure.get()); + assertTrue(registeredAtWrite.get()); + assertFalse(calls.containsKey(state.callId)); + assertEquals(CallInfo.State.ENDED, state.state); + assertTrue(state.pendingRemoteNotifications.isEmpty()); + } + @Test void readyNegotiatesBeforeCallEvents() throws Exception { events("{\"type\":\"ready\",\"signalingVersion\":2}");