Compare commits

..

No commits in common. "master" and "v0.14.8" have entirely different histories.

103 changed files with 508 additions and 4176 deletions

View File

@ -1,21 +1,5 @@
# Changelog # Changelog
## [Unreleased]
## [0.14.9] - 2026-10-07
### Added
- `send --attachment-dimensions` and `--attachment-blurhash` to set the placeholder shown before an attachment is downloaded
- Support linking phone-numberless Signal accounts
- Support recovering accounts with a recovery key
- Support replaying missed SSE events and configuring keep-alive intervals
### Improved
- Show the actual error for failed requests
- Improve attachment upload and download failure handling
## [0.14.8] - 2026-09-10 ## [0.14.8] - 2026-09-10
### Added ### Added

View File

@ -5,7 +5,6 @@ It supports registering, verifying, sending and receiving messages.
signal-cli uses a [patched libsignal-service-java](https://github.com/Turasa/libsignal-service-java), signal-cli uses a [patched libsignal-service-java](https://github.com/Turasa/libsignal-service-java),
extracted from the [Signal-Android source code](https://github.com/signalapp/Signal-Android/tree/main/lib/libsignal-service). extracted from the [Signal-Android source code](https://github.com/signalapp/Signal-Android/tree/main/lib/libsignal-service).
For registering you need a phone number where you can receive SMS or incoming calls. For registering you need a phone number where you can receive SMS or incoming calls.
Existing accounts without a phone number can be linked from a compatible Signal mobile app.
signal-cli is primarily intended to be used on servers to notify admins of important events. signal-cli is primarily intended to be used on servers to notify admins of important events.
For this use-case, it has a daemon mode with JSON-RPC interface ([man page](https://github.com/AsamK/signal-cli/blob/master/man/signal-cli-jsonrpc.5.adoc)) For this use-case, it has a daemon mode with JSON-RPC interface ([man page](https://github.com/AsamK/signal-cli/blob/master/man/signal-cli-jsonrpc.5.adoc))
@ -61,16 +60,14 @@ For a complete usage overview please read
the [man page](https://github.com/AsamK/signal-cli/blob/master/man/signal-cli.1.adoc) and the [man page](https://github.com/AsamK/signal-cli/blob/master/man/signal-cli.1.adoc) and
the [wiki](https://github.com/AsamK/signal-cli/wiki). the [wiki](https://github.com/AsamK/signal-cli/wiki).
For a numbered account, ACCOUNT is your phone number in international format and must include the country calling code. Hence it Important: The ACCOUNT is your phone number in international format and must include the country calling code. Hence it
should start with a "+" sign. (See [Wikipedia](https://en.wikipedia.org/wiki/List_of_country_calling_codes) for a list should start with a "+" sign. (See [Wikipedia](https://en.wikipedia.org/wiki/List_of_country_calling_codes) for a list
of all country codes.) of all country codes.)
For a linked account without a phone number, use its ACI (Account ID) instead.
See the [wiki](https://github.com/AsamK/signal-cli/wiki) for further documentation.
* Link to an existing account * Link to an existing account
If you have an existing Signal account, with or without a phone number, If you have an existing Signal account associated
you can link signal-cli to it with: with a number, you can link signal-cli to it with:
signal-cli link signal-cli link

View File

@ -5,12 +5,12 @@ plugins {
application application
eclipse eclipse
`check-lib-versions` `check-lib-versions`
id("org.graalvm.buildtools.native") version "1.1.14" id("org.graalvm.buildtools.native") version "1.1.12"
} }
allprojects { allprojects {
group = "org.asamk" group = "org.asamk"
version = "0.14.10-SNAPSHOT" version = "0.14.8"
} }
java { java {

6
client/Cargo.lock generated
View File

@ -850,9 +850,9 @@ dependencies = [
[[package]] [[package]]
name = "rustls" name = "rustls"
version = "0.23.46" version = "0.23.44"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" checksum = "6725596c3f2c3a0aef021139e145d4eafe314a6623e4680ca83852b2c67ab2ba"
dependencies = [ dependencies = [
"log", "log",
"once_cell", "once_cell",
@ -1014,7 +1014,7 @@ checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]] [[package]]
name = "signal-cli-client" name = "signal-cli-client"
version = "0.14.9" version = "0.14.8"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bytes", "bytes",

View File

@ -1,6 +1,6 @@
[package] [package]
name = "signal-cli-client" name = "signal-cli-client"
version = "0.14.9" version = "0.14.8"
edition = "2024" edition = "2024"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

View File

@ -45,9 +45,6 @@
<content_attribute id="social-chat">intense</content_attribute> <content_attribute id="social-chat">intense</content_attribute>
</content_rating> </content_rating>
<releases> <releases>
<release version="0.14.9" date="2026-10-07">
<url type="details">https://github.com/AsamK/signal-cli/releases/tag/v0.14.9</url>
</release>
<release version="0.14.8" date="2026-09-10"> <release version="0.14.8" date="2026-09-10">
<url type="details">https://github.com/AsamK/signal-cli/releases/tag/v0.14.8</url> <url type="details">https://github.com/AsamK/signal-cli/releases/tag/v0.14.8</url>
</release> </release>

View File

@ -1,14 +1,14 @@
[versions] [versions]
slf4j = "2.0.20" slf4j = "2.0.19"
coroutines = "1.11.0" coroutines = "1.11.0"
junit = "6.1.3" junit = "6.1.3"
micronaut-json-schema = "2.3.1" micronaut-json-schema = "2.2.0"
micronaut-core = "5.2.14" micronaut-core = "5.2.0"
signal-service = "2.15.3_unofficial_156" signal-service = "2.15.3_unofficial_153"
[libraries] [libraries]
bouncycastle = "org.bouncycastle:bcprov-jdk18on:1.86" bouncycastle = "org.bouncycastle:bcprov-jdk18on:1.85.2"
jackson-databind = "com.fasterxml.jackson.core:jackson-databind:2.22.3" jackson-databind = "com.fasterxml.jackson.core:jackson-databind:2.22.2"
argparse4j = "net.sourceforge.argparse4j:argparse4j:0.9.0" argparse4j = "net.sourceforge.argparse4j:argparse4j:0.9.0"
dbusjava = "com.github.hypfvieh:dbus-java-transport-native-unixsocket:5.0.0" dbusjava = "com.github.hypfvieh:dbus-java-transport-native-unixsocket:5.0.0"
zxing = "com.google.zxing:core:3.5.4" zxing = "com.google.zxing:core:3.5.4"
@ -18,7 +18,7 @@ micronaut-json-schema-generator = { module = "io.micronaut.jsonschema:micronaut-
micronaut-inject-java = { module = "io.micronaut:micronaut-inject-java", version.ref = "micronaut-core" } micronaut-inject-java = { module = "io.micronaut:micronaut-inject-java", version.ref = "micronaut-core" }
slf4j-api = { module = "org.slf4j:slf4j-api", version.ref = "slf4j" } slf4j-api = { module = "org.slf4j:slf4j-api", version.ref = "slf4j" }
slf4j-jul = { module = "org.slf4j:jul-to-slf4j", version.ref = "slf4j" } slf4j-jul = { module = "org.slf4j:jul-to-slf4j", version.ref = "slf4j" }
logback = "ch.qos.logback:logback-classic:1.6.5" logback = "ch.qos.logback:logback-classic:1.6.3"
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" }
signalnetwork = { module = "com.github.turasa:signal-network", version.ref = "signal-service" } signalnetwork = { module = "com.github.turasa:signal-network", version.ref = "signal-service" }

Binary file not shown.

View File

@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.2-bin.zip distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-bin.zip
networkTimeout=10000 networkTimeout=10000
retries=0 retries=0
retryBackOffMs=500 retryBackOffMs=500

52
gradlew.bat vendored
View File

@ -26,33 +26,6 @@
@rem Set local scope for the variables, and ensure extensions are enabled @rem Set local scope for the variables, and ensure extensions are enabled
setlocal EnableExtensions setlocal EnableExtensions
@rem Catch executions from older scripts and ensure they exit cleanly.
@rem This can be removed once we can be reasonably confident that few people
@rem will be migrating directly to this new wrapper.
goto afterSafetyNet
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
goto exitWithErrorLevel
:afterSafetyNet
set DIRNAME=%~dp0 set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=. if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused @rem This is normally unused
@ -72,14 +45,13 @@ set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1 %JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute if %ERRORLEVEL% equ 0 goto execute
1>&2 echo. echo. 1>&2
1>&2 echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
1>&2 echo. echo. 1>&2
1>&2 echo Please set the JAVA_HOME variable in your environment to match the echo Please set the JAVA_HOME variable in your environment to match the 1>&2
1>&2 echo location of your Java installation. echo location of your Java installation. 1>&2
"%COMSPEC%" /c exit 1 "%COMSPEC%" /c exit 1
goto exitWithErrorLevel
:findJavaFromJavaHome :findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=% set JAVA_HOME=%JAVA_HOME:"=%
@ -87,14 +59,13 @@ set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute if exist "%JAVA_EXE%" goto execute
1>&2 echo. echo. 1>&2
1>&2 echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
1>&2 echo. echo. 1>&2
1>&2 echo Please set the JAVA_HOME variable in your environment to match the echo Please set the JAVA_HOME variable in your environment to match the 1>&2
1>&2 echo location of your Java installation. echo location of your Java installation. 1>&2
"%COMSPEC%" /c exit 1 "%COMSPEC%" /c exit 1
goto exitWithErrorLevel
:execute :execute
@rem Setup the command line @rem Setup the command line
@ -104,9 +75,8 @@ goto exitWithErrorLevel
@rem Execute gradlew @rem Execute gradlew
@rem endlocal doesn't take effect until after the line is parsed and variables are expanded @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 @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 & goto exitWithErrorLevel 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
@rem This label must not be changed. We rely on old scripts being able to jump to this point.
:exitWithErrorLevel :exitWithErrorLevel
@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts @rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
"%COMSPEC%" /c exit %ERRORLEVEL% "%COMSPEC%" /c exit %ERRORLEVEL%

View File

@ -98,14 +98,6 @@ public interface Manager extends Closeable {
String getSelfACI(); String getSelfACI();
/**
* Returns the phone number for numbered accounts, or the ACI for numberless accounts.
*/
default String getSelfIdentifier() {
final var number = getSelfNumber();
return number != null ? number : getSelfACI();
}
/** /**
* This is used for checking a set of phone numbers for registration on Signal * This is used for checking a set of phone numbers for registration on Signal
* *

View File

@ -8,16 +8,14 @@ import java.util.function.Consumer;
public interface MultiAccountManager extends AutoCloseable { public interface MultiAccountManager extends AutoCloseable {
List<String> getAccountNumbers();
List<Manager> getManagers(); List<Manager> getManagers();
void addOnManagerAddedHandler(Consumer<Manager> handler); void addOnManagerAddedHandler(Consumer<Manager> handler);
void removeOnManagerAddedHandler(Consumer<Manager> handler);
void addOnManagerRemovedHandler(Consumer<Manager> handler); void addOnManagerRemovedHandler(Consumer<Manager> handler);
void removeOnManagerRemovedHandler(Consumer<Manager> handler);
Manager getManager(String phoneNumber); Manager getManager(String phoneNumber);
URI getNewProvisioningDeviceLinkUri() throws TimeoutException, IOException; URI getNewProvisioningDeviceLinkUri() throws TimeoutException, IOException;

View File

@ -10,8 +10,5 @@ public interface ProvisioningManager {
URI getDeviceLinkUri() throws TimeoutException, IOException; URI getDeviceLinkUri() throws TimeoutException, IOException;
/**
* Completes linking and returns the account's phone number, or ACI if it has no number.
*/
String finishDeviceLink(String deviceName) throws IOException, TimeoutException, UserAlreadyExistsException; String finishDeviceLink(String deviceName) throws IOException, TimeoutException, UserAlreadyExistsException;
} }

View File

@ -6,7 +6,6 @@ import org.asamk.signal.manager.api.NonNormalizedPhoneNumberException;
import org.asamk.signal.manager.api.PinLockMissingException; import org.asamk.signal.manager.api.PinLockMissingException;
import org.asamk.signal.manager.api.PinLockedException; import org.asamk.signal.manager.api.PinLockedException;
import org.asamk.signal.manager.api.RateLimitException; import org.asamk.signal.manager.api.RateLimitException;
import org.asamk.signal.manager.api.TwoFactorRequired;
import org.asamk.signal.manager.api.VerificationMethodNotAvailableException; import org.asamk.signal.manager.api.VerificationMethodNotAvailableException;
import java.io.Closeable; import java.io.Closeable;
@ -18,19 +17,13 @@ public interface RegistrationManager extends Closeable {
boolean voiceVerification, boolean voiceVerification,
String captcha, String captcha,
final boolean forceRegister final boolean forceRegister
) throws IOException, CaptchaRequiredException, NonNormalizedPhoneNumberException, RateLimitException, TwoFactorRequired, VerificationMethodNotAvailableException; ) throws IOException, CaptchaRequiredException, NonNormalizedPhoneNumberException, RateLimitException, VerificationMethodNotAvailableException;
void verifyAccount( void verifyAccount(
String verificationCode, String verificationCode,
String pin String pin
) throws IOException, PinLockedException, IncorrectPinException, PinLockMissingException; ) throws IOException, PinLockedException, IncorrectPinException, PinLockMissingException;
void registerWithRecoveryKey(
String recoveryKey,
boolean forceRegister,
Integer totp
) throws IOException, RateLimitException, TwoFactorRequired;
void deleteLocalAccountData() throws IOException; void deleteLocalAccountData() throws IOException;
boolean isRegistered(); boolean isRegistered();

View File

@ -25,7 +25,6 @@ import java.io.IOException;
import java.util.Objects; import java.util.Objects;
import java.util.Set; import java.util.Set;
import java.util.function.Consumer; import java.util.function.Consumer;
import java.util.stream.Collectors;
public class SignalAccountFiles { public class SignalAccountFiles {
@ -66,26 +65,15 @@ public class SignalAccountFiles {
return accountsStore.getAllNumbers(); return accountsStore.getAllNumbers();
} }
public Set<String> getAllLocalAccountIdentifiers() throws IOException {
return accountsStore.getAllAccounts()
.stream()
.map(a -> a.number() != null ? a.number() : a.uuid())
.collect(Collectors.toSet());
}
public MultiAccountManager initMultiAccountManager() throws IOException { public MultiAccountManager initMultiAccountManager() throws IOException {
final var managerPairs = accountsStore.getAllAccounts().parallelStream().map(a -> { final var managerPairs = accountsStore.getAllAccounts().parallelStream().map(a -> {
final var identifier = a.number() != null ? a.number() : a.uuid();
try { try {
final var manager = a.number() != null return new Pair<Manager, Throwable>(initManagerByNumber(a.number(), a.path()), null);
? initManagerByNumber(a.number(), a.path())
: initManagerByAci(ACI.parseOrThrow(a.uuid()), a.path());
return new Pair<Manager, Throwable>(manager, null);
} catch (NotRegisteredException e) { } catch (NotRegisteredException e) {
logger.warn("Ignoring {}: {} ({})", identifier, e.getMessage(), e.getClass().getSimpleName()); logger.warn("Ignoring {}: {} ({})", a.number(), e.getMessage(), e.getClass().getSimpleName());
return null; return null;
} catch (AccountCheckException | IOException e) { } catch (AccountCheckException | IOException e) {
logger.error("Failed to load {}: {} ({})", identifier, e.getMessage(), e.getClass().getSimpleName()); logger.error("Failed to load {}: {} ({})", a.number(), e.getMessage(), e.getClass().getSimpleName());
return new Pair<Manager, Throwable>(null, e); return new Pair<Manager, Throwable>(null, e);
} }
}).filter(Objects::nonNull).toList(); }).filter(Objects::nonNull).toList();
@ -211,11 +199,6 @@ public class SignalAccountFiles {
String number, String number,
Consumer<Manager> newManagerListener Consumer<Manager> newManagerListener
) throws IOException { ) throws IOException {
final var aci = ACI.parseOrNull(number);
if (aci != null) {
return initRegistrationManager(aci, newManagerListener);
}
final var accountPath = accountsStore.getPathByNumber(number); final var accountPath = accountsStore.getPathByNumber(number);
if (accountPath == null || !SignalAccount.accountFileExists(pathConfig.dataPath(), accountPath)) { if (accountPath == null || !SignalAccount.accountFileExists(pathConfig.dataPath(), accountPath)) {
final var newAccountPath = accountPath == null ? accountsStore.addAccount(number, null) : accountPath; final var newAccountPath = accountPath == null ? accountsStore.addAccount(number, null) : accountPath;
@ -255,43 +238,4 @@ public class SignalAccountFiles {
newManagerListener, newManagerListener,
new AccountFileUpdaterImpl(accountsStore, accountPath)); new AccountFileUpdaterImpl(accountsStore, accountPath));
} }
private RegistrationManager initRegistrationManager(
final ACI aci,
final Consumer<Manager> newManagerListener
) throws IOException {
final var accountPath = accountsStore.getPathByAci(aci);
if (accountPath == null || !SignalAccount.accountFileExists(pathConfig.dataPath(), accountPath)) {
final var newAccountPath = accountPath == null ? accountsStore.addAccount(null, aci) : accountPath;
final var account = SignalAccount.create(pathConfig.dataPath(),
newAccountPath,
null,
aci,
serviceEnvironment,
KeyUtils.generateIdentityKeyPair(),
KeyUtils.generateIdentityKeyPair(),
KeyUtils.createProfileKey(),
settings);
account.initDatabase();
return new RegistrationManagerImpl(account,
pathConfig,
serviceEnvironmentConfig,
userAgent,
newManagerListener,
new AccountFileUpdaterImpl(accountsStore, newAccountPath));
}
final var account = SignalAccount.load(pathConfig.dataPath(), accountPath, true, settings);
if (!aci.equals(account.getAci())) {
account.close();
throw new IOException("ACI in account file doesn't match expected ACI: " + account.getAci());
}
account.initDatabase();
return new RegistrationManagerImpl(account,
pathConfig,
serviceEnvironmentConfig,
userAgent,
newManagerListener,
new AccountFileUpdaterImpl(accountsStore, accountPath));
}
} }

View File

@ -1,19 +0,0 @@
package org.asamk.signal.manager.api;
import org.signal.libsignal.net.BadRequestError;
import java.io.IOException;
public class BadRequestException extends IOException {
private final BadRequestError error;
public BadRequestException(final BadRequestError error) {
super(error.toString());
this.error = error;
}
public BadRequestError getError() {
return error;
}
}

View File

@ -6,8 +6,6 @@ import java.util.Optional;
public record Message( public record Message(
String messageText, String messageText,
List<String> attachments, List<String> attachments,
List<AttachmentDimensions> attachmentDimensions,
List<String> attachmentBlurHashes,
boolean viewOnce, boolean viewOnce,
boolean voiceNote, boolean voiceNote,
List<Mention> mentions, List<Mention> mentions,
@ -19,8 +17,6 @@ public record Message(
boolean urgent boolean urgent
) { ) {
public record AttachmentDimensions(int width, int height) {}
public record Mention(RecipientIdentifier.Single recipient, int start, int length) {} public record Mention(RecipientIdentifier.Single recipient, int start, int length) {}
public record Quote( public record Quote(

View File

@ -1,10 +0,0 @@
package org.asamk.signal.manager.api;
import java.io.IOException;
public class TwoFactorRequired extends IOException {
public TwoFactorRequired() {
super("A TOTP token is required or the supplied token is incorrect");
}
}

View File

@ -3,6 +3,7 @@ package org.asamk.signal.manager.config;
import org.signal.libsignal.net.Network.Environment; import org.signal.libsignal.net.Network.Environment;
import org.signal.libsignal.protocol.InvalidKeyException; import org.signal.libsignal.protocol.InvalidKeyException;
import org.signal.libsignal.protocol.ecc.ECPublicKey; import org.signal.libsignal.protocol.ecc.ECPublicKey;
import org.signal.network.config.HttpProxy;
import org.signal.network.config.SignalCdnUrl; import org.signal.network.config.SignalCdnUrl;
import org.signal.network.config.SignalCdsiUrl; import org.signal.network.config.SignalCdsiUrl;
import org.signal.network.config.SignalProxy; import org.signal.network.config.SignalProxy;
@ -43,6 +44,7 @@ class LiveConfig {
private static final Optional<Dns> dns = Optional.empty(); private static final Optional<Dns> dns = Optional.empty();
private static final Optional<SignalProxy> proxy = Optional.empty(); private static final Optional<SignalProxy> proxy = Optional.empty();
private static final Optional<HttpProxy> systemProxy = Optional.empty();
private static final byte[] zkGroupServerPublicParams = Base64.getDecoder() 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=="); .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==");
@ -70,6 +72,7 @@ class LiveConfig {
interceptors, interceptors,
dns, dns,
proxy, proxy,
systemProxy,
zkGroupServerPublicParams, zkGroupServerPublicParams,
genericServerPublicParams, genericServerPublicParams,
backupServerPublicParams, backupServerPublicParams,

View File

@ -28,14 +28,11 @@ public class ServiceConfig {
public static final int MAXIMUM_ONE_OFF_REQUEST_SIZE = 3; public static final int MAXIMUM_ONE_OFF_REQUEST_SIZE = 3;
public static final long UNREGISTERED_LIFESPAN = TimeUnit.DAYS.toMillis(30); public static final long UNREGISTERED_LIFESPAN = TimeUnit.DAYS.toMillis(30);
public static AccountAttributes.Capabilities getCapabilities( public static AccountAttributes.Capabilities getCapabilities(boolean isPrimaryDevice) {
final boolean isPrimaryDevice,
final boolean hasPhoneNumber
) {
final var attachmentBackfill = !isPrimaryDevice; final var attachmentBackfill = !isPrimaryDevice;
final var spqr = true; final var spqr = true;
final var usernameSyncChangeMessage = !isPrimaryDevice; final var usernameSyncChangeMessage = !isPrimaryDevice;
final var optionalPhoneNumber = !isPrimaryDevice || !hasPhoneNumber; final var optionalPhoneNumber = !isPrimaryDevice;
return new AccountAttributes.Capabilities(true, return new AccountAttributes.Capabilities(true,
true, true,
attachmentBackfill, attachmentBackfill,

View File

@ -3,6 +3,7 @@ package org.asamk.signal.manager.config;
import org.signal.libsignal.net.Network; import org.signal.libsignal.net.Network;
import org.signal.libsignal.protocol.InvalidKeyException; import org.signal.libsignal.protocol.InvalidKeyException;
import org.signal.libsignal.protocol.ecc.ECPublicKey; import org.signal.libsignal.protocol.ecc.ECPublicKey;
import org.signal.network.config.HttpProxy;
import org.signal.network.config.SignalCdnUrl; import org.signal.network.config.SignalCdnUrl;
import org.signal.network.config.SignalCdsiUrl; import org.signal.network.config.SignalCdsiUrl;
import org.signal.network.config.SignalProxy; import org.signal.network.config.SignalProxy;
@ -43,6 +44,7 @@ class StagingConfig {
private static final Optional<Dns> dns = Optional.empty(); private static final Optional<Dns> dns = Optional.empty();
private static final Optional<SignalProxy> proxy = Optional.empty(); private static final Optional<SignalProxy> proxy = Optional.empty();
private static final Optional<HttpProxy> systemProxy = Optional.empty();
private static final byte[] zkGroupServerPublicParams = Base64.getDecoder() 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=="); .decode("ABSY21VckQcbSXVNCGRYJcfWHiAMZmpTtTELcDmxgdFbtp/bWsSxZdMKzfCp8rvIs8ocCU3B37fT3r4Mi5qAemeGeR2X+/YmOGR5ofui7tD5mDQfstAI9i+4WpMtIe8KC3wU5w3Inq3uNWVmoGtpKndsNfwJrCg0Hd9zmObhypUnSkfYn2ooMOOnBpfdanRtrvetZUayDMSC5iSRcXKpdlukrpzzsCIvEwjwQlJYVPOQPj4V0F4UXXBdHSLK05uoPBCQG8G9rYIGedYsClJXnbrgGYG3eMTG5hnx4X4ntARBgELuMWWUEEfSK0mjXg+/2lPmWcTZWR9nkqgQQP0tbzuiPm74H2wMO4u1Wafe+UwyIlIT9L7KLS19Aw8r4sPrXZSSsOZ6s7M1+rTJN0bI5CKY2PX29y5Ok3jSWufIKcgKOnWoP67d5b2du2ZVJjpjfibNIHbT/cegy/sBLoFwtHogVYUewANUAXIaMPyCLRArsKhfJ5wBtTminG/PAvuBdJ70Z/bXVPf8TVsR292zQ65xwvWTejROW6AZX6aqucUjlENAErBme1YHmOSpU6tr6doJ66dPzVAWIanmO/5mgjNEDeK7DDqQdB1xd03HT2Qs2TxY3kCK8aAb/0iM0HQiXjxZ9HIgYhbtvGEnDKW5ILSUydqH/KBhW4Pb0jZWnqN/YgbWDKeJxnDbYcUob5ZY5Lt5ZCMKuaGUvCJRrCtuugSMaqjowCGRempsDdJEt+cMaalhZ6gczklJB/IbdwENW9KeVFPoFNFzhxWUIS5ML9riVYhAtE6JE5jX0xiHNVIIPthb458cfA8daR0nYfYAUKogQArm0iBezOO+mPk5vCNWI+wwkyFCqNDXz/qxl1gAntuCJtSfq9OC3NkdhQlgYQ==");
@ -70,6 +72,7 @@ class StagingConfig {
interceptors, interceptors,
dns, dns,
proxy, proxy,
systemProxy,
zkGroupServerPublicParams, zkGroupServerPublicParams,
genericServerPublicParams, genericServerPublicParams,
backupServerPublicParams, backupServerPublicParams,

View File

@ -1,6 +1,5 @@
package org.asamk.signal.manager.helper; package org.asamk.signal.manager.helper;
import org.asamk.signal.manager.api.BadRequestException;
import org.asamk.signal.manager.api.CaptchaRequiredException; import org.asamk.signal.manager.api.CaptchaRequiredException;
import org.asamk.signal.manager.api.DeviceLinkUrl; import org.asamk.signal.manager.api.DeviceLinkUrl;
import org.asamk.signal.manager.api.IncorrectPinException; import org.asamk.signal.manager.api.IncorrectPinException;
@ -28,7 +27,6 @@ import org.signal.libsignal.protocol.state.SignedPreKeyRecord;
import org.signal.libsignal.protocol.util.KeyHelper; import org.signal.libsignal.protocol.util.KeyHelper;
import org.signal.libsignal.usernames.BaseUsernameException; import org.signal.libsignal.usernames.BaseUsernameException;
import org.signal.libsignal.usernames.Username; import org.signal.libsignal.usernames.Username;
import org.signal.network.api.AccountApiV2.SetAccountAttributesError;
import org.signal.network.exceptions.NonSuccessfulResponseCodeException; import org.signal.network.exceptions.NonSuccessfulResponseCodeException;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
@ -98,10 +96,10 @@ public class AccountHelper {
} else { } else {
context.getPreKeyHelper().refreshPreKeysIfNecessary(); context.getPreKeyHelper().refreshPreKeysIfNecessary();
} }
if (account.getPni() == null && account.getNumber() != null) { if (account.getPni() == null) {
checkWhoAmiI(); checkWhoAmiI();
} }
if (!account.isPrimaryDevice() && account.getPni() != null && account.getPniIdentityKeyPair() == null) { if (!account.isPrimaryDevice() && account.getPniIdentityKeyPair() == null) {
throw new IOException("Missing PNI identity key, relinking required"); throw new IOException("Missing PNI identity key, relinking required");
} }
if (account.getPreviousStorageVersion() < 10 if (account.getPreviousStorageVersion() < 10
@ -140,9 +138,8 @@ public class AccountHelper {
final var whoAmI = dependencies.getAccountManager().getWhoAmI(); final var whoAmI = dependencies.getAccountManager().getWhoAmI();
final var number = whoAmI.getNumber(); final var number = whoAmI.getNumber();
final var aci = ACI.parseOrThrow(whoAmI.getAci()); final var aci = ACI.parseOrThrow(whoAmI.getAci());
final var pni = whoAmI.getPni() == null ? null : PNI.parseOrThrow(whoAmI.getPni()); final var pni = PNI.parseOrThrow(whoAmI.getPni());
if (Objects.equals(number, account.getNumber()) && aci.equals(account.getAci()) && Objects.equals(pni, if (number.equals(account.getNumber()) && aci.equals(account.getAci()) && pni.equals(account.getPni())) {
account.getPni())) {
return; return;
} }
@ -153,7 +150,7 @@ public class AccountHelper {
account.setNumber(number); account.setNumber(number);
account.setAci(aci); account.setAci(aci);
account.setPni(pni); account.setPni(pni);
if (pni != null && account.isPrimaryDevice() && account.getPniIdentityKeyPair() == null) { if (account.isPrimaryDevice() && account.getPniIdentityKeyPair() == null) {
account.setPniIdentityKeyPair(KeyUtils.generateIdentityKeyPair()); account.setPniIdentityKeyPair(KeyUtils.generateIdentityKeyPair());
} }
account.getRecipientTrustedResolver().resolveSelfRecipientTrusted(account.getSelfRecipientAddress()); account.getRecipientTrustedResolver().resolveSelfRecipientTrusted(account.getSelfRecipientAddress());
@ -560,27 +557,14 @@ public class AccountHelper {
final List<DeviceInfo> devices = handleResponseExceptionSuspend(cont -> dependencies.getLinkDeviceApi() final List<DeviceInfo> devices = handleResponseExceptionSuspend(cont -> dependencies.getLinkDeviceApi()
.getDevices(cont)); .getDevices(cont));
final var deviceId = account.getDeviceId(); final var deviceId = account.getDeviceId();
final var device = devices.stream().filter(d -> d.getId() == deviceId).findFirst(); final var device = devices.stream().filter(d -> d.id == deviceId).findFirst();
if (device.isPresent()) { if (device.isPresent()) {
account.setEncryptedDeviceName(device.get().getName()); account.setEncryptedDeviceName(device.get().name);
} }
} }
public void updateAccountAttributes() throws IOException { public void updateAccountAttributes() throws IOException {
if (account.getNumber() != null) { handleResponseException(dependencies.getAccountApi().setAccountAttributes(account.getAccountAttributes(null)));
handleResponseException(dependencies.getAccountApi()
.setAccountAttributes(account.getAccountAttributes(null)));
return;
}
try {
handleResponseExceptionSuspend(cont -> dependencies.getAccountApiV2()
.setAccountAttributes(account.getAccountAttributesV2(), cont));
} catch (BadRequestException e) {
if (e.getError() instanceof SetAccountAttributesError.Unauthorized) {
throw new AuthorizationFailedException(401, "Authorization failed!");
}
throw new IOException("Account attribute update rate limited; try again later");
}
} }
public void addDevice(DeviceLinkUrl deviceLinkInfo) throws IOException, org.asamk.signal.manager.api.DeviceLimitExceededException { public void addDevice(DeviceLinkUrl deviceLinkInfo) throws IOException, org.asamk.signal.manager.api.DeviceLimitExceededException {

View File

@ -1,14 +1,11 @@
package org.asamk.signal.manager.helper; package org.asamk.signal.manager.helper;
import org.asamk.signal.manager.api.AttachmentInvalidException; import org.asamk.signal.manager.api.AttachmentInvalidException;
import org.asamk.signal.manager.api.Message.AttachmentDimensions;
import org.asamk.signal.manager.api.Pair;
import org.asamk.signal.manager.config.ServiceConfig; import org.asamk.signal.manager.config.ServiceConfig;
import org.asamk.signal.manager.internal.SignalDependencies; import org.asamk.signal.manager.internal.SignalDependencies;
import org.asamk.signal.manager.storage.AttachmentStore; import org.asamk.signal.manager.storage.AttachmentStore;
import org.asamk.signal.manager.util.AttachmentUtils; import org.asamk.signal.manager.util.AttachmentUtils;
import org.asamk.signal.manager.util.IOUtils; import org.asamk.signal.manager.util.IOUtils;
import org.asamk.signal.manager.util.MimeUtils;
import org.asamk.signal.manager.util.Utils; import org.asamk.signal.manager.util.Utils;
import org.signal.libsignal.protocol.InvalidMessageException; import org.signal.libsignal.protocol.InvalidMessageException;
import org.slf4j.Logger; import org.slf4j.Logger;
@ -23,7 +20,6 @@ import org.whispersystems.signalservice.api.util.StreamDetails;
import org.whispersystems.signalservice.internal.crypto.PaddingInputStream; import org.whispersystems.signalservice.internal.crypto.PaddingInputStream;
import org.whispersystems.signalservice.internal.push.http.ResumableUploadSpec; import org.whispersystems.signalservice.internal.push.http.ResumableUploadSpec;
import java.io.ByteArrayInputStream;
import java.io.File; import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
@ -31,8 +27,6 @@ import java.io.OutputStream;
import java.nio.file.Files; import java.nio.file.Files;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Optional;
import java.util.function.Supplier;
public class AttachmentHelper { public class AttachmentHelper {
@ -40,21 +34,12 @@ public class AttachmentHelper {
private final SignalDependencies dependencies; private final SignalDependencies dependencies;
private final AttachmentStore attachmentStore; private final AttachmentStore attachmentStore;
private final Uploader uploader; private final Context context;
private final Supplier<File> dataPath;
public AttachmentHelper(final Context context) { public AttachmentHelper(final Context context) {
this.context = context;
this.dependencies = context.getDependencies(); this.dependencies = context.getDependencies();
this.attachmentStore = context.getAttachmentStore(); this.attachmentStore = context.getAttachmentStore();
this.uploader = new DependenciesUploader(dependencies);
this.dataPath = () -> context.getAccount().getDataPath();
}
AttachmentHelper(final Uploader uploader, final Supplier<File> dataPath) {
this.dependencies = null;
this.attachmentStore = null;
this.uploader = uploader;
this.dataPath = dataPath;
} }
public File getAttachmentFile(SignalServiceAttachmentPointer pointer) { public File getAttachmentFile(SignalServiceAttachmentPointer pointer) {
@ -65,101 +50,75 @@ public class AttachmentHelper {
return attachmentStore.retrieveAttachment(id); return attachmentStore.retrieveAttachment(id);
} }
/**
* Prepares and uploads all attachments. I/O failures are reported as {@link AttachmentInvalidException}, so
* callers know that nothing has been sent yet. Attachments after the failing one are not uploaded.
*/
public List<SignalServiceAttachment> uploadAttachments( public List<SignalServiceAttachment> uploadAttachments(
final List<String> attachments, final List<String> attachments,
final List<AttachmentDimensions> dimensions,
final List<String> blurHashes,
boolean voiceNote boolean voiceNote
) throws AttachmentInvalidException { ) throws AttachmentInvalidException, IOException {
final var attachmentStreams = createAttachmentStreams(attachments, dimensions, blurHashes, voiceNote); final var attachmentStreams = createAttachmentStreams(attachments, voiceNote);
try { try {
// Upload attachments here, so we only upload once even for multiple recipients // Upload attachments here, so we only upload once even for multiple recipients
final var attachmentPointers = new ArrayList<SignalServiceAttachment>(attachmentStreams.size()); final var attachmentPointers = new ArrayList<SignalServiceAttachment>(attachmentStreams.size());
for (var i = 0; i < attachmentStreams.size(); i++) { for (final var attachmentStream : attachmentStreams) {
final var attachment = attachments.get(i); attachmentPointers.add(uploadAttachment(attachmentStream));
try {
attachmentPointers.add(uploader.upload(attachmentStreams.get(i)));
} catch (IOException e) {
throw invalid(describe(attachment, inlineAttachmentLabel(i)), e);
}
} }
return attachmentPointers; return attachmentPointers;
} finally { } finally {
closeAll(attachmentStreams); for (final var attachmentStream : attachmentStreams) {
attachmentStream.close();
}
} }
} }
public List<SignalServiceAttachment> uploadAttachments(final List<String> attachments) throws AttachmentInvalidException { public List<SignalServiceAttachment> uploadAttachments(final List<String> attachments) throws AttachmentInvalidException, IOException {
return uploadAttachments(attachments, List.of(), List.of(), false); return uploadAttachments(attachments, false);
} }
private List<SignalServiceAttachmentStream> createAttachmentStreams( private List<SignalServiceAttachmentStream> createAttachmentStreams(
List<String> attachments, List<String> attachments,
List<AttachmentDimensions> dimensions,
List<String> blurHashes,
boolean voiceNote boolean voiceNote
) throws AttachmentInvalidException { ) throws AttachmentInvalidException, IOException {
if (attachments == null) { if (attachments == null) {
return List.of(); return null;
} }
final var signalServiceAttachments = new ArrayList<SignalServiceAttachmentStream>(attachments.size()); final var signalServiceAttachments = new ArrayList<SignalServiceAttachmentStream>(attachments.size());
for (var i = 0; i < attachments.size(); i++) { for (var attachment : attachments) {
final var size = i < dimensions.size() ? dimensions.get(i) : null; final var attachmentStream = getAttachmentStream(attachment, voiceNote);
final var blurHash = i < blurHashes.size() && !blurHashes.get(i).isEmpty() ? blurHashes.get(i) : null; signalServiceAttachments.add(attachmentStream);
signalServiceAttachments.add(getAttachmentStream(attachments.get(i),
inlineAttachmentLabel(i),
size,
blurHash,
voiceNote));
} }
return signalServiceAttachments; return signalServiceAttachments;
} }
private SignalServiceAttachmentStream getAttachmentStream( private SignalServiceAttachmentStream getAttachmentStream(
final String attachment, final String attachment,
final String inlineLabel,
final AttachmentDimensions dimensions,
final String blurHash,
final boolean voiceNote final boolean voiceNote
) throws AttachmentInvalidException { ) throws AttachmentInvalidException {
final var label = describe(attachment, inlineLabel);
try { try {
final Pair<StreamDetails, Optional<String>> streamDetailsAndFileName;
if (isInline(attachment)) {
// Never open inline data as a file, whose errors could echo its payload. Check for the comma
// before parsing to avoid regex backtracking over long malformed values.
if (attachment.indexOf(',') < 0) {
throw new IOException("Invalid data URI");
}
try {
streamDetailsAndFileName = Utils.createStreamDetailsFromDataURI(attachment);
} catch (IllegalArgumentException e) {
throw new IOException("Invalid data URI");
}
} else {
// Reject local files that point into the signal-cli data directory // Reject local files that point into the signal-cli data directory
final var canonical = new File(attachment).getCanonicalFile(); if (attachment != null && !attachment.startsWith("data:")) {
final var dataPath = this.dataPath.get().getCanonicalFile(); try {
final var file = new File(attachment);
final var canonical = file.getCanonicalFile();
final var dataPath = context.getAccount().getDataPath().getCanonicalFile();
if (canonical.toPath().startsWith(dataPath.toPath())) { if (canonical.toPath().startsWith(dataPath.toPath())) {
throw new IOException("Attaching files from the signal-cli data directory is not allowed"); throw new AttachmentInvalidException(attachment,
new IOException("Attaching files from the signal-cli data directory is not allowed"));
} }
streamDetailsAndFileName = Utils.createStreamDetails(attachment); } catch (IOException e) {
throw new AttachmentInvalidException(attachment, e);
} }
}
final var streamDetailsAndFileName = Utils.createStreamDetails(attachment);
final var streamDetails = streamDetailsAndFileName.first(); final var streamDetails = streamDetailsAndFileName.first();
final var uploadSpec = getResumableUploadSpec(streamDetails); final var uploadSpec = getResumableUploadSpec(streamDetails);
return AttachmentUtils.createAttachmentStream(streamDetails, return AttachmentUtils.createAttachmentStream(streamDetails,
streamDetailsAndFileName.second(), streamDetailsAndFileName.second(),
voiceNote, voiceNote,
dimensions,
blurHash,
uploadSpec); uploadSpec);
} catch (IOException e) { } catch (IOException e) {
throw invalid(label, e); throw new AttachmentInvalidException(attachment, e);
} }
} }
@ -167,89 +126,17 @@ public class AttachmentHelper {
final var streamLength = streamDetails.getLength(); final var streamLength = streamDetails.getLength();
final var ciphertextLength = AttachmentCipherStreamUtil.getCiphertextLength(PaddingInputStream.getPaddedSize( final var ciphertextLength = AttachmentCipherStreamUtil.getCiphertextLength(PaddingInputStream.getPaddedSize(
streamLength)); streamLength));
return uploader.getResumableUploadSpec(ciphertextLength); return dependencies.getCdnService().getResumableUploadSpecBlocking(ciphertextLength);
} }
public SignalServiceAttachmentPointer uploadAttachment(String attachment) throws AttachmentInvalidException { public SignalServiceAttachmentPointer uploadAttachment(String attachment) throws IOException, AttachmentInvalidException {
return uploadAttachment(attachment, "inline attachment"); final var attachmentStream = getAttachmentStream(attachment, false);
return uploadAttachment(attachmentStream);
} }
public SignalServiceAttachmentPointer uploadAttachment(SignalServiceAttachmentStream attachment) throws IOException { public SignalServiceAttachmentPointer uploadAttachment(SignalServiceAttachmentStream attachment) throws IOException {
return uploader.upload(attachment); var messageSender = dependencies.getMessageSender();
} return messageSender.uploadAttachment(attachment);
/**
* Prepares and uploads a single attachment, such as a quote thumbnail or a link preview image. Any
* failure is reported as {@link AttachmentInvalidException} with the given label for inline data.
*/
public SignalServiceAttachmentPointer uploadAttachment(
final String attachment,
final String inlineLabel
) throws AttachmentInvalidException {
try (final var attachmentStream = getAttachmentStream(attachment, inlineLabel, null, null, false)) {
return uploader.upload(attachmentStream);
} catch (IOException e) {
throw invalid(describe(attachment, inlineLabel), e);
}
}
/**
* Uploads the full text of a message that is too long to be sent inline. Failures never include the text.
*/
public SignalServiceAttachmentPointer uploadLongTextAttachment(final byte[] messageBytes) throws AttachmentInvalidException {
final var streamDetails = new StreamDetails(new ByteArrayInputStream(messageBytes),
MimeUtils.LONG_TEXT,
messageBytes.length);
try (final var textAttachment = createAttachmentStream(streamDetails)) {
return uploader.upload(textAttachment);
} catch (IOException e) {
throw invalid("long text attachment", e);
}
}
/**
* Prepares a sticker for sending. The sticker itself is uploaded later by the message sender.
*/
public SignalServiceAttachmentStream createStickerAttachmentStream(final StreamDetails streamDetails) throws AttachmentInvalidException {
try {
return createAttachmentStream(streamDetails);
} catch (IOException e) {
throw invalid("sticker", e);
}
}
private SignalServiceAttachmentStream createAttachmentStream(final StreamDetails streamDetails) throws IOException {
final var uploadSpec = getResumableUploadSpec(streamDetails);
return AttachmentUtils.createAttachmentStream(streamDetails, Optional.empty(), uploadSpec);
}
private static String describe(final String attachment, final String inlineLabel) {
return isInline(attachment) ? inlineLabel : attachment;
}
static AttachmentInvalidException invalid(final String label, final Exception e) {
final var message = e.getMessage() != null ? e.getMessage() : e.getClass().getName();
final var exception = new AttachmentInvalidException(label + ": " + message);
exception.initCause(e);
return exception;
}
private static boolean isInline(final String attachment) {
return attachment != null && attachment.regionMatches(true, 0, "data:", 0, 5);
}
private static String inlineAttachmentLabel(final int index) {
return "inline attachment #" + (index + 1);
}
private static void closeAll(final List<SignalServiceAttachmentStream> streams) {
for (final var stream : streams) {
try {
stream.close();
} catch (IOException e) {
logger.warn("Failed to close attachment stream, ignoring: {}", e.getMessage());
}
}
} }
public void downloadAttachment(final SignalServiceAttachment attachment) { public void downloadAttachment(final SignalServiceAttachment attachment) {
@ -271,7 +158,7 @@ public class AttachmentHelper {
try { try {
attachmentStore.storeAttachment(pointer, outputStream -> this.retrieveAttachment(pointer, outputStream)); attachmentStore.storeAttachment(pointer, outputStream -> this.retrieveAttachment(pointer, outputStream));
} catch (IOException e) { } catch (IOException e) {
logger.warn("Failed to download attachment ({}), ignoring", pointer.getRemoteId(), e); logger.warn("Failed to download attachment ({}), ignoring: {}", pointer.getRemoteId(), e.getMessage());
} }
} }
@ -326,24 +213,4 @@ public class AttachmentHelper {
void handle(InputStream inputStream) throws IOException; void handle(InputStream inputStream) throws IOException;
} }
interface Uploader {
ResumableUploadSpec getResumableUploadSpec(long ciphertextLength) throws IOException;
SignalServiceAttachmentPointer upload(SignalServiceAttachmentStream stream) throws IOException;
}
private record DependenciesUploader(SignalDependencies dependencies) implements Uploader {
@Override
public ResumableUploadSpec getResumableUploadSpec(final long ciphertextLength) throws IOException {
return dependencies.getCdnService().getResumableUploadSpecBlocking(ciphertextLength);
}
@Override
public SignalServiceAttachmentPointer upload(final SignalServiceAttachmentStream stream) throws IOException {
return dependencies.getMessageSender().uploadAttachment(stream);
}
}
} }

View File

@ -458,14 +458,10 @@ public class CallManager implements AutoCloseable {
} }
private String buildConfig(CallState state) { private String buildConfig(CallState state) {
return buildConfig(state, account.getDeviceId());
}
static String buildConfig(CallState state, int localDeviceId) {
var config = mapper.createObjectNode(); var config = mapper.createObjectNode();
config.put("call_id", Utils.callIdUnsigned(state.callId)); config.put("call_id", Utils.callIdUnsigned(state.callId));
config.put("is_outgoing", state.isOutgoing); config.put("is_outgoing", state.isOutgoing);
config.put("local_device_id", localDeviceId); config.put("local_device_id", 1);
return writeJson(config); return writeJson(config);
} }

View File

@ -136,7 +136,7 @@ class GroupV2Helper {
int findRevisionWeWereAdded(DecryptedGroup partialDecryptedGroup) { int findRevisionWeWereAdded(DecryptedGroup partialDecryptedGroup) {
ByteString aciBytes = getSelfAci().toByteString(); ByteString aciBytes = getSelfAci().toByteString();
ByteString pniBytes = getSelfPni() == null ? null : getSelfPni().toByteString(); ByteString pniBytes = getSelfPni().toByteString();
for (DecryptedMember decryptedMember : partialDecryptedGroup.members) { for (DecryptedMember decryptedMember : partialDecryptedGroup.members) {
if (decryptedMember.aciBytes.equals(aciBytes) || decryptedMember.pniBytes.equals(pniBytes)) { if (decryptedMember.aciBytes.equals(aciBytes) || decryptedMember.pniBytes.equals(pniBytes)) {
return decryptedMember.joinedAtRevision; return decryptedMember.joinedAtRevision;
@ -264,7 +264,7 @@ class GroupV2Helper {
var pendingMembersList = groupInfoV2.getGroup().pendingMembers; var pendingMembersList = groupInfoV2.getGroup().pendingMembers;
final var selfAci = getSelfAci(); final var selfAci = getSelfAci();
var selfPendingMember = DecryptedGroupUtil.findPendingByServiceId(pendingMembersList, selfAci); var selfPendingMember = DecryptedGroupUtil.findPendingByServiceId(pendingMembersList, selfAci);
if (selfPendingMember.isEmpty() && getSelfPni() != null) { if (selfPendingMember.isEmpty()) {
selfPendingMember = DecryptedGroupUtil.findPendingByServiceId(pendingMembersList, getSelfPni()); selfPendingMember = DecryptedGroupUtil.findPendingByServiceId(pendingMembersList, getSelfPni());
} }
@ -480,16 +480,15 @@ class GroupV2Helper {
} }
// We need to accept the invite with the ACI or PNI that was used for the invitation // We need to accept the invite with the ACI or PNI that was used for the invitation
final var account = context.getAccount(); final var selfAddress = context.getRecipientHelper().resolveSignalServiceAddress(selfRecipientId);
final var selfServiceId = groupInfoV2.getPendingMemberAddresses() final var selfServiceId = groupInfoV2.getPendingMemberAddresses()
.stream() .stream()
.map(SignalServiceAddress::getServiceId) .filter(s -> s.matches(selfAddress))
.filter(s -> s.equals(account.getAci()) || s.equals(account.getPni()))
.findFirst(); .findFirst();
if (selfServiceId.isEmpty()) { if (selfServiceId.isEmpty()) {
throw new IOException("Cannot find service ID for self to accept invite"); throw new IOException("Cannot find service ID for self to accept invite");
} }
final var serviceId = selfServiceId.get(); final var serviceId = selfServiceId.get().getServiceId();
final GroupChange.Actions.Builder change; final GroupChange.Actions.Builder change;
if (serviceId instanceof ACI) { if (serviceId instanceof ACI) {
@ -498,8 +497,7 @@ class GroupV2Helper {
change = groupOperations.createAcceptPniInviteChange(profileKeyCredential); change = groupOperations.createAcceptPniInviteChange(profileKeyCredential);
} }
// The change is made by the account's ACI, also when it promotes a PNI invitation change.sourceUserId(serviceId.toByteString());
change.sourceUserId(account.getAci().toByteString());
return commitChange(groupInfoV2, change); return commitChange(groupInfoV2, change);
} }
@ -636,18 +634,16 @@ class GroupV2Helper {
final DecryptedGroupChange decryptedChange; final DecryptedGroupChange decryptedChange;
final DecryptedGroup decryptedGroupState; final DecryptedGroup decryptedGroupState;
var signedGroupChange = dependencies.getGroupsV2Api()
.patchGroup(changeActions, getGroupAuthForToday(groupSecretParams), Optional.empty());
final var groupId = groupSecretParams.getPublicParams().getGroupIdentifier();
try { try {
decryptedChange = groupOperations.decryptChange(signedGroupChange.group_change, decryptedChange = groupOperations.decryptChange(changeActions, getSelfAci());
DecryptChangeVerificationMode.verify(groupId)).orElseThrow();
decryptedGroupState = DecryptedGroupUtil.apply(previousGroupState, decryptedChange); decryptedGroupState = DecryptedGroupUtil.apply(previousGroupState, decryptedChange);
} catch (VerificationFailedException | InvalidGroupStateException | NotAbleToApplyGroupV2ChangeException e) { } catch (VerificationFailedException | InvalidGroupStateException | NotAbleToApplyGroupV2ChangeException e) {
throw new IOException(e); throw new IOException(e);
} }
var signedGroupChange = dependencies.getGroupsV2Api()
.patchGroup(changeActions, getGroupAuthForToday(groupSecretParams), Optional.empty());
groupInfoV2.setGroup(decryptedGroupState); groupInfoV2.setGroup(decryptedGroupState);
return new Pair<>(decryptedGroupState, signedGroupChange); return new Pair<>(decryptedGroupState, signedGroupChange);

View File

@ -339,7 +339,7 @@ public final class IncomingMessageHandler {
throw new AssertionError(e); throw new AssertionError(e);
} }
if (destination.serviceId.equals(account.getPni())) { if (account.getPni().equals(destination.serviceId)) {
account.getRecipientStore().markNeedsPniSignature(sender, true); account.getRecipientStore().markNeedsPniSignature(sender, true);
} else if (account.getAci().equals(destination.serviceId)) { } else if (account.getAci().equals(destination.serviceId)) {
account.getRecipientStore().markNeedsPniSignature(sender, false); account.getRecipientStore().markNeedsPniSignature(sender, false);
@ -585,21 +585,6 @@ public final class IncomingMessageHandler {
actions.addAll(dataResults.first()); actions.addAll(dataResults.first());
longTexts.putAll(dataResults.second()); longTexts.putAll(dataResults.second());
} }
if (message.getEditMessage().isPresent()) {
final var dataResults = handleSignalServiceDataMessage(message.getEditMessage()
.get()
.getDataMessage(),
true,
sender,
destination == null
? null
: new DeviceAddress(account.getRecipientResolver().resolveRecipient(destination),
destination.getServiceId(),
0),
receiveConfig);
actions.addAll(dataResults.first());
longTexts.putAll(dataResults.second());
}
if (message.getStoryMessage().isPresent()) { if (message.getStoryMessage().isPresent()) {
actions.addAll(handleSignalServiceStoryMessage(message.getStoryMessage().get(), actions.addAll(handleSignalServiceStoryMessage(message.getStoryMessage().get(),
sender.recipientId(), sender.recipientId(),
@ -1139,7 +1124,7 @@ public final class IncomingMessageHandler {
if (destination == null || destination.isUnknown()) { if (destination == null || destination.isUnknown()) {
throw new InvalidMessageException("Missing destination"); throw new InvalidMessageException("Missing destination");
} }
if (!destination.equals(account.getAci()) && !destination.equals(account.getPni())) { if (!account.getAci().equals(destination) && !account.getPni().equals(destination)) {
throw new InvalidMessageException("Message not intended for this account"); throw new InvalidMessageException("Message not intended for this account");
} }
return new DeviceAddress(account.getRecipientResolver().resolveRecipient(destination), return new DeviceAddress(account.getRecipientResolver().resolveRecipient(destination),

View File

@ -365,7 +365,7 @@ public final class ProfileHelper {
} }
if (recipientId.equals(account.getSelfRecipientId())) { if (recipientId.equals(account.getSelfRecipientId())) {
final var isUnrestricted = encryptedProfile.getUnrestrictedUnidentifiedAccess(); final var isUnrestricted = encryptedProfile.isUnrestrictedUnidentifiedAccess();
if (account.isUnrestrictedUnidentifiedAccess() != isUnrestricted) { if (account.isUnrestrictedUnidentifiedAccess() != isUnrestricted) {
account.setUnrestrictedUnidentifiedAccess(isUnrestricted); account.setUnrestrictedUnidentifiedAccess(isUnrestricted);
} }

View File

@ -42,8 +42,8 @@ public class ReceiveHelper {
private ReceiveConfig receiveConfig = new ReceiveConfig(false, false, false, false, false); private ReceiveConfig receiveConfig = new ReceiveConfig(false, false, false, false, false);
private boolean hasCaughtUpWithOldMessages = false; private boolean hasCaughtUpWithOldMessages = false;
private volatile boolean isWaitingForMessage = false; private boolean isWaitingForMessage = false;
private volatile boolean shouldStop = false; private boolean shouldStop = false;
private Callable authenticationFailureListener; private Callable authenticationFailureListener;
private Callable caughtUpWithOldMessagesListener; private Callable caughtUpWithOldMessagesListener;
@ -71,15 +71,6 @@ public class ReceiveHelper {
return isWaitingForMessage; return isWaitingForMessage;
} }
/**
* Must only be called right before a new receive loop is started, while no other receive loop is running.
* Ending a receive loop doesn't clear the stop request, so a loop that was asked to stop can't continue
* receiving, e.g. by retrying in receiveMessagesContinuously after an IOException.
*/
public void clearStopRequest() {
this.shouldStop = false;
}
public void receiveMessagesContinuously(Manager.ReceiveMessageHandler handler) { public void receiveMessagesContinuously(Manager.ReceiveMessageHandler handler) {
while (!shouldStop) { while (!shouldStop) {
try { try {
@ -120,6 +111,7 @@ public class ReceiveHelper {
signalWebSocket.removeKeepAliveToken("receive"); signalWebSocket.removeKeepAliveToken("receive");
signalWebSocket.disconnect(); signalWebSocket.disconnect();
webSocketStateDisposable.dispose(); webSocketStateDisposable.dispose();
shouldStop = false;
} }
} }

View File

@ -555,8 +555,7 @@ public class SendHelper {
recipients, recipients,
unidentifiedAccess, unidentifiedAccess,
groupSendEndorsements, groupSendEndorsements,
message, message),
null),
recipientIds, recipientIds,
groupInfo, groupInfo,
false); false);
@ -887,8 +886,7 @@ public class SendHelper {
var address = context.getRecipientHelper().resolveSignalServiceAddress(recipientId); var address = context.getRecipientHelper().resolveSignalServiceAddress(recipientId);
try { try {
final boolean includePniSignature = account.getPni() != null && account.getRecipientStore() final boolean includePniSignature = account.getRecipientStore().needsPniSignature(recipientId);
.needsPniSignature(recipientId);
try { try {
return s.send(messageSender, return s.send(messageSender,
address, address,

View File

@ -492,7 +492,7 @@ public class StorageHelper {
final var storageId = newStickerPackStorageIds.get(stickerPack.packId()); final var storageId = newStickerPackStorageIds.get(stickerPack.packId());
final var record = StorageSyncModels.localToRemoteRecord(stickerPack); final var record = StorageSyncModels.localToRemoteRecord(stickerPack);
newStorageRecords.add(new SignalStorageRecord(storageId, newStorageRecords.add(new SignalStorageRecord(storageId,
new StorageRecord.Builder().installedStickerPack(record).build())); new StorageRecord.Builder().stickerPack(record).build()));
} }
connection.commit(); connection.commit();
@ -680,8 +680,7 @@ public class StorageHelper {
throw new AssertionError("Missing local sticker pack model for storage id: " + storageId); throw new AssertionError("Missing local sticker pack model for storage id: " + storageId);
} }
final var record = StorageSyncModels.localToRemoteRecord(stickerPack); final var record = StorageSyncModels.localToRemoteRecord(stickerPack);
yield new SignalStorageRecord(storageId, yield new SignalStorageRecord(storageId, new StorageRecord.Builder().stickerPack(record).build());
new StorageRecord.Builder().installedStickerPack(record).build());
} }
case null, default -> { case null, default -> {
throw new AssertionError("Got unknown local storage record type: " + storageId); throw new AssertionError("Got unknown local storage record type: " + storageId);
@ -778,9 +777,9 @@ public class StorageHelper {
logger.debug("Reading record {} of type contact", record.getId()); logger.debug("Reading record {} of type contact", record.getId());
contactRecordProcessor.process(StorageRecordConvertersKt.toSignalContactRecord(record.getProto().contact, contactRecordProcessor.process(StorageRecordConvertersKt.toSignalContactRecord(record.getProto().contact,
record.getId())); record.getId()));
} else if (record.getProto().installedStickerPack != null) { } else if (record.getProto().stickerPack != null) {
logger.debug("Reading record {} of type stickerPack", record.getId()); logger.debug("Reading record {} of type stickerPack", record.getId());
stickerPackRecordProcessor.process(StorageRecordConvertersKt.toSignalInstalledStickerPackRecord(record.getProto().installedStickerPack, stickerPackRecordProcessor.process(StorageRecordConvertersKt.toSignalStickerPackRecord(record.getProto().stickerPack,
record.getId())); record.getId()));
} else { } else {
unknownRecords.add(record.getId()); unknownRecords.add(record.getId());

View File

@ -90,6 +90,7 @@ import org.asamk.signal.manager.storage.recipients.RecipientId;
import org.asamk.signal.manager.storage.stickerPacks.JsonStickerPack; import org.asamk.signal.manager.storage.stickerPacks.JsonStickerPack;
import org.asamk.signal.manager.storage.stickerPacks.StickerPackStore; import org.asamk.signal.manager.storage.stickerPacks.StickerPackStore;
import org.asamk.signal.manager.storage.stickers.StickerPack; import org.asamk.signal.manager.storage.stickers.StickerPack;
import org.asamk.signal.manager.util.AttachmentUtils;
import org.asamk.signal.manager.util.KeyUtils; import org.asamk.signal.manager.util.KeyUtils;
import org.asamk.signal.manager.util.MimeUtils; import org.asamk.signal.manager.util.MimeUtils;
import org.asamk.signal.manager.util.PhoneNumberFormatter; import org.asamk.signal.manager.util.PhoneNumberFormatter;
@ -123,8 +124,10 @@ import org.whispersystems.signalservice.api.messages.calls.OfferMessage;
import org.whispersystems.signalservice.api.messages.calls.SignalServiceCallMessage; import org.whispersystems.signalservice.api.messages.calls.SignalServiceCallMessage;
import org.whispersystems.signalservice.api.push.ServiceIdType; import org.whispersystems.signalservice.api.push.ServiceIdType;
import org.whispersystems.signalservice.api.push.exceptions.CdsiResourceExhaustedException; import org.whispersystems.signalservice.api.push.exceptions.CdsiResourceExhaustedException;
import org.whispersystems.signalservice.api.util.StreamDetails;
import org.whispersystems.signalservice.internal.util.Util; import org.whispersystems.signalservice.internal.util.Util;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream; import java.io.ByteArrayOutputStream;
import java.io.File; import java.io.File;
import java.io.FileNotFoundException; import java.io.FileNotFoundException;
@ -174,9 +177,6 @@ public class ManagerImpl implements Manager {
private boolean isReceivingSynchronous; private boolean isReceivingSynchronous;
private final Set<ReceiveMessageHandler> weakHandlers = new HashSet<>(); private final Set<ReceiveMessageHandler> weakHandlers = new HashSet<>();
private final Set<ReceiveMessageHandler> messageHandlers = new HashSet<>(); private final Set<ReceiveMessageHandler> messageHandlers = new HashSet<>();
// The last handler while its removal stops the receive thread: it still gets the envelope the thread is
// handling, but doesn't keep the thread receiving.
private final Set<ReceiveMessageHandler> drainingHandlers = new HashSet<>();
private final List<Runnable> closedListeners = new ArrayList<>(); private final List<Runnable> closedListeners = new ArrayList<>();
private final List<Runnable> addressChangedListeners = new ArrayList<>(); private final List<Runnable> addressChangedListeners = new ArrayList<>();
private final CompositeDisposable disposable = new CompositeDisposable(); private final CompositeDisposable disposable = new CompositeDisposable();
@ -953,8 +953,15 @@ public class ManagerImpl implements Manager {
final var remainder = result.getSecond(); final var remainder = result.getSecond();
if (remainder != null) { if (remainder != null) {
final var messageBytes = message.messageText().getBytes(StandardCharsets.UTF_8); final var messageBytes = message.messageText().getBytes(StandardCharsets.UTF_8);
final var streamDetails = new StreamDetails(new ByteArrayInputStream(messageBytes),
MimeUtils.LONG_TEXT,
messageBytes.length);
final var uploadSpec = context.getAttachmentHelper().getResumableUploadSpec(streamDetails);
final var textAttachment = AttachmentUtils.createAttachmentStream(streamDetails,
Optional.empty(),
uploadSpec);
messageBuilder.withBody(trimmed); messageBuilder.withBody(trimmed);
additionalAttachments.add(context.getAttachmentHelper().uploadLongTextAttachment(messageBytes)); additionalAttachments.add(context.getAttachmentHelper().uploadAttachment(textAttachment));
} else { } else {
messageBuilder.withBody(message.messageText()); messageBuilder.withBody(message.messageText());
} }
@ -963,10 +970,7 @@ public class ManagerImpl implements Manager {
} }
if (!message.attachments().isEmpty()) { if (!message.attachments().isEmpty()) {
final var uploadedAttachments = context.getAttachmentHelper() final var uploadedAttachments = context.getAttachmentHelper()
.uploadAttachments(message.attachments(), .uploadAttachments(message.attachments(), message.voiceNote());
message.attachmentDimensions(),
message.attachmentBlurHashes(),
message.voiceNote());
if (!additionalAttachments.isEmpty()) { if (!additionalAttachments.isEmpty()) {
additionalAttachments.addAll(uploadedAttachments); additionalAttachments.addAll(uploadedAttachments);
messageBuilder.withAttachments(additionalAttachments); messageBuilder.withAttachments(additionalAttachments);
@ -987,12 +991,9 @@ public class ManagerImpl implements Manager {
final var quote = message.quote().get(); final var quote = message.quote().get();
final var quotedAttachments = new ArrayList<SignalServiceDataMessage.Quote.QuotedAttachment>(); final var quotedAttachments = new ArrayList<SignalServiceDataMessage.Quote.QuotedAttachment>();
for (final var a : quote.attachments()) { for (final var a : quote.attachments()) {
final var label = "quote thumbnail #" + (quotedAttachments.size() + 1);
final var quotedAttachment = new SignalServiceDataMessage.Quote.QuotedAttachment(a.contentType(), final var quotedAttachment = new SignalServiceDataMessage.Quote.QuotedAttachment(a.contentType(),
a.filename(), a.filename(),
a.preview() == null a.preview() == null ? null : context.getAttachmentHelper().uploadAttachment(a.preview()));
? null
: context.getAttachmentHelper().uploadAttachment(a.preview(), label));
quotedAttachments.add(quotedAttachment); quotedAttachments.add(quotedAttachment);
} }
messageBuilder.withQuote(new SignalServiceDataMessage.Quote(quote.timestamp(), messageBuilder.withQuote(new SignalServiceDataMessage.Quote(quote.timestamp(),
@ -1023,7 +1024,10 @@ public class ManagerImpl implements Manager {
if (streamDetails == null) { if (streamDetails == null) {
throw new InvalidStickerException("Missing local sticker file"); throw new InvalidStickerException("Missing local sticker file");
} }
final var stickerAttachment = context.getAttachmentHelper().createStickerAttachmentStream(streamDetails); final var uploadSpec = context.getAttachmentHelper().getResumableUploadSpec(streamDetails);
final var stickerAttachment = AttachmentUtils.createAttachmentStream(streamDetails,
Optional.empty(),
uploadSpec);
messageBuilder.withSticker(new SignalServiceDataMessage.Sticker(packId.serialize(), messageBuilder.withSticker(new SignalServiceDataMessage.Sticker(packId.serialize(),
stickerPack.packKey(), stickerPack.packKey(),
stickerId, stickerId,
@ -1033,9 +1037,8 @@ public class ManagerImpl implements Manager {
if (!message.previews().isEmpty()) { if (!message.previews().isEmpty()) {
final var previews = new ArrayList<SignalServicePreview>(message.previews().size()); final var previews = new ArrayList<SignalServicePreview>(message.previews().size());
for (final var p : message.previews()) { for (final var p : message.previews()) {
final var label = "link preview image #" + (previews.size() + 1);
final var image = p.image().isPresent() ? context.getAttachmentHelper() final var image = p.image().isPresent() ? context.getAttachmentHelper()
.uploadAttachment(p.image().get(), label) : null; .uploadAttachment(p.image().get()) : null;
previews.add(new SignalServicePreview(p.url(), previews.add(new SignalServicePreview(p.url(),
p.title(), p.title(),
p.description(), p.description(),
@ -1513,18 +1516,15 @@ public class ManagerImpl implements Manager {
private static final AtomicInteger threadNumber = new AtomicInteger(0); private static final AtomicInteger threadNumber = new AtomicInteger(0);
private void startReceiveThreadIfRequired() { private void startReceiveThreadIfRequired() {
if (isReceiveThreadAlive() || isReceivingSynchronous) { if (receiveThread != null || isReceivingSynchronous) {
return; return;
} }
context.getReceiveHelper().clearStopRequest();
receiveThread = Thread.ofPlatform().name("receive-" + threadNumber.getAndIncrement()).start(() -> { receiveThread = Thread.ofPlatform().name("receive-" + threadNumber.getAndIncrement()).start(() -> {
logger.debug("Starting receiving messages"); logger.debug("Starting receiving messages");
context.getReceiveHelper().receiveMessagesContinuously(this::passReceivedMessageToHandlers); context.getReceiveHelper().receiveMessagesContinuously(this::passReceivedMessageToHandlers);
logger.debug("Finished receiving messages"); logger.debug("Finished receiving messages");
synchronized (messageHandlers) { synchronized (messageHandlers) {
if (receiveThread == Thread.currentThread()) {
receiveThread = null; receiveThread = null;
}
// Check if in the meantime another handler has been registered // Check if in the meantime another handler has been registered
if (!messageHandlers.isEmpty()) { if (!messageHandlers.isEmpty()) {
@ -1535,16 +1535,9 @@ public class ManagerImpl implements Manager {
}); });
} }
private boolean isReceiveThreadAlive() {
// The receive thread clears receiveThread itself when it has finished, so it stays set while the thread is
// still stopping and no second receive thread can be started in the meantime.
// A receive thread that died from an uncaught exception never clears it.
return receiveThread != null && receiveThread.isAlive();
}
private void passReceivedMessageToHandlers(MessageEnvelope envelope, Throwable e) { private void passReceivedMessageToHandlers(MessageEnvelope envelope, Throwable e) {
synchronized (messageHandlers) { synchronized (messageHandlers) {
Stream.of(messageHandlers, weakHandlers, drainingHandlers).flatMap(Set::stream).forEach(h -> { Stream.concat(messageHandlers.stream(), weakHandlers.stream()).forEach(h -> {
try { try {
h.handleMessage(envelope, e); h.handleMessage(envelope, e);
} catch (Throwable ex) { } catch (Throwable ex) {
@ -1559,26 +1552,15 @@ public class ManagerImpl implements Manager {
final Thread thread; final Thread thread;
synchronized (messageHandlers) { synchronized (messageHandlers) {
weakHandlers.remove(handler); weakHandlers.remove(handler);
final var removed = messageHandlers.remove(handler); messageHandlers.remove(handler);
if (!messageHandlers.isEmpty() || receiveThread == null || isReceivingSynchronous) { if (!messageHandlers.isEmpty() || receiveThread == null || isReceivingSynchronous) {
return; return;
} }
thread = receiveThread; thread = receiveThread;
// The receive thread may be handling an envelope that is already cached and acknowledged to the server, receiveThread = null;
// and the cache drops it once it's handled. So the last handler keeps getting envelopes until the thread
// has stopped, otherwise that envelope would reach no handler and be lost.
if (removed) {
drainingHandlers.add(handler);
}
} }
try {
stopReceiveThread(thread); stopReceiveThread(thread);
} finally {
synchronized (messageHandlers) {
drainingHandlers.remove(handler);
}
}
} }
private void stopReceiveThread(final Thread thread) { private void stopReceiveThread(final Thread thread) {
@ -1631,12 +1613,11 @@ public class ManagerImpl implements Manager {
ReceiveMessageHandler handler ReceiveMessageHandler handler
) throws IOException, AlreadyReceivingException { ) throws IOException, AlreadyReceivingException {
synchronized (messageHandlers) { synchronized (messageHandlers) {
if (isReceiving() || isReceiveThreadAlive()) { if (isReceiving()) {
throw new AlreadyReceivingException("Already receiving message."); throw new AlreadyReceivingException("Already receiving message.");
} }
isReceivingSynchronous = true; isReceivingSynchronous = true;
receiveThread = Thread.currentThread(); receiveThread = Thread.currentThread();
context.getReceiveHelper().clearStopRequest();
} }
try { try {
context.getReceiveHelper().receiveMessages(timeout, maxMessages, (envelope, e) -> { context.getReceiveHelper().receiveMessages(timeout, maxMessages, (envelope, e) -> {
@ -2061,6 +2042,7 @@ public class ManagerImpl implements Manager {
weakHandlers.clear(); weakHandlers.clear();
messageHandlers.clear(); messageHandlers.clear();
thread = receiveThread; thread = receiveThread;
receiveThread = null;
} }
if (thread != null) { if (thread != null) {
stopReceiveThread(thread); stopReceiveThread(thread);

View File

@ -39,6 +39,13 @@ public class MultiAccountManagerImpl implements MultiAccountManager {
managers.forEach(m -> m.addClosedListener(() -> this.removeManager(m))); managers.forEach(m -> m.addClosedListener(() -> this.removeManager(m)));
} }
@Override
public List<String> getAccountNumbers() {
synchronized (managers) {
return managers.stream().map(Manager::getSelfNumber).toList();
}
}
@Override @Override
public List<Manager> getManagers() { public List<Manager> getManagers() {
synchronized (managers) { synchronized (managers) {
@ -68,13 +75,6 @@ public class MultiAccountManagerImpl implements MultiAccountManager {
} }
} }
@Override
public void removeOnManagerAddedHandler(final Consumer<Manager> handler) {
synchronized (onManagerAddedHandlers) {
onManagerAddedHandlers.remove(handler);
}
}
void removeManager(final Manager m) { void removeManager(final Manager m) {
synchronized (managers) { synchronized (managers) {
if (!managers.remove(m)) { if (!managers.remove(m)) {
@ -95,13 +95,6 @@ public class MultiAccountManagerImpl implements MultiAccountManager {
} }
} }
@Override
public void removeOnManagerRemovedHandler(final Consumer<Manager> handler) {
synchronized (onManagerRemovedHandlers) {
onManagerRemovedHandlers.remove(handler);
}
}
@Override @Override
public Manager getManager(final String identifier) { public Manager getManager(final String identifier) {
synchronized (managers) { synchronized (managers) {
@ -128,7 +121,7 @@ public class MultiAccountManagerImpl implements MultiAccountManager {
} else { } else {
// Phone number — check already loaded managers // Phone number — check already loaded managers
var existing = managers.stream() var existing = managers.stream()
.filter(m -> identifier.equals(m.getSelfNumber())) .filter(m -> m.getSelfNumber().equals(identifier))
.findFirst() .findFirst()
.orElse(null); .orElse(null);
if (existing != null) { if (existing != null) {

View File

@ -19,8 +19,8 @@ package org.asamk.signal.manager.internal;
import org.asamk.signal.manager.Manager; import org.asamk.signal.manager.Manager;
import org.asamk.signal.manager.ProvisioningManager; import org.asamk.signal.manager.ProvisioningManager;
import org.asamk.signal.manager.Settings; import org.asamk.signal.manager.Settings;
import org.asamk.signal.manager.api.BadRequestException;
import org.asamk.signal.manager.api.UserAlreadyExistsException; import org.asamk.signal.manager.api.UserAlreadyExistsException;
import org.asamk.signal.manager.config.ServiceConfig;
import org.asamk.signal.manager.config.ServiceEnvironmentConfig; import org.asamk.signal.manager.config.ServiceEnvironmentConfig;
import org.asamk.signal.manager.storage.SignalAccount; import org.asamk.signal.manager.storage.SignalAccount;
import org.asamk.signal.manager.storage.accounts.AccountsStore; import org.asamk.signal.manager.storage.accounts.AccountsStore;
@ -34,15 +34,13 @@ import org.signal.libsignal.protocol.IdentityKey;
import org.signal.libsignal.protocol.IdentityKeyPair; import org.signal.libsignal.protocol.IdentityKeyPair;
import org.signal.libsignal.protocol.ecc.ECPrivateKey; import org.signal.libsignal.protocol.ecc.ECPrivateKey;
import org.signal.libsignal.zkgroup.profiles.ProfileKey; import org.signal.libsignal.zkgroup.profiles.ProfileKey;
import org.signal.network.api.RegistrationApiV2;
import org.signal.network.api.RegistrationApiV2.LinkDeviceResponse;
import org.signal.network.api.RegistrationApiV2.RegisterAsLinkedDeviceError;
import org.signal.network.rest.SignalRestClient;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.whispersystems.signalservice.api.account.PreKeyCollection; import org.whispersystems.signalservice.api.SignalServiceAccountManager;
import org.whispersystems.signalservice.api.account.DeviceAttributes;
import org.whispersystems.signalservice.api.provisioning.ProvisioningSocket; import org.whispersystems.signalservice.api.provisioning.ProvisioningSocket;
import org.whispersystems.signalservice.api.push.ServiceIdType; import org.whispersystems.signalservice.api.push.ServiceIdType;
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
import org.whispersystems.signalservice.api.push.exceptions.AuthorizationFailedException; import org.whispersystems.signalservice.api.push.exceptions.AuthorizationFailedException;
import org.whispersystems.signalservice.internal.crypto.SecondaryProvisioningCipher; import org.whispersystems.signalservice.internal.crypto.SecondaryProvisioningCipher;
import org.whispersystems.signalservice.internal.push.ProvisionMessage; import org.whispersystems.signalservice.internal.push.ProvisionMessage;
@ -69,7 +67,7 @@ import kotlinx.coroutines.BuildersKt;
import kotlinx.coroutines.CoroutineScope; import kotlinx.coroutines.CoroutineScope;
import static org.asamk.signal.manager.util.KeyUtils.generatePreKeysForType; import static org.asamk.signal.manager.util.KeyUtils.generatePreKeysForType;
import static org.asamk.signal.manager.util.Utils.handleResponseExceptionSuspend; import static org.asamk.signal.manager.util.Utils.handleResponseException;
public class ProvisioningManagerImpl implements ProvisioningManager, Closeable { public class ProvisioningManagerImpl implements ProvisioningManager, Closeable {
@ -150,20 +148,18 @@ public class ProvisioningManagerImpl implements ProvisioningManager, Closeable {
var number = msg.number; var number = msg.number;
var aci = ACI.parseOrThrow(msg.aci, msg.aciBinary); var aci = ACI.parseOrThrow(msg.aci, msg.aciBinary);
var pni = parsePni(msg); var pni = PNI.parseOrThrow(msg.pni, msg.pniBinary);
var identifier = number != null ? number : aci.toString();
logger.info("Received link information from {}, linking in progress ...", identifier); logger.info("Received link information from {}, linking in progress ...", number);
var accountPath = accountsStore.getPathByAci(aci); var accountPath = accountsStore.getPathByAci(aci);
if (accountPath == null && number != null) { if (accountPath == null) {
accountPath = accountsStore.getPathByNumber(number); accountPath = accountsStore.getPathByNumber(number);
} }
final var accountExists = accountPath != null && SignalAccount.accountFileExists(pathConfig.dataPath(), final var accountExists = accountPath != null && SignalAccount.accountFileExists(pathConfig.dataPath(),
accountPath); accountPath);
if (accountExists && !canRelinkExistingAccount(accountPath)) { if (accountExists && !canRelinkExistingAccount(accountPath)) {
throw new UserAlreadyExistsException(identifier, throw new UserAlreadyExistsException(number, SignalAccount.getFileName(pathConfig.dataPath(), accountPath));
SignalAccount.getFileName(pathConfig.dataPath(), accountPath));
} }
if (accountPath == null) { if (accountPath == null) {
accountPath = accountsStore.addAccount(number, aci); accountPath = accountsStore.addAccount(number, aci);
@ -177,9 +173,7 @@ public class ProvisioningManagerImpl implements ProvisioningManager, Closeable {
try { try {
aciIdentity = new IdentityKeyPair(new IdentityKey(msg.aciIdentityKeyPublic.toByteArray()), aciIdentity = new IdentityKeyPair(new IdentityKey(msg.aciIdentityKeyPublic.toByteArray()),
new ECPrivateKey(msg.aciIdentityKeyPrivate.toByteArray())); new ECPrivateKey(msg.aciIdentityKeyPrivate.toByteArray()));
pniIdentity = pni == null pniIdentity = new IdentityKeyPair(new IdentityKey(msg.pniIdentityKeyPublic.toByteArray()),
? null
: new IdentityKeyPair(new IdentityKey(msg.pniIdentityKeyPublic.toByteArray()),
new ECPrivateKey(msg.pniIdentityKeyPrivate.toByteArray())); new ECPrivateKey(msg.pniIdentityKeyPrivate.toByteArray()));
profileKey = msg.profileKey == null profileKey = msg.profileKey == null
? KeyUtils.createProfileKey() ? KeyUtils.createProfileKey()
@ -228,19 +222,28 @@ public class ProvisioningManagerImpl implements ProvisioningManager, Closeable {
} }
final var aciPreKeys = generatePreKeysForType(account.getAccountData(ServiceIdType.ACI)); final var aciPreKeys = generatePreKeysForType(account.getAccountData(ServiceIdType.ACI));
final var pniPreKeys = pni == null final var pniPreKeys = generatePreKeysForType(account.getAccountData(ServiceIdType.PNI));
? null
: generatePreKeysForType(account.getAccountData(ServiceIdType.PNI));
logger.debug("Finishing new device registration"); logger.debug("Finishing new device registration");
final var restClient = new SignalRestClient(serviceEnvironmentConfig.signalServiceConfiguration(), final var attrs = account.getAccountAttributes(null);
userAgent); final var deviceAttributes = new DeviceAttributes(attrs.getFetchesMessages(),
final var registrationApi = new RegistrationApiV2(restClient, false); attrs.getRegistrationId(),
final var deviceId = registerLinkedDevice(registrationApi, attrs.getPniRegistrationId(),
account, attrs.getName(),
msg.provisioningCode, attrs.getCapabilities());
aciPreKeys, final var unauthAccountManager = SignalServiceAccountManager.createWithStaticCredentials(
pniPreKeys); serviceEnvironmentConfig.signalServiceConfiguration(),
null,
null,
number,
SignalServiceAddress.DEFAULT_DEVICE_ID,
password,
userAgent,
ServiceConfig.AUTOMATIC_NETWORK_RETRY,
ServiceConfig.GROUP_MAX_SIZE);
final var registerResponse = handleResponseException(unauthAccountManager.getRegistrationApi()
.registerAsSecondaryDevice(msg.provisioningCode, deviceAttributes, aciPreKeys, pniPreKeys, null));
final var deviceId = Integer.parseInt(registerResponse.getDeviceId());
account.finishLinking(deviceId, aciPreKeys, pniPreKeys); account.finishLinking(deviceId, aciPreKeys, pniPreKeys);
linkingFinished = true; linkingFinished = true;
@ -274,7 +277,7 @@ public class ProvisioningManagerImpl implements ProvisioningManager, Closeable {
newManagerListener.accept(m); newManagerListener.accept(m);
m = null; m = null;
} }
return identifier; return number;
} finally { } finally {
if (m != null) { if (m != null) {
m.close(); m.close();
@ -293,71 +296,6 @@ public class ProvisioningManagerImpl implements ProvisioningManager, Closeable {
} }
} }
static PNI parsePni(final ProvisionMessage message) throws IOException {
if (message.number != null) {
return PNI.parseOrThrow(message.pni, message.pniBinary);
}
if (message.pni != null
|| message.pniBinary != null
|| message.pniIdentityKeyPublic != null
|| message.pniIdentityKeyPrivate != null) {
throw new IOException("Provisioning message has PNI material without a phone number");
}
if (message.authCredentialSalt == null || message.authCredentialSalt.size() == 0) {
throw new IOException("Numberless provisioning message is missing the group auth credential salt");
}
return null;
}
static int registerLinkedDevice(
final RegistrationApiV2 registrationApi,
final SignalAccount account,
final String provisioningCode,
final PreKeyCollection aciPreKeys,
final PreKeyCollection pniPreKeys
) throws IOException {
final var attrs = account.getAccountAttributesV2();
final var deviceAttributes = new RegistrationApiV2.DeviceAttributes(attrs.getFetchesMessages(),
attrs.getRegistrationId(),
attrs.getPniRegistrationId(),
attrs.getName(),
attrs.getCapabilities());
try {
final LinkDeviceResponse result = handleResponseExceptionSuspend(cont -> registrationApi.registerAsSecondaryDevice(
account.getAci(),
account.getPassword(),
provisioningCode,
deviceAttributes,
toRegistrationPreKeys(aciPreKeys),
toRegistrationPreKeys(pniPreKeys),
null,
cont));
return result.getDeviceId();
} catch (BadRequestException e) {
throw switch (e.getError()) {
case RegisterAsLinkedDeviceError.IncorrectVerification ignored ->
new AuthorizationFailedException(403, "Device verification failed");
case RegisterAsLinkedDeviceError.MissingCapability ignored ->
new IOException("Linked device is missing a required account capability");
case RegisterAsLinkedDeviceError.MaxLinkedDevices ignored ->
new IOException("Account has reached its linked device limit");
case RegisterAsLinkedDeviceError.InvalidRequest ignored ->
new IOException("Signal rejected the device linking request");
case RegisterAsLinkedDeviceError.RateLimited ignored ->
new IOException("Device linking rate limited; try again later");
default -> new IOException("Unexpected device linking response");
};
}
}
static RegistrationApiV2.PreKeyCollection toRegistrationPreKeys(final PreKeyCollection preKeys) {
return preKeys == null
? null
: new RegistrationApiV2.PreKeyCollection(preKeys.getIdentityKey(),
preKeys.getSignedPreKey(),
preKeys.getLastResortKyberPreKey());
}
@Override @Override
public void close() throws IOException { public void close() throws IOException {
socketHandle.close(); socketHandle.close();

View File

@ -18,14 +18,12 @@ package org.asamk.signal.manager.internal;
import org.asamk.signal.manager.Manager; import org.asamk.signal.manager.Manager;
import org.asamk.signal.manager.RegistrationManager; import org.asamk.signal.manager.RegistrationManager;
import org.asamk.signal.manager.api.BadRequestException;
import org.asamk.signal.manager.api.CaptchaRequiredException; import org.asamk.signal.manager.api.CaptchaRequiredException;
import org.asamk.signal.manager.api.IncorrectPinException; import org.asamk.signal.manager.api.IncorrectPinException;
import org.asamk.signal.manager.api.NonNormalizedPhoneNumberException; import org.asamk.signal.manager.api.NonNormalizedPhoneNumberException;
import org.asamk.signal.manager.api.PinLockMissingException; import org.asamk.signal.manager.api.PinLockMissingException;
import org.asamk.signal.manager.api.PinLockedException; import org.asamk.signal.manager.api.PinLockedException;
import org.asamk.signal.manager.api.RateLimitException; import org.asamk.signal.manager.api.RateLimitException;
import org.asamk.signal.manager.api.TwoFactorRequired;
import org.asamk.signal.manager.api.UpdateProfile; import org.asamk.signal.manager.api.UpdateProfile;
import org.asamk.signal.manager.api.VerificationMethodNotAvailableException; import org.asamk.signal.manager.api.VerificationMethodNotAvailableException;
import org.asamk.signal.manager.config.ServiceConfig; import org.asamk.signal.manager.config.ServiceConfig;
@ -35,14 +33,10 @@ import org.asamk.signal.manager.helper.PinHelper;
import org.asamk.signal.manager.storage.SignalAccount; import org.asamk.signal.manager.storage.SignalAccount;
import org.asamk.signal.manager.util.KeyUtils; import org.asamk.signal.manager.util.KeyUtils;
import org.asamk.signal.manager.util.NumberVerificationUtils; import org.asamk.signal.manager.util.NumberVerificationUtils;
import org.signal.core.models.AccountEntropyPool;
import org.signal.core.models.MasterKey; import org.signal.core.models.MasterKey;
import org.signal.core.models.ServiceId.ACI; import org.signal.core.models.ServiceId.ACI;
import org.signal.core.models.ServiceId.PNI; import org.signal.core.models.ServiceId.PNI;
import org.signal.libsignal.usernames.BaseUsernameException; import org.signal.libsignal.usernames.BaseUsernameException;
import org.signal.network.api.RegistrationApiV2;
import org.signal.network.api.RegistrationApiV2.RegisterAccountError;
import org.signal.network.rest.SignalRestClient;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.whispersystems.signalservice.api.SignalServiceAccountManager; import org.whispersystems.signalservice.api.SignalServiceAccountManager;
@ -56,25 +50,15 @@ import org.whispersystems.signalservice.api.svr.SecureValueRecovery;
import org.whispersystems.signalservice.internal.push.VerifyAccountResponse; import org.whispersystems.signalservice.internal.push.VerifyAccountResponse;
import java.io.IOException; import java.io.IOException;
import java.util.Base64;
import java.util.function.Consumer; import java.util.function.Consumer;
import static org.asamk.signal.manager.internal.ProvisioningManagerImpl.toRegistrationPreKeys;
import static org.asamk.signal.manager.util.KeyUtils.generatePreKeysForType; import static org.asamk.signal.manager.util.KeyUtils.generatePreKeysForType;
import static org.asamk.signal.manager.util.Utils.handleResponseException; import static org.asamk.signal.manager.util.Utils.handleResponseException;
import static org.asamk.signal.manager.util.Utils.handleResponseExceptionSuspend;
public class RegistrationManagerImpl implements RegistrationManager { public class RegistrationManagerImpl implements RegistrationManager {
private static final Logger logger = LoggerFactory.getLogger(RegistrationManagerImpl.class); private static final Logger logger = LoggerFactory.getLogger(RegistrationManagerImpl.class);
private static final class RecoveryRequestFailedException extends IOException {
private RecoveryRequestFailedException(final String message, final Throwable cause) {
super(message, cause);
}
}
private SignalAccount account; private SignalAccount account;
private final PathConfig pathConfig; private final PathConfig pathConfig;
private final ServiceEnvironmentConfig serviceEnvironmentConfig; private final ServiceEnvironmentConfig serviceEnvironmentConfig;
@ -123,7 +107,7 @@ public class RegistrationManagerImpl implements RegistrationManager {
boolean voiceVerification, boolean voiceVerification,
String captcha, String captcha,
final boolean forceRegister final boolean forceRegister
) throws IOException, CaptchaRequiredException, NonNormalizedPhoneNumberException, RateLimitException, TwoFactorRequired, VerificationMethodNotAvailableException { ) throws IOException, CaptchaRequiredException, NonNormalizedPhoneNumberException, RateLimitException, VerificationMethodNotAvailableException {
if (account.isRegistered() if (account.isRegistered()
&& account.getServiceEnvironment() != null && account.getServiceEnvironment() != null
&& account.getServiceEnvironment() != serviceEnvironmentConfig.type()) { && account.getServiceEnvironment() != serviceEnvironmentConfig.type()) {
@ -145,13 +129,6 @@ public class RegistrationManagerImpl implements RegistrationManager {
if (recoveryPassword != null && account.isPrimaryDevice() && attemptReregisterAccount(recoveryPassword)) { if (recoveryPassword != null && account.isPrimaryDevice() && attemptReregisterAccount(recoveryPassword)) {
return; return;
} }
if (account.getAci() != null && account.getAccountEntropyPool() != null && attemptRecoverAccount(null)) {
return;
}
if (account.getNumber() == null) {
throw new IOException("Failed to recover account using its ACI and Account Entropy Pool");
}
final var registrationApi = unauthenticatedAccountManager.getRegistrationApi(); final var registrationApi = unauthenticatedAccountManager.getRegistrationApi();
logger.trace("Creating verification session"); logger.trace("Creating verification session");
@ -207,109 +184,6 @@ public class RegistrationManagerImpl implements RegistrationManager {
finishAccountRegistration(response, pin, masterKey, aciPreKeys, pniPreKeys); finishAccountRegistration(response, pin, masterKey, aciPreKeys, pniPreKeys);
} }
@Override
public void registerWithRecoveryKey(
final String recoveryKey,
final boolean forceRegister,
final Integer totp
) throws IOException, RateLimitException, TwoFactorRequired {
if (account.getAci() == null) {
throw new IOException("Recovery-key registration requires an ACI account identifier");
}
if (account.isRegistered() && !forceRegister) {
throw new IOException("Account is already registered; use --reregister to register it again");
}
final var accountEntropyPool = AccountEntropyPool.Companion.parseOrNull(recoveryKey);
if (accountEntropyPool == null || !AccountEntropyPool.Companion.isFullyValid(accountEntropyPool.getValue())) {
throw new IOException("Invalid recovery key");
}
recoverAccount(totp, false, accountEntropyPool);
}
private boolean attemptRecoverAccount(
final Integer totp
) 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 (TwoFactorRequired | RateLimitException e) {
throw e;
} catch (RecoveryRequestFailedException e) {
logger.debug("Failed to reregister account using its ACI and Account Entropy Pool", e);
return false;
}
}
private void recoverAccount(
final Integer totp,
final boolean includeRegistrationLock,
final AccountEntropyPool accountEntropyPool
) throws IOException, RateLimitException, TwoFactorRequired {
if (account.getPniIdentityKeyPair() == null) {
account.setPniIdentityKeyPair(KeyUtils.generateIdentityKeyPair());
}
final var aciPreKeys = generatePreKeysForType(account.getAccountData(ServiceIdType.ACI));
final var pniPreKeys = generatePreKeysForType(account.getAccountData(ServiceIdType.PNI));
final var masterKey = accountEntropyPool.deriveMasterKey();
final var recoveryPassword = masterKey.deriveRegistrationRecoveryPassword();
final var registrationLock = includeRegistrationLock ? masterKey.deriveRegistrationLock() : null;
final var restClient = new SignalRestClient(serviceEnvironmentConfig.signalServiceConfiguration(), userAgent);
final var registrationApi = new RegistrationApiV2(restClient, true);
final RegistrationApiV2.RegisterAccountResponse response;
try {
response = handleResponseExceptionSuspend(cont -> registrationApi.registerAccount(null,
account.getPassword(),
null,
recoveryPassword,
null,
account.getAccountAttributesV2ForRecovery(registrationLock, recoveryPassword),
toRegistrationPreKeys(aciPreKeys),
toRegistrationPreKeys(pniPreKeys),
null,
true,
account.getAci(),
totp,
null,
cont));
} catch (BadRequestException e) {
switch (e.getError()) {
case RegisterAccountError.RegistrationLock ignored -> {
if (includeRegistrationLock) {
throw new RecoveryRequestFailedException("Registration lock recovery failed", e);
}
recoverAccount(totp, true, accountEntropyPool);
return;
}
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);
case RegisterAccountError.PostQuantumRatchetRequired ignored ->
throw new IOException("signal-cli is too old to register this account", e);
default -> throw new IOException("Signal rejected recovery-key registration", e);
}
}
final var aci = ACI.parseOrThrow(response.getAci());
final var pni = response.getPni() == null ? null : PNI.parseOrThrow(response.getPni());
final var authCredentialSalt = response.getAuthCredentialSalt() == null
? null
: Base64.getDecoder().decode(response.getAuthCredentialSalt());
account.finishRecoveryRegistration(aci,
pni,
response.getE164(),
accountEntropyPool,
authCredentialSalt,
aciPreKeys,
pni == null ? null : pniPreKeys);
accountFileUpdater.updateAccountIdentifiers(response.getE164(), aci);
finishManagerRegistration(response.getStorageCapable());
}
@Override @Override
public void deleteLocalAccountData() throws IOException { public void deleteLocalAccountData() throws IOException {
account.deleteAccountData(); account.deleteAccountData();
@ -417,17 +291,13 @@ public class RegistrationManagerImpl implements RegistrationManager {
account.finishRegistration(aci, pni, masterKey, pin, aciPreKeys, pniPreKeys); account.finishRegistration(aci, pni, masterKey, pin, aciPreKeys, pniPreKeys);
accountFileUpdater.updateAccountIdentifiers(account.getNumber(), aci); accountFileUpdater.updateAccountIdentifiers(account.getNumber(), aci);
finishManagerRegistration(response.getStorageCapable());
}
private void finishManagerRegistration(final boolean storageCapable) throws IOException {
ManagerImpl m = null; ManagerImpl m = null;
try { try {
m = new ManagerImpl(account, pathConfig, accountFileUpdater, serviceEnvironmentConfig, userAgent); m = new ManagerImpl(account, pathConfig, accountFileUpdater, serviceEnvironmentConfig, userAgent);
account = null; account = null;
m.refreshPreKeys(); m.refreshPreKeys();
if (storageCapable) { if (response.isStorageCapable()) {
m.syncRemoteStorage(); m.syncRemoteStorage();
} }
// Set an initial empty profile so user can be added to groups // Set an initial empty profile so user can be added to groups

View File

@ -8,10 +8,8 @@ import org.signal.libsignal.metadata.certificate.CertificateValidator;
import org.signal.libsignal.net.Network; import org.signal.libsignal.net.Network;
import org.signal.libsignal.protocol.SignalProtocolAddress; import org.signal.libsignal.protocol.SignalProtocolAddress;
import org.signal.libsignal.zkgroup.profiles.ClientZkProfileOperations; import org.signal.libsignal.zkgroup.profiles.ClientZkProfileOperations;
import org.signal.network.api.AccountApiV2;
import org.signal.network.api.AttachmentApi; import org.signal.network.api.AttachmentApi;
import org.signal.network.api.CallingApi; import org.signal.network.api.CallingApi;
import org.signal.network.api.CdnApi;
import org.signal.network.api.CdsApi; import org.signal.network.api.CdsApi;
import org.signal.network.api.CertificateApi; import org.signal.network.api.CertificateApi;
import org.signal.network.api.LinkDeviceApi; import org.signal.network.api.LinkDeviceApi;
@ -75,7 +73,6 @@ public class SignalDependencies {
private SignalServiceAccountManager accountManager; private SignalServiceAccountManager accountManager;
private AccountApi accountApi; private AccountApi accountApi;
private AccountApiV2 accountApiV2;
private RateLimitChallengeApi rateLimitChallengeApi; private RateLimitChallengeApi rateLimitChallengeApi;
private CdsApi cdsApi; private CdsApi cdsApi;
private UsernameApi usernameApi; private UsernameApi usernameApi;
@ -92,7 +89,6 @@ public class SignalDependencies {
private ClientZkOperations clientZkOperations; private ClientZkOperations clientZkOperations;
private ProfileService profileService; private ProfileService profileService;
private ProfileApi profileApi; private ProfileApi profileApi;
private CdnApi cdnApi;
private CdnService cdnService; private CdnService cdnService;
private PreKeyRepository preKeyRepository; private PreKeyRepository preKeyRepository;
private SignalRestClient signalRestClient; private SignalRestClient signalRestClient;
@ -225,11 +221,6 @@ public class SignalDependencies {
return getOrCreate(() -> accountApi, () -> accountApi = new AccountApi(getAuthenticatedSignalWebSocket())); return getOrCreate(() -> accountApi, () -> accountApi = new AccountApi(getAuthenticatedSignalWebSocket()));
} }
public AccountApiV2 getAccountApiV2() {
return getOrCreate(() -> accountApiV2,
() -> accountApiV2 = new AccountApiV2(getAuthenticatedSignalWebSocket()));
}
public RateLimitChallengeApi getRateLimitChallengeApi() { public RateLimitChallengeApi getRateLimitChallengeApi() {
return getOrCreate(() -> rateLimitChallengeApi, return getOrCreate(() -> rateLimitChallengeApi,
() -> rateLimitChallengeApi = new RateLimitChallengeApi(getAuthenticatedSignalWebSocket())); () -> rateLimitChallengeApi = new RateLimitChallengeApi(getAuthenticatedSignalWebSocket()));
@ -273,7 +264,7 @@ public class SignalDependencies {
public AttachmentApi getAttachmentApi() { public AttachmentApi getAttachmentApi() {
return getOrCreate(() -> attachmentApi, return getOrCreate(() -> attachmentApi,
() -> attachmentApi = new AttachmentApi(getAuthenticatedSignalWebSocket())); () -> attachmentApi = new AttachmentApi(getAuthenticatedSignalWebSocket(), getPushServiceSocket()));
} }
public CallingApi getCallingApi() { public CallingApi getCallingApi() {
@ -355,7 +346,7 @@ public class SignalDependencies {
public CdnService getCdnService() { public CdnService getCdnService() {
return getOrCreate(() -> cdnService, return getOrCreate(() -> cdnService,
() -> cdnService = new CdnService(getCdnApi(), getAttachmentApi(), () -> null)); () -> cdnService = new CdnService(getSignalRestClient(), getAttachmentApi()));
} }
public PreKeyRepository getPreKeyRepository() { public PreKeyRepository getPreKeyRepository() {
@ -380,8 +371,7 @@ public class SignalDependencies {
ServiceConfig.MAX_ENVELOPE_SIZE, ServiceConfig.MAX_ENVELOPE_SIZE,
ServiceConfig.MAX_INCREMENTAL_MACS_PER_ENVELOPE, ServiceConfig.MAX_INCREMENTAL_MACS_PER_ENVELOPE,
() -> true, () -> true,
getPreKeyRepository(), getPreKeyRepository()));
getCdnService()));
} }
public List<SecureValueRecovery> getSecureValueRecovery() { public List<SecureValueRecovery> getSecureValueRecovery() {
@ -400,10 +390,6 @@ public class SignalDependencies {
getClientZkProfileOperations())); getClientZkProfileOperations()));
} }
public CdnApi getCdnApi() {
return getOrCreate(() -> cdnApi, () -> cdnApi = new CdnApi(getSignalRestClient()));
}
public ProfileService getProfileService() { public ProfileService getProfileService() {
return getOrCreate(() -> profileService, return getOrCreate(() -> profileService,
() -> profileService = new ProfileService(getClientZkProfileOperations(), () -> profileService = new ProfileService(getClientZkProfileOperations(),

View File

@ -8,11 +8,9 @@ import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentRemo
import org.whispersystems.signalservice.api.util.StreamDetails; import org.whispersystems.signalservice.api.util.StreamDetails;
import java.io.File; import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException; import java.io.IOException;
import java.io.OutputStream; import java.io.OutputStream;
import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.Files;
import java.nio.file.StandardCopyOption;
import java.util.Optional; import java.util.Optional;
public class AttachmentStore { public class AttachmentStore {
@ -53,24 +51,9 @@ public class AttachmentStore {
private void storeAttachment(final File attachmentFile, final AttachmentStorer storer) throws IOException { private void storeAttachment(final File attachmentFile, final AttachmentStorer storer) throws IOException {
createAttachmentsDir(); createAttachmentsDir();
final var temporaryFile = Files.createTempFile(attachmentsPath.toPath(), try (OutputStream output = new FileOutputStream(attachmentFile)) {
attachmentFile.getName() + ".",
".tmp");
try {
try (OutputStream output = Files.newOutputStream(temporaryFile)) {
storer.store(output); storer.store(output);
} }
try {
Files.move(temporaryFile,
attachmentFile.toPath(),
StandardCopyOption.ATOMIC_MOVE,
StandardCopyOption.REPLACE_EXISTING);
} catch (AtomicMoveNotSupportedException e) {
Files.move(temporaryFile, attachmentFile.toPath(), StandardCopyOption.REPLACE_EXISTING);
}
} finally {
Files.deleteIfExists(temporaryFile);
}
} }
private File getAttachmentPreviewFile( private File getAttachmentPreviewFile(

View File

@ -71,7 +71,6 @@ import org.signal.libsignal.protocol.state.SignedPreKeyRecord;
import org.signal.libsignal.protocol.util.KeyHelper; import org.signal.libsignal.protocol.util.KeyHelper;
import org.signal.libsignal.zkgroup.InvalidInputException; import org.signal.libsignal.zkgroup.InvalidInputException;
import org.signal.libsignal.zkgroup.profiles.ProfileKey; import org.signal.libsignal.zkgroup.profiles.ProfileKey;
import org.signal.network.api.RegistrationApiV2;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.whispersystems.signalservice.api.SignalServiceAccountDataStore; import org.whispersystems.signalservice.api.SignalServiceAccountDataStore;
@ -234,28 +233,6 @@ public class SignalAccount implements Closeable {
IdentityKeyPair pniIdentityKey, IdentityKeyPair pniIdentityKey,
ProfileKey profileKey, ProfileKey profileKey,
final Settings settings final Settings settings
) throws IOException {
return create(dataPath,
accountPath,
number,
null,
serviceEnvironment,
aciIdentityKey,
pniIdentityKey,
profileKey,
settings);
}
public static SignalAccount create(
File dataPath,
String accountPath,
String number,
ACI aci,
ServiceEnvironment serviceEnvironment,
IdentityKeyPair aciIdentityKey,
IdentityKeyPair pniIdentityKey,
ProfileKey profileKey,
final Settings settings
) throws IOException { ) throws IOException {
IOUtils.createPrivateDirectories(dataPath); IOUtils.createPrivateDirectories(dataPath);
var fileName = getFileName(dataPath, accountPath); var fileName = getFileName(dataPath, accountPath);
@ -274,7 +251,6 @@ public class SignalAccount implements Closeable {
signalAccount.deviceId = SignalServiceAddress.DEFAULT_DEVICE_ID; signalAccount.deviceId = SignalServiceAddress.DEFAULT_DEVICE_ID;
signalAccount.dataPath = dataPath; signalAccount.dataPath = dataPath;
signalAccount.aciAccountData.setServiceId(aci);
signalAccount.aciAccountData.setIdentityKeyPair(aciIdentityKey); signalAccount.aciAccountData.setIdentityKeyPair(aciIdentityKey);
signalAccount.pniAccountData.setIdentityKeyPair(pniIdentityKey); signalAccount.pniAccountData.setIdentityKeyPair(pniIdentityKey);
signalAccount.aciAccountData.setLocalRegistrationId(KeyHelper.generateRegistrationId(false)); signalAccount.aciAccountData.setLocalRegistrationId(KeyHelper.generateRegistrationId(false));
@ -374,9 +350,7 @@ public class SignalAccount implements Closeable {
this.registered = true; this.registered = true;
this.deviceId = deviceId; this.deviceId = deviceId;
setPreKeys(ServiceIdType.ACI, aciPreKeys); setPreKeys(ServiceIdType.ACI, aciPreKeys);
if (pniPreKeys != null) {
setPreKeys(ServiceIdType.PNI, pniPreKeys); setPreKeys(ServiceIdType.PNI, pniPreKeys);
}
save(); save();
} }
@ -418,53 +392,6 @@ public class SignalAccount implements Closeable {
clearSessionId(); clearSessionId();
} }
public void finishRecoveryRegistration(
final ACI aci,
final PNI pni,
final String number,
final AccountEntropyPool accountEntropyPool,
final byte[] authCredentialSalt,
final PreKeyCollection aciPreKeys,
final PreKeyCollection pniPreKeys
) {
this.pinMasterKey = null;
this.accountEntropyPool = accountEntropyPool;
this.authCredentialSalt = authCredentialSalt;
this.number = number;
getKeyValueStore().storeEntry(storageManifestVersion, -1L);
this.setStorageManifest(null);
this.storageKey = null;
this.encryptedDeviceName = null;
this.deviceId = SignalServiceAddress.DEFAULT_DEVICE_ID;
this.isMultiDevice = false;
this.registered = true;
this.aciAccountData.setServiceId(aci);
this.pniAccountData.setServiceId(pni);
if (pni == null) {
this.pniAccountData.setIdentityKeyPair(null);
}
init();
this.registrationLockPin = null;
setLastReceiveTimestamp(0L);
setLastAppliedPniChangeServerTimestamp(0L);
save();
setPreKeys(ServiceIdType.ACI, aciPreKeys);
if (pni != null && pniPreKeys != null) {
setPreKeys(ServiceIdType.PNI, pniPreKeys);
}
aciAccountData.getSessionStore().archiveAllSessions();
pniAccountData.getSessionStore().archiveAllSessions();
getSenderKeyStore().deleteAll();
getRecipientTrustedResolver().resolveSelfRecipientTrusted(getSelfRecipientAddress());
trustSelfIdentity(ServiceIdType.ACI);
if (pni != null) {
trustSelfIdentity(ServiceIdType.PNI);
}
getKeyValueStore().storeEntry(lastRecipientsRefresh, null);
clearSessionId();
}
public void initDatabase() { public void initDatabase() {
getAccountDatabase(); getAccountDatabase();
} }
@ -474,7 +401,7 @@ public class SignalAccount implements Closeable {
} }
private void migrateLegacyConfigs() { private void migrateLegacyConfigs() {
if (isPrimaryDevice() && (number != null || getPni() != null) && getPniIdentityKeyPair() == null) { if (isPrimaryDevice() && getPniIdentityKeyPair() == null) {
logger.trace("Migrating legacy parts of account file"); logger.trace("Migrating legacy parts of account file");
setPniIdentityKeyPair(KeyUtils.generateIdentityKeyPair()); setPniIdentityKeyPair(KeyUtils.generateIdentityKeyPair());
} }
@ -1496,56 +1423,7 @@ public class SignalAccount implements Closeable {
} }
public AccountAttributes.Capabilities getAccountCapabilities() { public AccountAttributes.Capabilities getAccountCapabilities() {
return getCapabilities(isPrimaryDevice(), number != null); return getCapabilities(isPrimaryDevice());
}
public RegistrationApiV2.AccountAttributes getAccountAttributesV2() {
return getAccountAttributesV2(false, getRegistrationLock());
}
public RegistrationApiV2.AccountAttributes getAccountAttributesV2(
final boolean includePniRegistrationId,
final String registrationLock
) {
return getAccountAttributesV2(includePniRegistrationId,
registrationLock,
getRecoveryPassword(),
number == null ? null : isDiscoverableByPhoneNumber());
}
public RegistrationApiV2.AccountAttributes getAccountAttributesV2ForRecovery(
final String registrationLock,
final String recoveryPassword
) {
return getAccountAttributesV2(true, registrationLock, recoveryPassword, null);
}
private RegistrationApiV2.AccountAttributes getAccountAttributesV2(
final boolean includePniRegistrationId,
final String registrationLock,
final String recoveryPassword,
final Boolean discoverableByPhoneNumber
) {
final var attributes = getAccountAttributes(null);
final var capabilities = attributes.getCapabilities();
return new RegistrationApiV2.AccountAttributes(attributes.getSignalingKey(),
attributes.getRegistrationId(),
attributes.getVoice(),
attributes.getVideo(),
attributes.getFetchesMessages(),
registrationLock,
attributes.getUnidentifiedAccessKey(),
attributes.getUnrestrictedUnidentifiedAccess(),
discoverableByPhoneNumber,
new RegistrationApiV2.AccountAttributes.Capabilities(capabilities.getStorage(),
capabilities.getVersionedExpirationTimer(),
capabilities.getAttachmentBackfill(),
capabilities.getSpqr(),
capabilities.getUsernameChangeSyncMessage(),
capabilities.getOptionalPhoneNumber()),
attributes.getName(),
includePniRegistrationId || getPni() != null ? attributes.getPniRegistrationId() : null,
recoveryPassword);
} }
public ServiceId getAccountId(ServiceIdType serviceIdType) { public ServiceId getAccountId(ServiceIdType serviceIdType) {
@ -1758,10 +1636,6 @@ public class SignalAccount implements Closeable {
return accountEntropyPool; return accountEntropyPool;
} }
public AccountEntropyPool getAccountEntropyPool() {
return accountEntropyPool;
}
public void setAccountEntropyPool(final AccountEntropyPool accountEntropyPool) { public void setAccountEntropyPool(final AccountEntropyPool accountEntropyPool) {
this.accountEntropyPool = accountEntropyPool; this.accountEntropyPool = accountEntropyPool;
if (accountEntropyPool != null) { if (accountEntropyPool != null) {

View File

@ -63,14 +63,11 @@ public class AccountsStore {
public synchronized Set<AccountsStorage.Account> getAllAccounts() throws IOException { public synchronized Set<AccountsStorage.Account> getAllAccounts() throws IOException {
return readAccounts().stream() return readAccounts().stream()
.filter(a -> a.environment() == null || serviceEnvironment.equals(a.environment())) .filter(a -> a.environment() == null || serviceEnvironment.equals(a.environment()))
.filter(a -> a.number() != null || a.uuid() != null) .filter(a -> a.number() != null)
.collect(Collectors.toSet()); .collect(Collectors.toSet());
} }
public synchronized String getPathByNumber(String number) throws IOException { public synchronized String getPathByNumber(String number) throws IOException {
if (number == null) {
return null;
}
return readAccounts().stream() return readAccounts().stream()
.filter(a -> a.environment() == null || serviceEnvironment.equals(a.environment())) .filter(a -> a.environment() == null || serviceEnvironment.equals(a.environment()))
.filter(a -> number.equals(a.number())) .filter(a -> number.equals(a.number()))
@ -104,7 +101,7 @@ public class AccountsStore {
if (number != null && number.equals(a.number())) { if (number != null && number.equals(a.number())) {
return new AccountsStorage.Account(a.path(), a.environment(), null, a.uuid()); return new AccountsStorage.Account(a.path(), a.environment(), null, a.uuid());
} }
if (aci != null && aci.toString().equals(a.uuid())) { if (aci != null && aci.toString().equals(a.toString())) {
return new AccountsStorage.Account(a.path(), a.environment(), a.number(), null); return new AccountsStorage.Account(a.path(), a.environment(), a.number(), null);
} }
@ -191,7 +188,7 @@ public class AccountsStore {
private List<AccountsStorage.Account> readAccounts() throws IOException { private List<AccountsStorage.Account> readAccounts() throws IOException {
final var pair = openFileChannel(getAccountsFile()); final var pair = openFileChannel(getAccountsFile());
try (final var fileChannel = pair.first(); final var lock = pair.second()) { try (final var fileChannel = pair.first(); final var lock = pair.second()) {
var storage = readAccountsLocked(fileChannel); final var storage = readAccountsLocked(fileChannel);
var accountsVersion = storage.version() == null ? 1 : storage.version(); var accountsVersion = storage.version() == null ? 1 : storage.version();
if (accountsVersion > CURRENT_STORAGE_VERSION) { if (accountsVersion > CURRENT_STORAGE_VERSION) {
@ -200,7 +197,7 @@ public class AccountsStore {
throw new IOException("Accounts file was created by a no longer supported older version: " throw new IOException("Accounts file was created by a no longer supported older version: "
+ accountsVersion); + accountsVersion);
} else if (accountsVersion < CURRENT_STORAGE_VERSION) { } else if (accountsVersion < CURRENT_STORAGE_VERSION) {
storage = upgradeAccountsFile(fileChannel, storage, accountsVersion); return upgradeAccountsFile(fileChannel, storage, accountsVersion).accounts();
} }
return storage.accounts(); return storage.accounts();
} }

View File

@ -6,7 +6,7 @@ import org.asamk.signal.manager.storage.Utils;
import org.asamk.signal.manager.util.KeyUtils; import org.asamk.signal.manager.util.KeyUtils;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.whispersystems.signalservice.api.storage.SignalInstalledStickerPackRecord; import org.whispersystems.signalservice.api.storage.SignalStickerPackRecord;
import org.whispersystems.signalservice.api.storage.StorageId; import org.whispersystems.signalservice.api.storage.StorageId;
import java.sql.Connection; import java.sql.Connection;
@ -351,7 +351,7 @@ public class StickerStore {
public void upsertFromStorageSync( public void upsertFromStorageSync(
final Connection connection, final Connection connection,
final SignalInstalledStickerPackRecord record final SignalStickerPackRecord record
) throws SQLException { ) throws SQLException {
final var remote = record.getProto(); final var remote = record.getProto();
final var packId = StickerPackId.deserialize(remote.packId.toByteArray()); final var packId = StickerPackId.deserialize(remote.packId.toByteArray());

View File

@ -3,14 +3,14 @@ package org.asamk.signal.manager.syncStorage;
import org.asamk.signal.manager.api.StickerPackId; import org.asamk.signal.manager.api.StickerPackId;
import org.asamk.signal.manager.storage.SignalAccount; import org.asamk.signal.manager.storage.SignalAccount;
import org.asamk.signal.manager.util.KeyUtils; import org.asamk.signal.manager.util.KeyUtils;
import org.whispersystems.signalservice.api.storage.SignalInstalledStickerPackRecord; import org.whispersystems.signalservice.api.storage.SignalStickerPackRecord;
import org.whispersystems.signalservice.api.storage.StorageId; import org.whispersystems.signalservice.api.storage.StorageId;
import java.sql.Connection; import java.sql.Connection;
import java.sql.SQLException; import java.sql.SQLException;
import java.util.Optional; import java.util.Optional;
public class StickerPackRecordProcessor extends DefaultStorageRecordProcessor<SignalInstalledStickerPackRecord> { public class StickerPackRecordProcessor extends DefaultStorageRecordProcessor<SignalStickerPackRecord> {
private static final int PACK_ID_LENGTH = 16; private static final int PACK_ID_LENGTH = 16;
private static final int PACK_KEY_LENGTH = 32; private static final int PACK_KEY_LENGTH = 32;
@ -24,19 +24,19 @@ public class StickerPackRecordProcessor extends DefaultStorageRecordProcessor<Si
} }
@Override @Override
public int compare(final SignalInstalledStickerPackRecord lhs, final SignalInstalledStickerPackRecord rhs) { public int compare(final SignalStickerPackRecord lhs, final SignalStickerPackRecord rhs) {
return lhs.getProto().packId.equals(rhs.getProto().packId) ? 0 : 1; return lhs.getProto().packId.equals(rhs.getProto().packId) ? 0 : 1;
} }
@Override @Override
protected boolean isInvalid(final SignalInstalledStickerPackRecord remote) { protected boolean isInvalid(final SignalStickerPackRecord remote) {
return remote.getProto().packId.size() != PACK_ID_LENGTH || ( return remote.getProto().packId.size() != PACK_ID_LENGTH || (
remote.getProto().deletedAtTimestamp == 0 && remote.getProto().packKey.size() != PACK_KEY_LENGTH remote.getProto().deletedAtTimestamp == 0 && remote.getProto().packKey.size() != PACK_KEY_LENGTH
); );
} }
@Override @Override
protected Optional<SignalInstalledStickerPackRecord> getMatching(final SignalInstalledStickerPackRecord remote) throws SQLException { protected Optional<SignalStickerPackRecord> getMatching(final SignalStickerPackRecord remote) throws SQLException {
final var packId = StickerPackId.deserialize(remote.getProto().packId.toByteArray()); final var packId = StickerPackId.deserialize(remote.getProto().packId.toByteArray());
final var local = account.getStickerStore().getStickerPack(connection, packId); final var local = account.getStickerStore().getStickerPack(connection, packId);
@ -52,13 +52,13 @@ public class StickerPackRecordProcessor extends DefaultStorageRecordProcessor<Si
account.getStickerStore().updateStorageId(connection, packId, storageId); account.getStickerStore().updateStorageId(connection, packId, storageId);
} }
return Optional.of(new SignalInstalledStickerPackRecord(storageId, StorageSyncModels.localToRemoteRecord(local))); return Optional.of(new SignalStickerPackRecord(storageId, StorageSyncModels.localToRemoteRecord(local)));
} }
@Override @Override
protected SignalInstalledStickerPackRecord merge( protected SignalStickerPackRecord merge(
final SignalInstalledStickerPackRecord remoteRecord, final SignalStickerPackRecord remoteRecord,
final SignalInstalledStickerPackRecord localRecord final SignalStickerPackRecord localRecord
) { ) {
final var remote = remoteRecord.getProto(); final var remote = remoteRecord.getProto();
final var local = localRecord.getProto(); final var local = localRecord.getProto();
@ -75,12 +75,12 @@ public class StickerPackRecordProcessor extends DefaultStorageRecordProcessor<Si
} }
@Override @Override
protected void insertLocal(final SignalInstalledStickerPackRecord record) throws SQLException { protected void insertLocal(final SignalStickerPackRecord record) throws SQLException {
account.getStickerStore().upsertFromStorageSync(connection, record); account.getStickerStore().upsertFromStorageSync(connection, record);
} }
@Override @Override
protected void updateLocal(final StorageRecordUpdate<SignalInstalledStickerPackRecord> update) throws SQLException { protected void updateLocal(final StorageRecordUpdate<SignalStickerPackRecord> update) throws SQLException {
account.getStickerStore().upsertFromStorageSync(connection, update.newRecord()); account.getStickerStore().upsertFromStorageSync(connection, update.newRecord());
} }
} }

View File

@ -17,14 +17,14 @@ import org.whispersystems.signalservice.api.storage.SignalAccountRecord;
import org.whispersystems.signalservice.api.storage.SignalContactRecord; import org.whispersystems.signalservice.api.storage.SignalContactRecord;
import org.whispersystems.signalservice.api.storage.SignalGroupV1Record; import org.whispersystems.signalservice.api.storage.SignalGroupV1Record;
import org.whispersystems.signalservice.api.storage.SignalGroupV2Record; import org.whispersystems.signalservice.api.storage.SignalGroupV2Record;
import org.whispersystems.signalservice.api.storage.SignalInstalledStickerPackRecord; import org.whispersystems.signalservice.api.storage.SignalStickerPackRecord;
import org.whispersystems.signalservice.internal.storage.protos.AccountRecord; import org.whispersystems.signalservice.internal.storage.protos.AccountRecord;
import org.whispersystems.signalservice.internal.storage.protos.AccountRecord.UsernameLink; import org.whispersystems.signalservice.internal.storage.protos.AccountRecord.UsernameLink;
import org.whispersystems.signalservice.internal.storage.protos.ContactRecord; import org.whispersystems.signalservice.internal.storage.protos.ContactRecord;
import org.whispersystems.signalservice.internal.storage.protos.ContactRecord.IdentityState; import org.whispersystems.signalservice.internal.storage.protos.ContactRecord.IdentityState;
import org.whispersystems.signalservice.internal.storage.protos.GroupV1Record; import org.whispersystems.signalservice.internal.storage.protos.GroupV1Record;
import org.whispersystems.signalservice.internal.storage.protos.GroupV2Record; import org.whispersystems.signalservice.internal.storage.protos.GroupV2Record;
import org.whispersystems.signalservice.internal.storage.protos.InstalledStickerPackRecord; import org.whispersystems.signalservice.internal.storage.protos.StickerPackRecord;
import java.sql.Connection; import java.sql.Connection;
import java.sql.SQLException; import java.sql.SQLException;
@ -168,8 +168,8 @@ public final class StorageSyncModels {
return builder.build(); return builder.build();
} }
public static InstalledStickerPackRecord localToRemoteRecord(StickerPack stickerPack) { public static StickerPackRecord localToRemoteRecord(StickerPack stickerPack) {
final var builder = SignalInstalledStickerPackRecord.Companion.newBuilder(stickerPack.storageRecord()); final var builder = SignalStickerPackRecord.Companion.newBuilder(stickerPack.storageRecord());
builder.packId(ByteString.of(stickerPack.packId().serialize())); builder.packId(ByteString.of(stickerPack.packId().serialize()));
if (stickerPack.deletedTimestamp() > 0) { if (stickerPack.deletedTimestamp() > 0) {

View File

@ -1,6 +1,5 @@
package org.asamk.signal.manager.util; package org.asamk.signal.manager.util;
import org.asamk.signal.manager.api.Message.AttachmentDimensions;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentStream; import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentStream;
@ -28,21 +27,16 @@ public class AttachmentUtils {
StreamDetails streamDetails, StreamDetails streamDetails,
Optional<String> name, Optional<String> name,
boolean voiceNote, boolean voiceNote,
AttachmentDimensions dimensions,
String blurHash,
ResumableUploadSpec resumableUploadSpec ResumableUploadSpec resumableUploadSpec
) throws ResumeLocationInvalidException, IOException { ) throws ResumeLocationInvalidException, IOException {
final var uploadTimestamp = System.currentTimeMillis(); final var uploadTimestamp = System.currentTimeMillis();
final var probedStream = dimensions != null ? new ProbedStream(streamDetails.getStream(), final var probedStream = probeImageDimensions(streamDetails);
dimensions.width(),
dimensions.height()) : probeImageDimensions(streamDetails);
return SignalServiceAttachmentStream.newStreamBuilder() return SignalServiceAttachmentStream.newStreamBuilder()
.withStream(probedStream.inputStream()) .withStream(probedStream.inputStream())
.withContentType(streamDetails.getContentType()) .withContentType(streamDetails.getContentType())
.withLength(streamDetails.getLength()) .withLength(streamDetails.getLength())
.withFileName(name.orElse(null)) .withFileName(name.orElse(null))
.withVoiceNote(voiceNote) .withVoiceNote(voiceNote)
.withBlurHash(blurHash)
.withWidth(probedStream.width()) .withWidth(probedStream.width())
.withHeight(probedStream.height()) .withHeight(probedStream.height())
.withUploadTimestamp(uploadTimestamp) .withUploadTimestamp(uploadTimestamp)
@ -56,7 +50,7 @@ public class AttachmentUtils {
Optional<String> name, Optional<String> name,
ResumableUploadSpec resumableUploadSpec ResumableUploadSpec resumableUploadSpec
) throws ResumeLocationInvalidException, IOException { ) throws ResumeLocationInvalidException, IOException {
return createAttachmentStream(streamDetails, name, false, null, null, resumableUploadSpec); return createAttachmentStream(streamDetails, name, false, resumableUploadSpec);
} }
/** /**

View File

@ -42,19 +42,6 @@ public class PhoneNumberFormatter {
throw new InvalidNumberException("No valid characters found."); throw new InvalidNumberException("No valid characters found.");
} }
if (localNumber == null) {
if (!number.startsWith("+")) {
throw new InvalidNumberException(
"Use an international number including the country code for a numberless account.");
}
try {
final var util = PhoneNumberUtil.getInstance();
return util.format(util.parse(number, null), PhoneNumberFormat.E164);
} catch (NumberParseException e) {
throw new InvalidNumberException("Invalid international phone number.");
}
}
try { try {
PhoneNumberUtil util = PhoneNumberUtil.getInstance(); PhoneNumberUtil util = PhoneNumberUtil.getInstance();
PhoneNumber localNumberObject = util.parse(localNumber, null); PhoneNumber localNumberObject = util.parse(localNumber, null);

View File

@ -65,7 +65,7 @@ public class ProfileUtils {
final SignalServiceProfile encryptedProfile, final SignalServiceProfile encryptedProfile,
final ProfileCipher profileCipher final ProfileCipher profileCipher
) { ) {
if (encryptedProfile.getUnrestrictedUnidentifiedAccess()) { if (encryptedProfile.isUnrestrictedUnidentifiedAccess()) {
return Profile.UnidentifiedAccessMode.UNRESTRICTED; return Profile.UnidentifiedAccessMode.UNRESTRICTED;
} }
@ -81,14 +81,12 @@ public class ProfileUtils {
public static HashSet<Profile.Capability> getCapabilities(final SignalServiceProfile encryptedProfile) { public static HashSet<Profile.Capability> getCapabilities(final SignalServiceProfile encryptedProfile) {
final var capabilities = new HashSet<Profile.Capability>(); final var capabilities = new HashSet<Profile.Capability>();
if (encryptedProfile.getCapabilities() != null) { if (encryptedProfile.getCapabilities().isStorage()) {
if (encryptedProfile.getCapabilities().getStorage()) {
capabilities.add(Profile.Capability.storage); capabilities.add(Profile.Capability.storage);
} }
if (encryptedProfile.getCapabilities().getStorageServiceEncryptionV2()) { if (encryptedProfile.getCapabilities().isStorageServiceEncryptionV2()) {
capabilities.add(Profile.Capability.storageServiceEncryptionV2Capability); capabilities.add(Profile.Capability.storageServiceEncryptionV2Capability);
} }
}
return capabilities; return capabilities;
} }

View File

@ -1,6 +1,5 @@
package org.asamk.signal.manager.util; package org.asamk.signal.manager.util;
import org.asamk.signal.manager.api.BadRequestException;
import org.asamk.signal.manager.api.Pair; import org.asamk.signal.manager.api.Pair;
import org.signal.core.models.ServiceId; import org.signal.core.models.ServiceId;
import org.signal.libsignal.net.BadRequestError; import org.signal.libsignal.net.BadRequestError;
@ -195,8 +194,8 @@ public class Utils {
} }
} else if (result instanceof RequestResult.RetryableNetworkError e) { } else if (result instanceof RequestResult.RetryableNetworkError e) {
throw e.getNetworkError(); throw e.getNetworkError();
} else if (result instanceof RequestResult.NonSuccess<?> e) { } else if (result instanceof RequestResult.NonSuccess) {
throw new BadRequestException(e.getError()); throw new AssertionError();
} }
throw new IllegalStateException("Unexpected value: " + result); throw new IllegalStateException("Unexpected value: " + result);
} }

View File

@ -1,289 +0,0 @@
package org.asamk.signal.manager.helper;
import org.asamk.signal.manager.api.AttachmentInvalidException;
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.ValueSource;
import org.whispersystems.signalservice.api.messages.SignalServiceAttachment;
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentPointer;
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentRemoteId;
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentStream;
import org.whispersystems.signalservice.api.util.StreamDetails;
import org.whispersystems.signalservice.internal.push.http.ResumableUploadSpec;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.IOException;
import java.io.PrintWriter;
import java.io.StringWriter;
import java.net.SocketException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Base64;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
class AttachmentHelperTest {
private static final String PAYLOAD = Base64.getEncoder()
.encodeToString("private attachment marker ".repeat(200).getBytes(StandardCharsets.UTF_8));
@TempDir
Path tempDir;
@Test
void preparationFailureIsAttachmentInvalidAndNothingIsUploaded() {
final var uploader = new FakeUploader();
uploader.failSpecOnCall = 1;
final var error = assertThrows(AttachmentInvalidException.class,
() -> helper(uploader).uploadAttachments(List.of(inline("one"), inline("two"))));
assertEquals("inline attachment #1: spec failed", error.getMessage());
assertRedacted(error);
assertEquals(0, uploader.uploads.size());
}
@Test
void uploadFailureOnSecondInlineAttachmentStopsBeforeTheThird() {
final var uploader = new FakeUploader();
uploader.failUploadOnCall = 2;
final var error = assertThrows(AttachmentInvalidException.class,
() -> helper(uploader).uploadAttachments(List.of(inline("one"), inline("two"), inline("three"))));
assertEquals("inline attachment #2: upload failed", error.getMessage());
assertSame(uploader.uploadFailure, error.getCause());
assertRedacted(error);
assertEquals(List.of("one", "two"), uploader.uploads);
}
@Test
void uploadFailureOnSecondFileAttachmentNamesThePath() throws IOException {
final var files = List.of(file("a.txt"), file("b.txt"), file("c.txt"));
final var uploader = new FakeUploader();
uploader.failUploadOnCall = 2;
final var error = assertThrows(AttachmentInvalidException.class,
() -> helper(uploader).uploadAttachments(files));
assertEquals(files.get(1) + ": upload failed", error.getMessage());
assertInstanceOf(SocketException.class, error.getCause());
assertEquals(List.of("a.txt", "b.txt"), uploader.uploads);
}
@Test
void successfulUploadsKeepInputOrder() throws Exception {
final var uploader = new FakeUploader();
final var pointers = helper(uploader).uploadAttachments(List.of(inline("one"),
file("two.txt"),
inline("three")));
assertEquals(List.of("one", "two.txt", "three"),
pointers.stream()
.map(SignalServiceAttachment::asPointer)
.map(p -> p.getFileName().orElseThrow())
.toList());
assertEquals(3, uploader.specs);
}
@Test
void malformedInlineAttachmentDoesNotExposePayload() {
final var error = assertThrows(AttachmentInvalidException.class,
() -> helper(new FakeUploader()).uploadAttachments(List.of("data:application/octet-stream;base64,"
+ PAYLOAD
+ "!")));
assertEquals("inline attachment #1: Invalid data URI", error.getMessage());
assertRedacted(error);
}
@Test
void upperCaseInlineAttachmentIsNotTreatedAsAFilePath() throws Exception {
// Longer than PATH_MAX, so canonicalizing it as a file path would fail
final var uploader = new FakeUploader();
final var pointers = helper(uploader).uploadAttachments(List.of(
"DATA:application/octet-stream;filename=upper;base64," + PAYLOAD));
assertEquals(1, pointers.size());
assertEquals(List.of("upper"), uploader.uploads);
}
@ParameterizedTest
@ValueSource(strings = {"data:text/plain;base64", "data://", "data:;base64//", "DATA://"})
void inlineAttachmentWithoutCommaIsNotExposed(final String prefix) {
// Short enough to reach file opening if inline input is incorrectly treated as a path.
final var error = assertThrows(AttachmentInvalidException.class,
() -> helper(new FakeUploader()).uploadAttachments(List.of(prefix + PAYLOAD.substring(0, 80))));
assertEquals("inline attachment #1: Invalid data URI", error.getMessage());
assertRedacted(error);
}
@Test
void longInlineAttachmentWithoutCommaIsRejected() {
final var error = assertThrows(AttachmentInvalidException.class,
() -> helper(new FakeUploader()).uploadAttachments(List.of("data:" + PAYLOAD.repeat(160))));
assertEquals("inline attachment #1: Invalid data URI", error.getMessage());
assertRedacted(error);
}
@Test
void defaultUploadUsesTheSameErrorContract() {
final var uploader = new FakeUploader();
uploader.failUploadOnCall = 1;
final var error = assertThrows(AttachmentInvalidException.class,
() -> helper(uploader).uploadAttachment(inline("image")));
assertEquals("inline attachment: upload failed", error.getMessage());
assertSame(uploader.uploadFailure, error.getCause());
assertRedacted(error);
}
@Test
void stickerPreparationFailureIsLabelled() {
final var uploader = new FakeUploader();
uploader.failSpecOnCall = 1;
final var stream = new ByteArrayInputStream(new byte[4]);
final var error = assertThrows(AttachmentInvalidException.class,
() -> helper(uploader).createStickerAttachmentStream(new StreamDetails(stream, "image/webp", 4)));
assertEquals("sticker: spec failed", error.getMessage());
}
@ParameterizedTest
@ValueSource(booleans = {true, false})
void longTextFailureNeverEchoesTheText(final boolean preparation) {
final var uploader = new FakeUploader();
uploader.failSpecOnCall = preparation ? 1 : 0;
uploader.failUploadOnCall = preparation ? 0 : 1;
final var error = assertThrows(AttachmentInvalidException.class,
() -> helper(uploader).uploadLongTextAttachment(PAYLOAD.getBytes(StandardCharsets.UTF_8)));
assertEquals("long text attachment: " + (preparation ? "spec" : "upload") + " failed", error.getMessage());
assertRedacted(error);
assertInstanceOf(SocketException.class, error.getCause());
}
@ParameterizedTest
@ValueSource(strings = {"quote thumbnail #1", "link preview image #1"})
void imageFailuresUseTheirLabel(final String label) {
final var uploader = new FakeUploader();
uploader.failSpecOnCall = 1;
final var helper = helper(uploader);
final var preparationError = assertThrows(AttachmentInvalidException.class,
() -> helper.uploadAttachment(inline("image"), label));
assertEquals(label + ": spec failed", preparationError.getMessage());
assertRedacted(preparationError);
assertEquals(0, uploader.uploads.size());
uploader.failUploadOnCall = 1;
final var uploadError = assertThrows(AttachmentInvalidException.class,
() -> helper.uploadAttachment(inline("image"), label));
assertEquals(label + ": upload failed", uploadError.getMessage());
assertRedacted(uploadError);
}
@Test
void filesInTheDataDirectoryAreStillRejected() throws IOException {
final var dataDir = Files.createDirectory(tempDir.resolve("data")).toFile();
final var secret = new File(dataDir, "account.db");
Files.writeString(secret.toPath(), "secret");
final var uploader = new FakeUploader();
final var error = assertThrows(AttachmentInvalidException.class,
() -> new AttachmentHelper(uploader, () -> dataDir).uploadAttachments(List.of(secret.getPath())));
assertEquals(secret.getPath() + ": Attaching files from the signal-cli data directory is not allowed",
error.getMessage());
assertEquals(0, uploader.specs);
}
@Test
void invalidWithoutCauseMessageFallsBackToClassName() {
final var cause = new SocketException();
final var error = AttachmentHelper.invalid("inline attachment #1", cause);
assertEquals("inline attachment #1: java.net.SocketException", error.getMessage());
assertSame(cause, error.getCause());
}
private AttachmentHelper helper(final FakeUploader uploader) {
return new AttachmentHelper(uploader, () -> tempDir.resolve("data").toFile());
}
private String file(final String name) throws IOException {
final var path = tempDir.resolve(name);
Files.writeString(path, PAYLOAD);
return path.toString();
}
private static String inline(final String filename) {
return "data:application/octet-stream;filename=" + filename + ";base64," + PAYLOAD;
}
private static void assertRedacted(final AttachmentInvalidException error) {
final var trace = new StringWriter();
error.printStackTrace(new PrintWriter(trace));
assertFalse(trace.toString().contains(PAYLOAD.substring(0, 32)));
}
private static final class FakeUploader implements AttachmentHelper.Uploader {
final IOException uploadFailure = new SocketException("upload failed");
int failSpecOnCall;
int failUploadOnCall;
int specs;
final List<String> uploads = new ArrayList<>();
@Override
public ResumableUploadSpec getResumableUploadSpec(final long ciphertextLength) throws IOException {
if (++specs == failSpecOnCall) {
throw new SocketException("spec failed");
}
return new ResumableUploadSpec(new byte[64],
new byte[16],
"cdn-key",
3,
"https://cdn.invalid/upload",
Long.MAX_VALUE,
Map.of());
}
@Override
public SignalServiceAttachmentPointer upload(final SignalServiceAttachmentStream stream) throws IOException {
final var fileName = stream.getFileName().orElse("");
uploads.add(fileName);
if (uploads.size() == failUploadOnCall) {
throw uploadFailure;
}
return new SignalServiceAttachmentPointer(3,
SignalServiceAttachmentRemoteId.from("cdn-key", 3),
stream.getContentType(),
new byte[64],
Optional.of((int) stream.getLength()),
Optional.empty(),
0,
0,
Optional.empty(),
Optional.empty(),
0,
Optional.of(fileName),
false,
false,
false,
Optional.empty(),
Optional.empty(),
0,
UUID.randomUUID());
}
}
}

View File

@ -1,22 +1,16 @@
package org.asamk.signal.manager.helper; package org.asamk.signal.manager.helper;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.asamk.signal.manager.api.CallInfo; import org.asamk.signal.manager.api.CallInfo;
import org.asamk.signal.manager.storage.SignalAccount;
import org.asamk.signal.manager.storage.recipients.TestRecipientId; import org.asamk.signal.manager.storage.recipients.TestRecipientId;
import org.asamk.signal.manager.util.Utils; import org.asamk.signal.manager.util.Utils;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.ValueSource; import org.junit.jupiter.params.provider.ValueSource;
import java.lang.invoke.MethodHandle; import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodHandles; import java.lang.invoke.MethodHandles;
import java.lang.invoke.MethodType; import java.lang.invoke.MethodType;
import java.math.BigInteger; import java.math.BigInteger;
import java.nio.channels.FileChannel;
import java.nio.channels.FileLock;
import java.nio.file.Path; import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertArrayEquals;
@ -29,8 +23,6 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
*/ */
class CallManagerTest { class CallManagerTest {
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
// --- Reflection helpers for private static methods --- // --- Reflection helpers for private static methods ---
private static final MethodHandle GET_RAW_IDENTITY_KEY_BYTES; private static final MethodHandle GET_RAW_IDENTITY_KEY_BYTES;
@ -185,38 +177,6 @@ class CallManagerTest {
assertTrue(foundDifferent, "generateCallId returned same value 21 times in a row"); assertTrue(foundDifferent, "generateCallId returned same value 21 times in a row");
} }
@Test
void buildConfig_usesProvidedLinkedDeviceId() throws Exception {
var state = makeCallState(123L, CallInfo.State.RINGING_OUTGOING);
var config = OBJECT_MAPPER.readTree(CallManager.buildConfig(state, 7));
assertEquals(7, config.get("local_device_id").intValue());
assertEquals(123L, config.get("call_id").longValue());
assertTrue(config.get("is_outgoing").booleanValue());
}
@ParameterizedTest
@CsvSource({"1, true", "1, false", "2, true", "2, false", "127, true", "127, false"})
void buildConfig_readsAccountDeviceId(int deviceId, boolean outgoing) throws Exception {
var constructor = SignalAccount.class.getDeclaredConstructor(FileChannel.class, FileLock.class);
constructor.setAccessible(true);
var account = constructor.newInstance(null, null);
var deviceField = SignalAccount.class.getDeclaredField("deviceId");
deviceField.setAccessible(true);
deviceField.setInt(account, deviceId);
var builder = CallManager.class.getDeclaredMethod("buildConfig", CallManager.CallState.class);
builder.setAccessible(true);
try (var manager = new CallManager(new Context(account, null, null, null, null, null))) {
var state = new CallManager.CallState(-1L, CallInfo.State.IDLE, null, null, outgoing);
var config = new ObjectMapper().readTree((String) builder.invoke(manager, state));
assertEquals(deviceId, config.path("local_device_id").asInt());
assertEquals(outgoing, config.path("is_outgoing").asBoolean());
assertEquals("18446744073709551615", config.path("call_id").asText());
}
}
// ======================================================================== // ========================================================================
// handleStateChange state machine tests // handleStateChange state machine tests
// //

View File

@ -1,57 +0,0 @@
package org.asamk.signal.manager.helper;
import org.asamk.signal.manager.Settings;
import org.asamk.signal.manager.api.ServiceEnvironment;
import org.asamk.signal.manager.storage.SignalAccount;
import org.asamk.signal.manager.util.KeyUtils;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.signal.core.models.ServiceId.ACI;
import org.signal.storageservice.storage.protos.groups.local.DecryptedGroup;
import org.signal.storageservice.storage.protos.groups.local.DecryptedMember;
import java.nio.file.Path;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
class NumberlessGroupTest {
@TempDir
Path directory;
@Test
void findsOurGroupMembershipByAciWhenPniIsAbsent() throws Exception {
final var aci = ACI.parseOrThrow("11111111-1111-4111-8111-111111111111");
final var otherAci = ACI.parseOrThrow("22222222-2222-4222-8222-222222222222");
try (final var account = SignalAccount.createLinkedAccount(directory.toFile(),
"account",
ServiceEnvironment.STAGING,
Settings.DEFAULT); final var context = new Context(account, null, null, null, null, null)) {
account.setProvisioningData(null,
aci,
null,
"test-password",
new byte[]{1},
KeyUtils.generateIdentityKeyPair(),
null,
KeyUtils.createProfileKey(),
null,
new byte[32],
null);
final var otherMember = new DecryptedMember.Builder().aciBytes(otherAci.toByteString())
.joinedAtRevision(1)
.build();
final var selfMember = new DecryptedMember.Builder().aciBytes(aci.toByteString())
.joinedAtRevision(4)
.build();
final var group = new DecryptedGroup.Builder().revision(9)
.members(List.of(otherMember, selfMember))
.build();
assertEquals(4, context.getGroupV2Helper().findRevisionWeWereAdded(group));
assertEquals(9,
context.getGroupV2Helper()
.findRevisionWeWereAdded(group.newBuilder().members(List.of(otherMember)).build()));
}
}
}

View File

@ -1,184 +0,0 @@
package org.asamk.signal.manager.internal;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.asamk.signal.manager.Settings;
import org.asamk.signal.manager.api.ServiceEnvironment;
import org.asamk.signal.manager.config.ServiceConfig;
import org.asamk.signal.manager.storage.SignalAccount;
import org.asamk.signal.manager.util.KeyUtils;
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.CsvSource;
import org.signal.core.models.ServiceId.ACI;
import org.signal.core.models.ServiceId.PNI;
import org.signal.network.api.RegistrationApiV2;
import org.signal.network.rest.SignalRestClient;
import org.whispersystems.signalservice.api.push.ServiceIdType;
import org.whispersystems.signalservice.api.push.exceptions.AuthorizationFailedException;
import org.whispersystems.signalservice.internal.push.ProvisionMessage;
import java.io.IOException;
import java.nio.file.Path;
import java.security.SecureRandom;
import java.util.concurrent.atomic.AtomicReference;
import okhttp3.Credentials;
import okhttp3.MediaType;
import okhttp3.OkHttpClient;
import okhttp3.Protocol;
import okhttp3.Request;
import okhttp3.Response;
import okhttp3.ResponseBody;
import okio.Buffer;
import okio.ByteString;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
class NumberlessProvisioningTest {
private static final ACI ACI_ID = ACI.parseOrThrow("11111111-1111-4111-8111-111111111111");
private static final PNI PNI_ID = PNI.parseOrThrow("22222222-2222-4222-8222-222222222222");
@TempDir
Path directory;
@Test
void numberlessProvisioningRequiresGroupCredentialSalt() {
assertThrows(IOException.class, () -> ProvisioningManagerImpl.parsePni(new ProvisionMessage.Builder().build()));
final var emptySalt = new ProvisionMessage.Builder().authCredentialSalt(ByteString.EMPTY).build();
assertThrows(IOException.class, () -> ProvisioningManagerImpl.parsePni(emptySalt));
assertNull(ProvisioningManagerImpl.toRegistrationPreKeys(null));
}
@Test
void acceptsNumberlessProvisioningButRejectsInconsistentPhoneIdentity() throws Exception {
final var message = new ProvisionMessage.Builder().authCredentialSalt(ByteString.of(new byte[32]));
assertNull(ProvisioningManagerImpl.parsePni(message.build()));
assertThrows(IOException.class, () -> ProvisioningManagerImpl.parsePni(message.pni(PNI_ID.toString()).build()));
assertThrows(IllegalArgumentException.class,
() -> ProvisioningManagerImpl.parsePni(new ProvisionMessage.Builder().number("+12025550123").build()));
assertEquals(PNI_ID,
ProvisioningManagerImpl.parsePni(new ProvisionMessage.Builder().number("+12025550123")
.pni(PNI_ID.toString())
.build()));
}
@Test
void numberlessLinkRequestOmitsPniAndAuthenticatesWithAci() throws Exception {
checkLinkRequest(null, null, 200, null);
}
@Test
void numberedLinkRequestStillIncludesPniKeys() throws Exception {
checkLinkRequest("+12025550123", PNI_ID, 200, null);
}
@ParameterizedTest
@CsvSource({
"422, Signal rejected the device linking request",
"403, [403] Device verification failed",
"409, Linked device is missing a required account capability",
"411, Account has reached its linked device limit",
"429, Device linking rate limited; try again later"
})
void linkingErrorsAreReportedWithoutCrashingOrIncludingServerBody(
final int statusCode,
final String errorMessage
) throws Exception {
checkLinkRequest(null, null, statusCode, errorMessage);
}
private void checkLinkRequest(
final String number,
final PNI pni,
final int statusCode,
final String errorMessage
) throws Exception {
final var request = new AtomicReference<Request>();
final var body = new AtomicReference<JsonNode>();
final var mapper = new ObjectMapper();
final var client = new OkHttpClient.Builder().addInterceptor(chain -> {
request.set(chain.request());
final var buffer = new Buffer();
chain.request().body().writeTo(buffer);
body.set(mapper.readTree(buffer.readUtf8()));
return new Response.Builder().request(chain.request())
.protocol(Protocol.HTTP_1_1)
.code(statusCode)
.message("Test response")
.body(ResponseBody.create(statusCode == 200 ? "{\"deviceId\":2}" : "secret payload",
MediaType.get("application/json")))
.build();
}).build();
final var config = ServiceConfig.getServiceEnvironmentConfig(ServiceEnvironment.STAGING, "signal-cli-test");
final var restClient = new SignalRestClient(config.signalServiceConfiguration(),
"signal-cli-test",
null,
false,
config.signalServiceConfiguration(),
() -> false,
1000L,
new SecureRandom(),
client);
final var api = new RegistrationApiV2(restClient, false);
try (final var account = SignalAccount.createLinkedAccount(directory.toFile(),
"account",
ServiceEnvironment.STAGING,
Settings.DEFAULT)) {
account.setProvisioningData(number,
ACI_ID,
pni,
"test-password",
new byte[]{1},
KeyUtils.generateIdentityKeyPair(),
pni == null ? null : KeyUtils.generateIdentityKeyPair(),
KeyUtils.createProfileKey(),
null,
new byte[32],
null);
final var aciKeys = KeyUtils.generatePreKeysForType(account.getAccountData(ServiceIdType.ACI));
final var pniKeys = pni == null
? null
: KeyUtils.generatePreKeysForType(account.getAccountData(ServiceIdType.PNI));
if (statusCode == 200) {
assertEquals(2,
ProvisioningManagerImpl.registerLinkedDevice(api, account, "test-code", aciKeys, pniKeys));
} else {
final var error = assertThrows(IOException.class,
() -> ProvisioningManagerImpl.registerLinkedDevice(api,
account,
"test-code",
aciKeys,
pniKeys));
assertEquals(errorMessage, error.getMessage());
if (statusCode == 403) {
assertInstanceOf(AuthorizationFailedException.class, error);
}
}
assertEquals("PUT", request.get().method());
assertEquals("/v1/devices/link", request.get().url().encodedPath());
assertEquals(Credentials.basic(ACI_ID.toString(), "test-password"), request.get().header("Authorization"));
assertTrue(body.get()
.path("accountAttributes")
.path("capabilities")
.path("optionalPhoneNumber")
.asBoolean());
assertTrue(body.get().hasNonNull("aciSignedPreKey"));
assertTrue(body.get().hasNonNull("aciPqLastResortPreKey"));
assertEquals(pni != null, body.get().has("pniSignedPreKey"));
assertEquals(pni != null, body.get().has("pniPqLastResortPreKey"));
assertEquals(pni != null, body.get().path("accountAttributes").has("pniRegistrationId"));
} finally {
client.dispatcher().executorService().shutdown();
client.connectionPool().evictAll();
}
}
}

View File

@ -1,290 +0,0 @@
package org.asamk.signal.manager.internal;
import org.asamk.signal.manager.Manager;
import org.asamk.signal.manager.Settings;
import org.asamk.signal.manager.api.MessageEnvelope;
import org.asamk.signal.manager.api.ServiceEnvironment;
import org.asamk.signal.manager.config.ServiceConfig;
import org.asamk.signal.manager.helper.AccountFileUpdater;
import org.asamk.signal.manager.helper.Context;
import org.asamk.signal.manager.helper.ReceiveHelper;
import org.asamk.signal.manager.storage.SignalAccount;
import org.asamk.signal.manager.util.KeyUtils;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.Timeout;
import org.junit.jupiter.api.io.TempDir;
import org.signal.core.models.ServiceId.ACI;
import org.whispersystems.signalservice.api.push.ServiceIdType;
import java.nio.file.Path;
import java.security.Security;
import java.time.Duration;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.BooleanSupplier;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
@Timeout(60)
class ReceiveThreadTest {
private static final Duration TIMEOUT = Duration.ofSeconds(10);
@TempDir
Path directory;
private ManagerImpl manager;
private FakeReceiveHelper receiveHelper;
@BeforeEach
void setUp() throws Exception {
// ManagerImpl.close() creates the push service socket, whose trust store needs BouncyCastle
if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) == null) {
Security.addProvider(new BouncyCastleProvider());
}
final var account = SignalAccount.createLinkedAccount(directory.toFile(),
"account",
ServiceEnvironment.STAGING,
Settings.DEFAULT);
account.setProvisioningData(null,
ACI.parseOrThrow("11111111-1111-4111-8111-111111111111"),
null,
"test-password",
new byte[]{1},
KeyUtils.generateIdentityKeyPair(),
null,
KeyUtils.createProfileKey(),
null,
new byte[32],
null);
account.finishLinking(2, KeyUtils.generatePreKeysForType(account.getAccountData(ServiceIdType.ACI)), null);
manager = new ManagerImpl(account,
PathConfig.createDefault(directory.toFile()),
new AccountFileUpdater() {
@Override
public void updateAccountIdentifiers(final String number, final ACI aci) {
}
@Override
public void removeAccount() {
}
},
ServiceConfig.getServiceEnvironmentConfig(ServiceEnvironment.STAGING, "signal-cli-test"),
"signal-cli-test");
// Replace the receive helper, so the receive thread doesn't connect to the server
final var contextField = ManagerImpl.class.getDeclaredField("context");
contextField.setAccessible(true);
final var context = (Context) contextField.get(manager);
receiveHelper = new FakeReceiveHelper(context);
final var receiveHelperField = Context.class.getDeclaredField("receiveHelper");
receiveHelperField.setAccessible(true);
receiveHelperField.set(context, receiveHelper);
}
@AfterEach
void tearDown() {
receiveHelper.setHoldStoppingLoops(false);
manager.close();
}
@Test
void handlerAddedWhileReceiveThreadIsStoppingDoesNotStartSecondReceiveThread() throws Exception {
final var first = newHandler();
final var second = newHandler();
manager.addReceiveHandler(first);
assertTrue(receiveHelper.await(() -> receiveHelper.runningLoops == 1));
receiveHelper.setHoldStoppingLoops(true);
final var remover = Thread.ofPlatform().start(() -> manager.removeReceiveHandler(first));
assertTrue(receiveHelper.await(() -> receiveHelper.stoppingLoops == 1));
// Like a client that reconnects while the receive thread for its previous connection is still stopping
manager.addReceiveHandler(second);
assertTrue(receiveHelper.await(() -> receiveHelper.startedThreads == 1));
receiveHelper.setHoldStoppingLoops(false);
assertTrue(remover.join(TIMEOUT));
assertTrue(receiveHelper.await(() -> receiveHelper.startedLoops == 2 && receiveHelper.runningLoops == 1));
assertTrue(receiveHelper.await(() -> receiveHelper.startedThreads == 2 && receiveHelper.maxRunningLoops == 1));
manager.removeReceiveHandler(second);
assertTrue(receiveHelper.await(() -> receiveHelper.runningLoops == 0));
}
@Test
void stopRequestedForStoppingReceiveThreadDoesNotStopNextReceiveThread() throws Exception {
final var first = newHandler();
final var second = newHandler();
final var third = newHandler();
manager.addReceiveHandler(first);
assertTrue(receiveHelper.await(() -> receiveHelper.runningLoops == 1));
receiveHelper.setHoldStoppingLoops(true);
final var firstRemover = Thread.ofPlatform().start(() -> manager.removeReceiveHandler(first));
assertTrue(receiveHelper.await(() -> receiveHelper.stoppingLoops == 1));
manager.addReceiveHandler(second);
final var secondRemover = Thread.ofPlatform().start(() -> manager.removeReceiveHandler(second));
assertTrue(receiveHelper.await(() -> receiveHelper.stopRequests == 2));
receiveHelper.setHoldStoppingLoops(false);
assertTrue(firstRemover.join(TIMEOUT));
assertTrue(secondRemover.join(TIMEOUT));
assertTrue(receiveHelper.await(() -> receiveHelper.runningLoops == 0 && receiveHelper.startedThreads == 1));
manager.addReceiveHandler(third);
assertTrue(receiveHelper.await(() -> receiveHelper.startedLoops == 2 && receiveHelper.runningLoops == 1));
assertTrue(receiveHelper.await(() -> receiveHelper.startedThreads == 2 && receiveHelper.maxRunningLoops == 1));
}
@Test
void lastHandlerStillGetsTheMessageInHandWhenItIsRemoved() throws Exception {
final var received = new AtomicInteger();
final var handler = new Manager.ReceiveMessageHandler() {
@Override
public void handleMessage(final MessageEnvelope envelope, final Throwable e) {
received.incrementAndGet();
}
};
manager.addReceiveHandler(handler);
assertTrue(receiveHelper.await(() -> receiveHelper.runningLoops == 1));
// Like an envelope that is already cached and acknowledged, and still being handled when the last
// handler goes: the loop passes it on before it stops
receiveHelper.setPassOnOneWhenStopped(true);
manager.removeReceiveHandler(handler);
assertEquals(1, received.get());
assertTrue(receiveHelper.await(() -> receiveHelper.runningLoops == 0 && receiveHelper.startedThreads == 1));
}
private static Manager.ReceiveMessageHandler newHandler() {
// An anonymous class instead of a lambda, so every handler is a separate instance
return new Manager.ReceiveMessageHandler() {
@Override
public void handleMessage(final MessageEnvelope envelope, final Throwable e) {
}
};
}
/**
* Instead of receiving messages from the server, a receive loop waits until it has been asked to stop.
* While holdStoppingLoops is set, a loop that has been asked to stop doesn't return yet, like a loop that
* is still busy when the last handler is removed.
*/
private static class FakeReceiveHelper extends ReceiveHelper {
private final Object lock = new Object();
private boolean stopRequested;
private boolean holdStoppingLoops;
private boolean passOnOneWhenStopped;
private int stopRequests;
private int startedThreads;
private int startedLoops;
private int runningLoops;
private int maxRunningLoops;
private int stoppingLoops;
FakeReceiveHelper(final Context context) {
super(context);
}
@Override
public boolean requestStopReceiveMessages() {
super.requestStopReceiveMessages();
synchronized (lock) {
stopRequested = true;
stopRequests++;
lock.notifyAll();
}
// The fake loop isn't blocked in a read that needs to be interrupted
return false;
}
@Override
public void clearStopRequest() {
super.clearStopRequest();
synchronized (lock) {
stopRequested = false;
}
}
@Override
public void receiveMessagesContinuously(final Manager.ReceiveMessageHandler handler) {
synchronized (lock) {
startedThreads++;
lock.notifyAll();
}
super.receiveMessagesContinuously(handler);
}
@Override
public void receiveMessages(
final Optional<Duration> timeout,
final Integer maxMessages,
final Manager.ReceiveMessageHandler handler
) {
synchronized (lock) {
startedLoops++;
runningLoops++;
maxRunningLoops = Math.max(maxRunningLoops, runningLoops);
lock.notifyAll();
if (await(() -> stopRequested)) {
if (passOnOneWhenStopped) {
handler.handleMessage(null, null);
}
stoppingLoops++;
lock.notifyAll();
await(() -> !holdStoppingLoops);
stoppingLoops--;
}
runningLoops--;
lock.notifyAll();
}
}
void setPassOnOneWhenStopped(final boolean passOn) {
synchronized (lock) {
passOnOneWhenStopped = passOn;
}
}
void setHoldStoppingLoops(final boolean hold) {
synchronized (lock) {
holdStoppingLoops = hold;
lock.notifyAll();
}
}
/**
* Waits until the condition, which is evaluated while holding the lock, is true.
* Returns false if it didn't become true within the timeout.
*/
boolean await(final BooleanSupplier condition) {
synchronized (lock) {
final var deadline = System.nanoTime() + TIMEOUT.toNanos();
while (!condition.getAsBoolean()) {
final var remainingMillis = (deadline - System.nanoTime()) / 1_000_000;
if (remainingMillis <= 0) {
return false;
}
try {
lock.wait(remainingMillis);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return false;
}
}
return true;
}
}
}
}

View File

@ -1,115 +0,0 @@
package org.asamk.signal.manager.storage;
import org.asamk.signal.manager.Settings;
import org.asamk.signal.manager.api.ServiceEnvironment;
import org.asamk.signal.manager.util.KeyUtils;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.signal.core.models.AccountEntropyPool;
import org.signal.core.models.ServiceId.ACI;
import org.whispersystems.signalservice.api.push.ServiceIdType;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
class NumberlessAccountTest {
@TempDir
Path directory;
@Test
void numberlessLinkedAccountSurvivesReload() throws Exception {
final var aci = ACI.parseOrThrow("11111111-1111-4111-8111-111111111111");
final var identity = KeyUtils.generateIdentityKeyPair();
final var salt = new byte[32];
salt[0] = 42;
try (final var account = SignalAccount.createLinkedAccount(directory.toFile(),
"account",
ServiceEnvironment.STAGING,
Settings.DEFAULT)) {
account.setProvisioningData(null,
aci,
null,
"test-password",
new byte[]{1},
identity,
null,
KeyUtils.createProfileKey(),
null,
salt,
null);
account.finishLinking(2, KeyUtils.generatePreKeysForType(account.getAccountData(ServiceIdType.ACI)), null);
}
try (final var account = SignalAccount.load(directory.toFile(), "account", true, Settings.DEFAULT)) {
assertTrue(account.isRegistered());
assertFalse(account.isPrimaryDevice());
assertEquals(2, account.getDeviceId());
assertEquals(aci, account.getAci());
assertNull(account.getNumber());
assertNull(account.getPni());
assertNull(account.getPniIdentityKeyPair());
assertNull(account.getSignalServiceDataStore().pniOrNull());
assertArrayEquals(identity.serialize(), account.getAciIdentityKeyPair().serialize());
assertArrayEquals(salt, account.getAuthCredentialSalt());
assertNull(account.getAccountAttributesV2().getPniRegistrationId());
assertNull(account.getAccountAttributesV2().getDiscoverableByPhoneNumber());
assertTrue(account.getAccountAttributesV2().getCapabilities().getOptionalPhoneNumber());
}
}
@Test
void recoveredNumberlessPrimaryPreservesRecoveryMaterialAndDropsPniIdentity() throws Exception {
final var aci = ACI.parseOrThrow("11111111-1111-4111-8111-111111111111");
final var entropyPool = AccountEntropyPool.Companion.generate();
final var salt = new byte[32];
salt[0] = 42;
try (final var account = SignalAccount.create(directory.toFile(),
"account",
null,
aci,
ServiceEnvironment.STAGING,
KeyUtils.generateIdentityKeyPair(),
KeyUtils.generateIdentityKeyPair(),
KeyUtils.createProfileKey(),
Settings.DEFAULT)) {
final var aciPreKeys = KeyUtils.generatePreKeysForType(account.getAccountData(ServiceIdType.ACI));
final var pniPreKeys = KeyUtils.generatePreKeysForType(account.getAccountData(ServiceIdType.PNI));
account.finishRecoveryRegistration(aci, null, null, entropyPool, salt, aciPreKeys, pniPreKeys);
}
try (final var account = SignalAccount.load(directory.toFile(), "account", true, Settings.DEFAULT)) {
assertTrue(account.isRegistered());
assertTrue(account.isPrimaryDevice());
assertEquals(aci, account.getAci());
assertNull(account.getNumber());
assertNull(account.getPni());
assertNull(account.getPniIdentityKeyPair());
assertEquals(entropyPool.getValue(), account.getAccountEntropyPool().getValue());
assertArrayEquals(salt, account.getAuthCredentialSalt());
assertTrue(account.getAccountAttributesV2().getCapabilities().getOptionalPhoneNumber());
}
}
@Test
void recoveryAttributesOmitPhoneNumberDiscoverabilityForNumberedAccount() throws Exception {
final var aci = ACI.parseOrThrow("11111111-1111-4111-8111-111111111111");
try (final var account = SignalAccount.create(directory.toFile(),
"account",
"+12025550123",
aci,
ServiceEnvironment.STAGING,
KeyUtils.generateIdentityKeyPair(),
KeyUtils.generateIdentityKeyPair(),
KeyUtils.createProfileKey(),
Settings.DEFAULT)) {
assertNull(account.getAccountAttributesV2ForRecovery(null, "recovery-password")
.getDiscoverableByPhoneNumber());
}
}
}

View File

@ -1,81 +0,0 @@
package org.asamk.signal.manager.storage.accounts;
import org.asamk.signal.manager.api.ServiceEnvironment;
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.ValueSource;
import org.signal.core.models.ServiceId.ACI;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Set;
import java.util.stream.Collectors;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
class AccountsStoreTest {
private static final ACI OLD_ACI = ACI.parseOrThrow("11111111-1111-4111-8111-111111111111");
private static final ACI NEW_ACI = ACI.parseOrThrow("22222222-2222-4222-8222-222222222222");
@TempDir
Path directory;
@Test
void discoversNumberlessAccountsByAciAndKeepsEnvironmentsSeparate() throws Exception {
final var store = new AccountsStore(directory.toFile(), ServiceEnvironment.STAGING, path -> null);
final var aci = ACI.parseOrThrow("11111111-1111-4111-8111-111111111111");
final var path = store.addAccount(null, aci);
store.addAccount("+12025550123", null);
final var reopened = new AccountsStore(directory.toFile(), ServiceEnvironment.STAGING, ignored -> null);
assertEquals(2, reopened.getAllAccounts().size());
assertEquals(path, reopened.getPathByAci(aci));
assertNull(reopened.getPathByNumber(null));
assertEquals(Set.of("+12025550123"), reopened.getAllNumbers());
assertTrue(new AccountsStore(directory.toFile(), ServiceEnvironment.LIVE, ignored -> null).getAllAccounts()
.isEmpty());
}
@Test
void updatingAnAciDoesNotLeaveDuplicateNumberlessAccounts() throws Exception {
final var store = new AccountsStore(directory.toFile(), ServiceEnvironment.STAGING, path -> null);
final var aci = ACI.parseOrThrow("11111111-1111-4111-8111-111111111111");
final var oldPath = store.addAccount(null, aci);
Files.createFile(directory.resolve(oldPath));
final var newPath = store.addAccount(null, null);
store.updateAccount(newPath, null, aci);
assertEquals(newPath, store.getPathByAci(aci));
assertEquals(1, store.getAllAccounts().size());
}
@ParameterizedTest
@ValueSource(booleans = {false, true})
void replacingANumberKeepsTheOldAccountAvailableByAci(final boolean update) throws Exception {
final var store = new AccountsStore(directory.toFile(), ServiceEnvironment.STAGING, path -> null);
final var oldPath = store.addAccount("+12025550123", OLD_ACI);
Files.createFile(directory.resolve(oldPath));
final String newPath;
if (update) {
newPath = store.addAccount("+12025550124", NEW_ACI);
store.updateAccount(newPath, "+12025550123", NEW_ACI);
} else {
newPath = store.addAccount("+12025550123", NEW_ACI);
}
final var reopened = new AccountsStore(directory.toFile(), ServiceEnvironment.STAGING, path -> null);
assertEquals(Set.of(oldPath, newPath), getAccountPaths(reopened));
assertEquals(newPath, reopened.getPathByNumber("+12025550123"));
assertEquals(oldPath, reopened.getPathByAci(OLD_ACI));
}
private Set<String> getAccountPaths(final AccountsStore store) throws IOException {
return store.getAllAccounts().stream().map(AccountsStorage.Account::path).collect(Collectors.toSet());
}
}

View File

@ -1,6 +1,5 @@
package org.asamk.signal.manager.util; package org.asamk.signal.manager.util;
import org.asamk.signal.manager.api.Message.AttachmentDimensions;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.whispersystems.signalservice.api.util.StreamDetails; import org.whispersystems.signalservice.api.util.StreamDetails;
@ -44,42 +43,6 @@ class AttachmentUtilsTest {
assertArrayEquals(bytes, attachment.getInputStream().readAllBytes()); assertArrayEquals(bytes, attachment.getInputStream().readAllBytes());
} }
@Test
public void createAttachmentStream_setsSuppliedDimensionsAndBlurHash() throws Exception {
final var bytes = "opaque video bytes".getBytes();
final var blurHash = "LEHV6nWB2yk8pyo0adR*.7kCMdnj";
final var details = new StreamDetails(new ByteArrayInputStream(bytes), "video/mp4", bytes.length);
final var attachment = AttachmentUtils.createAttachmentStream(details,
Optional.of("clip.mp4"),
false,
new AttachmentDimensions(1080, 1920),
blurHash,
null);
assertEquals(1080, attachment.getWidth());
assertEquals(1920, attachment.getHeight());
assertEquals(Optional.of(blurHash), attachment.getBlurHash());
assertArrayEquals(bytes, attachment.getInputStream().readAllBytes());
}
@Test
public void createAttachmentStream_skipsProbingWhenDimensionsSupplied() throws Exception {
final var imageBytes = pngBytes(37, 21);
final var stream = new ByteArrayInputStream(imageBytes);
final var details = new StreamDetails(stream, "image/png", imageBytes.length);
final var attachment = AttachmentUtils.createAttachmentStream(details,
Optional.of("meme.png"),
false,
new AttachmentDimensions(100, 200),
null,
null);
assertEquals(imageBytes.length, stream.available());
assertEquals(100, attachment.getWidth());
assertEquals(200, attachment.getHeight());
assertArrayEquals(imageBytes, attachment.getInputStream().readAllBytes());
}
private static byte[] pngBytes(final int width, final int height) throws Exception { private static byte[] pngBytes(final int width, final int height) throws Exception {
final var image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB); final var image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
final var out = new ByteArrayOutputStream(); final var out = new ByteArrayOutputStream();

View File

@ -1,25 +0,0 @@
package org.asamk.signal.manager.util;
import org.asamk.signal.manager.api.InvalidNumberException;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
class PhoneNumberFormatterTest {
@Test
void numberlessAccountsCanAddressInternationalPhoneNumbers() throws Exception {
assertEquals("+12025550123", PhoneNumberFormatter.formatNumber("+1 (202) 555-0123", null));
}
@Test
void numberlessAccountsCannotInferACountryCode() {
assertThrows(InvalidNumberException.class, () -> PhoneNumberFormatter.formatNumber("2025550123", null));
}
@Test
void numberedAccountsStillInferTheirCountryCode() throws Exception {
assertEquals("+12025550123", PhoneNumberFormatter.formatNumber("2025550123", "+12025550124"));
}
}

View File

@ -1 +1 @@
0.105.4 0.102.1

View File

@ -15,13 +15,9 @@ signal-cli-dbus - A commandline and dbus interface for the Signal messenger
*signal-cli* [--verbose] [--data-dir DATA_DIR] [-a ACCOUNT] [-o {plain-text,json}] daemon [--dbus] [--dbus-system] *signal-cli* [--verbose] [--data-dir DATA_DIR] [-a ACCOUNT] [-o {plain-text,json}] daemon [--dbus] [--dbus-system]
*dbus-send* [--system | --session] [--print-reply] --type=method_call --dest="org.asamk.Signal" /org/asamk/Signal[/<account-id>] org.asamk.Signal.<method> [string:<string argument>] [array:<type>:<array argument>] *dbus-send* [--system | --session] [--print-reply] --type=method_call --dest="org.asamk.Signal" /org/asamk/Signal[/_<phonenumber>] org.asamk.Signal.<method> [string:<string argument>] [array:<type>:<array argument>]
Note: when daemon was started without explicit `-a ACCOUNT`, use the account's Note: when daemon was started without explicit `-a ACCOUNT`, the `dbus-send` command requires adding the phone number in `/org/asamk/Signal/_<phonenumber>`.
object path returned by `org.asamk.SignalControl.listAccounts` or
`org.asamk.SignalControl.getAccount`. Numbered accounts use
`/org/asamk/Signal/_<phonenumber>`. Accounts without a phone number use
`/org/asamk/Signal/<ACI>`, with hyphens replaced by underscores.
== Description == Description
@ -50,16 +46,9 @@ The final parenthetical value (such as "boolean:") is the type indicator used by
Exceptions are the names of the Java Exceptions returned in the body field. Exceptions are the names of the Java Exceptions returned in the body field.
They typically contain an additional message with details. They typically contain an additional message with details.
All Exceptions begin with "org.asamk.Signal.Error." which is omitted here for better readability. All Exceptions begin with "org.asamk.Signal.Error." which is omitted here for better readability.
When sending a message, AttachmentInvalid means an attachment could not be read or uploaded, and the message
was not sent to anyone; Failure may mean that it reached some recipients.
Phone numbers always have the format +<countrycode><regional number> Phone numbers always have the format +<countrycode><regional number>
For an account without a phone number, use its ACI (Account ID) with the
`signal-cli --dbus -a ACCOUNT` client. ACI selectors also work for numbered accounts.
An account without a phone number cannot infer the country code of a recipient;
use a complete international phone number, an ACI, or a Signal username.
== Methods == Methods
=== SignalControl interface === SignalControl interface
@ -84,29 +73,11 @@ For example:
dbus-send --session --dest=org.asamk.Signal --type=method_call --print-reply /org/asamk/Signal org.asamk.Signal.link string:"My secondary client" | tr '\n' '\0' | sed 's/.*string //g' | sed 's/\"//g' | qrencode -s10 -tANSI256 dbus-send --session --dest=org.asamk.Signal --type=method_call --print-reply /org/asamk/Signal org.asamk.Signal.link string:"My secondary client" | tr '\n' '\0' | sed 's/.*string //g' | sed 's/\"//g' | qrencode -s10 -tANSI256
---- ----
listAccounts() -> accountList<ao>:: listAccounts() -> accountList<as>::
* accountList : Array of all attached accounts in DBus object path form * accountList : Array of all attached accounts in DBus object path form
Exceptions: None Exceptions: None
getAccount(identifier<s>) -> account<o>::
* identifier : Phone number or ACI of an attached account
* account : DBus object path of the account
Exceptions: org.asamk.SignalControl.Error.Failure if the account is unknown
startLink() -> deviceLinkUri<s>::
* deviceLinkUri : Provisioning URI to scan with the primary Signal device
finishLink(deviceLinkUri<s>, newDeviceName<s>) -> identifier<s>::
* deviceLinkUri : URI returned by startLink
* newDeviceName : Name for the linked device
* identifier : Phone number of the linked account, or its ACI when it has no phone number
Both linking methods support accounts with or without a phone number.
Exceptions: org.asamk.SignalControl.Error.Failure
register(number<s>, voiceVerification<b>) -> <>:: register(number<s>, voiceVerification<b>) -> <>::
* number : Phone number * number : Phone number
* voiceVerification : true = use voice verification; false = use SMS verification * voiceVerification : true = use voice verification; false = use SMS verification
@ -144,10 +115,9 @@ Exceptions: None
The following methods listen to the account's object path, which is constructed as follows: The following methods listen to the account's object path, which is constructed as follows:
- for single-account mode: "/org/asamk/Signal" - for single-account mode: "/org/asamk/Signal/"
- for multi-account mode: "/org/asamk/Signal/" + DBusIdentifier - for multi-account mode: "/org/asamk/Signal/" + DBusNumber
* DBusIdentifier: account's phone number with underscore (_) replacing plus (+), * DBusNumber: account's phone number, with underscore (_) replacing plus (+)
or its ACI with underscores replacing hyphens when there is no phone number
getContactName(number<s>) -> name<s>:: getContactName(number<s>) -> name<s>::
* number : Phone number * number : Phone number
@ -165,12 +135,7 @@ May result in e.g. two entries if a contact and profile name is set.
Exceptions: None Exceptions: None
getSelfNumber() -> number<s>:: getSelfNumber() -> number<s>::
* number : Your phone number, or an empty string for an account without a phone number * number : Your phone number
Exceptions: None
getSelfACI() -> aci<s>::
* aci : Your account's ACI (Account ID), available with or without a phone number
Exceptions: None Exceptions: None
@ -202,14 +167,6 @@ This is a concatenated list of all defined contacts as well of profiles known (e
Exceptions: None Exceptions: None
listRecipientIdentifiers() -> identifiers<as>::
* identifiers : String array of known recipient identifiers, including recipients without a phone number
Uses phone numbers where known and includes ACIs for recipients without a phone
number. Unlike `listNumbers`, this method also includes numberless recipients.
Exceptions: None
removePin() -> <>:: removePin() -> <>::
Removes registration PIN protection. Removes registration PIN protection.

View File

@ -38,11 +38,6 @@ With `--http` signal-cli exposes three endpoints;
* GET /api/v1/events : Returns a Server-Sent Events (SSE) stream of incoming messages * GET /api/v1/events : Returns a Server-Sent Events (SSE) stream of incoming messages
* GET /api/v1/check : Responds with 200 OK if daemon is running * GET /api/v1/check : Responds with 200 OK if daemon is running
Each event of `/api/v1/events` has an `id`.
signal-cli keeps the last 1000 events in memory.
A client that reconnects with a `Last-Event-ID` header first receives the events after that id.
If the id is unknown, e.g. because signal-cli was restarted, all kept events are sent.
== Basic usage == Basic usage
In JSON-RPC mode, signal-cli will read requests from stdin. In JSON-RPC mode, signal-cli will read requests from stdin.
@ -81,9 +76,6 @@ Messages are then sent similar to the automatic mode, but wrapped in a subscript
When the daemon/jsonRpc command is started without an account parameter (-a), signal-cli will provide all local accounts and additional commands to register (`register`) and link (`startLink`, `finishLink`) new accounts. When the daemon/jsonRpc command is started without an account parameter (-a), signal-cli will provide all local accounts and additional commands to register (`register`) and link (`startLink`, `finishLink`) new accounts.
In multi-account mode, requests for a single account require an additional `account` param. In multi-account mode, requests for a single account require an additional `account` param.
For an account without a phone number, this parameter must be its ACI (Account ID).
`listAccounts` returns the ACI in `aci` and a null `number` for these accounts.
Receive events identify them by ACI in the `account` field.
REQUEST: `{"jsonrpc":"2.0","method":"listGroups","id":"my special mark","params":{"account":"+33123456789"}}` REQUEST: `{"jsonrpc":"2.0","method":"listGroups","id":"my special mark","params":{"account":"+33123456789"}}`
@ -140,10 +132,7 @@ Params:
REQUEST: `{"jsonrpc":"2.0","method":"finishLink","id":"6","params":{"deviceLinkUri":"sgnl://linkdevice?uuid=X&pub_key=X","deviceName":"new-name"}}` REQUEST: `{"jsonrpc":"2.0","method":"finishLink","id":"6","params":{"deviceLinkUri":"sgnl://linkdevice?uuid=X&pub_key=X","deviceName":"new-name"}}`
RESPONSE: `{"jsonrpc":"2.0","result":{"number":null,"aci":"11111111-1111-4111-8111-111111111111"},"id":"6"}` RESPONSE: `{"jsonrpc":"2.0","result":{"deviceLinkUri":"sgnl://linkdevice?uuid=X&pub_key=X"},"id":"6"}`
The `number` field contains the phone number for a numbered account. Linking a numberless
account uses the same QR flow; no Account Key is supplied to `finishLink`.
== Examples == Examples

View File

@ -19,8 +19,7 @@ signal-cli - A commandline interface for the Signal messenger
signal-cli is a commandline interface for libsignal-service-java. signal-cli is a commandline interface for libsignal-service-java.
It supports registering, verifying, sending and receiving messages. It supports registering, verifying, sending and receiving messages.
Accounts can be registered using a phone number that can receive SMS or incoming calls. For registering you need a phone number where you can receive SMS or incoming calls.
Existing accounts can also be recovered using the Account Key and Recovery Key shown by Signal Android.
signal-cli was primarily developed to be used on servers to notify admins of important events. signal-cli was primarily developed to be used on servers to notify admins of important events.
For this use-case, it has a dbus and a JSON-RPC interface, that can be used to send messages from other programs. For this use-case, it has a dbus and a JSON-RPC interface, that can be used to send messages from other programs.
@ -66,8 +65,6 @@ Make sure you have full read/write access to the given directory.
*-a* ACCOUNT, *--account* ACCOUNT:: *-a* ACCOUNT, *--account* ACCOUNT::
Specify your phone number, that will be your identifier. Specify your phone number, that will be your identifier.
The phone number must include the country calling code, i.e. the number must start with a "+" sign. The phone number must include the country calling code, i.e. the number must start with a "+" sign.
An ACI (the Account Key shown by Signal Android) can also select an account and is required for an account without a phone number.
The Account Key can be given as 32 hexadecimal characters or as a UUID with dashes.
This flag must not be given for the `link` command. This flag must not be given for the `link` command.
It is optional for the `daemon` command. It is optional for the `daemon` command.
@ -105,13 +102,12 @@ Disable message send log (for resending messages that recipient couldn't decrypt
=== register === register
Register a phone number with SMS or voice verification, or recover an existing account using its Account Key and Recovery Key. Register a phone number with SMS or voice verification.
Use the verify command to complete SMS or voice verification. Use the verify command to complete the verification.
If the account is just deactivated, the register command will just reactivate account, without requiring an SMS verification. If the account is just deactivated, the register command will just reactivate account, without requiring an SMS verification.
By default the unregister command just deactivates the account, in which case it can be reactivated without sms verification if the local data is still available. By default the unregister command just deactivates the account, in which case it can be reactivated without sms verification if the local data is still available.
If the account was deleted (with --delete-account) it cannot be reactivated. If the account was deleted (with --delete-account) it cannot be reactivated.
If normal recovery of an existing local account fails and its Account Key and Recovery Key are available locally, recovery using those credentials is attempted before SMS verification.
*-v*, *--voice*:: *-v*, *--voice*::
The verification should be done over voice, not SMS. The verification should be done over voice, not SMS.
@ -124,23 +120,11 @@ For the staging environment, use: https://signalcaptchas.org/staging/registratio
After solving the captcha, right-click on the "Open Signal" link and copy the link. After solving the captcha, right-click on the "Open Signal" link and copy the link.
*--reregister*:: *--reregister*::
Register even if the local account is already registered. Register even if account is already registered.
This option is required when using `--recovery-key` with an account that is still marked as registered locally.
*--recovery-key* RECOVERY-KEY::
Recover an existing account using the 64-character Recovery Key shown by Signal Android.
Specify the corresponding ACI (Account Key) with `-a`.
This option cannot be combined with `--voice` or `--captcha`.
*--totp* TOKEN::
The six-digit TOTP token requested during Account Key and Recovery Key based account recovery.
If automatic account recovery reports that a TOTP token is required, rerun `register` with both `--recovery-key` and `--totp`.
This option requires `--recovery-key`.
This option cannot be combined with `--voice` or `--captcha`.
=== verify === verify
Verify a number using the code received via SMS or voice. Verify the number using the code received via SMS or voice.
VERIFICATIONCODE:: VERIFICATIONCODE::
The verification code. The verification code.
@ -263,12 +247,7 @@ Remove the registration lock pin.
Link to an existing device, instead of registering a new number. Link to an existing device, instead of registering a new number.
This shows a "sgnl://linkdevice?uuid=..." URI. This shows a "sgnl://linkdevice?uuid=..." URI.
If you want to connect to another signal-cli instance, you can just use this URI. If you want to connect to another signal-cli instance, you can just use this URI.
If you want to link to an Android/iOS device, scan the QR code that signal-cli prints in the terminal with the Signal app. If you want to link to an Android/iOS device, create a QR code with the URI (e.g. with qrencode) and scan that in the Signal app.
If signal-cli is not running in a terminal, only the URI is printed and you can create a QR code from it yourself (e.g. with qrencode).
Accounts without a phone number can also be linked.
After linking, use the account's ACI (Account Key) with `-a`; recipient phone numbers must include the international country code.
Existing primary accounts without a phone number can be recovered using the `register --recovery-key` command.
*-n* NAME, *--name* NAME:: *-n* NAME, *--name* NAME::
Optionally specify a name to describe this new device. Optionally specify a name to describe this new device.
@ -365,15 +344,6 @@ Data URI encoded attachments must follow the RFC 2397.
Additionally a file name can be added: Additionally a file name can be added:
e.g.: `data:<MIME-TYPE>;filename=<FILENAME>;base64,<BASE64 ENCODED DATA>` e.g.: `data:<MIME-TYPE>;filename=<FILENAME>;base64,<BASE64 ENCODED DATA>`
*--attachment-dimensions* [WIDTHxHEIGHT [WIDTHxHEIGHT ...]]::
Specify the displayed dimensions (after rotation) of the attachment at the same position in `--attachment`.
Use an empty string to skip one; image dimensions are otherwise detected automatically.
*--attachment-blurhash* [BLURHASH [BLURHASH ...]]::
Specify a BlurHash (https://blurha.sh) for the attachment at the same position in `--attachment`, which clients show while it's downloading.
Use an empty string to skip one.
e.g.: `--attachment a.jpg clip.mp4 --attachment-dimensions '' 1080x1920 --attachment-blurhash '' 'LEHV6nWB2yk8pyo0adR*.7kCMdnj'`
*--view-once*:: *--view-once*::
Send the message as a view once message. Send the message as a view once message.
A conformant client will only allow the receiver to view the message once. A conformant client will only allow the receiver to view the message once.
@ -1105,10 +1075,6 @@ Expose a JSON-RPC interface as http endpoint (default localhost:8080).
The JSON-RPC endpoint is `/api/v1/rpc`. + The JSON-RPC endpoint is `/api/v1/rpc`. +
See **signal-cli-jsonrpc**(5) for info on the JSON-RPC interface. See **signal-cli-jsonrpc**(5) for info on the JSON-RPC interface.
*--sse-keepalive-interval* SECONDS::
Seconds between keep-alive messages on the http events endpoint `/api/v1/events` (default 15).
A shorter interval lets signal-cli notice sooner that a client has disconnected.
*--dbus*:: *--dbus*::
Export DBus interface on user bus. + Export DBus interface on user bus. +
See **signal-cli-dbus**(5) for info on the dbus interface. See **signal-cli-dbus**(5) for info on the dbus interface.

View File

@ -285,7 +285,6 @@ done
run_main -a "$NUMBER_1" --output="$OUTPUT" receive run_main -a "$NUMBER_1" --output="$OUTPUT" receive
run_main -a "$NUMBER_1" removeDevice -d 2 || true run_main -a "$NUMBER_1" removeDevice -d 2 || true
run_main -a "$NUMBER_2" send "$NUMBER_1" -m "message-after-linked-device-removal"
run_main -a "$NUMBER_1" removeDevice -d 2 || true run_main -a "$NUMBER_1" removeDevice -d 2 || true
## Unregister ## Unregister

View File

@ -20,9 +20,6 @@ import java.util.Map;
*/ */
public interface Signal extends DBusInterface { public interface Signal extends DBusInterface {
/**
* Returns the account's phone number, or an empty string for a numberless account.
*/
String getSelfNumber(); String getSelfNumber();
String getSelfACI(); String getSelfACI();
@ -189,8 +186,6 @@ public interface Signal extends DBusInterface {
List<String> listNumbers(); List<String> listNumbers();
List<String> listRecipientIdentifiers();
List<String> getContactNumber(final String name) throws Error.Failure; List<String> getContactNumber(final String name) throws Error.Failure;
@Deprecated @Deprecated

View File

@ -37,10 +37,7 @@ public interface SignalControl extends DBusInterface {
List<DBusPath> listAccounts(); List<DBusPath> listAccounts();
/** DBusPath getAccount(String number);
* Resolves a phone number or ACI to an exported account's object path.
*/
DBusPath getAccount(String identifier) throws Error.Failure;
interface Error { interface Error {

View File

@ -1,32 +0,0 @@
package org.asamk.signal;
import java.util.Locale;
import java.util.UUID;
public final class AccountIdentifier {
private AccountIdentifier() {
}
public static String normalize(final String identifier) {
if (identifier == null) {
return null;
}
final var compact = identifier.replace("-", "").replaceAll("\\s", "").toLowerCase(Locale.ROOT);
if (compact.length() != 32 || !compact.matches("[0-9a-f]{32}")) {
return identifier;
}
final var uuid = "%s-%s-%s-%s-%s".formatted(compact.substring(0, 8),
compact.substring(8, 12),
compact.substring(12, 16),
compact.substring(16, 20),
compact.substring(20));
try {
return UUID.fromString(uuid).toString();
} catch (IllegalArgumentException e) {
return identifier;
}
}
}

View File

@ -142,7 +142,7 @@ public class App {
final var outputWriter = getOutputWriter(command); final var outputWriter = getOutputWriter(command);
final var commandHandler = new CommandHandler(ns, outputWriter); final var commandHandler = new CommandHandler(ns, outputWriter);
var account = AccountIdentifier.normalize(ns.getString("account")); var account = ns.getString("account");
final var useDbus = Boolean.TRUE.equals(ns.getBoolean("global-dbus")); final var useDbus = Boolean.TRUE.equals(ns.getBoolean("global-dbus"));
final var useDbusSystem = Boolean.TRUE.equals(ns.getBoolean("global-dbus-system")); final var useDbusSystem = Boolean.TRUE.equals(ns.getBoolean("global-dbus-system"));
@ -187,8 +187,8 @@ public class App {
} }
if (command instanceof RegistrationCommand registrationCommand) { if (command instanceof RegistrationCommand registrationCommand) {
if (!Manager.isValidNumber(account, null) && !Manager.isValidAci(account)) { if (!Manager.isValidNumber(account, null)) {
throw new UserErrorException("Invalid account (E164 phone number or Account Key)."); throw new UserErrorException("Invalid account (phone number), make sure you include the country code.");
} }
handleRegistrationCommand(registrationCommand, account, signalAccountFiles, commandHandler); handleRegistrationCommand(registrationCommand, account, signalAccountFiles, commandHandler);
return; return;
@ -208,15 +208,14 @@ public class App {
private static String getAccountIfOnlyOne(final SignalAccountFiles signalAccountFiles) throws IOErrorException, UserErrorException { private static String getAccountIfOnlyOne(final SignalAccountFiles signalAccountFiles) throws IOErrorException, UserErrorException {
Set<String> accounts; Set<String> accounts;
try { try {
accounts = signalAccountFiles.getAllLocalAccountIdentifiers(); accounts = signalAccountFiles.getAllLocalAccountNumbers();
} catch (IOException e) { } catch (IOException e) {
throw new IOErrorException("Failed to load local accounts file", e); throw new IOErrorException("Failed to load local accounts file", e);
} }
if (accounts.isEmpty()) { if (accounts.isEmpty()) {
throw new UserErrorException("No local users found, you first need to register or link an account"); throw new UserErrorException("No local users found, you first need to register or link an account");
} else if (accounts.size() > 1) { } else if (accounts.size() > 1) {
throw new UserErrorException( throw new UserErrorException("Multiple users found, you need to specify an account (phone number) with -a");
"Multiple users found, you need to specify an account (phone number or ACI) with -a");
} }
return accounts.stream().findFirst().get(); return accounts.stream().findFirst().get();
} }

View File

@ -8,7 +8,7 @@ public class BaseConfig {
public static final String PROJECT_VERSION = BaseConfig.class.getPackage().getImplementationVersion(); public static final String PROJECT_VERSION = BaseConfig.class.getPackage().getImplementationVersion();
static final String USER_AGENT_SIGNAL_ANDROID = Optional.ofNullable(System.getenv("SIGNAL_CLI_USER_AGENT")) static final String USER_AGENT_SIGNAL_ANDROID = Optional.ofNullable(System.getenv("SIGNAL_CLI_USER_AGENT"))
.orElse("Signal-Android/8.33.1"); .orElse("Signal-Android/8.27.0");
static final String USER_AGENT_SIGNAL_CLI = PROJECT_NAME == null static final String USER_AGENT_SIGNAL_CLI = PROJECT_NAME == null
? "signal-cli" ? "signal-cli"
: PROJECT_NAME + "/" + PROJECT_VERSION; : PROJECT_NAME + "/" + PROJECT_VERSION;

View File

@ -39,7 +39,7 @@ public class ReceiveMessageHandler implements Manager.ReceiveMessageHandler {
writer.println("Envelope from: {} (device: {}) to {}", writer.println("Envelope from: {} (device: {}) to {}",
source.map(this::formatContact).orElse("unknown source"), source.map(this::formatContact).orElse("unknown source"),
envelope.sourceDevice(), envelope.sourceDevice(),
m.getSelfIdentifier()); m.getSelfNumber());
writer.println("Timestamp: {}", DateUtils.formatTimestamp(envelope.timestamp())); writer.println("Timestamp: {}", DateUtils.formatTimestamp(envelope.timestamp()));
writer.println("Server timestamps: received: {} delivered: {}", writer.println("Server timestamps: received: {} delivered: {}",
DateUtils.formatTimestamp(envelope.serverReceivedTimestamp()), DateUtils.formatTimestamp(envelope.serverReceivedTimestamp()),
@ -55,13 +55,13 @@ public class ReceiveMessageHandler implements Manager.ReceiveMessageHandler {
final var recipientName = e.getSender().getLegacyIdentifier(); final var recipientName = e.getSender().getLegacyIdentifier();
writer.println( writer.println(
"Use 'signal-cli -a {} listIdentities -n {}', verify the key and run 'signal-cli -a {} trust -v \"FINGER_PRINT\" {}' to mark it as trusted", "Use 'signal-cli -a {} listIdentities -n {}', verify the key and run 'signal-cli -a {} trust -v \"FINGER_PRINT\" {}' to mark it as trusted",
m.getSelfIdentifier(), m.getSelfNumber(),
recipientName, recipientName,
m.getSelfIdentifier(), m.getSelfNumber(),
recipientName); recipientName);
writer.println( writer.println(
"If you don't care about security, use 'signal-cli -a {} trust -a {}' to trust it without verification", "If you don't care about security, use 'signal-cli -a {} trust -a {}' to trust it without verification",
m.getSelfIdentifier(), m.getSelfNumber(),
recipientName); recipientName);
} else { } else {
writer.println("Exception: {} ({})", exception.getMessage(), exception.getClass().getSimpleName()); writer.println("Exception: {} ({})", exception.getMessage(), exception.getClass().getSimpleName());

View File

@ -30,7 +30,6 @@ import java.net.InetSocketAddress;
import java.net.UnixDomainSocketAddress; import java.net.UnixDomainSocketAddress;
import java.nio.channels.Channel; import java.nio.channels.Channel;
import java.nio.channels.ServerSocketChannel; import java.nio.channels.ServerSocketChannel;
import java.time.Duration;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.SequencedCollection; import java.util.SequencedCollection;
@ -70,10 +69,6 @@ public class DaemonCommand implements MultiLocalCommand, LocalCommand {
.nargs("?") .nargs("?")
.setConst("localhost:8080") .setConst("localhost:8080")
.help("Expose a JSON-RPC interface as http endpoint (default localhost:8080)."); .help("Expose a JSON-RPC interface as http endpoint (default localhost:8080).");
subparser.addArgument("--sse-keepalive-interval")
.type(int.class)
.setDefault(15)
.help("Seconds between keep-alive messages on the http events endpoint (default 15).");
subparser.addArgument("--no-receive-stdout") subparser.addArgument("--no-receive-stdout")
.help("Don’t print received messages to stdout.") .help("Don’t print received messages to stdout.")
.action(Arguments.storeTrue()); .action(Arguments.storeTrue());
@ -193,11 +188,7 @@ public class DaemonCommand implements MultiLocalCommand, LocalCommand {
final var httpAddress = ns.getString("http"); final var httpAddress = ns.getString("http");
if (httpAddress != null) { if (httpAddress != null) {
final var address = IOUtils.parseInetSocketAddress(httpAddress); final var address = IOUtils.parseInetSocketAddress(httpAddress);
final var keepAliveInterval = ns.getInt("sse-keepalive-interval"); daemonHandler.runHttp(address);
if (keepAliveInterval < 1) {
throw new UserErrorException("--sse-keepalive-interval must be at least 1 second.");
}
daemonHandler.runHttp(address, Duration.ofSeconds(keepAliveInterval));
} }
final var isDbusSystem = Boolean.TRUE.equals(ns.getBoolean("dbus-system")); final var isDbusSystem = Boolean.TRUE.equals(ns.getBoolean("dbus-system"));
@ -244,7 +235,7 @@ public class DaemonCommand implements MultiLocalCommand, LocalCommand {
public abstract void runDbus(boolean isDbusSystem, final String busname) throws CommandException; public abstract void runDbus(boolean isDbusSystem, final String busname) throws CommandException;
public abstract void runHttp(InetSocketAddress address, Duration keepAliveInterval) throws CommandException; public abstract void runHttp(InetSocketAddress address) throws CommandException;
protected final void runSocket(final SocketHandler socketHandler) { protected final void runSocket(final SocketHandler socketHandler) {
socketHandler.init(); socketHandler.init();
@ -300,8 +291,8 @@ public class DaemonCommand implements MultiLocalCommand, LocalCommand {
} }
@Override @Override
public void runHttp(final InetSocketAddress address, final Duration keepAliveInterval) throws CommandException { public void runHttp(InetSocketAddress address) throws CommandException {
runHttp(new HttpServerHandler(address, keepAliveInterval, m)); runHttp(new HttpServerHandler(address, m));
} }
} }
@ -325,8 +316,8 @@ public class DaemonCommand implements MultiLocalCommand, LocalCommand {
} }
@Override @Override
public void runHttp(final InetSocketAddress address, final Duration keepAliveInterval) throws CommandException { public void runHttp(final InetSocketAddress address) throws CommandException {
runHttp(new HttpServerHandler(address, keepAliveInterval, c)); runHttp(new HttpServerHandler(address, c));
} }
} }
} }

View File

@ -54,9 +54,9 @@ public class FinishLinkCommand implements JsonRpcMultiCommand<FinishLinkCommand.
if (deviceName == null) { if (deviceName == null) {
deviceName = "cli"; deviceName = "cli";
} }
final String identifier; final String number;
try { try {
identifier = provisioningManager.finishDeviceLink(deviceName); number = provisioningManager.finishDeviceLink(deviceName);
} catch (TimeoutException e) { } catch (TimeoutException e) {
throw new UserErrorException("Link request timed out, please try again."); throw new UserErrorException("Link request timed out, please try again.");
} catch (IOException e) { } catch (IOException e) {
@ -73,11 +73,10 @@ public class FinishLinkCommand implements JsonRpcMultiCommand<FinishLinkCommand.
+ "\" before trying again."); + "\" before trying again.");
} }
final var manager = m.getManager(identifier); jsonWriter.write(new JsonFinishLink(number));
jsonWriter.write(new JsonFinishLink(manager.getSelfNumber(), manager.getSelfACI()));
} }
public record FinishLinkParams(String deviceLinkUri, String deviceName) {} public record FinishLinkParams(String deviceLinkUri, String deviceName) {}
private record JsonFinishLink(String number, String aci) {} private record JsonFinishLink(String number) {}
} }

View File

@ -27,22 +27,19 @@ public class ListAccountsCommand implements JsonRpcMultiLocalCommand {
final MultiAccountManager c, final MultiAccountManager c,
final OutputWriter outputWriter final OutputWriter outputWriter
) throws CommandException { ) throws CommandException {
final var managers = c.getManagers(); final var accountNumbers = c.getAccountNumbers();
switch (outputWriter) { switch (outputWriter) {
case JsonWriter jsonWriter -> { case JsonWriter jsonWriter -> {
final var jsonAccounts = managers.stream() final var jsonAccounts = accountNumbers.stream().map(JsonAccount::new).toList();
.map(m -> new JsonAccount(m.getSelfNumber(), m.getSelfACI()))
.toList();
jsonWriter.write(jsonAccounts); jsonWriter.write(jsonAccounts);
} }
case PlainTextWriter plainTextWriter -> { case PlainTextWriter plainTextWriter -> {
for (final var manager : managers) { for (final var number : accountNumbers) {
plainTextWriter.println(manager.getSelfNumber() != null ? "Number: {}" : "ACI: {}", plainTextWriter.println("Number: {}", number);
manager.getSelfIdentifier());
} }
} }
} }
} }
private record JsonAccount(String number, String aci) {} private record JsonAccount(String number) {}
} }

View File

@ -15,7 +15,6 @@ import org.asamk.signal.manager.RegistrationManager;
import org.asamk.signal.manager.api.CaptchaRequiredException; import org.asamk.signal.manager.api.CaptchaRequiredException;
import org.asamk.signal.manager.api.NonNormalizedPhoneNumberException; import org.asamk.signal.manager.api.NonNormalizedPhoneNumberException;
import org.asamk.signal.manager.api.RateLimitException; import org.asamk.signal.manager.api.RateLimitException;
import org.asamk.signal.manager.api.TwoFactorRequired;
import org.asamk.signal.manager.api.VerificationMethodNotAvailableException; import org.asamk.signal.manager.api.VerificationMethodNotAvailableException;
import org.asamk.signal.output.JsonWriter; import org.asamk.signal.output.JsonWriter;
import org.asamk.signal.util.CommandUtil; import org.asamk.signal.util.CommandUtil;
@ -42,8 +41,6 @@ public class RegisterCommand implements RegistrationCommand, JsonRpcRegistration
subparser.addArgument("--reregister") subparser.addArgument("--reregister")
.action(Arguments.storeTrue()) .action(Arguments.storeTrue())
.help("Register even if account is already registered"); .help("Register even if account is already registered");
subparser.addArgument("--recovery-key").help("Recover an account using its 64-character Signal Recovery Key.");
subparser.addArgument("--totp").help("A six-digit TOTP token required for account recovery.");
} }
@Override @Override
@ -51,10 +48,8 @@ public class RegisterCommand implements RegistrationCommand, JsonRpcRegistration
final boolean voiceVerification = Boolean.TRUE.equals(ns.getBoolean("voice")); final boolean voiceVerification = Boolean.TRUE.equals(ns.getBoolean("voice"));
final var captcha = ns.getString("captcha"); final var captcha = ns.getString("captcha");
final var reregister = Boolean.TRUE.equals(ns.getBoolean("reregister")); final var reregister = Boolean.TRUE.equals(ns.getBoolean("reregister"));
final var recoveryKey = ns.getString("recovery-key");
final var totp = ns.getString("totp");
register(m, voiceVerification, captcha, reregister, recoveryKey, totp); register(m, voiceVerification, captcha, reregister);
} }
@Override @Override
@ -73,53 +68,15 @@ public class RegisterCommand implements RegistrationCommand, JsonRpcRegistration
final RegistrationManager m, final RegistrationManager m,
final JsonWriter jsonWriter final JsonWriter jsonWriter
) throws CommandException { ) throws CommandException {
register(m, register(m, Boolean.TRUE.equals(request.voice()), request.captcha(), Boolean.TRUE.equals(request.reregister()));
Boolean.TRUE.equals(request.voice()),
request.captcha(),
Boolean.TRUE.equals(request.reregister()),
request.recoveryKey(),
request.totp());
} }
private void register( private void register(
final RegistrationManager m, final RegistrationManager m,
final boolean voiceVerification, final boolean voiceVerification,
final String captcha, final String captcha,
final boolean reregister, final boolean reregister
final String recoveryKey,
final String totpValue
) throws CommandException { ) throws CommandException {
if (recoveryKey == null && totpValue != null) {
throw new UserErrorException("--totp requires --recovery-key");
}
final Integer totp;
if (totpValue == null) {
totp = null;
} else if (!totpValue.matches("[0-9]{6}")) {
throw new UserErrorException("TOTP token must contain exactly six digits");
} else {
totp = Integer.parseInt(totpValue);
}
if (recoveryKey != null) {
if (voiceVerification || captcha != null) {
throw new UserErrorException("--recovery-key cannot be combined with --voice or --captcha");
}
try {
m.registerWithRecoveryKey(recoveryKey, reregister, totp);
} catch (RateLimitException e) {
final var message = CommandUtil.getRateLimitMessage(e);
throw new RateLimitErrorException(message, 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(),
e.getClass().getSimpleName()), e);
}
return;
}
try { try {
m.register(voiceVerification, captcha, reregister); m.register(voiceVerification, captcha, reregister);
} catch (RateLimitException e) { } catch (RateLimitException e) {
@ -130,9 +87,6 @@ public class RegisterCommand implements RegistrationCommand, JsonRpcRegistration
throw new UserErrorException(message); throw new UserErrorException(message);
} catch (NonNormalizedPhoneNumberException e) { } catch (NonNormalizedPhoneNumberException e) {
throw new UserErrorException("Failed to register: " + e.getMessage(), e); throw new UserErrorException("Failed to register: " + e.getMessage(), e);
} catch (TwoFactorRequired e) {
throw new UserErrorException(
"A TOTP token is required; rerun register with --recovery-key RECOVERY-KEY --totp TOKEN");
} catch (IOException e) { } catch (IOException e) {
throw new IOErrorException("Failed to register: %s (%s)".formatted(e.getMessage(), throw new IOErrorException("Failed to register: %s (%s)".formatted(e.getMessage(),
e.getClass().getSimpleName()), e); e.getClass().getSimpleName()), e);
@ -145,7 +99,5 @@ public class RegisterCommand implements RegistrationCommand, JsonRpcRegistration
} }
} }
public record RegistrationParams( public record RegistrationParams(Boolean voice, String captcha, Boolean reregister) {}
Boolean voice, String captcha, Boolean reregister, String recoveryKey, String totp
) {}
} }

View File

@ -36,7 +36,6 @@ import static org.asamk.signal.util.SendMessageResultUtils.outputResult;
public class SendCommand implements JsonRpcLocalCommand { public class SendCommand implements JsonRpcLocalCommand {
private static final Logger logger = LoggerFactory.getLogger(SendCommand.class); private static final Logger logger = LoggerFactory.getLogger(SendCommand.class);
private static final String BLURHASH_DIGITS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~";
@Override @Override
public String getName() { public String getName() {
@ -113,13 +112,6 @@ public class SendCommand implements JsonRpcLocalCommand {
subparser.addArgument("--voice-note") subparser.addArgument("--voice-note")
.action(Arguments.storeTrue()) .action(Arguments.storeTrue())
.help("Mark audio attachments as voice notes. Voice notes are displayed inline in Signal clients."); .help("Mark audio attachments as voice notes. Voice notes are displayed inline in Signal clients.");
subparser.addArgument("--attachment-dimensions")
.nargs("*")
.help("Specify displayed WIDTHxHEIGHT for each attachment in order. Use an empty string to skip one.");
subparser.addArgument("--attachment-blurhash")
.nargs("*")
.help("Specify a BlurHash (https://blurha.sh) for each attachment in order, which clients show "
+ "while it's downloading. Use an empty string to skip one.");
} }
@Override @Override
@ -183,13 +175,6 @@ public class SendCommand implements JsonRpcLocalCommand {
final var viewOnce = Boolean.TRUE.equals(ns.getBoolean("view-once")); final var viewOnce = Boolean.TRUE.equals(ns.getBoolean("view-once"));
final var voiceNote = Boolean.TRUE.equals(ns.getBoolean("voice-note")); final var voiceNote = Boolean.TRUE.equals(ns.getBoolean("voice-note"));
final var dimensionStrings = ns.<String>getList("attachment-dimensions");
final var attachmentDimensions = dimensionStrings == null
? List.<Message.AttachmentDimensions>of()
: parseAttachmentDimensions(dimensionStrings);
final var blurHashes = ns.<String>getList("attachment-blurhash");
final var attachmentBlurHashes = blurHashes == null ? List.<String>of() : parseAttachmentBlurHashes(blurHashes);
final var selfNumber = m.getSelfNumber(); final var selfNumber = m.getSelfNumber();
final var mentionStrings = ns.<String>getList("mention"); final var mentionStrings = ns.<String>getList("mention");
@ -264,8 +249,6 @@ public class SendCommand implements JsonRpcLocalCommand {
try { try {
final var message = new Message(messageText, final var message = new Message(messageText,
attachments, attachments,
attachmentDimensions,
attachmentBlurHashes,
viewOnce, viewOnce,
voiceNote, voiceNote,
mentions, mentions,
@ -297,53 +280,6 @@ public class SendCommand implements JsonRpcLocalCommand {
} }
} }
private List<Message.AttachmentDimensions> parseAttachmentDimensions(
final List<String> dimensionStrings
) throws UserErrorException {
final var dimensionPattern = Pattern.compile("([1-9]\\d*)x([1-9]\\d*)");
final var dimensions = new ArrayList<Message.AttachmentDimensions>();
for (final var dimension : dimensionStrings) {
if (dimension.isEmpty()) {
dimensions.add(null);
continue;
}
final var matcher = dimensionPattern.matcher(dimension);
if (!matcher.matches()) {
throw new UserErrorException("Invalid attachment dimensions syntax ("
+ dimension
+ ") expected 'WIDTHxHEIGHT'");
}
try {
dimensions.add(new Message.AttachmentDimensions(Integer.parseInt(matcher.group(1)),
Integer.parseInt(matcher.group(2))));
} catch (NumberFormatException e) {
throw new UserErrorException("Invalid attachment dimensions syntax ("
+ dimension
+ ") expected 'WIDTHxHEIGHT' with positive 32-bit integer values",
e);
}
}
return dimensions;
}
private List<String> parseAttachmentBlurHashes(final List<String> blurHashes) throws UserErrorException {
for (final var blurHash : blurHashes) {
if (!blurHash.isEmpty() && !isValidBlurHash(blurHash)) {
throw new UserErrorException("Invalid attachment BlurHash (" + blurHash + ")");
}
}
return blurHashes;
}
// Same check the BlurHash decoders make: the first digit fixes the length.
private static boolean isValidBlurHash(final String blurHash) {
if (blurHash.length() < 6) {
return false;
}
final var sizeFlag = BLURHASH_DIGITS.indexOf(blurHash.charAt(0));
return blurHash.length() == 4 + 2 * (sizeFlag % 9 + 1) * (sizeFlag / 9 + 1);
}
private List<Message.Mention> parseMentions( private List<Message.Mention> parseMentions(
final String selfNumber, final String selfNumber,
final List<String> mentionStrings final List<String> mentionStrings

View File

@ -55,7 +55,7 @@ public class DbusCommandHandler {
try { try {
if (command instanceof ProvisioningCommand c) { if (command instanceof ProvisioningCommand c) {
if (account != null) { if (account != null) {
throw new UserErrorException("You cannot specify an account when linking"); throw new UserErrorException("You cannot specify a account (phone number) when linking");
} }
handleProvisioningCommand(c, dBusConn, busname, commandHandler); handleProvisioningCommand(c, dBusConn, busname, commandHandler);
@ -74,9 +74,7 @@ public class DbusCommandHandler {
throw new UserErrorException("Command only works in multi-account mode"); throw new UserErrorException("Command only works in multi-account mode");
} }
var accountObjectPath = account == null var accountObjectPath = account == null ? tryGetSingleAccountObjectPath(dBusConn, busname) : null;
? tryGetSingleAccountObjectPath(dBusConn, busname)
: getAccountObjectPath(account, dBusConn, busname);
if (accountObjectPath == null) { if (accountObjectPath == null) {
accountObjectPath = DbusConfig.getObjectPath(account); accountObjectPath = DbusConfig.getObjectPath(account);
} }
@ -88,24 +86,6 @@ public class DbusCommandHandler {
} }
} }
private static String getAccountObjectPath(
final String account,
final DBusConnection dBusConn,
final String busname
) throws DBusException, CommandException {
final var control = dBusConn.getRemoteObject(busname, DbusConfig.getObjectPath(), SignalControl.class);
try {
return control.getAccount(account).getPath();
} catch (UnknownMethod e) {
// A single-account daemon exports Signal directly at the base path.
final var signal = dBusConn.getRemoteObject(busname, DbusConfig.getObjectPath(), Signal.class);
if (!account.equals(signal.getSelfNumber()) && !account.equals(signal.getSelfACI())) {
throw new UserErrorException("Unknown account: " + account);
}
return DbusConfig.getObjectPath();
}
}
private static String tryGetSingleAccountObjectPath( private static String tryGetSingleAccountObjectPath(
final DBusConnection dBusConn, final DBusConnection dBusConn,
final String busname final String busname
@ -117,7 +97,7 @@ public class DbusCommandHandler {
throw new UserErrorException("No local users found, you first need to register or link an account"); throw new UserErrorException("No local users found, you first need to register or link an account");
} else if (accounts.size() > 1) { } else if (accounts.size() > 1) {
throw new UserErrorException( throw new UserErrorException(
"Multiple users found, you need to specify an account (phone number or ACI) with -a"); "Multiple users found, you need to specify an account (phone number) with -a");
} }
return accounts.getFirst().getPath(); return accounts.getFirst().getPath();

View File

@ -67,7 +67,7 @@ public class DbusHandler implements AutoCloseable {
} }
}); });
c.addOnManagerRemovedHandler(m -> { c.addOnManagerRemovedHandler(m -> {
final var path = DbusConfig.getObjectPath(m.getSelfIdentifier()); final var path = DbusConfig.getObjectPath(m.getSelfNumber());
try { try {
final var object = connection.getExportedObject(null, path); final var object = connection.getExportedObject(null, path);
if (object instanceof DbusSignalImpl dbusSignal) { if (object instanceof DbusSignalImpl dbusSignal) {
@ -135,11 +135,11 @@ public class DbusHandler implements AutoCloseable {
final var signal = new DbusSignalImpl(m, conn, objectPath, noReceiveOnStart); final var signal = new DbusSignalImpl(m, conn, objectPath, noReceiveOnStart);
closeables.add(signal); closeables.add(signal);
return Thread.ofPlatform().name("dbus-init-" + m.getSelfIdentifier()).start(signal::initObjects); return Thread.ofPlatform().name("dbus-init-" + m.getSelfNumber()).start(signal::initObjects);
} }
private Thread exportManager(final DBusConnection conn, final Manager m) { private Thread exportManager(final DBusConnection conn, final Manager m) {
final var objectPath = DbusConfig.getObjectPath(m.getSelfIdentifier()); final var objectPath = DbusConfig.getObjectPath(m.getSelfNumber());
return exportDbusObject(conn, objectPath, m); return exportDbusObject(conn, objectPath, m);
} }

View File

@ -77,7 +77,6 @@ import java.util.Map;
import java.util.Objects; import java.util.Objects;
import java.util.Optional; import java.util.Optional;
import java.util.Set; import java.util.Set;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.atomic.AtomicLong;
import java.util.function.Function; import java.util.function.Function;
@ -111,8 +110,7 @@ public class DbusManagerImpl implements Manager {
@Override @Override
public String getSelfNumber() { public String getSelfNumber() {
final var number = signal.getSelfNumber(); return signal.getSelfNumber();
return number.isEmpty() ? null : number;
} }
@Override @Override
@ -440,7 +438,7 @@ public class DbusManagerImpl implements Manager {
numbers.forEach(n -> signal.sendTyping(n, action == TypingAction.STOP)); numbers.forEach(n -> signal.sendTyping(n, action == TypingAction.STOP));
return 0L; return 0L;
}, () -> { }, () -> {
signal.sendTyping(getSelfIdentifier(), action == TypingAction.STOP); signal.sendTyping(signal.getSelfNumber(), action == TypingAction.STOP);
return 0L; return 0L;
}, groupId -> { }, groupId -> {
signal.sendGroupTyping(groupId, action == TypingAction.STOP); signal.sendGroupTyping(groupId, action == TypingAction.STOP);
@ -488,7 +486,7 @@ public class DbusManagerImpl implements Manager {
) throws IOException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException { ) throws IOException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException {
return handleMessage(recipients, return handleMessage(recipients,
numbers -> signal.sendRemoteDeleteMessage(targetSentTimestamp, numbers), numbers -> signal.sendRemoteDeleteMessage(targetSentTimestamp, numbers),
() -> signal.sendRemoteDeleteMessage(targetSentTimestamp, getSelfIdentifier()), () -> signal.sendRemoteDeleteMessage(targetSentTimestamp, signal.getSelfNumber()),
groupId -> signal.sendGroupRemoteDeleteMessage(targetSentTimestamp, groupId)); groupId -> signal.sendGroupRemoteDeleteMessage(targetSentTimestamp, groupId));
} }
@ -512,7 +510,7 @@ public class DbusManagerImpl implements Manager {
remove, remove,
targetAuthor.getIdentifier(), targetAuthor.getIdentifier(),
targetSentTimestamp, targetSentTimestamp,
getSelfIdentifier()), signal.getSelfNumber()),
groupId -> signal.sendGroupMessageReaction(emoji, groupId -> signal.sendGroupMessageReaction(emoji,
remove, remove,
targetAuthor.getIdentifier(), targetAuthor.getIdentifier(),
@ -811,13 +809,11 @@ public class DbusManagerImpl implements Manager {
final Collection<RecipientIdentifier.Single> addresses, final Collection<RecipientIdentifier.Single> addresses,
final Optional<String> name final Optional<String> name
) { ) {
final var identifiers = addresses.stream() final var numbers = addresses.stream()
.map(RecipientIdentifier.Single::getIdentifier) .filter(s -> s instanceof RecipientIdentifier.Number)
.map(s -> ((RecipientIdentifier.Number) s).number())
.collect(Collectors.toSet()); .collect(Collectors.toSet());
return signal.listRecipientIdentifiers() return signal.listNumbers().stream().filter(n -> addresses.isEmpty() || numbers.contains(n)).map(n -> {
.stream()
.filter(n -> addresses.isEmpty() || identifiers.contains(n))
.map(n -> {
final var contactBlocked = signal.isContactBlocked(n); final var contactBlocked = signal.isContactBlocked(n);
if (blocked.isPresent() && blocked.get() != contactBlocked) { if (blocked.isPresent() && blocked.get() != contactBlocked) {
return null; return null;
@ -830,7 +826,7 @@ public class DbusManagerImpl implements Manager {
return null; return null;
} }
return Recipient.newBuilder() return Recipient.newBuilder()
.withAddress(getRecipientAddress(n)) .withAddress(new RecipientAddress(n))
.withContact(new Contact(contactName, .withContact(new Contact(contactName,
null, null,
null, null,
@ -849,9 +845,7 @@ public class DbusManagerImpl implements Manager {
false, false,
null)) null))
.build(); .build();
}) }).filter(Objects::nonNull).toList();
.filter(Objects::nonNull)
.toList();
} }
@Override @Override
@ -876,16 +870,16 @@ public class DbusManagerImpl implements Manager {
(String) group.get("Description").getValue(), (String) group.get("Description").getValue(),
GroupInviteLinkUrl.fromUri((String) group.get("GroupInviteLink").getValue()), GroupInviteLinkUrl.fromUri((String) group.get("GroupInviteLink").getValue()),
((List<String>) group.get("Members").getValue()).stream() ((List<String>) group.get("Members").getValue()).stream()
.map(m -> new GroupMember(getRecipientAddress(m), admins.contains(m), null, null)) .map(m -> new GroupMember(new RecipientAddress(m), admins.contains(m), null, null))
.collect(Collectors.toSet()), .collect(Collectors.toSet()),
((List<String>) group.get("PendingMembers").getValue()).stream() ((List<String>) group.get("PendingMembers").getValue()).stream()
.map(DbusManagerImpl::getRecipientAddress) .map(RecipientAddress::new)
.collect(Collectors.toSet()), .collect(Collectors.toSet()),
((List<String>) group.get("RequestingMembers").getValue()).stream() ((List<String>) group.get("RequestingMembers").getValue()).stream()
.map(DbusManagerImpl::getRecipientAddress) .map(RecipientAddress::new)
.collect(Collectors.toSet()), .collect(Collectors.toSet()),
((List<String>) group.get("Banned").getValue()).stream() ((List<String>) group.get("Banned").getValue()).stream()
.map(DbusManagerImpl::getRecipientAddress) .map(RecipientAddress::new)
.collect(Collectors.toSet()), .collect(Collectors.toSet()),
(boolean) group.get("IsBlocked").getValue(), (boolean) group.get("IsBlocked").getValue(),
(int) group.get("MessageExpirationTimer").getValue(), (int) group.get("MessageExpirationTimer").getValue(),
@ -916,10 +910,7 @@ public class DbusManagerImpl implements Manager {
final var group = getRemoteObject(identityPath, Signal.Identity.class).GetAll("org.asamk.Signal.Identity"); final var group = getRemoteObject(identityPath, Signal.Identity.class).GetAll("org.asamk.Signal.Identity");
final var aci = (String) group.get("Uuid").getValue(); final var aci = (String) group.get("Uuid").getValue();
final var number = (String) group.get("Number").getValue(); final var number = (String) group.get("Number").getValue();
return new Identity(new RecipientAddress(aci.isEmpty() ? null : aci, return new Identity(new RecipientAddress(aci, null, number, null),
null,
number.isEmpty() ? null : number,
null),
(byte[]) group.get("Fingerprint").getValue(), (byte[]) group.get("Fingerprint").getValue(),
(String) group.get("SafetyNumber").getValue(), (String) group.get("SafetyNumber").getValue(),
(byte[]) group.get("ScannableSafetyNumber").getValue(), (byte[]) group.get("ScannableSafetyNumber").getValue(),
@ -1082,14 +1073,6 @@ public class DbusManagerImpl implements Manager {
return string == null ? "" : string; return string == null ? "" : string;
} }
private static RecipientAddress getRecipientAddress(final String identifier) {
try {
return new RecipientAddress(UUID.fromString(identifier));
} catch (IllegalArgumentException e) {
return new RecipientAddress(identifier);
}
}
private <T extends DBusInterface> T getRemoteObject(final DBusPath path, final Class<T> type) { private <T extends DBusInterface> T getRemoteObject(final DBusPath path, final Class<T> type) {
try { try {
return connection.getRemoteObject(busname, path.getPath(), type); return connection.getRemoteObject(busname, path.getPath(), type);
@ -1102,7 +1085,7 @@ public class DbusManagerImpl implements Manager {
try { try {
this.dbusMsgHandler = messageReceived -> { this.dbusMsgHandler = messageReceived -> {
final var extras = messageReceived.getExtras(); final var extras = messageReceived.getExtras();
final var envelope = new MessageEnvelope(Optional.of(getRecipientAddress(messageReceived.getSender())), final var envelope = new MessageEnvelope(Optional.of(new RecipientAddress(messageReceived.getSender())),
0, 0,
messageReceived.getTimestamp(), messageReceived.getTimestamp(),
0, 0,
@ -1149,7 +1132,7 @@ public class DbusManagerImpl implements Manager {
connection.addSigHandler(Signal.MessageReceivedV2.class, signal, this.dbusMsgHandler); connection.addSigHandler(Signal.MessageReceivedV2.class, signal, this.dbusMsgHandler);
this.dbusEditMsgHandler = messageReceived -> { this.dbusEditMsgHandler = messageReceived -> {
final var extras = messageReceived.getExtras(); final var extras = messageReceived.getExtras();
final var envelope = new MessageEnvelope(Optional.of(getRecipientAddress(messageReceived.getSender())), final var envelope = new MessageEnvelope(Optional.of(new RecipientAddress(messageReceived.getSender())),
0, 0,
messageReceived.getTimestamp(), messageReceived.getTimestamp(),
0, 0,
@ -1203,7 +1186,7 @@ public class DbusManagerImpl implements Manager {
case "delivery" -> MessageEnvelope.Receipt.Type.DELIVERY; case "delivery" -> MessageEnvelope.Receipt.Type.DELIVERY;
default -> MessageEnvelope.Receipt.Type.UNKNOWN; default -> MessageEnvelope.Receipt.Type.UNKNOWN;
}; };
final var envelope = new MessageEnvelope(Optional.of(getRecipientAddress(receiptReceived.getSender())), final var envelope = new MessageEnvelope(Optional.of(new RecipientAddress(receiptReceived.getSender())),
0, 0,
receiptReceived.getTimestamp(), receiptReceived.getTimestamp(),
0, 0,
@ -1224,7 +1207,7 @@ public class DbusManagerImpl implements Manager {
this.dbusSyncHandler = syncReceived -> { this.dbusSyncHandler = syncReceived -> {
final var extras = syncReceived.getExtras(); final var extras = syncReceived.getExtras();
final var envelope = new MessageEnvelope(Optional.of(getRecipientAddress(syncReceived.getSource())), final var envelope = new MessageEnvelope(Optional.of(new RecipientAddress(syncReceived.getSource())),
0, 0,
syncReceived.getTimestamp(), syncReceived.getTimestamp(),
0, 0,
@ -1238,7 +1221,7 @@ public class DbusManagerImpl implements Manager {
syncReceived.getTimestamp(), syncReceived.getTimestamp(),
syncReceived.getDestination().isEmpty() syncReceived.getDestination().isEmpty()
? Optional.empty() ? Optional.empty()
: Optional.of(getRecipientAddress(syncReceived.getDestination())), : Optional.of(new RecipientAddress(syncReceived.getDestination())),
Set.of(), Set.of(),
Optional.of(new MessageEnvelope.Data(syncReceived.getTimestamp(), Optional.of(new MessageEnvelope.Data(syncReceived.getTimestamp(),
syncReceived.getGroupId().length > 0 syncReceived.getGroupId().length > 0
@ -1343,7 +1326,7 @@ public class DbusManagerImpl implements Manager {
final List<Map<String, Variant<?>>> mentions = getValue(extras, "mentions"); final List<Map<String, Variant<?>>> mentions = getValue(extras, "mentions");
return mentions.stream() return mentions.stream()
.map(a -> new MessageEnvelope.Data.Mention(getRecipientAddress(this.<String>getValue(a, "recipient")), .map(a -> new MessageEnvelope.Data.Mention(new RecipientAddress(this.<String>getValue(a, "recipient")),
getValue(a, "start"), getValue(a, "start"),
getValue(a, "length"))) getValue(a, "length")))
.toList(); .toList();

View File

@ -43,6 +43,14 @@ public class DbusMultiAccountManagerImpl implements MultiAccountManager {
this.busname = busname; this.busname = busname;
} }
@Override
public List<String> getAccountNumbers() {
return signalControl.listAccounts()
.stream()
.map(a -> getRemoteObject(a, Signal.class).getSelfNumber())
.toList();
}
@Override @Override
public List<Manager> getManagers() { public List<Manager> getManagers() {
return signalControl.listAccounts() return signalControl.listAccounts()
@ -58,13 +66,6 @@ public class DbusMultiAccountManagerImpl implements MultiAccountManager {
} }
} }
@Override
public void removeOnManagerAddedHandler(final Consumer<Manager> handler) {
synchronized (onManagerAddedHandlers) {
onManagerAddedHandlers.remove(handler);
}
}
@Override @Override
public void addOnManagerRemovedHandler(final Consumer<Manager> handler) { public void addOnManagerRemovedHandler(final Consumer<Manager> handler) {
synchronized (onManagerRemovedHandlers) { synchronized (onManagerRemovedHandlers) {
@ -73,15 +74,8 @@ public class DbusMultiAccountManagerImpl implements MultiAccountManager {
} }
@Override @Override
public void removeOnManagerRemovedHandler(final Consumer<Manager> handler) { public Manager getManager(final String phoneNumber) {
synchronized (onManagerRemovedHandlers) { return new DbusManagerImpl(getRemoteObject(signalControl.getAccount(phoneNumber), Signal.class),
onManagerRemovedHandlers.remove(handler);
}
}
@Override
public Manager getManager(final String identifier) {
return new DbusManagerImpl(getRemoteObject(signalControl.getAccount(identifier), Signal.class),
connection, connection,
busname); busname);
} }

View File

@ -165,10 +165,7 @@ public class DbusReceiveMessageHandler implements Manager.ReceiveMessageHandler
} }
if (message.remoteDeleteId().isPresent()) { if (message.remoteDeleteId().isPresent()) {
extras.put("remoteDelete", extras.put("remoteDelete",
new Variant<>(Map.of("targetTimestamp", new Variant<>(Map.of("timestamp", new Variant<>(message.remoteDeleteId().get())), "a{sv}"));
new Variant<>(message.remoteDeleteId().get()),
"timestamp",
new Variant<>(message.remoteDeleteId().get())), "a{sv}"));
} }
if (message.sticker().isPresent()) { if (message.sticker().isPresent()) {
final var sticker = message.sticker().get(); final var sticker = message.sticker().get();

View File

@ -5,7 +5,6 @@ import org.asamk.signal.manager.RegistrationManager;
import org.asamk.signal.manager.api.CaptchaRequiredException; import org.asamk.signal.manager.api.CaptchaRequiredException;
import org.asamk.signal.manager.api.IncorrectPinException; import org.asamk.signal.manager.api.IncorrectPinException;
import org.asamk.signal.manager.api.PinLockedException; import org.asamk.signal.manager.api.PinLockedException;
import org.asamk.signal.manager.api.TwoFactorRequired;
import org.freedesktop.dbus.connections.impl.DBusConnection; import org.freedesktop.dbus.connections.impl.DBusConnection;
import java.io.IOException; import java.io.IOException;
@ -54,15 +53,6 @@ public class DbusRegistrationManagerImpl implements RegistrationManager {
} }
} }
@Override
public void registerWithRecoveryKey(
final String recoveryKey,
final boolean forceRegister,
final Integer totp
) throws IOException, TwoFactorRequired {
throw new IOException("Recovery-key registration is not supported over D-Bus");
}
@Override @Override
public void deleteLocalAccountData() throws IOException { public void deleteLocalAccountData() throws IOException {
throw new UnsupportedOperationException(); throw new UnsupportedOperationException();

View File

@ -159,19 +159,11 @@ public class DbusSignalControlImpl implements org.asamk.SignalControl {
@Override @Override
public List<DBusPath> listAccounts() { public List<DBusPath> listAccounts() {
return c.getManagers() return c.getAccountNumbers().stream().map(u -> new DBusPath(DbusConfig.getObjectPath(u))).toList();
.stream()
.map(m -> new DBusPath(DbusConfig.getObjectPath(m.getSelfIdentifier())))
.toList();
} }
@Override @Override
public DBusPath getAccount(final String identifier) { public DBusPath getAccount(final String number) {
final var manager = c.getManagers() return new DBusPath(DbusConfig.getObjectPath(number));
.stream()
.filter(m -> identifier.equals(m.getSelfNumber()) || identifier.equals(m.getSelfACI()))
.findFirst()
.orElseThrow(() -> new Error.Failure("Unknown account: " + identifier));
return new DBusPath(DbusConfig.getObjectPath(manager.getSelfIdentifier()));
} }
} }

View File

@ -138,7 +138,7 @@ public class DbusSignalImpl implements Signal, AutoCloseable {
@Override @Override
public String getSelfNumber() { public String getSelfNumber() {
return emptyIfNull(m.getSelfNumber()); return m.getSelfNumber();
} }
@Override @Override
@ -242,8 +242,6 @@ public class DbusSignalImpl implements Signal, AutoCloseable {
try { try {
final var message = new Message(messageText, final var message = new Message(messageText,
attachments, attachments,
List.of(),
List.of(),
false, false,
false, false,
List.of(), List.of(),
@ -411,8 +409,6 @@ public class DbusSignalImpl implements Signal, AutoCloseable {
try { try {
final var message = new Message(messageText, final var message = new Message(messageText,
attachments, attachments,
List.of(),
List.of(),
false, false,
false, false,
List.of(), List.of(),
@ -460,8 +456,6 @@ public class DbusSignalImpl implements Signal, AutoCloseable {
try { try {
final var message = new Message(messageText, final var message = new Message(messageText,
attachments, attachments,
List.of(),
List.of(),
false, false,
false, false,
List.of(), List.of(),
@ -803,15 +797,6 @@ public class DbusSignalImpl implements Signal, AutoCloseable {
.toList(); .toList();
} }
@Override
public List<String> listRecipientIdentifiers() {
return m.getRecipients(false, Optional.empty(), Set.of(), Optional.empty())
.stream()
.map(r -> r.getAddress().getLegacyIdentifier())
.distinct()
.toList();
}
@Override @Override
public List<String> getContactNumber(final String name) { public List<String> getContactNumber(final String name) {
return m.getRecipients(false, Optional.empty(), Set.of(), Optional.of(name)) return m.getRecipients(false, Optional.empty(), Set.of(), Optional.of(name))

View File

@ -5,13 +5,13 @@ import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer; import com.sun.net.httpserver.HttpServer;
import org.asamk.signal.commands.Commands; import org.asamk.signal.commands.Commands;
import org.asamk.signal.json.JsonReceiveMessageHandler;
import org.asamk.signal.jsonrpc.JsonRpcReader; import org.asamk.signal.jsonrpc.JsonRpcReader;
import org.asamk.signal.jsonrpc.JsonRpcResponse; import org.asamk.signal.jsonrpc.JsonRpcResponse;
import org.asamk.signal.jsonrpc.JsonRpcSender; import org.asamk.signal.jsonrpc.JsonRpcSender;
import org.asamk.signal.jsonrpc.SignalJsonRpcCommandHandler; import org.asamk.signal.jsonrpc.SignalJsonRpcCommandHandler;
import org.asamk.signal.manager.Manager; import org.asamk.signal.manager.Manager;
import org.asamk.signal.manager.MultiAccountManager; import org.asamk.signal.manager.MultiAccountManager;
import org.asamk.signal.manager.api.MessageEnvelope;
import org.asamk.signal.manager.api.Pair; import org.asamk.signal.manager.api.Pair;
import org.asamk.signal.util.Util; import org.asamk.signal.util.Util;
import org.slf4j.Logger; import org.slf4j.Logger;
@ -19,7 +19,6 @@ import org.slf4j.LoggerFactory;
import java.io.IOException; import java.io.IOException;
import java.net.InetSocketAddress; import java.net.InetSocketAddress;
import java.time.Duration;
import java.util.HashSet; import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
@ -35,7 +34,6 @@ public class HttpServerHandler implements AutoCloseable {
private final ObjectMapper objectMapper = Util.createJsonObjectMapper(); private final ObjectMapper objectMapper = Util.createJsonObjectMapper();
private final InetSocketAddress address; private final InetSocketAddress address;
private final Duration keepAliveInterval;
private final SignalJsonRpcCommandHandler commandHandler; private final SignalJsonRpcCommandHandler commandHandler;
private final MultiAccountManager c; private final MultiAccountManager c;
@ -43,24 +41,17 @@ public class HttpServerHandler implements AutoCloseable {
private HttpServer server; private HttpServer server;
private final AtomicBoolean shutdown = new AtomicBoolean(false); private final AtomicBoolean shutdown = new AtomicBoolean(false);
private final Set<String> allowedHosts; private final Set<String> allowedHosts;
private final SseEventBuffer eventBuffer = new SseEventBuffer(objectMapper);
public HttpServerHandler(final InetSocketAddress address, final Duration keepAliveInterval, final Manager m) { public HttpServerHandler(final InetSocketAddress address, final Manager m) {
this.address = address; this.address = address;
this.keepAliveInterval = keepAliveInterval;
commandHandler = new SignalJsonRpcCommandHandler(m, Commands::getCommand); commandHandler = new SignalJsonRpcCommandHandler(m, Commands::getCommand);
this.c = null; this.c = null;
this.m = m; this.m = m;
this.allowedHosts = buildAllowedHosts(address); this.allowedHosts = buildAllowedHosts(address);
} }
public HttpServerHandler( public HttpServerHandler(final InetSocketAddress address, final MultiAccountManager c) {
final InetSocketAddress address,
final Duration keepAliveInterval,
final MultiAccountManager c
) {
this.address = address; this.address = address;
this.keepAliveInterval = keepAliveInterval;
commandHandler = new SignalJsonRpcCommandHandler(c, Commands::getCommand); commandHandler = new SignalJsonRpcCommandHandler(c, Commands::getCommand);
this.c = c; this.c = c;
this.m = null; this.m = null;
@ -102,7 +93,6 @@ public class HttpServerHandler implements AutoCloseable {
// Increase this delay when https://bugs.openjdk.org/browse/JDK-8304065 is fixed // Increase this delay when https://bugs.openjdk.org/browse/JDK-8304065 is fixed
server.stop(2); server.stop(2);
server = null; server = null;
eventBuffer.close();
shutdown.set(false); shutdown.set(false);
} }
} }
@ -209,27 +199,22 @@ public class HttpServerHandler implements AutoCloseable {
// Flush HTTP response headers to the client immediately. // Flush HTTP response headers to the client immediately.
// Without this, the JVM HttpServer buffers everything until a later write // Without this, the JVM HttpServer buffers everything until a later write
// in the keep-alive loop, causing clients with shorter timeouts // in the keep-alive loop (15 s), causing clients with shorter timeouts
// (e.g. 10 s) to abort before receiving the initial response. // (e.g. 10 s) to abort before receiving the initial response.
httpExchange.getResponseBody().flush(); httpExchange.getResponseBody().flush();
final var shouldStop = new AtomicBoolean(false); final var shouldStop = new AtomicBoolean(false);
managers.forEach(eventBuffer::record); final var handlers = subscribeReceiveHandlers(managers, sender, () -> {
final var client = eventBuffer.connect(managers,
sender,
httpExchange.getRequestHeaders().getFirst("Last-Event-ID"),
() -> {
shouldStop.set(true); shouldStop.set(true);
synchronized (this) { synchronized (this) {
this.notifyAll(); this.notifyAll();
} }
}); });
final var handlers = subscribeReceiveHandlers(managers);
try { try {
while (true) { while (true) {
synchronized (this) { synchronized (this) {
wait(keepAliveInterval.toMillis()); wait(15_000);
} }
if (shouldStop.get() || shutdown.get()) { if (shouldStop.get() || shutdown.get()) {
break; break;
@ -242,7 +227,6 @@ public class HttpServerHandler implements AutoCloseable {
} }
} }
} finally { } finally {
eventBuffer.disconnect(client);
for (final var pair : handlers) { for (final var pair : handlers) {
unsubscribeReceiveHandler(pair); unsubscribeReceiveHandler(pair);
} }
@ -292,17 +276,19 @@ public class HttpServerHandler implements AutoCloseable {
throw new AssertionError("Unreachable state"); throw new AssertionError("Unreachable state");
} }
/** private List<Pair<Manager, Manager.ReceiveMessageHandler>> subscribeReceiveHandlers(
* The events are sent by eventBuffer, these handlers only keep the managers receiving while the client is connected. final List<Manager> managers,
*/ final ServerSentEventSender sender,
private List<Pair<Manager, Manager.ReceiveMessageHandler>> subscribeReceiveHandlers(final List<Manager> managers) { Callable unsubscribe
) {
return managers.stream().map(m1 -> { return managers.stream().map(m1 -> {
// An anonymous class instead of a lambda, as every connection needs its own handler instance final var receiveMessageHandler = new JsonReceiveMessageHandler(m1, s -> {
final var receiveMessageHandler = new Manager.ReceiveMessageHandler() { try {
@Override sender.sendEvent(null, "receive", List.of(objectMapper.writeValueAsString(s)));
public void handleMessage(final MessageEnvelope envelope, final Throwable e) { } catch (IOException e) {
unsubscribe.call();
} }
}; });
m1.addReceiveHandler(receiveMessageHandler); m1.addReceiveHandler(receiveMessageHandler);
return new Pair<>(m1, (Manager.ReceiveMessageHandler) receiveMessageHandler); return new Pair<>(m1, (Manager.ReceiveMessageHandler) receiveMessageHandler);
}).toList(); }).toList();
@ -314,6 +300,11 @@ public class HttpServerHandler implements AutoCloseable {
m.removeReceiveHandler(handler); m.removeReceiveHandler(handler);
} }
private interface Callable {
void call();
}
private Set<String> buildAllowedHosts(final InetSocketAddress address) { private Set<String> buildAllowedHosts(final InetSocketAddress address) {
final var s = new HashSet<String>(); final var s = new HashSet<String>();
final var host = address == null ? null : address.getHostString(); final var host = address == null ? null : address.getHostString();

View File

@ -1,153 +0,0 @@
package org.asamk.signal.http;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.asamk.signal.json.JsonReceiveMessageHandler;
import org.asamk.signal.manager.Manager;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* Keeps the most recent SSE events and sends new events to the connected clients.
* A client that reconnects with a Last-Event-ID header first gets the buffered events it missed.
* <p>
* Event ids have the form "{start time}-{sequence}", so an id from an earlier daemon run never matches.
*/
final class SseEventBuffer implements AutoCloseable {
private static final Logger logger = LoggerFactory.getLogger(SseEventBuffer.class);
static final int MAX_EVENTS = 1000;
private final ObjectMapper objectMapper;
private final String idPrefix = System.currentTimeMillis() + "-";
private final Map<Manager, Manager.ReceiveMessageHandler> recorders = new HashMap<>();
private final ArrayDeque<Event> events = new ArrayDeque<>();
private final List<Client> clients = new ArrayList<>();
private long nextSequence = 1;
SseEventBuffer(final ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
/**
* Start recording the messages of the given account.
* The handler is registered as a weak listener, so it never starts receiving by itself.
*/
void record(final Manager m) {
synchronized (recorders) {
if (recorders.containsKey(m)) {
return;
}
final var handler = new JsonReceiveMessageHandler(m, s -> add(m, s));
m.addReceiveHandler(handler, true);
recorders.put(m, handler);
}
}
/**
* Register a client for the given accounts.
* If lastEventId is set, the buffered events after it are sent first.
* An unknown id (too old, or from an earlier run) sends all buffered events.
*/
synchronized Client connect(
final Collection<Manager> managers,
final ServerSentEventSender sender,
final String lastEventId,
final Runnable onClosed
) {
final var client = new Client(managers, sender, onClosed);
if (lastEventId != null) {
final var lastSequence = getSequence(lastEventId);
for (final var event : events) {
if (event.sequence() > lastSequence && managers.contains(event.manager())) {
client.send(event);
}
}
}
clients.add(client);
return client;
}
synchronized void disconnect(final Client client) {
clients.remove(client);
}
@Override
public void close() {
synchronized (recorders) {
recorders.forEach(Manager::removeReceiveHandler);
recorders.clear();
}
}
private void add(final Manager m, final Object message) {
final String data;
try {
data = objectMapper.writeValueAsString(message);
} catch (IOException e) {
logger.warn("Failed to serialize received message, ignoring", e);
return;
}
final Event event;
final List<Client> receivers;
synchronized (this) {
event = new Event(nextSequence++, m, data);
events.addLast(event);
if (events.size() > MAX_EVENTS) {
events.removeFirst();
}
receivers = clients.stream().filter(c -> c.managers.contains(m)).toList();
}
// Sent outside the lock, so a slow client doesn't block the other accounts
for (final var client : receivers) {
client.send(event);
}
}
private long getSequence(final String eventId) {
if (eventId.startsWith(idPrefix)) {
try {
return Long.parseLong(eventId.substring(idPrefix.length()));
} catch (NumberFormatException ignored) {
}
}
return 0;
}
private record Event(long sequence, Manager manager, String data) {}
final class Client {
private final Collection<Manager> managers;
private final ServerSentEventSender sender;
private final Runnable onClosed;
private Client(
final Collection<Manager> managers,
final ServerSentEventSender sender,
final Runnable onClosed
) {
this.managers = managers;
this.sender = sender;
this.onClosed = onClosed;
}
private void send(final Event event) {
try {
sender.sendEvent(idPrefix + event.sequence(), "receive", List.of(event.data()));
} catch (IOException e) {
onClosed.run();
}
}
}
}

View File

@ -8,11 +8,7 @@ import io.micronaut.jsonschema.JsonSchema;
@JsonSchema(title = "AdminDelete") @JsonSchema(title = "AdminDelete")
public record JsonAdminDelete( public record JsonAdminDelete(
@Deprecated String targetAuthor, @Deprecated String targetAuthor, String targetAuthorNumber, String targetAuthorUuid, long targetSentTimestamp
String targetAuthorNumber,
String targetAuthorUuid,
long targetTimestamp,
@Deprecated long targetSentTimestamp
) { ) {
static JsonAdminDelete from(MessageEnvelope.Data.AdminDelete adminDelete) { static JsonAdminDelete from(MessageEnvelope.Data.AdminDelete adminDelete) {
@ -20,13 +16,9 @@ public record JsonAdminDelete(
final var targetAuthor = address.getLegacyIdentifier(); final var targetAuthor = address.getLegacyIdentifier();
final var targetAuthorNumber = address.number().orElse(null); final var targetAuthorNumber = address.number().orElse(null);
final var targetAuthorUuid = address.uuid().map(UUID::toString).orElse(null); final var targetAuthorUuid = address.uuid().map(UUID::toString).orElse(null);
final var targetTimestamp = adminDelete.targetSentTimestamp(); final var targetSentTimestamp = adminDelete.targetSentTimestamp();
return new JsonAdminDelete(targetAuthor, return new JsonAdminDelete(targetAuthor, targetAuthorNumber, targetAuthorUuid, targetSentTimestamp);
targetAuthorNumber,
targetAuthorUuid,
targetTimestamp,
targetTimestamp);
} }
} }

View File

@ -67,7 +67,7 @@ record JsonDataMessage(
.map(JsonPreview::from) .map(JsonPreview::from)
.toList() : null; .toList() : null;
final var remoteDelete = dataMessage.remoteDeleteId().isPresent() final var remoteDelete = dataMessage.remoteDeleteId().isPresent()
? new JsonRemoteDelete(dataMessage.remoteDeleteId().get(), dataMessage.remoteDeleteId().get()) ? new JsonRemoteDelete(dataMessage.remoteDeleteId().get())
: null; : null;
final var attachments = !dataMessage.attachments().isEmpty() ? dataMessage.attachments() final var attachments = !dataMessage.attachments().isEmpty() ? dataMessage.attachments()
.stream() .stream()

View File

@ -23,7 +23,7 @@ public class JsonReceiveMessageHandler implements Manager.ReceiveMessageHandler
@Override @Override
public void handleMessage(MessageEnvelope envelope, Throwable exception) { public void handleMessage(MessageEnvelope envelope, Throwable exception) {
final var object = new HashMap<String, Object>(); final var object = new HashMap<String, Object>();
object.put("account", m.getSelfIdentifier()); object.put("account", m.getSelfNumber());
if (exception != null) { if (exception != null) {
object.put("exception", JsonError.from(exception)); object.put("exception", JsonError.from(exception));
} }

View File

@ -3,4 +3,4 @@ package org.asamk.signal.json;
import io.micronaut.jsonschema.JsonSchema; import io.micronaut.jsonschema.JsonSchema;
@JsonSchema(title = "RemoteDelete") @JsonSchema(title = "RemoteDelete")
record JsonRemoteDelete(long targetTimestamp, @Deprecated long timestamp) {} record JsonRemoteDelete(long timestamp) {}

View File

@ -1,7 +1,6 @@
package org.asamk.signal.jsonrpc; package org.asamk.signal.jsonrpc;
import com.fasterxml.jackson.core.JsonParseException; import com.fasterxml.jackson.core.JsonParseException;
import com.fasterxml.jackson.core.StreamReadConstraints;
import com.fasterxml.jackson.databind.JsonMappingException; import com.fasterxml.jackson.databind.JsonMappingException;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
@ -9,7 +8,6 @@ import com.fasterxml.jackson.databind.node.ContainerNode;
import com.fasterxml.jackson.databind.node.ObjectNode; import com.fasterxml.jackson.databind.node.ObjectNode;
import com.fasterxml.jackson.databind.node.ValueNode; import com.fasterxml.jackson.databind.node.ValueNode;
import org.asamk.signal.manager.config.ServiceConfig;
import org.asamk.signal.util.Util; import org.asamk.signal.util.Util;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
@ -26,7 +24,6 @@ import java.util.stream.StreamSupport;
public class JsonRpcReader { public class JsonRpcReader {
private static final Logger logger = LoggerFactory.getLogger(JsonRpcReader.class); private static final Logger logger = LoggerFactory.getLogger(JsonRpcReader.class);
private static final int MAX_JSON_STRING_LENGTH = ((ServiceConfig.MAX_ATTACHMENT_SIZE + 2) / 3) * 4 + 1024;
private final JsonRpcSender jsonRpcSender; private final JsonRpcSender jsonRpcSender;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
@ -37,23 +34,14 @@ public class JsonRpcReader {
this.jsonRpcSender = jsonRpcSender; this.jsonRpcSender = jsonRpcSender;
this.input = null; this.input = null;
this.lineSupplier = lineSupplier; this.lineSupplier = lineSupplier;
this.objectMapper = createObjectMapper(); this.objectMapper = Util.createJsonObjectMapper();
} }
public JsonRpcReader(final JsonRpcSender jsonRpcSender, final InputStream input) { public JsonRpcReader(final JsonRpcSender jsonRpcSender, final InputStream input) {
this.jsonRpcSender = jsonRpcSender; this.jsonRpcSender = jsonRpcSender;
this.input = input; this.input = input;
this.lineSupplier = null; this.lineSupplier = null;
this.objectMapper = createObjectMapper(); this.objectMapper = Util.createJsonObjectMapper();
}
private static ObjectMapper createObjectMapper() {
final var objectMapper = Util.createJsonObjectMapper();
objectMapper.getFactory()
.setStreamReadConstraints(StreamReadConstraints.builder()
.maxStringLength(MAX_JSON_STRING_LENGTH)
.build());
return objectMapper;
} }
public void readMessages(final RequestHandler requestHandler, final Consumer<JsonRpcResponse> responseHandler) { public void readMessages(final RequestHandler requestHandler, final Consumer<JsonRpcResponse> responseHandler) {

View File

@ -8,7 +8,6 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ContainerNode; import com.fasterxml.jackson.databind.node.ContainerNode;
import com.fasterxml.jackson.databind.node.ObjectNode; import com.fasterxml.jackson.databind.node.ObjectNode;
import org.asamk.signal.AccountIdentifier;
import org.asamk.signal.commands.Command; import org.asamk.signal.commands.Command;
import org.asamk.signal.commands.JsonRpcMultiCommand; import org.asamk.signal.commands.JsonRpcMultiCommand;
import org.asamk.signal.commands.JsonRpcRegistrationCommand; import org.asamk.signal.commands.JsonRpcRegistrationCommand;
@ -127,7 +126,7 @@ public class SignalJsonRpcCommandHandler {
private Manager getManagerFromParams(final ContainerNode<?> params) throws JsonRpcException { private Manager getManagerFromParams(final ContainerNode<?> params) throws JsonRpcException {
if (params != null && params.hasNonNull("account")) { if (params != null && params.hasNonNull("account")) {
final var manager = c.getManager(AccountIdentifier.normalize(params.get("account").asText())); final var manager = c.getManager(params.get("account").asText());
((ObjectNode) params).remove("account"); ((ObjectNode) params).remove("account");
if (manager == null) { if (manager == null) {
throw new JsonRpcException(new JsonRpcResponse.Error(JsonRpcResponse.Error.INVALID_PARAMS, throw new JsonRpcException(new JsonRpcResponse.Error(JsonRpcResponse.Error.INVALID_PARAMS,
@ -141,7 +140,7 @@ public class SignalJsonRpcCommandHandler {
private Pair<String, RegistrationManager> getRegistrationManagerFromParams(final ContainerNode<?> params) { private Pair<String, RegistrationManager> getRegistrationManagerFromParams(final ContainerNode<?> params) {
if (params != null && params.has("account")) { if (params != null && params.has("account")) {
final var account = AccountIdentifier.normalize(params.get("account").asText()); final var account = params.get("account").asText();
((ObjectNode) params).remove("account"); ((ObjectNode) params).remove("account");
try { try {
return new Pair<>(account, c.getNewRegistrationManager(account)); return new Pair<>(account, c.getNewRegistrationManager(account));

View File

@ -31,7 +31,6 @@ import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Consumer;
import java.util.function.Supplier; import java.util.function.Supplier;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@ -62,31 +61,17 @@ public class SignalJsonRpcDispatcherHandler {
public void handleConnection(final MultiAccountManager c) { public void handleConnection(final MultiAccountManager c) {
this.commandHandler = new SignalJsonRpcCommandHandler(c, this::getCommand); this.commandHandler = new SignalJsonRpcCommandHandler(c, this::getCommand);
final Consumer<Manager> onManagerAddedReceiveHandler = m -> subscribeReceive(m, true);
final Consumer<Manager> onManagerRemovedReceiveHandler = this::unsubscribeReceive;
final Consumer<Manager> onManagerAddedReceiveSubscriptionsHandler = m -> receiveHandlers.forEach((subscriptionId, handlers) -> handlers.add(
createReceiveHandler(m, subscriptionId, false)));
final Consumer<Manager> onManagerAddedCallEventSubscriptionsHandler = m -> callEventHandlers.forEach((subscriptionId, handlers) -> handlers.add(
createCallEventHandler(m, subscriptionId)));
if (!noReceiveOnStart) { if (!noReceiveOnStart) {
this.subscribeReceive(c.getManagers(), true); this.subscribeReceive(c.getManagers(), true);
c.addOnManagerAddedHandler(onManagerAddedReceiveHandler); c.addOnManagerAddedHandler(m -> subscribeReceive(m, true));
c.addOnManagerRemovedHandler(onManagerRemovedReceiveHandler); c.addOnManagerRemovedHandler(this::unsubscribeReceive);
} }
c.addOnManagerAddedHandler(onManagerAddedReceiveSubscriptionsHandler); c.addOnManagerAddedHandler(m -> receiveHandlers.forEach((subscriptionId, handlers) -> handlers.add(
c.addOnManagerAddedHandler(onManagerAddedCallEventSubscriptionsHandler); createReceiveHandler(m, subscriptionId, false))));
c.addOnManagerAddedHandler(m -> callEventHandlers.forEach((subscriptionId, handlers) -> handlers.add(
createCallEventHandler(m, subscriptionId))));
try {
handleConnection(); handleConnection();
} finally {
if (!noReceiveOnStart) {
c.removeOnManagerAddedHandler(onManagerAddedReceiveHandler);
c.removeOnManagerRemovedHandler(onManagerRemovedReceiveHandler);
}
c.removeOnManagerAddedHandler(onManagerAddedReceiveSubscriptionsHandler);
c.removeOnManagerAddedHandler(onManagerAddedCallEventSubscriptionsHandler);
}
} }
public void handleConnection(final Manager m) { public void handleConnection(final Manager m) {

View File

@ -1415,9 +1415,6 @@
} }
] ]
}, },
{
"type": "java.math.BigDecimal"
},
{ {
"type": "java.math.BigInteger" "type": "java.math.BigInteger"
}, },
@ -1774,9 +1771,6 @@
{ {
"type": "kotlin.Any" "type": "kotlin.Any"
}, },
{
"type": "kotlin.Array"
},
{ {
"type": "kotlin.Boolean" "type": "kotlin.Boolean"
}, },
@ -1952,7 +1946,42 @@
}, },
{ {
"type": "kotlin.reflect.jvm.internal.impl.resolve.scopes.DescriptorKindFilter", "type": "kotlin.reflect.jvm.internal.impl.resolve.scopes.DescriptorKindFilter",
"allPublicFields": true "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"
}
]
}, },
{ {
"type": "kotlinx.coroutines.AwaitAll" "type": "kotlinx.coroutines.AwaitAll"
@ -2454,10 +2483,6 @@
"type": "org.asamk.signal.commands.FinishLinkCommand$JsonFinishLink", "type": "org.asamk.signal.commands.FinishLinkCommand$JsonFinishLink",
"allDeclaredFields": true, "allDeclaredFields": true,
"methods": [ "methods": [
{
"name": "aci",
"parameterTypes": []
},
{ {
"name": "number", "name": "number",
"parameterTypes": [] "parameterTypes": []
@ -2488,10 +2513,6 @@
"type": "org.asamk.signal.commands.ListAccountsCommand$JsonAccount", "type": "org.asamk.signal.commands.ListAccountsCommand$JsonAccount",
"allDeclaredFields": true, "allDeclaredFields": true,
"methods": [ "methods": [
{
"name": "aci",
"parameterTypes": []
},
{ {
"name": "number", "name": "number",
"parameterTypes": [] "parameterTypes": []
@ -2696,28 +2717,21 @@
"name": "<init>", "name": "<init>",
"parameterTypes": [ "parameterTypes": [
"java.lang.Boolean", "java.lang.Boolean",
"java.lang.String",
"java.lang.Boolean",
"java.lang.String",
"java.lang.String" "java.lang.String"
] ]
}, },
{
"name": "<init>",
"parameterTypes": [
"java.lang.Boolean",
"java.lang.String",
"java.lang.Boolean"
]
},
{ {
"name": "captcha", "name": "captcha",
"parameterTypes": [] "parameterTypes": []
}, },
{
"name": "recoveryKey",
"parameterTypes": []
},
{
"name": "reregister",
"parameterTypes": []
},
{
"name": "totp",
"parameterTypes": []
},
{ {
"name": "voice", "name": "voice",
"parameterTypes": [] "parameterTypes": []
@ -3225,9 +3239,6 @@
"allDeclaredMethods": true, "allDeclaredMethods": true,
"allDeclaredConstructors": true "allDeclaredConstructors": true
}, },
{
"type": "org.asamk.signal.json.JsonGroupCallUpdate"
},
{ {
"type": "org.asamk.signal.json.JsonGroupInfo", "type": "org.asamk.signal.json.JsonGroupInfo",
"allDeclaredFields": true, "allDeclaredFields": true,
@ -5104,15 +5115,6 @@
} }
] ]
}, },
{
"type": "org.bouncycastle.jcajce.provider.asymmetric.XMSS$Mappings",
"methods": [
{
"name": "<init>",
"parameterTypes": []
}
]
},
{ {
"type": "org.bouncycastle.jcajce.provider.asymmetric.edec.SignatureSpi$Ed25519", "type": "org.bouncycastle.jcajce.provider.asymmetric.edec.SignatureSpi$Ed25519",
"methods": [ "methods": [
@ -6188,22 +6190,6 @@
} }
] ]
}, },
{
"type": "org.signal.libsignal.internal.LinkedDeviceInternal_ReturnConverter",
"jniAccessible": true,
"methods": [
{
"name": "fromNative",
"parameterTypes": [
"java.lang.Object",
"java.lang.Object",
"java.lang.Object",
"java.lang.Object",
"java.lang.Object"
]
}
]
},
{ {
"type": "org.signal.libsignal.internal.NativeHandleGuard$SimpleOwner", "type": "org.signal.libsignal.internal.NativeHandleGuard$SimpleOwner",
"jniAccessible": true, "jniAccessible": true,
@ -6248,18 +6234,6 @@
} }
] ]
}, },
{
"type": "org.signal.libsignal.net.ChallengeOption",
"jniAccessible": true,
"fields": [
{
"name": "CAPTCHA"
},
{
"name": "PUSH_CHALLENGE"
}
]
},
{ {
"type": "org.signal.libsignal.net.ChatConnection$ListenerBridge", "type": "org.signal.libsignal.net.ChatConnection$ListenerBridge",
"jniAccessible": true, "jniAccessible": true,
@ -6383,46 +6357,6 @@
} }
] ]
}, },
{
"type": "org.signal.libsignal.net.DeviceIdNotFoundException",
"jniAccessible": true,
"methods": [
{
"name": "<init>",
"parameterTypes": [
"java.lang.String"
]
}
]
},
{
"type": "org.signal.libsignal.net.MismatchedDeviceException",
"jniAccessible": true,
"methods": [
{
"name": "<init>",
"parameterTypes": [
"java.lang.String",
"org.signal.libsignal.net.MismatchedDeviceException$Entry[]"
]
}
]
},
{
"type": "org.signal.libsignal.net.MismatchedDeviceException$Entry",
"jniAccessible": true,
"methods": [
{
"name": "<init>",
"parameterTypes": [
"byte[]",
"int[]",
"int[]",
"int[]"
]
}
]
},
{ {
"type": "org.signal.libsignal.net.NetworkException", "type": "org.signal.libsignal.net.NetworkException",
"jniAccessible": true, "jniAccessible": true,
@ -6435,33 +6369,6 @@
} }
] ]
}, },
{
"type": "org.signal.libsignal.net.RateLimitChallengeException",
"jniAccessible": true,
"methods": [
{
"name": "<init>",
"parameterTypes": [
"java.lang.String",
"java.lang.String",
"org.signal.libsignal.net.ChallengeOption[]",
"long"
]
}
]
},
{
"type": "org.signal.libsignal.net.RequestUnauthorizedException",
"jniAccessible": true,
"methods": [
{
"name": "<init>",
"parameterTypes": [
"java.lang.String"
]
}
]
},
{ {
"type": "org.signal.libsignal.net.RetryLaterException", "type": "org.signal.libsignal.net.RetryLaterException",
"jniAccessible": true, "jniAccessible": true,
@ -6474,42 +6381,6 @@
} }
] ]
}, },
{
"type": "org.signal.libsignal.net.ServerSideErrorException",
"jniAccessible": true,
"methods": [
{
"name": "<init>",
"parameterTypes": [
"java.lang.String"
]
}
]
},
{
"type": "org.signal.libsignal.net.ServiceIdNotFoundException",
"jniAccessible": true,
"methods": [
{
"name": "<init>",
"parameterTypes": [
"java.lang.String"
]
}
]
},
{
"type": "org.signal.libsignal.net.TimeoutException",
"jniAccessible": true,
"methods": [
{
"name": "<init>",
"parameterTypes": [
"java.lang.String"
]
}
]
},
{ {
"type": "org.signal.libsignal.net.TransportFailureException", "type": "org.signal.libsignal.net.TransportFailureException",
"jniAccessible": true, "jniAccessible": true,
@ -6522,18 +6393,6 @@
} }
] ]
}, },
{
"type": "org.signal.libsignal.net.UnexpectedResponseException",
"jniAccessible": true,
"methods": [
{
"name": "<init>",
"parameterTypes": [
"java.lang.String"
]
}
]
},
{ {
"type": "org.signal.libsignal.net.UploadForm", "type": "org.signal.libsignal.net.UploadForm",
"jniAccessible": true, "jniAccessible": true,
@ -6549,30 +6408,6 @@
} }
] ]
}, },
{
"type": "org.signal.libsignal.net.UploadTooLargeException",
"jniAccessible": true,
"methods": [
{
"name": "<init>",
"parameterTypes": [
"java.lang.String"
]
}
]
},
{
"type": "org.signal.libsignal.net.UsernameNotAvailableException",
"jniAccessible": true,
"methods": [
{
"name": "<init>",
"parameterTypes": [
"java.lang.String"
]
}
]
},
{ {
"type": "org.signal.libsignal.net.internal.BridgeChatListener", "type": "org.signal.libsignal.net.internal.BridgeChatListener",
"jniAccessible": true "jniAccessible": true
@ -6657,13 +6492,6 @@
"parameterTypes": [ "parameterTypes": [
"java.lang.String" "java.lang.String"
] ]
},
{
"name": "<init>",
"parameterTypes": [
"org.signal.libsignal.protocol.SignalProtocolAddress",
"java.lang.String"
]
} }
] ]
}, },
@ -7191,9 +7019,6 @@
"type": "org.signal.libsignal.zkgroup.profiles.ExpiringProfileKeyCredential", "type": "org.signal.libsignal.zkgroup.profiles.ExpiringProfileKeyCredential",
"allDeclaredFields": true "allDeclaredFields": true
}, },
{
"type": "org.signal.libsignal.zkgroup.profiles.ExpiringProfileKeyCredentialResponse"
},
{ {
"type": "org.signal.libsignal.zkgroup.profiles.ProfileKey", "type": "org.signal.libsignal.zkgroup.profiles.ProfileKey",
"allDeclaredFields": true "allDeclaredFields": true
@ -7260,15 +7085,6 @@
} }
] ]
}, },
{
"type": "org.signal.network.util.JsonUtil$UuidDeserializer",
"methods": [
{
"name": "<init>",
"parameterTypes": []
}
]
},
{ {
"type": "org.signal.storageservice.storage.protos.groups.AccessControl", "type": "org.signal.storageservice.storage.protos.groups.AccessControl",
"fields": [ "fields": [
@ -8340,13 +8156,6 @@
{ {
"name": "<init>", "name": "<init>",
"parameterTypes": [] "parameterTypes": []
},
{
"name": "<init>",
"parameterTypes": [
"int",
"int"
]
} }
] ]
}, },
@ -8442,17 +8251,6 @@
{ {
"name": "<init>", "name": "<init>",
"parameterTypes": [] "parameterTypes": []
},
{
"name": "<init>",
"parameterTypes": [
"java.lang.String",
"java.lang.String",
"java.lang.String",
"java.util.List",
"java.util.List",
"java.lang.Long"
]
} }
] ]
}, },
@ -8500,12 +8298,6 @@
"allDeclaredMethods": true, "allDeclaredMethods": true,
"allDeclaredConstructors": 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", "type": "org.whispersystems.signalservice.api.profiles.SignalServiceProfileWrite",
"allDeclaredFields": true, "allDeclaredFields": true,
@ -8514,13 +8306,7 @@
}, },
{ {
"type": "org.whispersystems.signalservice.api.provisioning.ProvisioningMessage", "type": "org.whispersystems.signalservice.api.provisioning.ProvisioningMessage",
"allDeclaredFields": true, "allDeclaredFields": true
"methods": [
{
"name": "getBody",
"parameterTypes": []
}
]
}, },
{ {
"type": "org.whispersystems.signalservice.api.push.ServiceId", "type": "org.whispersystems.signalservice.api.push.ServiceId",
@ -8968,9 +8754,6 @@
"allDeclaredMethods": true, "allDeclaredMethods": true,
"allDeclaredConstructors": true "allDeclaredConstructors": true
}, },
{
"type": "org.whispersystems.signalservice.internal.push.AuthCredentials$Companion"
},
{ {
"type": "org.whispersystems.signalservice.internal.push.ByteArrayDeserializerBase64", "type": "org.whispersystems.signalservice.internal.push.ByteArrayDeserializerBase64",
"methods": [ "methods": [
@ -8980,15 +8763,6 @@
} }
] ]
}, },
{
"type": "org.whispersystems.signalservice.internal.push.ByteArraySerializerBase64NoPadding",
"methods": [
{
"name": "<init>",
"parameterTypes": []
}
]
},
{ {
"type": "org.whispersystems.signalservice.internal.push.CdsiAuthResponse", "type": "org.whispersystems.signalservice.internal.push.CdsiAuthResponse",
"allDeclaredFields": true, "allDeclaredFields": true,
@ -8996,13 +8770,6 @@
{ {
"name": "<init>", "name": "<init>",
"parameterTypes": [] "parameterTypes": []
},
{
"name": "<init>",
"parameterTypes": [
"java.lang.String",
"java.lang.String"
]
} }
] ]
}, },
@ -9014,21 +8781,7 @@
}, },
{ {
"type": "org.whispersystems.signalservice.internal.push.ConfirmUsernameRequest", "type": "org.whispersystems.signalservice.internal.push.ConfirmUsernameRequest",
"allDeclaredFields": true, "allDeclaredFields": true
"methods": [
{
"name": "getEncryptedUsername",
"parameterTypes": []
},
{
"name": "getUsernameHash",
"parameterTypes": []
},
{
"name": "getZkProof",
"parameterTypes": []
}
]
}, },
{ {
"type": "org.whispersystems.signalservice.internal.push.ConfirmUsernameResponse", "type": "org.whispersystems.signalservice.internal.push.ConfirmUsernameResponse",
@ -9178,14 +8931,6 @@
"name": "<init>", "name": "<init>",
"parameterTypes": [] "parameterTypes": []
}, },
{
"name": "<init>",
"parameterTypes": [
"long",
"org.signal.libsignal.protocol.kem.KEMPublicKey",
"byte[]"
]
},
{ {
"name": "getKeyId", "name": "getKeyId",
"parameterTypes": [] "parameterTypes": []
@ -9272,9 +9017,6 @@
"allDeclaredMethods": true, "allDeclaredMethods": true,
"allDeclaredConstructors": true "allDeclaredConstructors": true
}, },
{
"type": "org.whispersystems.signalservice.internal.push.MismatchedDevices$Companion"
},
{ {
"type": "org.whispersystems.signalservice.internal.push.OneTimePreKeyCounts", "type": "org.whispersystems.signalservice.internal.push.OneTimePreKeyCounts",
"allDeclaredFields": true, "allDeclaredFields": true,
@ -9669,67 +9411,6 @@
{ {
"type": "org.whispersystems.signalservice.internal.push.StaleDevices", "type": "org.whispersystems.signalservice.internal.push.StaleDevices",
"allDeclaredFields": true, "allDeclaredFields": true,
"methods": [
{
"name": "<init>",
"parameterTypes": []
},
{
"name": "<init>",
"parameterTypes": [
"java.util.List"
]
}
]
},
{
"type": "org.whispersystems.signalservice.internal.push.StickerUploadAttributes",
"fields": [
{
"name": "acl"
},
{
"name": "algorithm"
},
{
"name": "credential"
},
{
"name": "date"
},
{
"name": "id"
},
{
"name": "key"
},
{
"name": "policy"
},
{
"name": "signature"
}
],
"methods": [
{
"name": "<init>",
"parameterTypes": []
}
]
},
{
"type": "org.whispersystems.signalservice.internal.push.StickerUploadAttributesResponse",
"fields": [
{
"name": "manifest"
},
{
"name": "packId"
},
{
"name": "stickers"
}
],
"methods": [ "methods": [
{ {
"name": "<init>", "name": "<init>",
@ -10178,29 +9859,6 @@
{ {
"type": "org.whispersystems.signalservice.internal.storage.protos.GroupV2Record$StorySendMode" "type": "org.whispersystems.signalservice.internal.storage.protos.GroupV2Record$StorySendMode"
}, },
{
"type": "org.whispersystems.signalservice.internal.storage.protos.InstalledStickerPackRecord",
"fields": [
{
"name": "deletedAtTimestamp"
},
{
"name": "packId"
},
{
"name": "packKey"
},
{
"name": "position"
}
]
},
{
"type": "org.whispersystems.signalservice.internal.storage.protos.InstalledStickerPackRecord$Builder"
},
{
"type": "org.whispersystems.signalservice.internal.storage.protos.InstalledStickerPackRecord$Companion"
},
{ {
"type": "org.whispersystems.signalservice.internal.storage.protos.ManifestRecord", "type": "org.whispersystems.signalservice.internal.storage.protos.ManifestRecord",
"fields": [ "fields": [
@ -10241,6 +9899,29 @@
} }
] ]
}, },
{
"type": "org.whispersystems.signalservice.internal.storage.protos.StickerPackRecord",
"fields": [
{
"name": "deletedAtTimestamp"
},
{
"name": "packId"
},
{
"name": "packKey"
},
{
"name": "position"
}
]
},
{
"type": "org.whispersystems.signalservice.internal.storage.protos.StickerPackRecord$Builder"
},
{
"type": "org.whispersystems.signalservice.internal.storage.protos.StickerPackRecord$Companion"
},
{ {
"type": "org.whispersystems.signalservice.internal.storage.protos.StorageItem", "type": "org.whispersystems.signalservice.internal.storage.protos.StorageItem",
"fields": [ "fields": [

View File

@ -1,23 +0,0 @@
package org.asamk.signal;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
class AccountIdentifierTest {
@Test
void normalizesSignalAndroidAccountKeys() {
assertEquals("a6b28482-2e32-83d0-7f23-91360a4c2b91",
AccountIdentifier.normalize("A6B284822E3283D07F2391360A4C2B91"));
assertEquals("a6b28482-2e32-83d0-7f23-91360a4c2b91",
AccountIdentifier.normalize("A6B28482-2E32-83D0-7F23-91360A4C2B91"));
}
@Test
void leavesNonAccountIdentifiersUnchanged() {
assertEquals("+12025550123", AccountIdentifier.normalize("+12025550123"));
assertEquals("not-an-account", AccountIdentifier.normalize("not-an-account"));
assertEquals("NOT-AN-ACCOUNT", AccountIdentifier.normalize("NOT-AN-ACCOUNT"));
}
}

View File

@ -1,59 +0,0 @@
package org.asamk.signal.commands;
import org.asamk.signal.commands.exceptions.UnexpectedErrorException;
import org.asamk.signal.manager.Manager;
import org.asamk.signal.manager.api.AttachmentInvalidException;
import org.asamk.signal.output.JsonWriter;
import org.asamk.signal.testutil.ManagerMock;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.MethodSource;
import java.io.IOException;
import java.lang.reflect.Proxy;
import java.net.SocketException;
import java.util.List;
import java.util.Map;
import java.util.stream.Stream;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
class SendCommandErrorTest {
@ParameterizedTest
@MethodSource("failures")
void uploadAndTransmissionFailuresKeepTheirClassification(final Exception cause) {
final var error = send(cause);
assertEquals("Failed to send message: " + cause.getMessage() + " (" + cause.getClass().getSimpleName() + ")",
error.getMessage());
assertSame(cause, error.getCause());
}
private static Stream<Exception> failures() {
return Stream.of(new AttachmentInvalidException("inline attachment #2: Connection reset"),
new SocketException("Connection reset"),
new IOException("Server error: 500"));
}
private static UnexpectedErrorException send(final Exception failure) {
final var delegate = ManagerMock.create("+15551234567");
final var manager = (Manager) Proxy.newProxyInstance(Manager.class.getClassLoader(),
new Class<?>[]{Manager.class}, (proxy, method, args) -> {
if (method.getName().equals("sendMessage")) {
throw failure;
}
return method.invoke(delegate, args);
});
// Upload and transmission failures must map to the same exception type, so the exit code and the
// JSON-RPC error code stay the same.
return assertThrows(UnexpectedErrorException.class,
() -> new SendCommand().handleCommand(new JsonRpcNamespace(Map.of("noteToSelf",
true,
"message",
"hi",
"attachments",
List.of("photo.jpg"))), manager, (JsonWriter) ignored -> {}));
}
}

View File

@ -1,164 +0,0 @@
package org.asamk.signal.commands;
import net.sourceforge.argparse4j.ArgumentParsers;
import net.sourceforge.argparse4j.DefaultSettings;
import net.sourceforge.argparse4j.inf.Namespace;
import org.asamk.signal.commands.exceptions.UserErrorException;
import org.asamk.signal.manager.Manager;
import org.asamk.signal.manager.api.Message;
import org.asamk.signal.manager.api.SendMessageResults;
import org.asamk.signal.output.JsonWriter;
import org.asamk.signal.testutil.ManagerMock;
import org.junit.jupiter.api.Test;
import java.lang.reflect.Proxy;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicReference;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
class SendCommandParsingTest {
private static final List<String> ATTACHMENTS = List.of("photo.jpg", "portrait.mp4", "other.mp4");
private static final String BLUR_HASH = "LEHV6nWB2yk8pyo0adR*.7kCMdnj";
@Test
void cliPreservesEmptyAndMissingDimensionPositions() throws Exception {
final var parser = ArgumentParsers.newFor("signal-cli", DefaultSettings.VERSION_0_9_0_DEFAULT_SETTINGS)
.includeArgumentNamesAsKeysInResult(true)
.build();
new SendCommand().attachToSubparser(parser.addSubparsers().addParser("send"));
final var message = send(parser.parseArgs(new String[]{
"send",
"--note-to-self",
"-a",
"photo.jpg",
"portrait.mp4",
"other.mp4",
"--attachment-dimensions",
"",
"1080x1920"
}));
assertEquals(ATTACHMENTS, message.attachments());
assertEquals(Arrays.asList(null, new Message.AttachmentDimensions(1080, 1920)), message.attachmentDimensions());
}
@Test
void jsonRpcPassesDimensionsIntoTheSentMessage() throws Exception {
final var message = send(namespace(List.of("", "1920x1080")));
assertEquals(ATTACHMENTS, message.attachments());
assertEquals(Arrays.asList(null, new Message.AttachmentDimensions(1920, 1080)), message.attachmentDimensions());
}
@Test
void cliPreservesBlurHashPositions() throws Exception {
final var parser = ArgumentParsers.newFor("signal-cli", DefaultSettings.VERSION_0_9_0_DEFAULT_SETTINGS)
.includeArgumentNamesAsKeysInResult(true)
.build();
new SendCommand().attachToSubparser(parser.addSubparsers().addParser("send"));
final var message = send(parser.parseArgs(new String[]{
"send",
"--note-to-self",
"-a",
"photo.jpg",
"portrait.mp4",
"other.mp4",
"--attachment-blurhash",
"",
BLUR_HASH
}));
assertEquals(List.of("", BLUR_HASH), message.attachmentBlurHashes());
}
@Test
void jsonRpcPassesBlurHashesWithDimensions() throws Exception {
final var message = send(new JsonRpcNamespace(Map.of("noteToSelf",
true,
"attachments",
ATTACHMENTS,
"attachmentDimensions",
List.of("", "1920x1080"),
"attachmentBlurhash",
List.of("", BLUR_HASH))));
assertEquals(Arrays.asList(null, new Message.AttachmentDimensions(1920, 1080)), message.attachmentDimensions());
assertEquals(List.of("", BLUR_HASH), message.attachmentBlurHashes());
}
@Test
void omittedDimensionsKeepExistingBehavior() throws Exception {
final var message = send(new JsonRpcNamespace(Map.of("noteToSelf", true, "attachments", ATTACHMENTS)));
assertEquals(List.of(), message.attachmentDimensions());
assertEquals(List.of(), message.attachmentBlurHashes());
}
@Test
void rejectsMalformedDimensionsBeforeSending() {
for (final var value : List.of("0x1080", "1920x0", "-1x2", "1.5x2", "1920")) {
assertThrows(UserErrorException.class, () -> send(namespace(List.of(value))), value);
}
}
@Test
void rejectsAttachmentDimensionsOutsideIntegerRange() {
for (final var value : List.of("2147483648x1", "1x2147483648")) {
assertThrows(UserErrorException.class, () -> send(namespace(List.of(value))), value);
}
}
@Test
void acceptsMaximumPositiveAttachmentDimensions() throws Exception {
final var message = send(namespace(List.of("2147483647x2147483647")));
assertEquals(List.of(new Message.AttachmentDimensions(Integer.MAX_VALUE, Integer.MAX_VALUE)),
message.attachmentDimensions());
}
@Test
void rejectsMalformedBlurHashesBeforeSending() {
// Wrong length for its size digit, an unknown size digit, too short.
for (final var value : List.of(BLUR_HASH + "0", "!" + BLUR_HASH.substring(1), "00000")) {
assertThrows(UserErrorException.class, () -> send(blurHashNamespace(List.of(value))), value);
}
}
private JsonRpcNamespace blurHashNamespace(List<String> blurHashes) {
return new JsonRpcNamespace(Map.of("noteToSelf",
true,
"attachments",
ATTACHMENTS,
"attachmentBlurhash",
blurHashes));
}
private JsonRpcNamespace namespace(List<String> dimensions) {
return new JsonRpcNamespace(Map.of("noteToSelf",
true,
"attachments",
ATTACHMENTS,
"attachmentDimensions",
dimensions));
}
private Message send(Namespace namespace) throws Exception {
final var captured = new AtomicReference<Message>();
final var delegate = ManagerMock.create("+15551234567");
final var manager = (Manager) Proxy.newProxyInstance(Manager.class.getClassLoader(),
new Class<?>[]{Manager.class},
(proxy, method, args) -> {
if (method.getName().equals("sendMessage")) {
captured.set((Message) args[0]);
return new SendMessageResults(1, Map.of());
}
return method.invoke(delegate, args);
});
new SendCommand().handleCommand(namespace, manager, (JsonWriter) ignored -> {
});
return captured.get();
}
}

View File

@ -1,415 +0,0 @@
package org.asamk.signal.dbus;
import org.asamk.Signal;
import org.asamk.SignalControl;
import org.asamk.signal.DbusConfig;
import org.asamk.signal.commands.CommandHandler;
import org.asamk.signal.commands.ListGroupsCommand;
import org.asamk.signal.commands.LocalCommand;
import org.asamk.signal.commands.exceptions.UserErrorException;
import org.asamk.signal.manager.Manager;
import org.asamk.signal.manager.api.Group;
import org.asamk.signal.manager.api.GroupId;
import org.asamk.signal.manager.api.GroupMember;
import org.asamk.signal.manager.api.GroupPermission;
import org.asamk.signal.manager.api.Identity;
import org.asamk.signal.manager.api.MessageEnvelope;
import org.asamk.signal.manager.api.Recipient;
import org.asamk.signal.manager.api.RecipientAddress;
import org.asamk.signal.manager.api.RecipientIdentifier;
import org.asamk.signal.manager.api.SendMessageResults;
import org.asamk.signal.manager.api.TrustLevel;
import org.asamk.signal.manager.api.TypingAction;
import org.asamk.signal.manager.internal.MultiAccountManagerImpl;
import org.freedesktop.dbus.DBusPath;
import org.freedesktop.dbus.connections.impl.DBusConnection;
import org.freedesktop.dbus.connections.impl.DBusConnectionBuilder;
import org.freedesktop.dbus.exceptions.DBusExecutionException;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.Timeout;
import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.NullSource;
import org.junit.jupiter.params.provider.ValueSource;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Proxy;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import java.util.stream.Collectors;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
class NumberlessDbusTest {
private static final String NUMBER = "+12025550123";
private static final String ACI = "11111111-1111-4111-8111-111111111111";
private static final String OTHER_ACI = "22222222-2222-4222-8222-222222222222";
private static final String THIRD_ACI = "33333333-3333-4333-8333-333333333333";
private static final String NUMBERLESS_PATH = "/org/asamk/Signal/11111111_1111_4111_8111_111111111111";
private static final String NUMBERED_PATH = "/org/asamk/Signal/_12025550123";
@Test
void resolvesNumberlessAndNumberedAccountsWithoutChangingNumberedPaths() {
final var numberless = new TestAccount(null, ACI);
final var numbered = new TestAccount(NUMBER, OTHER_ACI);
final var accounts = new MultiAccountManagerImpl(List.of(numberless.manager, numbered.manager), null);
final var control = new DbusSignalControlImpl(accounts, DbusConfig.getObjectPath());
assertEquals(Set.of(new DBusPath(NUMBERLESS_PATH), new DBusPath(NUMBERED_PATH)),
Set.copyOf(control.listAccounts()));
assertEquals(NUMBERLESS_PATH, control.getAccount(ACI).getPath());
assertEquals(NUMBERED_PATH, control.getAccount(NUMBER).getPath());
assertEquals(NUMBERED_PATH, control.getAccount(OTHER_ACI).getPath());
assertThrows(SignalControl.Error.Failure.class, () -> control.getAccount(THIRD_ACI));
}
@ParameterizedTest
@NullSource
@ValueSource(strings = NUMBER)
void mapsOptionalPhoneNumbersAndAddressesSelfByAnAvailableIdentifier(final String number) throws Exception {
final var account = new TestAccount(number, ACI);
final var signal = new DbusSignalImpl(account.manager, null, DbusConfig.getObjectPath(), true);
final var client = new DbusManagerImpl(signal, null, "org.asamk.Signal.Test");
assertEquals(number == null ? "" : number, signal.getSelfNumber());
assertEquals(number, client.getSelfNumber());
assertEquals(number == null ? ACI : number, client.getSelfIdentifier());
checkSelfOperations(client,
account,
number == null
? new RecipientIdentifier.Uuid(UUID.fromString(ACI))
: new RecipientIdentifier.Number(number));
}
// Run with: dbus-run-session -- ./gradlew --no-daemon :test --tests '*NumberlessDbusTest*' --rerun-tasks
@Nested
@Timeout(20)
@EnabledIfEnvironmentVariable(named = "DBUS_SESSION_BUS_ADDRESS", matches = ".+")
class SessionBus {
@Test
void exportsMixedAccountsRoutesAciSelectorsAndRemovesOnlyTheClosedAccount() throws Exception {
final var numberless = new TestAccount(null, ACI);
final var numbered = new TestAccount(NUMBER, OTHER_ACI);
final var secondNumberless = new TestAccount(null, THIRD_ACI);
final var accounts = new MultiAccountManagerImpl(List.of(numberless.manager,
numbered.manager,
secondNumberless.manager), null);
final var busname = newBusname();
try (final var server = new DbusHandler(false,
busname,
accounts,
true); final var connection = DBusConnectionBuilder.forSessionBus().withShared(false).build()) {
server.init();
final var control = connection.getRemoteObject(busname,
DbusConfig.getObjectPath(),
SignalControl.class);
assertEquals(Set.of(NUMBERLESS_PATH, NUMBERED_PATH, DbusConfig.getObjectPath(THIRD_ACI)),
control.listAccounts().stream().map(DBusPath::getPath).collect(Collectors.toSet()));
assertEquals(NUMBERED_PATH, control.getAccount(OTHER_ACI).getPath());
assertEquals(NUMBERED_PATH, control.getAccount(NUMBER).getPath());
assertEquals(NUMBERLESS_PATH, control.getAccount(ACI).getPath());
assertEquals(ACI, selectAccount(ACI, connection, busname));
assertEquals(OTHER_ACI, selectAccount(OTHER_ACI, connection, busname));
assertEquals(OTHER_ACI, selectAccount(NUMBER, connection, busname));
assertThrows(UserErrorException.class, () -> selectAccount(null, connection, busname));
final var client = new DbusMultiAccountManagerImpl(control, connection, busname);
assertEquals(3, client.getManagers().size());
assertEquals(2, client.getManagers().stream().filter(m -> m.getSelfNumber() == null).count());
assertNull(client.getManager(ACI).getSelfNumber());
assertEquals(NUMBER, client.getManager(OTHER_ACI).getSelfNumber());
final var removed = connection.getRemoteObject(busname, NUMBERLESS_PATH, Signal.class);
numberless.manager.close();
assertEquals(2, control.listAccounts().size());
assertThrows(SignalControl.Error.Failure.class, () -> control.getAccount(ACI));
assertThrows(DBusExecutionException.class, removed::getSelfACI);
assertEquals(THIRD_ACI, client.getManager(THIRD_ACI).getSelfACI());
assertEquals(NUMBER, client.getManager(NUMBER).getSelfNumber());
}
}
@ParameterizedTest
@NullSource
@ValueSource(strings = NUMBER)
void selectsSingleAccountDaemonsImplicitlyAndByAci(final String number) throws Exception {
final var account = new TestAccount(number, ACI);
final var busname = newBusname();
try (final var server = new DbusHandler(false,
busname,
account.manager,
true); final var connection = DBusConnectionBuilder.forSessionBus().withShared(false).build()) {
server.init();
assertEquals(ACI, selectAccount(null, connection, busname));
assertEquals(ACI, selectAccount(ACI, connection, busname));
if (number != null) {
assertEquals(ACI, selectAccount(number, connection, busname));
}
assertThrows(UserErrorException.class, () -> selectAccount(OTHER_ACI, connection, busname));
}
}
@Test
void preservesAciAddressesInContactsGroupMembersAndIdentities() throws Exception {
final var account = new TestAccount(null, ACI);
final var contact = new RecipientAddress(UUID.fromString(OTHER_ACI));
final var numberedContact = new RecipientAddress(NUMBER);
account.recipients = List.of(Recipient.newBuilder().withAddress(contact).build(),
Recipient.newBuilder().withAddress(numberedContact).build());
account.identities = List.of(new Identity(contact,
new byte[]{1},
"test",
new byte[]{2},
TrustLevel.TRUSTED_UNVERIFIED,
123));
final var groupId = GroupId.unknownVersion(new byte[32]);
account.groups = List.of(new Group(groupId,
"Test group",
"",
null,
Set.of(new GroupMember(contact, true, null, null),
new GroupMember(numberedContact, false, null, null)),
Set.of(contact),
Set.of(contact),
Set.of(contact),
false,
0,
GroupPermission.EVERY_MEMBER,
GroupPermission.EVERY_MEMBER,
GroupPermission.EVERY_MEMBER,
true,
true,
false));
final var busname = newBusname();
try (final var server = new DbusHandler(false,
busname,
account.manager,
true); final var connection = DBusConnectionBuilder.forSessionBus().withShared(false).build()) {
server.init();
final var signal = connection.getRemoteObject(busname, DbusConfig.getObjectPath(), Signal.class);
assertEquals(List.of(NUMBER), signal.listNumbers());
assertEquals(Set.of(OTHER_ACI, NUMBER), Set.copyOf(signal.listRecipientIdentifiers()));
try (final var client = new DbusManagerImpl(signal, connection, busname)) {
final var contacts = client.getRecipients(false,
Optional.empty(),
Set.of(new RecipientIdentifier.Uuid(UUID.fromString(OTHER_ACI))),
Optional.empty());
assertEquals(1, contacts.size());
assertEquals(contact, contacts.getFirst().getAddress());
assertEquals(contact, client.getIdentities().getFirst().recipient());
final var group = client.getGroups().getFirst();
assertEquals(Set.of(contact, numberedContact),
group.members().stream().map(GroupMember::recipientAddress).collect(Collectors.toSet()));
assertEquals(Set.of(contact), group.pendingMembers());
assertEquals(Set.of(contact), group.requestingMembers());
assertEquals(Set.of(contact), group.bannedMembers());
}
}
}
@Test
void sendsAndReceivesNumberlessDirectGroupAndSelfMessagesOverDbus() throws Exception {
final var account = new TestAccount(null, ACI);
final var accounts = new MultiAccountManagerImpl(List.of(account.manager), null);
final var busname = newBusname();
try (final var server = new DbusHandler(false,
busname,
accounts,
true); final var connection = DBusConnectionBuilder.forSessionBus().withShared(false).build()) {
server.init();
assertEquals(ACI, selectAccount(null, connection, busname));
final var signal = connection.getRemoteObject(busname, NUMBERLESS_PATH, Signal.class);
assertEquals("", signal.getSelfNumber());
final var groupId = GroupId.unknownVersion(new byte[32]);
assertEquals(42, signal.sendMessage("Direct", List.of(), OTHER_ACI));
assertEquals(42, signal.sendGroupMessage("Group", List.of(), groupId.serialize()));
assertEquals(42, signal.sendNoteToSelfMessage("Self", List.of()));
assertEquals(Set.of(new RecipientIdentifier.Uuid(UUID.fromString(OTHER_ACI))),
account.calls.get(0).arguments().get(1));
assertEquals(Set.of(new RecipientIdentifier.Group(groupId)), account.calls.get(1).arguments().get(1));
assertEquals(Set.of(RecipientIdentifier.NoteToSelf.INSTANCE), account.calls.get(2).arguments().get(1));
assertThrows(Signal.Error.InvalidNumber.class,
() -> signal.sendMessage("Invalid local number", List.of(), "2025550124"));
account.calls.clear();
try (final var client = new DbusManagerImpl(signal, connection, busname)) {
checkSelfOperations(client, account, new RecipientIdentifier.Uuid(UUID.fromString(ACI)));
final var received = new LinkedBlockingQueue<MessageEnvelope>();
client.addReceiveHandler((envelope, error) -> received.add(envelope));
for (final var group : List.of(Optional.<GroupId>empty(), Optional.of(groupId))) {
account.emit(messageEnvelope(group));
final var envelope = received.poll(5, TimeUnit.SECONDS);
assertNotNull(envelope);
assertEquals(OTHER_ACI, envelope.sourceAddress().orElseThrow().aci().orElseThrow());
assertEquals(Optional.empty(), envelope.sourceAddress().orElseThrow().number());
assertEquals("Incoming", envelope.data().orElseThrow().body().orElseThrow());
assertEquals(Optional.of(ACI),
envelope.data().orElseThrow().mentions().getFirst().recipient().aci());
assertEquals(group,
envelope.data()
.orElseThrow()
.groupContext()
.map(MessageEnvelope.Data.GroupContext::groupId));
}
}
assertEquals(0, account.receivers.size());
}
}
}
private static void checkSelfOperations(
final Manager client,
final TestAccount account,
final RecipientIdentifier.Single self
) throws Exception {
final Set<RecipientIdentifier> recipients = Set.of(RecipientIdentifier.NoteToSelf.INSTANCE);
client.sendTypingMessage(TypingAction.START, recipients);
client.sendRemoteDeleteMessage(123, recipients);
client.sendMessageReaction("\uD83D\uDC4D", false, self, 123, recipients, false, false);
assertEquals(List.of("sendTypingMessage", "sendRemoteDeleteMessage", "sendMessageReaction"),
account.calls.stream().map(Call::name).toList());
assertEquals(Set.of(self), account.calls.get(0).arguments().get(1));
assertEquals(Set.of(self), account.calls.get(1).arguments().get(1));
assertEquals(Set.of(self), account.calls.get(2).arguments().get(4));
}
private static String selectAccount(
final String account,
final DBusConnection connection,
final String busname
) throws Exception {
final var selected = new AtomicReference<String>();
final var handler = new CommandHandler(null, null) {
@Override
public void handleLocalCommand(final LocalCommand command, final Manager manager) {
selected.set(manager.getSelfACI());
}
};
DbusCommandHandler.handleCommand(new ListGroupsCommand(), account, connection, busname, handler);
return selected.get();
}
private static String newBusname() {
return "org.asamk.Signal.Test" + UUID.randomUUID().toString().replace("-", "");
}
private static MessageEnvelope messageEnvelope(final Optional<GroupId> groupId) {
final var data = new MessageEnvelope.Data(123,
groupId.map(id -> new MessageEnvelope.Data.GroupContext(id, false, 0)),
Optional.empty(),
Optional.empty(),
Optional.of("Incoming"),
0,
false,
false,
false,
false,
false,
Optional.empty(),
Optional.empty(),
Optional.empty(),
List.of(),
Optional.empty(),
Optional.empty(),
List.of(),
Optional.empty(),
Optional.empty(),
Optional.empty(),
List.of(new MessageEnvelope.Data.Mention(new RecipientAddress(UUID.fromString(ACI)), 0, 1)),
List.of(),
List.of(),
Optional.empty(),
Optional.empty(),
Optional.empty());
return new MessageEnvelope(Optional.of(new RecipientAddress(UUID.fromString(OTHER_ACI))),
1,
123,
0,
0,
false,
Optional.empty(),
Optional.empty(),
Optional.of(data),
Optional.empty(),
Optional.empty(),
Optional.empty(),
Optional.empty());
}
private record Call(String name, List<Object> arguments) {}
private static final class TestAccount {
private final Manager manager;
private final List<Call> calls = new CopyOnWriteArrayList<>();
private final List<Runnable> closedListeners = new CopyOnWriteArrayList<>();
private final List<Manager.ReceiveMessageHandler> receivers = new CopyOnWriteArrayList<>();
private List<Recipient> recipients = List.of();
private List<Group> groups = List.of();
private List<Identity> identities = List.of();
private TestAccount(final String number, final String aci) {
manager = (Manager) Proxy.newProxyInstance(Manager.class.getClassLoader(),
new Class<?>[]{Manager.class},
(proxy, method, args) -> {
if (method.isDefault()) {
return InvocationHandler.invokeDefault(proxy, method, args);
}
return switch (method.getName()) {
case "getSelfNumber" -> number;
case "getSelfACI" -> aci;
case "getLinkedDevices" -> List.of();
case "getGroups" -> groups;
case "getGroup" -> groups.getFirst();
case "getIdentities" -> identities;
case "getRecipients" -> recipients;
case "isContactBlocked" -> false;
case "getContactOrProfileName" -> "Test contact";
case "addAddressChangedListener" -> null;
case "addClosedListener" -> {
closedListeners.add((Runnable) args[0]);
yield null;
}
case "close" -> {
closedListeners.forEach(Runnable::run);
closedListeners.clear();
yield null;
}
case "addReceiveHandler" -> {
receivers.add((Manager.ReceiveMessageHandler) args[0]);
yield null;
}
case "removeReceiveHandler" -> {
receivers.remove(args[0]);
yield null;
}
case "sendMessage", "sendTypingMessage", "sendRemoteDeleteMessage",
"sendMessageReaction" -> {
calls.add(new Call(method.getName(), List.of(args)));
yield new SendMessageResults(42, Map.of());
}
case "hashCode" -> System.identityHashCode(proxy);
case "equals" -> proxy == args[0];
default -> throw new UnsupportedOperationException(method.getName());
};
});
}
private void emit(final MessageEnvelope envelope) {
receivers.forEach(receiver -> receiver.handleMessage(envelope, null));
}
}
}

View File

@ -10,7 +10,6 @@ import java.net.HttpURLConnection;
import java.net.InetSocketAddress; import java.net.InetSocketAddress;
import java.net.ServerSocket; import java.net.ServerSocket;
import java.net.URI; import java.net.URI;
import java.time.Duration;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
@ -45,7 +44,7 @@ class SseInitialFlushTest {
void setUp() throws Exception { void setUp() throws Exception {
port = freePort(); port = freePort();
final Manager manager = ManagerMock.create("+10000000000"); final Manager manager = ManagerMock.create("+10000000000");
handler = new HttpServerHandler(new InetSocketAddress("127.0.0.1", port), Duration.ofSeconds(15), manager); handler = new HttpServerHandler(new InetSocketAddress("127.0.0.1", port), manager);
handler.init(); handler.init();
} }

View File

@ -1,201 +0,0 @@
package org.asamk.signal.http;
import org.asamk.signal.testutil.ManagerMock;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import java.io.BufferedReader;
import java.io.EOFException;
import java.io.IOException;
import java.io.InputStreamReader;
import java.net.HttpURLConnection;
import java.net.InetSocketAddress;
import java.net.ServerSocket;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Tests the event ids, the replay of missed events for a client that reconnects with a
* Last-Event-ID header, and the configurable keep-alive interval of GET /api/v1/events.
*/
class SseReplayTest {
private final ManagerMock.State state = new ManagerMock.State();
private HttpServerHandler handler;
private int port;
private void start(final Duration keepAliveInterval) throws Exception {
try (var ss = new ServerSocket(0)) {
port = ss.getLocalPort();
}
handler = new HttpServerHandler(new InetSocketAddress("127.0.0.1", port),
keepAliveInterval,
ManagerMock.create("+10000000000", state));
handler.init();
}
@AfterEach
void tearDown() {
if (handler != null) {
handler.close();
}
}
@Test
void eventsHaveIds() throws Exception {
start(Duration.ofSeconds(15));
try (var client = connect(null, 2)) {
receive("one");
final var event = client.next();
assertNotNull(event.id());
assertTrue(event.data().contains("\"one\""));
}
}
@Test
void reconnectWithLastEventIdReplaysMissedEvents() throws Exception {
start(Duration.ofSeconds(15));
final String firstId;
try (var client = connect(null, 2)) {
receive("one");
receive("two");
firstId = client.next().id();
client.next();
}
receive("three");
try (var client = connect(firstId, 3)) {
assertTrue(client.next().data().contains("\"two\""));
assertTrue(client.next().data().contains("\"three\""));
receive("four");
assertTrue(client.next().data().contains("\"four\""));
}
}
@Test
void unknownLastEventIdReplaysAllBufferedEvents() throws Exception {
start(Duration.ofSeconds(15));
try (var client = connect(null, 2)) {
receive("one");
receive("two");
client.next();
client.next();
}
try (var client = connect("0-1", 3)) {
assertTrue(client.next().data().contains("\"one\""));
assertTrue(client.next().data().contains("\"two\""));
}
}
@Test
void connectWithoutLastEventIdReplaysNothing() throws Exception {
start(Duration.ofSeconds(15));
try (var client = connect(null, 2)) {
receive("one");
client.next();
}
try (var client = connect(null, 3)) {
receive("two");
assertTrue(client.next().data().contains("\"two\""));
}
}
@Test
void keepAliveIntervalIsConfigurable() throws Exception {
start(Duration.ofSeconds(1));
try (var client = connect(null, 2)) {
// The read timeout is 5 s, the default interval of 15 s would fail here
assertEquals(":", client.nextLine());
}
}
/**
* Passes a message to the registered receive handlers, like the manager's receive thread does.
*/
private void receive(final String message) {
final var exception = new RuntimeException(message);
for (final var receiveHandler : state.receiveHandlers) {
receiveHandler.handleMessage(null, exception);
}
}
/**
* Connects and waits until the server has registered the client, i.e. until the manager has
* seen the given total number of addReceiveHandler calls.
*/
private SseClient connect(final String lastEventId, final int addReceiveHandlerCount) throws Exception {
final var client = new SseClient(port, lastEventId);
final var deadline = System.nanoTime() + Duration.ofSeconds(5).toNanos();
while (state.addReceiveHandlerCount.get() < addReceiveHandlerCount) {
if (System.nanoTime() > deadline) {
client.close();
throw new AssertionError("Client was not registered in time");
}
Thread.sleep(10);
}
return client;
}
private record Event(String id, String data) {}
private static final class SseClient implements AutoCloseable {
private final HttpURLConnection connection;
private final BufferedReader reader;
SseClient(final int port, final String lastEventId) throws IOException {
final var url = URI.create("http://127.0.0.1:" + port + "/api/v1/events").toURL();
connection = (HttpURLConnection) url.openConnection();
connection.setRequestProperty("Accept", "text/event-stream");
if (lastEventId != null) {
connection.setRequestProperty("Last-Event-ID", lastEventId);
}
connection.setConnectTimeout(2_000);
connection.setReadTimeout(5_000);
assertEquals(200, connection.getResponseCode());
reader = new BufferedReader(new InputStreamReader(connection.getInputStream(), StandardCharsets.UTF_8));
}
String nextLine() throws IOException {
final var line = reader.readLine();
if (line == null) {
throw new EOFException();
}
return line;
}
/**
* Reads the next event, skipping keep-alive comments.
*/
Event next() throws IOException {
String id = null;
String data = null;
while (true) {
final var line = nextLine();
if (line.isEmpty()) {
if (data != null) {
return new Event(id, data);
}
} else if (line.startsWith("id:")) {
id = line.substring("id:".length());
} else if (line.startsWith("data:")) {
data = line.substring("data:".length());
}
}
}
@Override
public void close() {
connection.disconnect();
}
}
}

View File

@ -1,71 +0,0 @@
package org.asamk.signal.json;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.asamk.signal.commands.ListAccountsCommand;
import org.asamk.signal.manager.Manager;
import org.asamk.signal.manager.internal.MultiAccountManagerImpl;
import org.junit.jupiter.api.Test;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Proxy;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicReference;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertSame;
class NumberlessAccountOutputTest {
private static final String ACI = "11111111-1111-4111-8111-111111111111";
@Test
void receiveEventsIdentifyNumberlessAccountByAci() {
final var output = new AtomicReference<Object>();
new JsonReceiveMessageHandler(manager(null), output::set).handleMessage(null, null);
assertEquals(ACI, ((Map<?, ?>) output.get()).get("account"));
}
@Test
void receiveEventsKeepNumberedAccountIdentifiers() {
final var output = new AtomicReference<Object>();
new JsonReceiveMessageHandler(manager("+12025550123"), output::set).handleMessage(null, null);
assertEquals("+12025550123", ((Map<?, ?>) output.get()).get("account"));
}
@Test
void listAccountsIncludesAciWithoutPuttingItInTheNumberField() throws Exception {
final var numberless = manager(null);
final var numbered = manager("+12025550123");
final var multi = new MultiAccountManagerImpl(List.of(numberless, numbered), null);
final var output = new AtomicReference<Object>();
new ListAccountsCommand().handleCommand(Map.of(), multi, output::set);
final var accounts = new ObjectMapper().valueToTree(output.get());
assertEquals(2, accounts.size());
for (final var account : accounts) {
assertEquals(ACI, account.path("aci").asText());
assertFalse(ACI.equals(account.path("number").asText()));
}
assertSame(numbered, multi.getManager("+12025550123"));
}
private static Manager manager(final String number) {
return (Manager) Proxy.newProxyInstance(Manager.class.getClassLoader(),
new Class<?>[]{Manager.class},
(proxy, method, args) -> {
if (method.isDefault()) {
return InvocationHandler.invokeDefault(proxy, method, args);
}
return switch (method.getName()) {
case "getSelfNumber" -> number;
case "getSelfACI" -> ACI;
case "hashCode" -> System.identityHashCode(proxy);
case "equals" -> proxy == args[0];
case "addClosedListener" -> null;
default -> throw new UnsupportedOperationException(method.getName());
};
});
}
}

View File

@ -67,13 +67,16 @@ class SubscribeCallEventsTest {
final List<Manager> managers; final List<Manager> managers;
final List<Consumer<Manager>> addedHandlers = new ArrayList<>(); final List<Consumer<Manager>> addedHandlers = new ArrayList<>();
final List<Consumer<Manager>> removedHandlers = new ArrayList<>();
int addedHandlerRegistrationCount;
StubMultiAccountManager(List<Manager> managers) { StubMultiAccountManager(List<Manager> managers) {
this.managers = new ArrayList<>(managers); this.managers = new ArrayList<>(managers);
} }
@Override
public List<String> getAccountNumbers() {
return managers.stream().map(Manager::getSelfNumber).toList();
}
@Override @Override
public List<Manager> getManagers() { public List<Manager> getManagers() {
return managers; return managers;
@ -81,23 +84,11 @@ class SubscribeCallEventsTest {
@Override @Override
public void addOnManagerAddedHandler(Consumer<Manager> handler) { public void addOnManagerAddedHandler(Consumer<Manager> handler) {
addedHandlerRegistrationCount++;
addedHandlers.add(handler); addedHandlers.add(handler);
} }
@Override
public void removeOnManagerAddedHandler(Consumer<Manager> handler) {
addedHandlers.remove(handler);
}
@Override @Override
public void addOnManagerRemovedHandler(Consumer<Manager> handler) { public void addOnManagerRemovedHandler(Consumer<Manager> handler) {
removedHandlers.add(handler);
}
@Override
public void removeOnManagerRemovedHandler(Consumer<Manager> handler) {
removedHandlers.remove(handler);
} }
@Override @Override
@ -237,9 +228,8 @@ class SubscribeCallEventsTest {
assertEquals(1, manager1.state().addCallEventListenerCount.get(), "manager1 should have one listener"); assertEquals(1, manager1.state().addCallEventListenerCount.get(), "manager1 should have one listener");
assertEquals(1, manager2.state().addCallEventListenerCount.get(), "manager2 should have one listener"); assertEquals(1, manager2.state().addCallEventListenerCount.get(), "manager2 should have one listener");
assertEquals(2, multi.addedHandlerRegistrationCount, "should register onManagerAdded handlers"); // Also registers an onManagerAdded handler for receive and one for call events
assertEquals(0, multi.addedHandlers.size(), "should remove onManagerAdded handlers after connection closes"); assertEquals(2, multi.addedHandlers.size(), "should register onManagerAdded handlers");
assertEquals(0, multi.removedHandlers.size(), "should not register onManagerRemoved handlers in manual mode");
} }
@Test @Test
@ -278,17 +268,4 @@ class SubscribeCallEventsTest {
manager1.state().addCallEventListenerCount.get(), manager1.state().addCallEventListenerCount.get(),
"call events should not be auto-subscribed in multi mode"); "call events should not be auto-subscribed in multi mode");
} }
@Test
void multiAccountConnectionRemovesAllManagerHandlersWhenReceiveIsEnabled() {
var manager = createManager("+15551111111");
var multi = new StubMultiAccountManager(List.of(manager.manager()));
var handler = new SignalJsonRpcDispatcherHandler(new CapturingJsonWriter(), () -> null, false);
handler.handleConnection(multi);
assertEquals(3, multi.addedHandlerRegistrationCount);
assertEquals(0, multi.addedHandlers.size(), "should remove all onManagerAdded handlers after close");
assertEquals(0, multi.removedHandlers.size(), "should remove onManagerRemoved handler after close");
}
} }

View File

@ -9,7 +9,6 @@ import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Optional; import java.util.Optional;
import java.util.Set; import java.util.Set;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicInteger;
public final class ManagerMock { public final class ManagerMock {
@ -25,8 +24,7 @@ public final class ManagerMock {
public final AtomicInteger addCallEventListenerCount = new AtomicInteger(0); public final AtomicInteger addCallEventListenerCount = new AtomicInteger(0);
public final AtomicInteger removeCallEventListenerCount = new AtomicInteger(0); public final AtomicInteger removeCallEventListenerCount = new AtomicInteger(0);
// Written by server threads while tests read it public final List<Manager.ReceiveMessageHandler> receiveHandlers = new ArrayList<>();
public final List<Manager.ReceiveMessageHandler> receiveHandlers = new CopyOnWriteArrayList<>();
public final AtomicInteger addReceiveHandlerCount = new AtomicInteger(0); public final AtomicInteger addReceiveHandlerCount = new AtomicInteger(0);
public final AtomicInteger removeReceiveHandlerCount = new AtomicInteger(0); public final AtomicInteger removeReceiveHandlerCount = new AtomicInteger(0);
} }