mirror of
https://github.com/AsamK/signal-cli.git
synced 2026-09-01 06:28:11 +00:00
Compare commits
17 Commits
047e64ceae
...
ebd47ed9a5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ebd47ed9a5 | ||
|
|
78dba20a59 | ||
|
|
b48ccf2605 | ||
|
|
54d713ea7d | ||
|
|
4dda7582a3 | ||
|
|
248c0c0dab | ||
|
|
e70bddd790 | ||
|
|
ac5ed431d3 | ||
|
|
fb10e1a501 | ||
|
|
2a744edd43 | ||
|
|
c95a67862e | ||
|
|
5a525f51f6 | ||
|
|
eab6ffbd8c | ||
|
|
8809b90b72 | ||
|
|
bacced0773 | ||
|
|
d2639d10c0 | ||
|
|
8f07078bc3 |
10
.github/workflows/release.yml
vendored
10
.github/workflows/release.yml
vendored
@ -75,6 +75,16 @@ jobs:
|
||||
asset_name: signal-cli-${{ steps.version.outputs.version }}-Linux-client.tar.gz
|
||||
asset_content_type: application/x-compressed-tar # .tar.gz
|
||||
|
||||
- name: Upload JSON schemas archive
|
||||
uses: actions/upload-release-asset@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||
asset_path: signal-cli-${{ steps.version.outputs.version }}-json-schemas.tar.gz
|
||||
asset_name: signal-cli-${{ steps.version.outputs.version }}-json-schemas.tar.gz
|
||||
asset_content_type: application/x-compressed-tar # .tar.gz
|
||||
|
||||
build-container:
|
||||
needs: release
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@ -2,6 +2,10 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- New `sendStory` command to post file attachment stories to "My Story"
|
||||
|
||||
### Fixed
|
||||
|
||||
- Sending to large groups is no longer slowed down by members that are already known to be unregistered; they are skipped instead of being retried via the legacy 1:1 send path on every send.
|
||||
|
||||
@ -5,7 +5,7 @@ plugins {
|
||||
application
|
||||
eclipse
|
||||
`check-lib-versions`
|
||||
id("org.graalvm.buildtools.native") version "1.1.3"
|
||||
id("org.graalvm.buildtools.native") version "1.1.4"
|
||||
}
|
||||
|
||||
allprojects {
|
||||
@ -74,7 +74,7 @@ val excludePatterns = mapOf(
|
||||
)
|
||||
)
|
||||
|
||||
val schemaAnnotationProcessor by configurations.creating {
|
||||
val schemaAnnotationProcessor = configurations.create("schemaAnnotationProcessor") {
|
||||
isCanBeConsumed = false
|
||||
isCanBeResolved = true
|
||||
}
|
||||
|
||||
@ -3,7 +3,7 @@ slf4j = "2.0.18"
|
||||
junit = "6.1.0"
|
||||
micronaut-json-schema = "2.0.1"
|
||||
micronaut-core = "5.0.0"
|
||||
signal-service = "2.15.3_unofficial_148"
|
||||
signal-service = "2.15.3_unofficial_149"
|
||||
|
||||
[libraries]
|
||||
bouncycastle = "org.bouncycastle:bcprov-jdk18on:1.84"
|
||||
|
||||
2
gradle/wrapper/gradle-wrapper.properties
vendored
2
gradle/wrapper/gradle-wrapper.properties
vendored
@ -1,6 +1,6 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip
|
||||
networkTimeout=10000
|
||||
retries=0
|
||||
retryBackOffMs=500
|
||||
|
||||
6
gradlew
vendored
6
gradlew
vendored
@ -20,7 +20,7 @@
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Gradle start up script for POSIX generated by Gradle.
|
||||
# gradlew start up script for POSIX generated by Gradle.
|
||||
#
|
||||
# Important for running:
|
||||
#
|
||||
@ -29,7 +29,7 @@
|
||||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line, like:
|
||||
#
|
||||
# ksh Gradle
|
||||
# ksh gradlew
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX shell features:
|
||||
@ -57,7 +57,7 @@
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# https://github.com/gradle/gradle/blob/<unknown>/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
|
||||
4
gradlew.bat
vendored
4
gradlew.bat
vendored
@ -19,7 +19,7 @@
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem gradlew startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@ -72,7 +72,7 @@ echo location of your Java installation. 1>&2
|
||||
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
@rem Execute gradlew
|
||||
@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
|
||||
@rem which allows us to clear the local environment before executing the java command
|
||||
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel
|
||||
|
||||
@ -54,6 +54,7 @@ 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.manager.api.VerificationMethodNotAvailableException;
|
||||
import org.signal.core.util.UuidUtil;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
@ -74,6 +75,10 @@ public interface Manager extends Closeable {
|
||||
return PhoneNumberUtil.getInstance().isPossibleNumber(e164Number, countryCode);
|
||||
}
|
||||
|
||||
static boolean isValidAci(final String aci) {
|
||||
return UuidUtil.INSTANCE.isUuid(aci);
|
||||
}
|
||||
|
||||
static boolean isSignalClientAvailable() {
|
||||
final Logger logger = LoggerFactory.getLogger(Manager.class);
|
||||
try {
|
||||
@ -91,6 +96,8 @@ public interface Manager extends Closeable {
|
||||
|
||||
String getSelfNumber();
|
||||
|
||||
String getSelfACI();
|
||||
|
||||
/**
|
||||
* This is used for checking a set of phone numbers for registration on Signal
|
||||
*
|
||||
@ -213,6 +220,14 @@ public interface Manager extends Closeable {
|
||||
long editTargetTimestamp
|
||||
) throws IOException, AttachmentInvalidException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException, UnregisteredRecipientException, InvalidStickerException;
|
||||
|
||||
/**
|
||||
* Post a file attachment story to "My Story".
|
||||
*
|
||||
* @param attachment path to the file to upload and post as a story
|
||||
* @param allowsReplies whether other users are allowed to reply to this story
|
||||
*/
|
||||
SendMessageResults sendStory(String attachment, boolean allowsReplies) throws IOException, AttachmentInvalidException;
|
||||
|
||||
SendMessageResults sendRemoteDeleteMessage(
|
||||
long targetSentTimestamp,
|
||||
Set<RecipientIdentifier> recipients
|
||||
@ -407,6 +422,10 @@ public interface Manager extends Closeable {
|
||||
|
||||
void addClosedListener(Runnable listener);
|
||||
|
||||
void addUnidentifiedKeepAlive(String token);
|
||||
|
||||
void removeUnidentifiedKeepAlive(String token);
|
||||
|
||||
InputStream retrieveAttachment(final String id) throws IOException;
|
||||
|
||||
InputStream retrieveContactAvatar(final RecipientIdentifier.Single recipient) throws IOException, UnregisteredRecipientException;
|
||||
|
||||
@ -15,6 +15,7 @@ import org.asamk.signal.manager.internal.RegistrationManagerImpl;
|
||||
import org.asamk.signal.manager.storage.SignalAccount;
|
||||
import org.asamk.signal.manager.storage.accounts.AccountsStore;
|
||||
import org.asamk.signal.manager.util.KeyUtils;
|
||||
import org.signal.core.models.ServiceId.ACI;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.whispersystems.signalservice.api.push.exceptions.DeprecatedVersionException;
|
||||
@ -67,7 +68,7 @@ public class SignalAccountFiles {
|
||||
public MultiAccountManager initMultiAccountManager() throws IOException {
|
||||
final var managerPairs = accountsStore.getAllAccounts().parallelStream().map(a -> {
|
||||
try {
|
||||
return new Pair<Manager, Throwable>(initManager(a.number(), a.path()), null);
|
||||
return new Pair<Manager, Throwable>(initManagerByNumber(a.number(), a.path()), null);
|
||||
} catch (NotRegisteredException e) {
|
||||
logger.warn("Ignoring {}: {} ({})", a.number(), e.getMessage(), e.getClass().getSimpleName());
|
||||
return null;
|
||||
@ -90,15 +91,31 @@ public class SignalAccountFiles {
|
||||
return new MultiAccountManagerImpl(managers, this);
|
||||
}
|
||||
|
||||
public Manager initManager(String number) throws IOException, NotRegisteredException, AccountCheckException {
|
||||
public Manager initManagerByNumber(String number) throws IOException, NotRegisteredException, AccountCheckException {
|
||||
final var accountPath = accountsStore.getPathByNumber(number);
|
||||
return this.initManager(number, accountPath);
|
||||
return this.initManagerByNumber(number, accountPath);
|
||||
}
|
||||
|
||||
private Manager initManager(
|
||||
public Manager initManagerByAci(String aciStr) throws IOException, NotRegisteredException, AccountCheckException {
|
||||
final var aci = ACI.parseOrThrow(aciStr);
|
||||
final var accountPath = accountsStore.getPathByAci(aci);
|
||||
return this.initManagerByAci(aci, accountPath);
|
||||
}
|
||||
|
||||
private Manager initManagerByNumber(
|
||||
String number,
|
||||
String accountPath
|
||||
) throws IOException, NotRegisteredException, AccountCheckException {
|
||||
final var account = loadAccount(accountPath);
|
||||
if (!number.equals(account.getNumber())) {
|
||||
account.close();
|
||||
throw new IOException("Number in account file doesn't match expected number: " + account.getNumber());
|
||||
}
|
||||
|
||||
return initManagerFromAccount(number, accountPath, account);
|
||||
}
|
||||
|
||||
private SignalAccount loadAccount(final String accountPath) throws NotRegisteredException, IOException {
|
||||
if (accountPath == null) {
|
||||
throw new NotRegisteredException();
|
||||
}
|
||||
@ -106,12 +123,27 @@ public class SignalAccountFiles {
|
||||
throw new NotRegisteredException();
|
||||
}
|
||||
|
||||
var account = SignalAccount.load(pathConfig.dataPath(), accountPath, true, settings);
|
||||
if (!number.equals(account.getNumber())) {
|
||||
return SignalAccount.load(pathConfig.dataPath(), accountPath, true, settings);
|
||||
}
|
||||
|
||||
private Manager initManagerByAci(
|
||||
ACI aci,
|
||||
String accountPath
|
||||
) throws IOException, NotRegisteredException, AccountCheckException {
|
||||
final var account = loadAccount(accountPath);
|
||||
if (!aci.equals(account.getAci())) {
|
||||
account.close();
|
||||
throw new IOException("Number in account file doesn't match expected number: " + account.getNumber());
|
||||
throw new IOException("ACI in account file doesn't match expected ACI: " + account.getAci());
|
||||
}
|
||||
|
||||
return initManagerFromAccount(aci.toString(), accountPath, account);
|
||||
}
|
||||
|
||||
private ManagerImpl initManagerFromAccount(
|
||||
final String identifier,
|
||||
final String accountPath,
|
||||
final SignalAccount account
|
||||
) throws NotRegisteredException, IOException, AccountCheckException {
|
||||
if (!account.isRegistered()) {
|
||||
account.close();
|
||||
throw new NotRegisteredException();
|
||||
@ -136,7 +168,7 @@ public class SignalAccountFiles {
|
||||
throw new IOException("signal-cli version is too old for the Signal-Server, please update.");
|
||||
} catch (IOException e) {
|
||||
manager.close();
|
||||
throw new AccountCheckException("Error while checking account " + number + ": " + e.getMessage(), e);
|
||||
throw new AccountCheckException("Error while checking account " + identifier + ": " + e.getMessage(), e);
|
||||
}
|
||||
|
||||
if (account.getServiceEnvironment() == null) {
|
||||
|
||||
@ -19,6 +19,7 @@ import org.signal.core.models.ServiceId.PNI;
|
||||
import org.signal.core.util.Base64;
|
||||
import org.signal.libsignal.protocol.IdentityKeyPair;
|
||||
import org.signal.libsignal.protocol.InvalidKeyException;
|
||||
import org.signal.libsignal.protocol.NoSessionException;
|
||||
import org.signal.libsignal.protocol.SignalProtocolAddress;
|
||||
import org.signal.libsignal.protocol.state.KyberPreKeyRecord;
|
||||
import org.signal.libsignal.protocol.state.SignedPreKeyRecord;
|
||||
@ -280,7 +281,7 @@ public class AccountHelper {
|
||||
final var message = messageSender.getEncryptedSyncPniInitializeDeviceMessage(deviceId,
|
||||
pniChangeNumber);
|
||||
encryptedDeviceMessages.add(message);
|
||||
} catch (UntrustedIdentityException | IOException | InvalidKeyException e) {
|
||||
} catch (UntrustedIdentityException | IOException | InvalidKeyException | NoSessionException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
@ -35,6 +35,8 @@ import org.whispersystems.signalservice.api.messages.SendMessageResult;
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage;
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceEditMessage;
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceReceiptMessage;
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceStoryMessage;
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceStoryMessageRecipient;
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceTypingMessage;
|
||||
import org.whispersystems.signalservice.api.messages.calls.SignalServiceCallMessage;
|
||||
import org.whispersystems.signalservice.api.messages.multidevice.SentTranscriptMessage;
|
||||
@ -330,6 +332,53 @@ public class SendHelper {
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a story message (file attachment) to "My Story".
|
||||
*/
|
||||
public List<SendMessageResult> sendStoryMessage(
|
||||
SignalServiceStoryMessage storyMessage,
|
||||
long timestamp,
|
||||
Set<RecipientId> recipientIds,
|
||||
boolean allowsReplies
|
||||
) throws IOException {
|
||||
final var messageSender = dependencies.getMessageSender();
|
||||
|
||||
final var recipientIdList = List.copyOf(recipientIds);
|
||||
final var addressesMap = recipientIdList.stream()
|
||||
.collect(Collectors.toMap(id -> id, context.getRecipientHelper()::resolveSignalServiceAddress));
|
||||
final var unidentifiedAccessesMap = context.getUnidentifiedAccessHelper().getAccessFor(recipientIds);
|
||||
|
||||
final var addresses = recipientIdList.stream().map(addressesMap::get).toList();
|
||||
final var unidentifiedAccesses = recipientIdList.stream().map(unidentifiedAccessesMap::get).toList();
|
||||
final var storyMessageRecipients = recipientIdList.stream()
|
||||
.map(id -> new SignalServiceStoryMessageRecipient(addressesMap.get(id),
|
||||
List.of(DistributionId.MY_STORY.asUuid().toString()),
|
||||
allowsReplies))
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
final List<SendMessageResult> results;
|
||||
try {
|
||||
results = messageSender.sendGroupStory(DistributionId.MY_STORY,
|
||||
Optional.empty(),
|
||||
addresses,
|
||||
unidentifiedAccesses,
|
||||
null,
|
||||
false,
|
||||
storyMessage,
|
||||
timestamp,
|
||||
storyMessageRecipients,
|
||||
null);
|
||||
} catch (UntrustedIdentityException | InvalidKeyException | NoSessionException | InvalidRegistrationIdException e) {
|
||||
throw new IOException(e);
|
||||
}
|
||||
|
||||
for (var r : results) {
|
||||
handleSendMessageResult(r);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private List<SendMessageResult> sendAsGroupMessage(
|
||||
final SignalServiceDataMessage.Builder messageBuilder,
|
||||
final GroupInfo g,
|
||||
@ -520,8 +569,7 @@ public class SendHelper {
|
||||
if (unregisteredRecipientIds.isEmpty()) {
|
||||
targetRecipientIds = recipientIds;
|
||||
} else {
|
||||
logger.debug("Skipping {} known-unregistered recipient(s) in group send.",
|
||||
unregisteredRecipientIds.size());
|
||||
logger.debug("Skipping {} known-unregistered recipient(s) in group send.", unregisteredRecipientIds.size());
|
||||
targetRecipientIds = new HashSet<>(recipientIds);
|
||||
targetRecipientIds.removeAll(unregisteredRecipientIds);
|
||||
for (final var recipientId : unregisteredRecipientIds) {
|
||||
@ -556,7 +604,9 @@ public class SendHelper {
|
||||
logger.debug("Too few sender-key-capable users ({}). Doing all legacy sends.", senderKeyTargets.size());
|
||||
senderKeyTargets = Set.of();
|
||||
} else {
|
||||
logger.debug("Can use sender key for {}/{} recipients.", senderKeyTargets.size(), targetRecipientIds.size());
|
||||
logger.debug("Can use sender key for {}/{} recipients.",
|
||||
senderKeyTargets.size(),
|
||||
targetRecipientIds.size());
|
||||
}
|
||||
|
||||
final var allResults = new ArrayList<SendMessageResult>(recipientIds.size());
|
||||
@ -691,7 +741,7 @@ public class SendHelper {
|
||||
final var successCount = results.stream().filter(SendMessageResult::isSuccess).count();
|
||||
logger.debug("Successfully sent using 1:1 to {}/{} legacy targets.", successCount, addresses.size());
|
||||
return results;
|
||||
} catch (org.whispersystems.signalservice.api.crypto.UntrustedIdentityException e) {
|
||||
} catch (org.whispersystems.signalservice.api.crypto.UntrustedIdentityException | NoSessionException e) {
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
@ -883,7 +933,7 @@ public class SendHelper {
|
||||
SignalServiceAddress address,
|
||||
SealedSenderAccess unidentifiedAccess,
|
||||
boolean includePniSignature
|
||||
) throws IOException, UnregisteredUserException, ProofRequiredException, RateLimitException, org.whispersystems.signalservice.api.crypto.UntrustedIdentityException;
|
||||
) throws IOException, UnregisteredUserException, ProofRequiredException, RateLimitException, org.whispersystems.signalservice.api.crypto.UntrustedIdentityException, NoSessionException;
|
||||
}
|
||||
|
||||
interface SenderKeySenderHandler {
|
||||
@ -903,6 +953,6 @@ public class SendHelper {
|
||||
List<SignalServiceAddress> recipients,
|
||||
List<SealedSenderAccess> unidentifiedAccess,
|
||||
boolean isRecipientUpdate
|
||||
) throws IOException, UntrustedIdentityException;
|
||||
) throws IOException, UntrustedIdentityException, NoSessionException;
|
||||
}
|
||||
}
|
||||
|
||||
@ -100,14 +100,17 @@ import org.signal.core.models.ServiceId.PNI;
|
||||
import org.signal.core.util.Base64;
|
||||
import org.signal.core.util.Hex;
|
||||
import org.signal.libsignal.protocol.InvalidMessageException;
|
||||
import org.signal.libsignal.protocol.NoSessionException;
|
||||
import org.signal.libsignal.usernames.BaseUsernameException;
|
||||
import org.signal.network.exceptions.NonSuccessfulResponseCodeException;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.whispersystems.signalservice.api.crypto.UntrustedIdentityException;
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceAttachment;
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage;
|
||||
import org.whispersystems.signalservice.api.messages.SignalServicePreview;
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceReceiptMessage;
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceStoryMessage;
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceTypingMessage;
|
||||
import org.whispersystems.signalservice.api.messages.calls.AnswerMessage;
|
||||
import org.whispersystems.signalservice.api.messages.calls.BusyMessage;
|
||||
@ -244,6 +247,11 @@ public class ManagerImpl implements Manager {
|
||||
return account.getNumber();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getSelfACI() {
|
||||
return account.getAci().toString();
|
||||
}
|
||||
|
||||
public void checkAccountState() throws IOException {
|
||||
context.getAccountHelper().checkAccountState();
|
||||
final var lastRecipientsRefresh = account.getLastRecipientsRefresh();
|
||||
@ -826,6 +834,50 @@ public class ManagerImpl implements Manager {
|
||||
return sendMessage(messageBuilder, recipients, false, Optional.of(editTargetTimestamp), message.urgent());
|
||||
}
|
||||
|
||||
@Override
|
||||
public SendMessageResults sendStory(
|
||||
String attachment,
|
||||
boolean allowsReplies
|
||||
) throws IOException, AttachmentInvalidException {
|
||||
final var file = new File(attachment);
|
||||
final var mimeType = MimeUtils.getFileMimeType(file);
|
||||
if (mimeType.isEmpty() || (!mimeType.get().startsWith("image/") && !mimeType.get().startsWith("video/"))) {
|
||||
throw new AttachmentInvalidException(attachment,
|
||||
new IOException("Stories only support image and video attachments"));
|
||||
}
|
||||
|
||||
final var recipients = account.getRecipientStore()
|
||||
.getRecipients(true, Optional.of(false), Set.of(), Optional.empty());
|
||||
final var recipientIds = recipients.stream()
|
||||
.filter(r -> !r.getRecipientId().equals(account.getSelfRecipientId()))
|
||||
.filter(r -> r.getContact() != null && !r.getContact().hideStory())
|
||||
.map(r -> r.getRecipientId())
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
if (recipientIds.isEmpty()) {
|
||||
throw new IOException("No eligible contacts found for story delivery");
|
||||
}
|
||||
|
||||
final var uploadedAttachment = context.getAttachmentHelper().uploadAttachment(attachment);
|
||||
final var storyMessage = SignalServiceStoryMessage.forFileAttachment(account.getProfileKey().serialize(),
|
||||
null,
|
||||
uploadedAttachment,
|
||||
allowsReplies,
|
||||
List.of());
|
||||
final var timestamp = getNextMessageTimestamp();
|
||||
|
||||
final var sendResults = context.getSendHelper()
|
||||
.sendStoryMessage(storyMessage, timestamp, recipientIds, allowsReplies);
|
||||
|
||||
final var results = new HashMap<RecipientIdentifier, List<SendMessageResult>>();
|
||||
for (final var sendResult : sendResults) {
|
||||
final var result = toSendMessageResult(sendResult);
|
||||
results.put(RecipientIdentifier.Single.fromAddress(result.address()), List.of(result));
|
||||
}
|
||||
|
||||
return new SendMessageResults(timestamp, results);
|
||||
}
|
||||
|
||||
private void applyMessage(
|
||||
final SignalServiceDataMessage.Builder messageBuilder,
|
||||
final Message message
|
||||
@ -1709,6 +1761,16 @@ public class ManagerImpl implements Manager {
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void addUnidentifiedKeepAlive(final String token) {
|
||||
dependencies.getUnauthenticatedSignalWebSocket().registerKeepAliveToken(token);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void removeUnidentifiedKeepAlive(final String token) {
|
||||
dependencies.getUnauthenticatedSignalWebSocket().removeKeepAliveToken(token);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void addCallEventListener(final CallEventListener listener) {
|
||||
context.getCallManager().addCallEventListener(listener);
|
||||
@ -1820,6 +1882,8 @@ public class ManagerImpl implements Manager {
|
||||
dependencies.getMessageSender().sendCallMessage(address, null, callMessage);
|
||||
} catch (org.whispersystems.signalservice.api.crypto.UntrustedIdentityException e) {
|
||||
throw new IOException("Untrusted identity for call recipient", e);
|
||||
} catch (NoSessionException e) {
|
||||
throw new IOException("No session for call recipient", e);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1837,6 +1901,8 @@ public class ManagerImpl implements Manager {
|
||||
dependencies.getMessageSender().sendCallMessage(address, null, callMessage);
|
||||
} catch (org.whispersystems.signalservice.api.crypto.UntrustedIdentityException e) {
|
||||
throw new IOException("Untrusted identity for call recipient", e);
|
||||
} catch (NoSessionException e) {
|
||||
throw new IOException("No session for call recipient", e);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1854,6 +1920,8 @@ public class ManagerImpl implements Manager {
|
||||
dependencies.getMessageSender().sendCallMessage(address, null, callMessage);
|
||||
} catch (org.whispersystems.signalservice.api.crypto.UntrustedIdentityException e) {
|
||||
throw new IOException("Untrusted identity for call recipient", e);
|
||||
} catch (NoSessionException e) {
|
||||
throw new IOException("No session for call recipient", e);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1878,6 +1946,8 @@ public class ManagerImpl implements Manager {
|
||||
dependencies.getMessageSender().sendCallMessage(address, null, callMessage);
|
||||
} catch (org.whispersystems.signalservice.api.crypto.UntrustedIdentityException e) {
|
||||
throw new IOException("Untrusted identity for call recipient", e);
|
||||
} catch (NoSessionException e) {
|
||||
throw new IOException("No session for call recipient", e);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1894,6 +1964,8 @@ public class ManagerImpl implements Manager {
|
||||
dependencies.getMessageSender().sendCallMessage(address, null, callMessage);
|
||||
} catch (org.whispersystems.signalservice.api.crypto.UntrustedIdentityException e) {
|
||||
throw new IOException("Untrusted identity for call recipient", e);
|
||||
} catch (NoSessionException e) {
|
||||
throw new IOException("No session for call recipient", e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -7,6 +7,7 @@ import org.asamk.signal.manager.RegistrationManager;
|
||||
import org.asamk.signal.manager.SignalAccountFiles;
|
||||
import org.asamk.signal.manager.api.AccountCheckException;
|
||||
import org.asamk.signal.manager.api.NotRegisteredException;
|
||||
import org.signal.core.util.UuidUtil;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
@ -95,23 +96,47 @@ public class MultiAccountManagerImpl implements MultiAccountManager {
|
||||
}
|
||||
|
||||
@Override
|
||||
public Manager getManager(final String number) {
|
||||
public Manager getManager(final String identifier) {
|
||||
synchronized (managers) {
|
||||
final var manager = managers.stream()
|
||||
.filter(m -> m.getSelfNumber().equals(number))
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
if (manager != null) {
|
||||
return manager;
|
||||
}
|
||||
try {
|
||||
final var newManager = signalAccountFiles.initManager(number);
|
||||
managers.add(newManager);
|
||||
return newManager;
|
||||
} catch (IOException | NotRegisteredException | AccountCheckException e) {
|
||||
logger.warn("Failed to load new manager", e);
|
||||
return null;
|
||||
if (UuidUtil.INSTANCE.isUuid(identifier)) {
|
||||
// Check if UUID corresponds to an already-loaded manager
|
||||
final var existing = managers.stream()
|
||||
.filter(m -> m.getSelfACI().equals(identifier))
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
if (existing != null) {
|
||||
logger.debug("Found already loaded manager for ACI: {}", identifier);
|
||||
return existing;
|
||||
}
|
||||
// Load by ACI
|
||||
try {
|
||||
final var newManager = signalAccountFiles.initManagerByAci(identifier);
|
||||
managers.add(newManager);
|
||||
return newManager;
|
||||
} catch (NotRegisteredException e) {
|
||||
logger.debug("Manager not found by ACI: {}", identifier);
|
||||
} catch (IOException | IllegalArgumentException | AccountCheckException e) {
|
||||
logger.warn("Failed to load new manager by ACI: {}", identifier, e);
|
||||
}
|
||||
} else {
|
||||
// Phone number — check already loaded managers
|
||||
var existing = managers.stream()
|
||||
.filter(m -> m.getSelfNumber().equals(identifier))
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
if (existing != null) {
|
||||
return existing;
|
||||
}
|
||||
// Load by phone number
|
||||
try {
|
||||
final var newManager = signalAccountFiles.initManagerByNumber(identifier);
|
||||
managers.add(newManager);
|
||||
return newManager;
|
||||
} catch (NotRegisteredException | IOException | IllegalArgumentException | AccountCheckException e) {
|
||||
logger.warn("Failed to load manager by number: {}", identifier, e);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1924,7 +1924,8 @@ public class SignalAccount implements Closeable {
|
||||
getSessionStore(),
|
||||
getIdentityKeyStore(),
|
||||
getSenderKeyStore(),
|
||||
SignalAccount.this::isMultiDevice));
|
||||
SignalAccount.this::isMultiDevice,
|
||||
SignalAccount.this::setMultiDevice));
|
||||
}
|
||||
|
||||
public PreKeyStore getPreKeyStore() {
|
||||
|
||||
@ -26,6 +26,7 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
public class SignalProtocolStore implements SignalServiceAccountDataStore {
|
||||
@ -37,6 +38,7 @@ public class SignalProtocolStore implements SignalServiceAccountDataStore {
|
||||
private final IdentityKeyStore identityKeyStore;
|
||||
private final SignalServiceSenderKeyStore senderKeyStore;
|
||||
private final Supplier<Boolean> isMultiDevice;
|
||||
private final Consumer<Boolean> setMultiDeviceCallback;
|
||||
|
||||
public SignalProtocolStore(
|
||||
final SignalServicePreKeyStore preKeyStore,
|
||||
@ -45,7 +47,8 @@ public class SignalProtocolStore implements SignalServiceAccountDataStore {
|
||||
final SignalServiceSessionStore sessionStore,
|
||||
final IdentityKeyStore identityKeyStore,
|
||||
final SignalServiceSenderKeyStore senderKeyStore,
|
||||
final Supplier<Boolean> isMultiDevice
|
||||
final Supplier<Boolean> isMultiDevice,
|
||||
final Consumer<Boolean> setMultiDeviceCallback
|
||||
) {
|
||||
this.preKeyStore = preKeyStore;
|
||||
this.signedPreKeyStore = signedPreKeyStore;
|
||||
@ -54,6 +57,7 @@ public class SignalProtocolStore implements SignalServiceAccountDataStore {
|
||||
this.identityKeyStore = identityKeyStore;
|
||||
this.senderKeyStore = senderKeyStore;
|
||||
this.isMultiDevice = isMultiDevice;
|
||||
this.setMultiDeviceCallback = setMultiDeviceCallback;
|
||||
}
|
||||
|
||||
@Override
|
||||
@ -209,6 +213,11 @@ public class SignalProtocolStore implements SignalServiceAccountDataStore {
|
||||
return isMultiDevice.get();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setMultiDevice(final boolean isMultiDevice) {
|
||||
setMultiDeviceCallback.accept(isMultiDevice);
|
||||
}
|
||||
|
||||
@Override
|
||||
public KyberPreKeyRecord loadKyberPreKey(final int kyberPreKeyId) throws InvalidKeyIdException {
|
||||
return kyberPreKeyStore.loadKyberPreKey(kyberPreKeyId);
|
||||
|
||||
@ -30,6 +30,7 @@ import java.util.Arrays;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
@ -51,7 +52,16 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
|
||||
|
||||
private final Map<Long, Long> recipientsMerged = new HashMap<>();
|
||||
|
||||
private final Map<ServiceId, RecipientWithAddress> recipientAddressCache = Collections.synchronizedMap(new HashMap<>());
|
||||
private static final int MAX_RECIPIENT_CACHE_SIZE = 2000;
|
||||
|
||||
private final Map<ServiceId, RecipientWithAddress> recipientAddressCache = Collections.synchronizedMap(
|
||||
new LinkedHashMap<>(16, 0.75f, true) {
|
||||
|
||||
@Override
|
||||
protected boolean removeEldestEntry(Map.Entry<ServiceId, RecipientWithAddress> eldest) {
|
||||
return size() > MAX_RECIPIENT_CACHE_SIZE;
|
||||
}
|
||||
});
|
||||
|
||||
public static void createSql(Connection connection) throws SQLException {
|
||||
// When modifying the CREATE statement here, also add a migration in AccountDatabase.java
|
||||
|
||||
@ -18,7 +18,7 @@ import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
@ -28,8 +28,15 @@ public class SessionStore implements SignalServiceSessionStore {
|
||||
|
||||
private static final String TABLE_SESSION = "session";
|
||||
private static final Logger logger = LoggerFactory.getLogger(SessionStore.class);
|
||||
private static final int MAX_CACHE_SIZE = 1000;
|
||||
|
||||
private final Map<Key, SessionRecord> cachedSessions = new HashMap<>();
|
||||
private final Map<Key, SessionRecord> cachedSessions = new LinkedHashMap<>(16, 0.75f, true) {
|
||||
|
||||
@Override
|
||||
protected boolean removeEldestEntry(Map.Entry<Key, SessionRecord> eldest) {
|
||||
return size() > MAX_CACHE_SIZE;
|
||||
}
|
||||
};
|
||||
|
||||
private final Database database;
|
||||
private final int accountIdType;
|
||||
|
||||
@ -117,24 +117,35 @@ public class ContactRecordProcessor extends DefaultStorageRecordProcessor<Signal
|
||||
|
||||
IdentityState identityState;
|
||||
ByteString identityKey;
|
||||
if (remote.identityKey.size() > 0 && (
|
||||
!account.isPrimaryDevice()
|
||||
|| remote.identityState != local.identityState
|
||||
|| local.identityKey.size() == 0
|
||||
|
||||
)) {
|
||||
identityState = remote.identityState;
|
||||
identityKey = remote.identityKey;
|
||||
} else {
|
||||
identityState = local.identityState;
|
||||
identityKey = local.identityKey.size() > 0 ? local.identityKey : ByteString.EMPTY;
|
||||
}
|
||||
|
||||
// Parse ACI/PNI first so we can determine if contact has identity
|
||||
final var localAci = ACI.parseOrNull(local.aci, local.aciBinary);
|
||||
final var localPni = PNI.parseOrNull(local.pni, local.pniBinary);
|
||||
final var remoteAci = ACI.parseOrNull(remote.aci, remote.aciBinary);
|
||||
final var remotePni = PNI.parseOrNull(remote.pni, remote.pniBinary);
|
||||
|
||||
final var hasLocalIdentity = localAci != null || localPni != null;
|
||||
final var hasRemoteIdentity = remoteAci != null || remotePni != null;
|
||||
|
||||
final var remoteIdentityKeySize = remote.identityKey.size();
|
||||
final var localIdentityKeySize = local.identityKey.size();
|
||||
final var statesDiffer = remote.identityState != local.identityState;
|
||||
|
||||
if (remoteIdentityKeySize > 0 && (!account.isPrimaryDevice() || statesDiffer || localIdentityKeySize == 0)) {
|
||||
identityState = remote.identityState;
|
||||
identityKey = remote.identityKey;
|
||||
} else {
|
||||
identityState = local.identityState;
|
||||
// Only use local's identity key if:
|
||||
// 1. Contact has ACI or PNI
|
||||
// 2. Remote also has an identity key (if remote size=0, respect that decision)
|
||||
if (hasLocalIdentity && localIdentityKeySize > 0 && remoteIdentityKeySize > 0) {
|
||||
identityKey = local.identityKey;
|
||||
} else {
|
||||
identityKey = ByteString.EMPTY;
|
||||
}
|
||||
}
|
||||
|
||||
if (localAci != null
|
||||
&& local.identityKey.size() > 0
|
||||
&& remote.identityKey.size() > 0
|
||||
|
||||
@ -92,8 +92,11 @@ public final class StorageSyncModels {
|
||||
|
||||
public static ContactRecord localToRemoteRecord(Recipient recipient, IdentityInfo identity) {
|
||||
final var address = recipient.getAddress();
|
||||
final var aciPresent = address.aci().isPresent();
|
||||
final var pniPresent = address.pni().isPresent();
|
||||
|
||||
final var builder = SignalContactRecord.Companion.newBuilder(recipient.getStorageRecord())
|
||||
.e164(address.number().orElse(""))
|
||||
.e164(pniPresent ? address.number().orElse("") : "")
|
||||
.username(address.username().orElse(""))
|
||||
.profileKey(recipient.getProfileKey() == null
|
||||
? ByteString.EMPTY
|
||||
@ -126,7 +129,7 @@ public final class StorageSyncModels {
|
||||
.archived(recipient.getContact().isArchived())
|
||||
.hidden(recipient.getContact().isHidden());
|
||||
}
|
||||
if (identity != null) {
|
||||
if (identity != null && aciPresent) {
|
||||
builder.identityKey(ByteString.of(identity.getIdentityKey().serialize()))
|
||||
.identityState(localToRemote(identity.getTrustLevel()));
|
||||
}
|
||||
|
||||
@ -632,6 +632,16 @@ Specify the timestamp of the message to which to react.
|
||||
*--type* TYPE::
|
||||
Specify the receipt type, either `read` (the default) or `viewed`.
|
||||
|
||||
=== sendStory
|
||||
|
||||
Post a file attachment story to your Story, visible to all contacts.
|
||||
|
||||
*-a* ATTACHMENT, *--attachment* ATTACHMENT::
|
||||
Specify the file path to the image or video to post as a story.
|
||||
|
||||
*--no-replies*::
|
||||
Disable replies on this story. By default, replies are allowed.
|
||||
|
||||
=== sendTyping
|
||||
|
||||
Send typing message to trigger a typing indicator for the recipient.
|
||||
|
||||
@ -18,12 +18,13 @@ fi
|
||||
VERSION=$(sed -n 's/\s*version\s*=\s*"\(.*\)".*/\1/p' build.gradle.kts | tail -n1)
|
||||
echo "$VERSION" >dist/VERSION
|
||||
|
||||
# Build jar
|
||||
# Build jar and schemas
|
||||
$ENGINE build -t signal-cli:build ${OVERRIDE_JAVA_VERSION:+--build-arg ZULU_TAG=$OVERRIDE_JAVA_VERSION} -f reproducible-builds/build.Containerfile .
|
||||
git clean -Xfd -e '!/dist/' -e '!/dist/**' -e '!/github/' -e '!/github/**'
|
||||
# shellcheck disable=SC2086
|
||||
$ENGINE run --pull=never --rm -v "$(pwd)":/signal-cli:Z -e VERSION="$VERSION" $USER signal-cli:build
|
||||
mv build/distributions/signal-cli-*.tar.gz dist/
|
||||
mv build/signal-cli-*-json-schemas.tar.gz dist/
|
||||
|
||||
if [ -n "${OVERRIDE_JAVA_VERSION:-}" ]; then
|
||||
echo -e "\e[33mBuild was performed with overridden Java version $OVERRIDE_JAVA_VERSION, native-image and client will not be built.\e[0m"
|
||||
|
||||
@ -12,7 +12,7 @@ reset_file_dates
|
||||
|
||||
if [ "$1" == "build" ]; then
|
||||
|
||||
./gradlew build \
|
||||
./gradlew build jsonSchemas \
|
||||
--no-daemon \
|
||||
--max-workers=1 \
|
||||
-Dkotlin.compiler.execution.strategy=in-process \
|
||||
@ -20,6 +20,12 @@ if [ "$1" == "build" ]; then
|
||||
-Dorg.gradle.caching=false \
|
||||
-Porg.gradle.java.installations.auto-download=false \
|
||||
-Porg.gradle.java.installations.auto-detect=false
|
||||
|
||||
schemas_tar="build/signal-cli-${VERSION}-json-schemas.tar"
|
||||
reset_file_dates
|
||||
tar --sort=name --mtime="@$SOURCE_DATE_EPOCH" --owner=0 --group=0 --numeric-owner -cf "$schemas_tar" -C build/generated/META-INF/schemas .
|
||||
gzip -n -9 "$schemas_tar"
|
||||
|
||||
cd man
|
||||
make install
|
||||
cd ..
|
||||
|
||||
@ -22,6 +22,8 @@ public interface Signal extends DBusInterface {
|
||||
|
||||
String getSelfNumber();
|
||||
|
||||
String getSelfACI();
|
||||
|
||||
void subscribeReceive();
|
||||
|
||||
void unsubscribeReceive();
|
||||
|
||||
@ -184,16 +184,20 @@ public class App {
|
||||
}
|
||||
|
||||
account = getAccountIfOnlyOne(signalAccountFiles);
|
||||
} else if (!Manager.isValidNumber(account, null)) {
|
||||
throw new UserErrorException("Invalid account (phone number), make sure you include the country code.");
|
||||
}
|
||||
|
||||
if (command instanceof RegistrationCommand registrationCommand) {
|
||||
if (!Manager.isValidNumber(account, null)) {
|
||||
throw new UserErrorException("Invalid account (phone number), make sure you include the country code.");
|
||||
}
|
||||
handleRegistrationCommand(registrationCommand, account, signalAccountFiles, commandHandler);
|
||||
return;
|
||||
}
|
||||
|
||||
if (command instanceof LocalCommand localCommand) {
|
||||
if (!Manager.isValidNumber(account, null) && !Manager.isValidAci(account)) {
|
||||
throw new UserErrorException("Invalid account (phone number), make sure you include the country code.");
|
||||
}
|
||||
handleLocalCommand(localCommand, account, signalAccountFiles, commandHandler);
|
||||
return;
|
||||
}
|
||||
@ -330,7 +334,11 @@ public class App {
|
||||
) throws CommandException {
|
||||
logger.trace("Loading account file for {}", account);
|
||||
try {
|
||||
return signalAccountFiles.initManager(account);
|
||||
if (Manager.isValidAci(account)) {
|
||||
return signalAccountFiles.initManagerByAci(account);
|
||||
} else {
|
||||
return signalAccountFiles.initManagerByNumber(account);
|
||||
}
|
||||
} catch (NotRegisteredException e) {
|
||||
throw new UserErrorException("User " + account + " is not registered.");
|
||||
} catch (AccountCheckException ace) {
|
||||
|
||||
@ -52,6 +52,7 @@ public class Commands {
|
||||
addCommand(new SendPollTerminateCommand());
|
||||
addCommand(new SendReactionCommand());
|
||||
addCommand(new SendReceiptCommand());
|
||||
addCommand(new SendStoryCommand());
|
||||
addCommand(new SendSyncRequestCommand());
|
||||
addCommand(new SendTypingCommand());
|
||||
addCommand(new SendUnpinMessageCommand());
|
||||
|
||||
@ -0,0 +1,57 @@
|
||||
package org.asamk.signal.commands;
|
||||
|
||||
import net.sourceforge.argparse4j.impl.Arguments;
|
||||
import net.sourceforge.argparse4j.inf.Namespace;
|
||||
import net.sourceforge.argparse4j.inf.Subparser;
|
||||
|
||||
import org.asamk.signal.commands.exceptions.CommandException;
|
||||
import org.asamk.signal.commands.exceptions.UnexpectedErrorException;
|
||||
import org.asamk.signal.commands.exceptions.UserErrorException;
|
||||
import org.asamk.signal.manager.Manager;
|
||||
import org.asamk.signal.manager.api.AttachmentInvalidException;
|
||||
import org.asamk.signal.output.OutputWriter;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
import static org.asamk.signal.util.SendMessageResultUtils.outputResult;
|
||||
|
||||
public class SendStoryCommand implements JsonRpcLocalCommand {
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return "sendStory";
|
||||
}
|
||||
|
||||
@Override
|
||||
public void attachToSubparser(final Subparser subparser) {
|
||||
subparser.help("Post a story to your Story.");
|
||||
subparser.addArgument("-a", "--attachment")
|
||||
.required(true)
|
||||
.help("Specify the file path to the image or video to post as a story.");
|
||||
subparser.addArgument("--no-replies")
|
||||
.action(Arguments.storeTrue())
|
||||
.help("Disable replies on this story.");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handleCommand(
|
||||
final Namespace ns,
|
||||
final Manager m,
|
||||
final OutputWriter outputWriter
|
||||
) throws CommandException {
|
||||
final var attachment = ns.getString("attachment");
|
||||
if (attachment == null || attachment.isEmpty()) {
|
||||
throw new UserErrorException("An attachment is required for sending a story.");
|
||||
}
|
||||
|
||||
final var noReplies = Boolean.TRUE.equals(ns.getBoolean("no-replies"));
|
||||
|
||||
try {
|
||||
final var results = m.sendStory(attachment, !noReplies);
|
||||
outputResult(outputWriter, results);
|
||||
} catch (AttachmentInvalidException | IOException e) {
|
||||
throw new UnexpectedErrorException("Failed to send story: " + e.getMessage() + " (" + e.getClass()
|
||||
.getSimpleName() + ")", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -113,6 +113,11 @@ public class DbusManagerImpl implements Manager {
|
||||
return signal.getSelfNumber();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getSelfACI() {
|
||||
return signal.getSelfACI();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, UserStatus> getUserStatus(final Set<String> numbers) throws IOException {
|
||||
final var numbersList = new ArrayList<>(numbers);
|
||||
@ -542,6 +547,11 @@ public class DbusManagerImpl implements Manager {
|
||||
return new SendMessageResults(timestamp, Map.of());
|
||||
}
|
||||
|
||||
@Override
|
||||
public SendMessageResults sendStory(String attachment, boolean allowsReplies) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void sendEndSessionMessage(final Set<RecipientIdentifier.Single> recipients) throws IOException {
|
||||
signal.sendEndSessionMessage(recipients.stream().map(RecipientIdentifier.Single::getIdentifier).toList());
|
||||
@ -915,6 +925,14 @@ public class DbusManagerImpl implements Manager {
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void addUnidentifiedKeepAlive(final String token) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void removeUnidentifiedKeepAlive(final String token) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void addCallEventListener(final CallEventListener listener) {
|
||||
// Not supported over DBus
|
||||
|
||||
@ -100,6 +100,7 @@ public class DbusSignalImpl implements Signal, AutoCloseable {
|
||||
|
||||
public void initObjects() {
|
||||
exportObjects();
|
||||
m.addUnidentifiedKeepAlive("dbus");
|
||||
if (!noReceiveOnStart) {
|
||||
subscribeReceive();
|
||||
}
|
||||
@ -116,6 +117,7 @@ public class DbusSignalImpl implements Signal, AutoCloseable {
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
m.removeUnidentifiedKeepAlive("dbus");
|
||||
if (dbusMessageHandler != null) {
|
||||
m.removeReceiveHandler(dbusMessageHandler);
|
||||
dbusMessageHandler = null;
|
||||
@ -141,6 +143,11 @@ public class DbusSignalImpl implements Signal, AutoCloseable {
|
||||
return m.getSelfNumber();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getSelfACI() {
|
||||
return m.getSelfACI();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void subscribeReceive() {
|
||||
if (dbusMessageHandler == null) {
|
||||
|
||||
@ -262,6 +262,9 @@ public class HttpServerHandler implements AutoCloseable {
|
||||
} else {
|
||||
final var manager = c.getManager(account);
|
||||
if (manager == null) {
|
||||
// Account not found by the given identifier (number or ACI/UUID)
|
||||
// Log the available accounts to help debug
|
||||
logger.warn("Account not found for identifier: {}", account);
|
||||
return null;
|
||||
}
|
||||
return List.of(manager);
|
||||
|
||||
@ -30,6 +30,7 @@ import java.util.Collection;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.function.Supplier;
|
||||
import java.util.stream.Collectors;
|
||||
@ -43,8 +44,11 @@ public class SignalJsonRpcDispatcherHandler {
|
||||
private final JsonRpcReader jsonRpcReader;
|
||||
private final boolean noReceiveOnStart;
|
||||
|
||||
private final Map<Integer, ArrayList<Pair<Manager, Manager.ReceiveMessageHandler>>> receiveHandlers = new HashMap<>();
|
||||
private final Map<Integer, ArrayList<Pair<Manager, Manager.CallEventListener>>> callEventHandlers = new HashMap<>();
|
||||
private final Map<Integer, List<Pair<Manager, Manager.ReceiveMessageHandler>>> receiveHandlers = new HashMap<>();
|
||||
private final Map<Integer, List<Pair<Manager, Manager.CallEventListener>>> callEventHandlers = new HashMap<>();
|
||||
private final String connectionKeepAliveToken = "jsonrpc-" + UUID.randomUUID();
|
||||
private final List<Manager> keepAliveManagers = new ArrayList<>();
|
||||
private boolean connectionActive = true;
|
||||
private SignalJsonRpcCommandHandler commandHandler;
|
||||
|
||||
public SignalJsonRpcDispatcherHandler(
|
||||
@ -71,6 +75,10 @@ public class SignalJsonRpcDispatcherHandler {
|
||||
c.addOnManagerAddedHandler(m -> callEventHandlers.forEach((subscriptionId, handlers) -> handlers.add(
|
||||
createCallEventHandler(m, subscriptionId))));
|
||||
|
||||
c.getManagers().forEach(this::registerKeepAlive);
|
||||
c.addOnManagerAddedHandler(this::registerKeepAlive);
|
||||
c.addOnManagerRemovedHandler(this::unregisterKeepAlive);
|
||||
|
||||
handleConnection();
|
||||
}
|
||||
|
||||
@ -84,6 +92,8 @@ public class SignalJsonRpcDispatcherHandler {
|
||||
final var currentThread = Thread.currentThread();
|
||||
m.addClosedListener(currentThread::interrupt);
|
||||
|
||||
registerKeepAlive(m);
|
||||
|
||||
handleConnection();
|
||||
}
|
||||
|
||||
@ -204,14 +214,29 @@ public class SignalJsonRpcDispatcherHandler {
|
||||
subscriptionId.ifPresent(this::unsubscribeReceive);
|
||||
}
|
||||
|
||||
private void registerKeepAlive(final Manager m) {
|
||||
if (!connectionActive) return;
|
||||
m.addUnidentifiedKeepAlive(connectionKeepAliveToken);
|
||||
keepAliveManagers.add(m);
|
||||
}
|
||||
|
||||
private void unregisterKeepAlive(final Manager m) {
|
||||
if (!connectionActive) return;
|
||||
m.removeUnidentifiedKeepAlive(connectionKeepAliveToken);
|
||||
keepAliveManagers.remove(m);
|
||||
}
|
||||
|
||||
private void handleConnection() {
|
||||
try {
|
||||
jsonRpcReader.readMessages((method, params) -> commandHandler.handleRequest(objectMapper, method, params),
|
||||
response -> logger.debug("Received unexpected response for id {}", response.getId()));
|
||||
} finally {
|
||||
connectionActive = false;
|
||||
receiveHandlers.forEach((_subscriptionId, handlers) -> handlers.forEach(this::unsubscribeReceiveHandler));
|
||||
receiveHandlers.clear();
|
||||
unsubscribeAllCallEvents();
|
||||
keepAliveManagers.forEach(m -> m.removeUnidentifiedKeepAlive(connectionKeepAliveToken));
|
||||
keepAliveManagers.clear();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -79,7 +79,7 @@ public class Util {
|
||||
for (var param : params) {
|
||||
final var paramParts = param.split("=", 2);
|
||||
var name = URLDecoder.decode(paramParts[0], StandardCharsets.UTF_8);
|
||||
var value = paramParts.length == 1 ? null : URLDecoder.decode(paramParts[1], StandardCharsets.UTF_8);
|
||||
var value = paramParts.length == 1 ? null : URLDecoder.decode(paramParts[1].replace("+", "%2B"), StandardCharsets.UTF_8);
|
||||
map.put(name, value);
|
||||
}
|
||||
return map;
|
||||
|
||||
@ -9835,6 +9835,15 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "sun.net.www.protocol.http.Handler",
|
||||
"methods": [
|
||||
{
|
||||
"name": "<init>",
|
||||
"parameterTypes": []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "sun.security.provider.DSA$SHA224withDSA",
|
||||
"methods": [
|
||||
|
||||
@ -123,6 +123,11 @@ class SseInitialFlushTest {
|
||||
return "+10000000000";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getSelfACI() {
|
||||
return "00000000-0000-0000-0000-000000000000";
|
||||
}
|
||||
|
||||
@Override
|
||||
public void addReceiveHandler(ReceiveMessageHandler handler, boolean isWeakListener) {
|
||||
// no-op
|
||||
@ -349,6 +354,11 @@ class SseInitialFlushTest {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public SendMessageResults sendStory(String attachment, boolean allowsReplies) {
|
||||
return new SendMessageResults(0, Map.of());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void hideRecipient(RecipientIdentifier.Single recipient) {
|
||||
}
|
||||
@ -447,6 +457,14 @@ class SseInitialFlushTest {
|
||||
public void addClosedListener(Runnable listener) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void addUnidentifiedKeepAlive(String token) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void removeUnidentifiedKeepAlive(String token) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public InputStream retrieveAttachment(String id) {
|
||||
return null;
|
||||
|
||||
@ -120,6 +120,11 @@ class SubscribeCallEventsTest {
|
||||
return selfNumber;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getSelfACI() {
|
||||
return "00000000-0000-0000-0000-000000000000";
|
||||
}
|
||||
|
||||
// --- Stubs for remaining Manager interface methods ---
|
||||
@Override
|
||||
public Map<String, UserStatus> getUserStatus(Set<String> n) {
|
||||
@ -367,6 +372,11 @@ class SubscribeCallEventsTest {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public SendMessageResults sendStory(String attachment, boolean allowsReplies) {
|
||||
return new SendMessageResults(0, Map.of());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void hideRecipient(RecipientIdentifier.Single r) {
|
||||
}
|
||||
@ -495,6 +505,14 @@ class SubscribeCallEventsTest {
|
||||
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;
|
||||
@ -740,8 +758,8 @@ class SubscribeCallEventsTest {
|
||||
|
||||
assertEquals(1, manager1.addCount.get(), "manager1 should have one listener");
|
||||
assertEquals(1, manager2.addCount.get(), "manager2 should have one listener");
|
||||
// Also registers an onManagerAdded handler for receive and one for call events
|
||||
assertEquals(2, multi.addedHandlers.size(), "should register onManagerAdded handlers");
|
||||
// Registers onManagerAdded handlers for receive, call events, and keep-alive
|
||||
assertEquals(3, multi.addedHandlers.size(), "should register onManagerAdded handlers");
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user