mirror of
https://github.com/AsamK/signal-cli.git
synced 2026-08-29 06:06:22 +00:00
Compare commits
35 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c17e01c37 | ||
|
|
9605c94f09 | ||
|
|
d2f5696df1 | ||
|
|
81e513d6b7 | ||
|
|
dbf11fd006 | ||
|
|
7e802166a3 | ||
|
|
0f88410465 | ||
|
|
13819c8f09 | ||
|
|
85580d1ff6 | ||
|
|
f8ea8ed314 | ||
|
|
472784e614 | ||
|
|
db97731f63 | ||
|
|
ff9f2028ae | ||
|
|
9ee5262876 | ||
|
|
b01b6b370d | ||
|
|
890f798a03 | ||
|
|
3c319f8b4a | ||
|
|
fd3f572499 | ||
|
|
854cb35d15 | ||
|
|
e78521bb5c | ||
|
|
86ad7bf53f | ||
|
|
de5daf0ad4 | ||
|
|
ecc76ec52b | ||
|
|
6fa1d1bf90 | ||
|
|
d29f6c1641 | ||
|
|
b3e3eda4f7 | ||
|
|
1e59c6f6cc | ||
|
|
457658aca0 | ||
|
|
2cf5e25e9e | ||
|
|
151b42c6e9 | ||
|
|
91138452bb | ||
|
|
304ffb0e33 | ||
|
|
987832099d | ||
|
|
64a629002d | ||
|
|
c26e0632c5 |
27
.github/workflows/dependency-graph.yml
vendored
Normal file
27
.github/workflows/dependency-graph.yml
vendored
Normal file
@ -0,0 +1,27 @@
|
||||
name: dependency-graph
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- "**"
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
submit-dependency-graph:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Set up JDK
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: 'zulu'
|
||||
java-version: '25'
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v5
|
||||
with:
|
||||
dependency-graph: generate-and-submit
|
||||
- name: Generate dependency graph
|
||||
run: ./gradlew --no-daemon dependencies
|
||||
72
.github/workflows/release.yml
vendored
72
.github/workflows/release.yml
vendored
@ -35,55 +35,19 @@ jobs:
|
||||
mv ./signal-cli-archive-${{ env.ARCHIVE_JAVA_VERSION }}/* .
|
||||
echo "version=$(cat VERSION)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Create release
|
||||
id: create_release
|
||||
uses: actions/create-release@v1
|
||||
- name: Create draft release and upload assets
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
tag_name: v${{ steps.version.outputs.version }} # note: added `v`
|
||||
release_name: v${{ steps.version.outputs.version }} # note: added `v`
|
||||
draft: true
|
||||
|
||||
- name: Upload archive
|
||||
uses: actions/upload-release-asset@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||
asset_path: signal-cli-${{ steps.version.outputs.version }}.tar.gz
|
||||
asset_name: signal-cli-${{ steps.version.outputs.version }}.tar.gz
|
||||
asset_content_type: application/x-compressed-tar # .tar.gz
|
||||
|
||||
- name: Upload Linux native archive
|
||||
uses: actions/upload-release-asset@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||
asset_path: signal-cli-${{ steps.version.outputs.version }}-Linux-native.tar.gz
|
||||
asset_name: signal-cli-${{ steps.version.outputs.version }}-Linux-native.tar.gz
|
||||
asset_content_type: application/x-compressed-tar # .tar.gz
|
||||
|
||||
- name: Upload Linux client archive
|
||||
uses: actions/upload-release-asset@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||
asset_path: signal-cli-${{ steps.version.outputs.version }}-Linux-client.tar.gz
|
||||
asset_name: signal-cli-${{ steps.version.outputs.version }}-Linux-client.tar.gz
|
||||
asset_content_type: application/x-compressed-tar # .tar.gz
|
||||
|
||||
- name: Upload JSON schemas archive
|
||||
uses: actions/upload-release-asset@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||
asset_path: signal-cli-${{ steps.version.outputs.version }}-json-schemas.tar.gz
|
||||
asset_name: signal-cli-${{ steps.version.outputs.version }}-json-schemas.tar.gz
|
||||
asset_content_type: application/x-compressed-tar # .tar.gz
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
gh release create "v${{ steps.version.outputs.version }}" \
|
||||
--repo "${{ github.repository }}" \
|
||||
--title "v${{ steps.version.outputs.version }}" \
|
||||
--draft \
|
||||
--verify-tag \
|
||||
"signal-cli-${{ steps.version.outputs.version }}.tar.gz" \
|
||||
"signal-cli-${{ steps.version.outputs.version }}-Linux-native.tar.gz" \
|
||||
"signal-cli-${{ steps.version.outputs.version }}-Linux-client.tar.gz" \
|
||||
"signal-cli-${{ steps.version.outputs.version }}-json-schemas.tar.gz"
|
||||
|
||||
build-container:
|
||||
needs: release
|
||||
@ -104,7 +68,7 @@ jobs:
|
||||
|
||||
- name: Build Image
|
||||
id: build_image
|
||||
uses: redhat-actions/buildah-build@v2
|
||||
uses: redhat-actions/buildah-build@v3
|
||||
with:
|
||||
image: ${{ env.IMAGE_NAME }}
|
||||
tags: latest ${{ github.sha }} ${{ needs.release.outputs.version }}
|
||||
@ -112,7 +76,7 @@ jobs:
|
||||
oci: true
|
||||
|
||||
- name: Push To GHCR
|
||||
uses: redhat-actions/push-to-registry@v2
|
||||
uses: redhat-actions/push-to-registry@v3
|
||||
id: push
|
||||
with:
|
||||
image: ${{ steps.build_image.outputs.image }}
|
||||
@ -145,7 +109,7 @@ jobs:
|
||||
|
||||
- name: Build Image
|
||||
id: build_image
|
||||
uses: redhat-actions/buildah-build@v2
|
||||
uses: redhat-actions/buildah-build@v3
|
||||
with:
|
||||
image: ${{ env.IMAGE_NAME }}
|
||||
tags: latest-native ${{ github.sha }}-native ${{ needs.release.outputs.version }}-native
|
||||
@ -153,7 +117,7 @@ jobs:
|
||||
oci: true
|
||||
|
||||
- name: Push To GHCR
|
||||
uses: redhat-actions/push-to-registry@v2
|
||||
uses: redhat-actions/push-to-registry@v3
|
||||
id: push
|
||||
with:
|
||||
image: ${{ steps.build_image.outputs.image }}
|
||||
@ -186,7 +150,7 @@ jobs:
|
||||
|
||||
- name: Build Image
|
||||
id: build_image
|
||||
uses: redhat-actions/buildah-build@v2
|
||||
uses: redhat-actions/buildah-build@v3
|
||||
with:
|
||||
image: ${{ env.IMAGE_NAME }}
|
||||
tags: latest-client ${{ github.sha }}-client ${{ needs.release.outputs.version }}-client
|
||||
@ -194,7 +158,7 @@ jobs:
|
||||
oci: true
|
||||
|
||||
- name: Push To GHCR
|
||||
uses: redhat-actions/push-to-registry@v2
|
||||
uses: redhat-actions/push-to-registry@v3
|
||||
id: push
|
||||
with:
|
||||
image: ${{ steps.build_image.outputs.image }}
|
||||
|
||||
19
CHANGELOG.md
19
CHANGELOG.md
@ -1,5 +1,24 @@
|
||||
# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.14.7] - 2026-08-01
|
||||
|
||||
### Added
|
||||
|
||||
- Sync installed sticker packs via storage sync
|
||||
|
||||
### Improved
|
||||
|
||||
- Set width/height for outgoing image attachments (Thanks @mikesimone)
|
||||
- Update to new registration/provisioning API
|
||||
|
||||
### Fixed
|
||||
|
||||
- Removing self from pending group invite now works if invited via PNI
|
||||
- Correctly set needs PNI signature for recipients
|
||||
- Fix linking after previous unsuccessful link attempt
|
||||
|
||||
## [0.14.6] - 2026-07-12
|
||||
|
||||
### Added
|
||||
|
||||
12
README.md
12
README.md
@ -64,8 +64,20 @@ Important: The ACCOUNT is your phone number in international format and must inc
|
||||
should start with a "+" sign. (See [Wikipedia](https://en.wikipedia.org/wiki/List_of_country_calling_codes) for a list
|
||||
of all country codes.)
|
||||
|
||||
* Link to an existing account
|
||||
|
||||
If you have an existing Signal account associated
|
||||
with a number, you can link signal-cli to it with:
|
||||
|
||||
signal-cli link
|
||||
|
||||
* Register a number (with SMS verification)
|
||||
|
||||
Alternatively, if you don't have an existing Signal
|
||||
account, you can register one from signal-cli. Note
|
||||
that this will unregister any existing client
|
||||
associated with the same number.
|
||||
|
||||
signal-cli -a ACCOUNT register
|
||||
|
||||
You can register Signal using a landline number. In this case, you need to follow the procedure below:
|
||||
|
||||
@ -5,12 +5,12 @@ plugins {
|
||||
application
|
||||
eclipse
|
||||
`check-lib-versions`
|
||||
id("org.graalvm.buildtools.native") version "1.1.4"
|
||||
id("org.graalvm.buildtools.native") version "1.1.9"
|
||||
}
|
||||
|
||||
allprojects {
|
||||
group = "org.asamk"
|
||||
version = "0.14.6"
|
||||
version = "0.14.8"
|
||||
}
|
||||
|
||||
java {
|
||||
|
||||
@ -45,6 +45,9 @@
|
||||
<content_attribute id="social-chat">intense</content_attribute>
|
||||
</content_rating>
|
||||
<releases>
|
||||
<release version="0.14.7" date="2026-08-01">
|
||||
<url type="details">https://github.com/AsamK/signal-cli/releases/tag/v0.14.7</url>
|
||||
</release>
|
||||
<release version="0.14.6" date="2026-07-12">
|
||||
<url type="details">https://github.com/AsamK/signal-cli/releases/tag/v0.14.6</url>
|
||||
</release>
|
||||
|
||||
@ -1,13 +1,14 @@
|
||||
[versions]
|
||||
slf4j = "2.0.18"
|
||||
junit = "6.1.0"
|
||||
micronaut-json-schema = "2.0.1"
|
||||
micronaut-core = "5.0.0"
|
||||
signal-service = "2.15.3_unofficial_149"
|
||||
coroutines = "1.10.2"
|
||||
junit = "6.1.2"
|
||||
micronaut-json-schema = "2.1.0"
|
||||
micronaut-core = "5.1.10"
|
||||
signal-service = "2.15.3_unofficial_152"
|
||||
|
||||
[libraries]
|
||||
bouncycastle = "org.bouncycastle:bcprov-jdk18on:1.84"
|
||||
jackson-databind = "com.fasterxml.jackson.core:jackson-databind:2.21.5"
|
||||
bouncycastle = "org.bouncycastle:bcprov-jdk18on:1.85"
|
||||
jackson-databind = "com.fasterxml.jackson.core:jackson-databind:2.22.1"
|
||||
argparse4j = "net.sourceforge.argparse4j:argparse4j:0.9.0"
|
||||
dbusjava = "com.github.hypfvieh:dbus-java-transport-native-unixsocket:5.0.0"
|
||||
zxing = "com.google.zxing:core:3.5.4"
|
||||
@ -17,11 +18,12 @@ micronaut-json-schema-generator = { module = "io.micronaut.jsonschema:micronaut-
|
||||
micronaut-inject-java = { module = "io.micronaut:micronaut-inject-java", version.ref = "micronaut-core" }
|
||||
slf4j-api = { module = "org.slf4j:slf4j-api", version.ref = "slf4j" }
|
||||
slf4j-jul = { module = "org.slf4j:jul-to-slf4j", version.ref = "slf4j" }
|
||||
logback = "ch.qos.logback:logback-classic:1.5.32"
|
||||
logback = "ch.qos.logback:logback-classic:1.6.1"
|
||||
|
||||
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" }
|
||||
signalnetwork = { module = "com.github.turasa:signal-network", version.ref = "signal-service" }
|
||||
sqlite = "org.xerial:sqlite-jdbc:3.53.1.0"
|
||||
hikari = "com.zaxxer:HikariCP:7.0.2"
|
||||
sqlite = "org.xerial:sqlite-jdbc:3.53.2.1"
|
||||
hikari = "com.zaxxer:HikariCP:7.1.0"
|
||||
junit-jupiter-bom = { module = "org.junit:junit-bom", version.ref = "junit" }
|
||||
junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit" }
|
||||
junit-launcher = { module = "org.junit.platform:junit-platform-launcher", version.ref = "junit" }
|
||||
|
||||
@ -30,6 +30,7 @@ dependencies {
|
||||
implementation(libs.slf4j.api)
|
||||
implementation(libs.sqlite)
|
||||
implementation(libs.hikari)
|
||||
compileOnly(libs.kotlinx.coroutines.core)
|
||||
|
||||
testImplementation(libs.junit.jupiter)
|
||||
testImplementation(platform(libs.junit.jupiter.bom))
|
||||
|
||||
@ -184,6 +184,10 @@ public interface Manager extends Closeable {
|
||||
|
||||
void deleteGroup(GroupId groupId) throws IOException;
|
||||
|
||||
SendGroupMessageResults terminateGroup(
|
||||
GroupId groupId
|
||||
) throws IOException, GroupNotFoundException, NotAGroupMemberException;
|
||||
|
||||
Pair<GroupId, SendGroupMessageResults> createGroup(
|
||||
String name,
|
||||
Set<RecipientIdentifier.Single> members,
|
||||
|
||||
@ -15,6 +15,7 @@ public record Contact(
|
||||
long muteUntil,
|
||||
boolean hideStory,
|
||||
boolean isBlocked,
|
||||
long blockedAt,
|
||||
boolean isArchived,
|
||||
boolean isProfileSharingEnabled,
|
||||
boolean isHidden,
|
||||
@ -34,6 +35,7 @@ public record Contact(
|
||||
builder.muteUntil,
|
||||
builder.hideStory,
|
||||
builder.isBlocked,
|
||||
builder.blockedAt,
|
||||
builder.isArchived,
|
||||
builder.isProfileSharingEnabled,
|
||||
builder.isHidden,
|
||||
@ -58,6 +60,7 @@ public record Contact(
|
||||
builder.muteUntil = copy.muteUntil();
|
||||
builder.hideStory = copy.hideStory();
|
||||
builder.isBlocked = copy.isBlocked();
|
||||
builder.blockedAt = copy.blockedAt();
|
||||
builder.isArchived = copy.isArchived();
|
||||
builder.isProfileSharingEnabled = copy.isProfileSharingEnabled();
|
||||
builder.isHidden = copy.isHidden();
|
||||
@ -80,6 +83,21 @@ public record Contact(
|
||||
return givenName + " " + familyName;
|
||||
}
|
||||
|
||||
public String getDisplayNickname() {
|
||||
final var noNickGivenName = Util.isEmpty(nickNameGivenName);
|
||||
final var noNickFamilyName = Util.isEmpty(nickNameFamilyName);
|
||||
|
||||
if (noNickGivenName && noNickFamilyName) {
|
||||
return null;
|
||||
} else if (noNickGivenName) {
|
||||
return nickNameFamilyName;
|
||||
} else if (noNickFamilyName) {
|
||||
return nickNameGivenName;
|
||||
}
|
||||
|
||||
return nickNameGivenName + " " + nickNameFamilyName;
|
||||
}
|
||||
|
||||
public static final class Builder {
|
||||
|
||||
private String givenName;
|
||||
@ -94,6 +112,7 @@ public record Contact(
|
||||
private long muteUntil;
|
||||
private boolean hideStory;
|
||||
private boolean isBlocked;
|
||||
private long blockedAt;
|
||||
private boolean isArchived;
|
||||
private boolean isProfileSharingEnabled;
|
||||
private boolean isHidden;
|
||||
@ -162,10 +181,20 @@ public record Contact(
|
||||
}
|
||||
|
||||
public Builder withIsBlocked(final boolean val) {
|
||||
if (val && !isBlocked) {
|
||||
blockedAt = System.currentTimeMillis();
|
||||
} else if (!val) {
|
||||
blockedAt = 0;
|
||||
}
|
||||
isBlocked = val;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder withBlockedAt(final long val) {
|
||||
blockedAt = val;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder withIsArchived(final boolean val) {
|
||||
isArchived = val;
|
||||
return this;
|
||||
|
||||
@ -22,7 +22,8 @@ public record Group(
|
||||
GroupPermission permissionEditDetails,
|
||||
GroupPermission permissionSendMessage,
|
||||
boolean isMember,
|
||||
boolean isAdmin
|
||||
boolean isAdmin,
|
||||
boolean isTerminated
|
||||
) {
|
||||
|
||||
public static Group from(
|
||||
@ -59,6 +60,7 @@ public record Group(
|
||||
groupInfo.getPermissionEditDetails(),
|
||||
groupInfo.getPermissionSendMessage(),
|
||||
groupInfo.isMember(selfRecipientId),
|
||||
groupInfo.isAdmin(selfRecipientId));
|
||||
groupInfo.isAdmin(selfRecipientId),
|
||||
groupInfo.isTerminated());
|
||||
}
|
||||
}
|
||||
|
||||
@ -739,7 +739,10 @@ public record MessageEnvelope(
|
||||
null,
|
||||
d.getE164(),
|
||||
null))
|
||||
.toList(), blockedListMessage.groupIds.stream().map(GroupId::unknownVersion).toList());
|
||||
.toList(),
|
||||
blockedListMessage.groups.stream()
|
||||
.map(group -> GroupId.unknownVersion(group.getGroupId()))
|
||||
.toList());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -3,15 +3,15 @@ package org.asamk.signal.manager.config;
|
||||
import org.signal.libsignal.net.Network.Environment;
|
||||
import org.signal.libsignal.protocol.InvalidKeyException;
|
||||
import org.signal.libsignal.protocol.ecc.ECPublicKey;
|
||||
import org.whispersystems.signalservice.api.push.TrustStore;
|
||||
import org.whispersystems.signalservice.internal.configuration.HttpProxy;
|
||||
import org.whispersystems.signalservice.internal.configuration.SignalCdnUrl;
|
||||
import org.whispersystems.signalservice.internal.configuration.SignalCdsiUrl;
|
||||
import org.whispersystems.signalservice.internal.configuration.SignalProxy;
|
||||
import org.whispersystems.signalservice.internal.configuration.SignalServiceConfiguration;
|
||||
import org.whispersystems.signalservice.internal.configuration.SignalServiceUrl;
|
||||
import org.whispersystems.signalservice.internal.configuration.SignalStorageUrl;
|
||||
import org.whispersystems.signalservice.internal.configuration.SignalSvr2Url;
|
||||
import org.signal.network.config.HttpProxy;
|
||||
import org.signal.network.config.SignalCdnUrl;
|
||||
import org.signal.network.config.SignalCdsiUrl;
|
||||
import org.signal.network.config.SignalProxy;
|
||||
import org.signal.network.config.SignalServiceConfiguration;
|
||||
import org.signal.network.config.SignalServiceUrl;
|
||||
import org.signal.network.config.SignalStorageUrl;
|
||||
import org.signal.network.config.SignalSvr2Url;
|
||||
import org.signal.network.config.TrustStore;
|
||||
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
|
||||
@ -32,7 +32,13 @@ public class ServiceConfig {
|
||||
final var attachmentBackfill = !isPrimaryDevice;
|
||||
final var spqr = true;
|
||||
final var usernameSyncChangeMessage = !isPrimaryDevice;
|
||||
return new AccountAttributes.Capabilities(true, true, attachmentBackfill, spqr, usernameSyncChangeMessage);
|
||||
final var optionalPhoneNumber = !isPrimaryDevice;
|
||||
return new AccountAttributes.Capabilities(true,
|
||||
true,
|
||||
attachmentBackfill,
|
||||
spqr,
|
||||
usernameSyncChangeMessage,
|
||||
optionalPhoneNumber);
|
||||
}
|
||||
|
||||
public static ServiceEnvironmentConfig getServiceEnvironmentConfig(
|
||||
|
||||
@ -3,7 +3,7 @@ package org.asamk.signal.manager.config;
|
||||
import org.asamk.signal.manager.api.ServiceEnvironment;
|
||||
import org.signal.libsignal.net.Network;
|
||||
import org.signal.libsignal.protocol.ecc.ECPublicKey;
|
||||
import org.whispersystems.signalservice.internal.configuration.SignalServiceConfiguration;
|
||||
import org.signal.network.config.SignalServiceConfiguration;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
|
||||
@ -3,15 +3,15 @@ package org.asamk.signal.manager.config;
|
||||
import org.signal.libsignal.net.Network;
|
||||
import org.signal.libsignal.protocol.InvalidKeyException;
|
||||
import org.signal.libsignal.protocol.ecc.ECPublicKey;
|
||||
import org.whispersystems.signalservice.api.push.TrustStore;
|
||||
import org.whispersystems.signalservice.internal.configuration.HttpProxy;
|
||||
import org.whispersystems.signalservice.internal.configuration.SignalCdnUrl;
|
||||
import org.whispersystems.signalservice.internal.configuration.SignalCdsiUrl;
|
||||
import org.whispersystems.signalservice.internal.configuration.SignalProxy;
|
||||
import org.whispersystems.signalservice.internal.configuration.SignalServiceConfiguration;
|
||||
import org.whispersystems.signalservice.internal.configuration.SignalServiceUrl;
|
||||
import org.whispersystems.signalservice.internal.configuration.SignalStorageUrl;
|
||||
import org.whispersystems.signalservice.internal.configuration.SignalSvr2Url;
|
||||
import org.signal.network.config.HttpProxy;
|
||||
import org.signal.network.config.SignalCdnUrl;
|
||||
import org.signal.network.config.SignalCdsiUrl;
|
||||
import org.signal.network.config.SignalProxy;
|
||||
import org.signal.network.config.SignalServiceConfiguration;
|
||||
import org.signal.network.config.SignalServiceUrl;
|
||||
import org.signal.network.config.SignalStorageUrl;
|
||||
import org.signal.network.config.SignalSvr2Url;
|
||||
import org.signal.network.config.TrustStore;
|
||||
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
package org.asamk.signal.manager.config;
|
||||
|
||||
import org.whispersystems.signalservice.api.push.TrustStore;
|
||||
import org.signal.network.config.TrustStore;
|
||||
|
||||
import java.io.InputStream;
|
||||
|
||||
|
||||
@ -17,6 +17,7 @@ import org.asamk.signal.manager.util.Utils;
|
||||
import org.signal.core.models.ServiceId.ACI;
|
||||
import org.signal.core.models.ServiceId.PNI;
|
||||
import org.signal.core.util.Base64;
|
||||
import org.signal.core.util.crypto.DeviceNameCipher;
|
||||
import org.signal.libsignal.protocol.IdentityKeyPair;
|
||||
import org.signal.libsignal.protocol.InvalidKeyException;
|
||||
import org.signal.libsignal.protocol.NoSessionException;
|
||||
@ -32,6 +33,7 @@ import org.slf4j.LoggerFactory;
|
||||
import org.whispersystems.signalservice.api.account.ChangePhoneNumberRequest;
|
||||
import org.whispersystems.signalservice.api.crypto.UntrustedIdentityException;
|
||||
import org.whispersystems.signalservice.api.link.LinkedDeviceVerificationCodeResponse;
|
||||
import org.whispersystems.signalservice.api.messages.multidevice.DeviceInfo;
|
||||
import org.whispersystems.signalservice.api.push.ServiceIdType;
|
||||
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
|
||||
import org.whispersystems.signalservice.api.push.SignedPreKeyEntity;
|
||||
@ -39,7 +41,6 @@ import org.whispersystems.signalservice.api.push.UsernameLinkComponents;
|
||||
import org.whispersystems.signalservice.api.push.exceptions.AlreadyVerifiedException;
|
||||
import org.whispersystems.signalservice.api.push.exceptions.AuthorizationFailedException;
|
||||
import org.whispersystems.signalservice.api.push.exceptions.DeprecatedVersionException;
|
||||
import org.whispersystems.signalservice.api.util.DeviceNameUtil;
|
||||
import org.whispersystems.signalservice.internal.push.DeviceLimitExceededException;
|
||||
import org.whispersystems.signalservice.internal.push.KyberPreKeyEntity;
|
||||
import org.whispersystems.signalservice.internal.push.OutgoingPushMessage;
|
||||
@ -47,7 +48,9 @@ import org.whispersystems.signalservice.internal.push.SyncMessage;
|
||||
import org.whispersystems.signalservice.internal.push.exceptions.MismatchedDevicesException;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
@ -58,6 +61,7 @@ import okio.ByteString;
|
||||
|
||||
import static org.asamk.signal.manager.config.ServiceConfig.PREKEY_MAXIMUM_ID;
|
||||
import static org.asamk.signal.manager.util.Utils.handleResponseException;
|
||||
import static org.asamk.signal.manager.util.Utils.handleResponseExceptionSuspend;
|
||||
import static org.whispersystems.signalservice.internal.util.Util.isEmpty;
|
||||
|
||||
public class AccountHelper {
|
||||
@ -321,10 +325,14 @@ public class AccountHelper {
|
||||
return;
|
||||
}
|
||||
|
||||
handlePniChangeNumberMessage(selfChangeNumber, updatePni);
|
||||
handlePniChangeNumberMessage(selfChangeNumber, updatePni, false);
|
||||
}
|
||||
|
||||
public void handlePniChangeNumberMessage(final SyncMessage.PniChangeNumber pniChangeNumber, final PNI updatedPni) {
|
||||
public boolean handlePniChangeNumberMessage(
|
||||
final SyncMessage.PniChangeNumber pniChangeNumber,
|
||||
final PNI updatedPni,
|
||||
final boolean forcePniPreKeyRotation
|
||||
) {
|
||||
if (pniChangeNumber.identityKeyPair != null
|
||||
&& pniChangeNumber.registrationId != null
|
||||
&& pniChangeNumber.signedPreKey != null) {
|
||||
@ -338,10 +346,19 @@ public class AccountHelper {
|
||||
pniChangeNumber.lastResortKyberPreKey != null
|
||||
? new KyberPreKeyRecord(pniChangeNumber.lastResortKyberPreKey.toByteArray())
|
||||
: null);
|
||||
if (forcePniPreKeyRotation) {
|
||||
try {
|
||||
context.getPreKeyHelper().forceRefreshPreKeys(ServiceIdType.PNI);
|
||||
} catch (IOException e) {
|
||||
logger.warn("Failed to force refresh PNI pre keys after PNI change sync", e);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
logger.warn("Failed to handle change number message", e);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public static final int USERNAME_MIN_LENGTH = 3;
|
||||
@ -383,13 +400,17 @@ public class AccountHelper {
|
||||
}
|
||||
|
||||
private void reserveUsername(final List<Username> candidates) throws IOException {
|
||||
final var candidateHashes = new ArrayList<String>();
|
||||
final var candidateHashes = new ArrayList<byte[]>();
|
||||
for (final var candidate : candidates) {
|
||||
candidateHashes.add(Base64.encodeUrlSafeWithoutPadding(candidate.getHash()));
|
||||
candidateHashes.add(candidate.getHash());
|
||||
}
|
||||
|
||||
final var response = handleResponseException(dependencies.getAccountApi().reserveUsername(candidateHashes));
|
||||
final var hashIndex = candidateHashes.indexOf(response.getUsernameHash());
|
||||
final var usernameHash = handleResponseException(dependencies.getAccountApi().reserveUsername(candidateHashes));
|
||||
final var hashIndex = candidateHashes.stream()
|
||||
.filter(candidateHash -> Arrays.equals(candidateHash, usernameHash))
|
||||
.findFirst()
|
||||
.map(candidateHashes::indexOf)
|
||||
.orElse(-1);
|
||||
if (hashIndex == -1) {
|
||||
logger.warn("[reserveUsername] The response hash could not be found in our set of candidateHashes.");
|
||||
throw new IOException("Unexpected username response");
|
||||
@ -482,8 +503,7 @@ public class AccountHelper {
|
||||
final var usernameLink = account.getUsernameLink();
|
||||
|
||||
if (usernameLink == null) {
|
||||
handleResponseException(dependencies.getAccountApi()
|
||||
.reserveUsername(List.of(Base64.encodeUrlSafeWithoutPadding(username.getHash()))));
|
||||
handleResponseException(dependencies.getAccountApi().reserveUsername(List.of(username.getHash())));
|
||||
logger.debug("[reserveUsername] Successfully reserved existing username.");
|
||||
final var linkComponents = confirmUsernameAndCreateNewLink(username);
|
||||
account.setUsernameLink(linkComponents);
|
||||
@ -508,28 +528,34 @@ public class AccountHelper {
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
public void deleteUsername() throws IOException {
|
||||
handleResponseException(dependencies.getAccountApi().deleteUsername());
|
||||
handleResponseException(dependencies.getAccountApi().deleteUsernameHash());
|
||||
account.setUsernameLink(null);
|
||||
account.setUsername(null);
|
||||
logger.debug("[deleteUsername] Successfully deleted the username.");
|
||||
}
|
||||
|
||||
public void setDeviceName(String deviceName) {
|
||||
final var privateKey = account.getAciIdentityKeyPair().getPrivateKey();
|
||||
final var encryptedDeviceName = DeviceNameUtil.encryptDeviceName(deviceName, privateKey);
|
||||
account.setEncryptedDeviceName(encryptedDeviceName);
|
||||
final var encryptedDeviceName = getEncryptedDeviceName(deviceName);
|
||||
account.setEncryptedDeviceName(Base64.encodeWithoutPadding(encryptedDeviceName));
|
||||
}
|
||||
|
||||
public void setDeviceName(int deviceId, String deviceName) throws IOException {
|
||||
final var privateKey = account.getAciIdentityKeyPair().getPrivateKey();
|
||||
final var encryptedDeviceName = DeviceNameUtil.encryptDeviceName(deviceName, privateKey);
|
||||
handleResponseException(dependencies.getLinkDeviceApi().setDeviceName(encryptedDeviceName, deviceId));
|
||||
final var encryptedDeviceName = getEncryptedDeviceName(deviceName);
|
||||
handleResponseExceptionSuspend(cont -> dependencies.getLinkDeviceApi()
|
||||
.setDeviceName(encryptedDeviceName, deviceId, cont));
|
||||
context.getSyncHelper().sendDeviceNameChange(deviceId);
|
||||
}
|
||||
|
||||
private byte[] getEncryptedDeviceName(final String deviceName) {
|
||||
final var identityKey = account.getAciIdentityKeyPair();
|
||||
return DeviceNameCipher.encryptDeviceName(deviceName.getBytes(StandardCharsets.UTF_8), identityKey);
|
||||
}
|
||||
|
||||
public void refreshDeviceName() throws IOException {
|
||||
final var devices = handleResponseException(dependencies.getLinkDeviceApi().getDevices());
|
||||
final List<DeviceInfo> devices = handleResponseExceptionSuspend(cont -> dependencies.getLinkDeviceApi()
|
||||
.getDevices(cont));
|
||||
final var deviceId = account.getDeviceId();
|
||||
final var device = devices.stream().filter(d -> d.id == deviceId).findFirst();
|
||||
if (device.isPresent()) {
|
||||
@ -563,14 +589,16 @@ public class AccountHelper {
|
||||
account.getOrCreatePinMasterKey(),
|
||||
account.getOrCreateMediaRootBackupKey(),
|
||||
verificationCode.getVerificationCode(),
|
||||
null));
|
||||
null,
|
||||
account.getAuthCredentialSalt()));
|
||||
account.setMultiDevice(true);
|
||||
context.getJobExecutor().enqueueJob(new SyncStorageJob());
|
||||
}
|
||||
|
||||
public void removeLinkedDevices(int deviceId) throws IOException {
|
||||
handleResponseException(dependencies.getLinkDeviceApi().removeDevice(deviceId));
|
||||
var devices = handleResponseException(dependencies.getLinkDeviceApi().getDevices());
|
||||
handleResponseExceptionSuspend(cont -> dependencies.getLinkDeviceApi().removeDevice(deviceId, cont));
|
||||
final List<DeviceInfo> devices = handleResponseExceptionSuspend(cont -> dependencies.getLinkDeviceApi()
|
||||
.getDevices(cont));
|
||||
account.setMultiDevice(devices.size() > 1);
|
||||
}
|
||||
|
||||
@ -578,16 +606,14 @@ public class AccountHelper {
|
||||
var masterKey = account.getOrCreatePinMasterKey();
|
||||
|
||||
context.getPinHelper().migrateRegistrationLockPin(account.getRegistrationLockPin(), masterKey);
|
||||
handleResponseException(dependencies.getAccountApi()
|
||||
.enableRegistrationLock(masterKey.deriveRegistrationLock()));
|
||||
handleResponseException(dependencies.getAccountApi().enableRegistrationLock(masterKey));
|
||||
}
|
||||
|
||||
public void setRegistrationPin(String pin) throws IOException {
|
||||
var masterKey = account.getOrCreatePinMasterKey();
|
||||
|
||||
context.getPinHelper().setRegistrationLockPin(pin, masterKey);
|
||||
handleResponseException(dependencies.getAccountApi()
|
||||
.enableRegistrationLock(masterKey.deriveRegistrationLock()));
|
||||
handleResponseException(dependencies.getAccountApi().enableRegistrationLock(masterKey));
|
||||
|
||||
account.setRegistrationLockPin(pin);
|
||||
updateAccountAttributes();
|
||||
@ -605,7 +631,7 @@ public class AccountHelper {
|
||||
// When setting an empty GCM id, the Signal-Server also sets the fetchesMessages property to false.
|
||||
// If this is the primary device, other users can't send messages to this number anymore.
|
||||
// If this is a linked device, other users can still send messages, but this device doesn't receive them anymore.
|
||||
handleResponseException(dependencies.getAccountApi().clearFcmToken());
|
||||
handleResponseExceptionSuspend(cont -> dependencies.getAccountApi().clearFcmToken(cont));
|
||||
|
||||
account.setRegistered(false);
|
||||
unregisteredListener.call();
|
||||
|
||||
@ -85,12 +85,18 @@ public class ContactHelper {
|
||||
}
|
||||
|
||||
public void setContactBlocked(RecipientId recipientId, boolean blocked) {
|
||||
setContactBlocked(recipientId, blocked, blocked ? System.currentTimeMillis() : 0);
|
||||
}
|
||||
|
||||
public void setContactBlocked(RecipientId recipientId, boolean blocked, long blockedAt) {
|
||||
var contact = account.getContactStore().getContact(recipientId);
|
||||
final var builder = contact == null ? Contact.newBuilder() : Contact.newBuilder(contact);
|
||||
if (blocked) {
|
||||
builder.withIsProfileSharingEnabled(false);
|
||||
}
|
||||
account.getContactStore().storeContact(recipientId, builder.withIsBlocked(blocked).build());
|
||||
account.getContactStore()
|
||||
.storeContact(recipientId,
|
||||
builder.withIsBlocked(blocked).withBlockedAt(blocked ? blockedAt : 0).build());
|
||||
}
|
||||
|
||||
public void setContactProfileSharing(RecipientId recipientId, boolean profileSharing) {
|
||||
|
||||
@ -363,6 +363,28 @@ public class GroupHelper {
|
||||
return results;
|
||||
}
|
||||
|
||||
public SendGroupMessageResults terminateGroup(final GroupId groupId) throws IOException, GroupNotFoundException, NotAGroupMemberException {
|
||||
final var group = getGroupForUpdating(groupId);
|
||||
if (!(group instanceof GroupInfoV2)) {
|
||||
throw new IOException("Terminating a group is only supported for Signal group v2 groups.");
|
||||
}
|
||||
|
||||
SendGroupMessageResults results;
|
||||
try {
|
||||
results = terminateGroupV2((GroupInfoV2) group);
|
||||
} catch (ConflictException e) {
|
||||
// Detected conflicting update, refreshing group and trying again
|
||||
results = terminateGroupV2((GroupInfoV2) getGroup(groupId, true));
|
||||
}
|
||||
context.getJobExecutor().enqueueJob(new SyncStorageJob());
|
||||
return results;
|
||||
}
|
||||
|
||||
private SendGroupMessageResults terminateGroupV2(final GroupInfoV2 group) throws IOException {
|
||||
final var groupGroupChangePair = context.getGroupV2Helper().terminateGroup(group);
|
||||
return sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second());
|
||||
}
|
||||
|
||||
public void updateGroupProfileKey(GroupIdV2 groupId) throws GroupNotFoundException, NotAGroupMemberException, IOException {
|
||||
var group = getGroupForUpdating(groupId);
|
||||
|
||||
@ -437,12 +459,21 @@ public class GroupHelper {
|
||||
}
|
||||
|
||||
public void setGroupBlocked(final GroupId groupId, final boolean blocked) throws GroupNotFoundException {
|
||||
setGroupBlocked(groupId, blocked, blocked ? System.currentTimeMillis() : 0);
|
||||
}
|
||||
|
||||
public void setGroupBlocked(
|
||||
final GroupId groupId,
|
||||
final boolean blocked,
|
||||
final long blockedAt
|
||||
) throws GroupNotFoundException {
|
||||
var group = getGroup(groupId);
|
||||
if (group == null) {
|
||||
throw new GroupNotFoundException(groupId);
|
||||
}
|
||||
|
||||
group.setBlocked(blocked);
|
||||
group.setBlockedAt(blocked ? blockedAt : 0);
|
||||
account.getGroupStore().updateGroup(group);
|
||||
context.getJobExecutor().enqueueJob(new SyncStorageJob());
|
||||
}
|
||||
|
||||
@ -264,6 +264,9 @@ class GroupV2Helper {
|
||||
var pendingMembersList = groupInfoV2.getGroup().pendingMembers;
|
||||
final var selfAci = getSelfAci();
|
||||
var selfPendingMember = DecryptedGroupUtil.findPendingByServiceId(pendingMembersList, selfAci);
|
||||
if (selfPendingMember.isEmpty()) {
|
||||
selfPendingMember = DecryptedGroupUtil.findPendingByServiceId(pendingMembersList, getSelfPni());
|
||||
}
|
||||
|
||||
if (selfPendingMember.isPresent()) {
|
||||
return revokeInvites(groupInfoV2, Set.of(selfPendingMember.get()));
|
||||
@ -540,6 +543,14 @@ class GroupV2Helper {
|
||||
return commitChange(groupInfoV2, change);
|
||||
}
|
||||
|
||||
Pair<DecryptedGroup, GroupChangeResponse> terminateGroup(
|
||||
GroupInfoV2 groupInfoV2
|
||||
) throws IOException {
|
||||
final GroupsV2Operations.GroupOperations groupOperations = getGroupOperations(groupInfoV2);
|
||||
final var change = groupOperations.createTerminateGroup();
|
||||
return commitChange(groupInfoV2, change);
|
||||
}
|
||||
|
||||
Pair<DecryptedGroup, GroupChangeResponse> setMemberLabels(
|
||||
GroupInfoV2 groupInfoV2,
|
||||
String labelEmoji,
|
||||
|
||||
@ -340,9 +340,9 @@ public final class IncomingMessageHandler {
|
||||
}
|
||||
|
||||
if (account.getPni().equals(destination.serviceId)) {
|
||||
account.getRecipientStore().markNeedsPniSignature(destination.recipientId, true);
|
||||
account.getRecipientStore().markNeedsPniSignature(sender, true);
|
||||
} else if (account.getAci().equals(destination.serviceId)) {
|
||||
account.getRecipientStore().markNeedsPniSignature(destination.recipientId, false);
|
||||
account.getRecipientStore().markNeedsPniSignature(sender, false);
|
||||
}
|
||||
|
||||
if (content.getReceiptMessage().isPresent()) {
|
||||
@ -498,10 +498,11 @@ public final class IncomingMessageHandler {
|
||||
}
|
||||
|
||||
logger.debug("Verified association of ACI {} with PNI {}", aci, pni);
|
||||
account.getRecipientTrustedResolver()
|
||||
final var recipientId = account.getRecipientTrustedResolver()
|
||||
.resolveRecipientTrusted(Optional.of(ACI.from(aci.getRawUuid())),
|
||||
Optional.of(pni),
|
||||
senderAddress.getNumber());
|
||||
account.getRecipientStore().markPniSignatureVerified(recipientId);
|
||||
return true;
|
||||
}
|
||||
|
||||
@ -625,13 +626,12 @@ public final class IncomingMessageHandler {
|
||||
for (var individual : blockedListMessage.individuals) {
|
||||
final var address = new RecipientAddress(individual.getAci(), individual.getE164());
|
||||
final var recipientId = account.getRecipientResolver().resolveRecipient(address);
|
||||
context.getContactHelper().setContactBlocked(recipientId, true);
|
||||
context.getContactHelper().setContactBlocked(recipientId, true, individual.getBlockedAt());
|
||||
}
|
||||
for (var groupId : blockedListMessage.groupIds.stream()
|
||||
.map(GroupId::unknownVersion)
|
||||
.collect(Collectors.toSet())) {
|
||||
for (var group : blockedListMessage.groups) {
|
||||
final var groupId = GroupId.unknownVersion(group.getGroupId());
|
||||
try {
|
||||
context.getGroupHelper().setGroupBlocked(groupId, true);
|
||||
context.getGroupHelper().setGroupBlocked(groupId, true, group.getBlockedAt());
|
||||
} catch (GroupNotFoundException e) {
|
||||
logger.warn("BlockedListMessage contained groupID that was not found in GroupStore: {}",
|
||||
groupId.toBase64());
|
||||
@ -717,11 +717,30 @@ public final class IncomingMessageHandler {
|
||||
}
|
||||
if (syncMessage.getPniChangeNumber().isPresent()) {
|
||||
final var pniChangeNumber = syncMessage.getPniChangeNumber().get();
|
||||
logger.debug("Received PNI change number sync message, applying.");
|
||||
final var updatedPniString = envelope.getUpdatedPni();
|
||||
if (updatedPniString != null && !updatedPniString.isEmpty()) {
|
||||
final var updatedPni = ServiceId.PNI.parseOrThrow(updatedPniString);
|
||||
context.getAccountHelper().handlePniChangeNumberMessage(pniChangeNumber, updatedPni);
|
||||
if (account.isPrimaryDevice()) {
|
||||
logger.warn("Received PNI change number sync message on primary device, ignoring.");
|
||||
} else if (sender.deviceId() != SignalServiceAddress.DEFAULT_DEVICE_ID) {
|
||||
logger.warn("Received PNI change number sync message from non-primary device {}, ignoring.",
|
||||
sender.deviceId());
|
||||
} else {
|
||||
final var envelopeServerTimestamp = envelope.getServerDeliveredTimestamp();
|
||||
final var lastAppliedServerTimestamp = account.getLastAppliedPniChangeServerTimestamp();
|
||||
if (envelopeServerTimestamp <= lastAppliedServerTimestamp) {
|
||||
logger.warn(
|
||||
"PNI change number sync server timestamp ({}) is not newer than last applied ({}), treating as replay.",
|
||||
envelopeServerTimestamp,
|
||||
lastAppliedServerTimestamp);
|
||||
} else {
|
||||
final var updatedPniString = envelope.getUpdatedPni();
|
||||
if (updatedPniString != null && !updatedPniString.isEmpty()) {
|
||||
final var updatedPni = ServiceId.PNI.parseOrThrow(updatedPniString);
|
||||
final var applied = context.getAccountHelper()
|
||||
.handlePniChangeNumberMessage(pniChangeNumber, updatedPni, true);
|
||||
if (applied) {
|
||||
account.setLastAppliedPniChangeServerTimestamp(envelopeServerTimestamp);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (syncMessage.getDeviceNameChange().isPresent()) {
|
||||
@ -817,6 +836,15 @@ public final class IncomingMessageHandler {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (group.isTerminated()) {
|
||||
return message == null
|
||||
|| message.getBody().isPresent()
|
||||
|| message.getAttachments().isPresent()
|
||||
|| message.getQuote().isPresent()
|
||||
|| message.getPreviews().isPresent()
|
||||
|| message.getMentions().isPresent()
|
||||
|| message.getSticker().isPresent();
|
||||
}
|
||||
if (group.isAnnouncementGroup() && !group.isAdmin(recipientId)) {
|
||||
return message == null
|
||||
|| message.getBody().isPresent()
|
||||
|
||||
@ -12,6 +12,7 @@ import org.asamk.signal.manager.storage.recipients.RecipientAddress;
|
||||
import org.signal.core.models.ServiceId.ACI;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.whispersystems.signalservice.api.messages.EnvelopeResponse;
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceEnvelope;
|
||||
import org.whispersystems.signalservice.api.websocket.SignalWebSocket;
|
||||
import org.whispersystems.signalservice.api.websocket.WebSocketConnectionState;
|
||||
@ -147,15 +148,19 @@ public class ReceiveHelper {
|
||||
logger.debug("Retrieved {} envelopes!", batch.size());
|
||||
isWaitingForMessage = false;
|
||||
for (final var it : batch) {
|
||||
SignalServiceEnvelope envelope1 = new SignalServiceEnvelope(it.getEnvelope(),
|
||||
it.getServerDeliveredTimestamp());
|
||||
final var sourceServiceId = envelope1.getSourceServiceId();
|
||||
final var recipientId = sourceServiceId == null
|
||||
? null
|
||||
: account.getRecipientResolver().resolveRecipient(sourceServiceId);
|
||||
logger.trace("Storing new message from {}", recipientId);
|
||||
// store message on disk, before acknowledging receipt to the server
|
||||
cachedMessage[0] = account.getMessageCache().cacheMessage(envelope1, recipientId);
|
||||
if (it instanceof EnvelopeResponse.Unparseable) {
|
||||
logger.warn("Received unparseable envelope from server, ignoring.");
|
||||
} else if (it instanceof EnvelopeResponse.Parsed parsed) {
|
||||
SignalServiceEnvelope envelope1 = new SignalServiceEnvelope(parsed.getEnvelope(),
|
||||
parsed.getServerDeliveredTimestamp());
|
||||
final var sourceServiceId = envelope1.getSourceServiceId();
|
||||
final var recipientId = sourceServiceId == null
|
||||
? null
|
||||
: account.getRecipientResolver().resolveRecipient(sourceServiceId);
|
||||
logger.trace("Storing new message from {}", recipientId);
|
||||
// store message on disk, before acknowledging receipt to the server
|
||||
cachedMessage[0] = account.getMessageCache().cacheMessage(envelope1, recipientId);
|
||||
}
|
||||
try {
|
||||
signalWebSocket.sendAck(it);
|
||||
} catch (IOException e) {
|
||||
@ -167,6 +172,9 @@ public class ReceiveHelper {
|
||||
backOffCounter = 0;
|
||||
|
||||
if (queueNotEmpty) {
|
||||
if (cachedMessage[0] == null) {
|
||||
continue;
|
||||
}
|
||||
if (remainingMessages > 0) {
|
||||
remainingMessages -= 1;
|
||||
}
|
||||
|
||||
@ -567,7 +567,7 @@ public class SendHelper {
|
||||
return results;
|
||||
}
|
||||
|
||||
private GroupInfo getGroupForSending(GroupId groupId) throws GroupNotFoundException, NotAGroupMemberException {
|
||||
private GroupInfo getGroupForSending(GroupId groupId) throws GroupNotFoundException, NotAGroupMemberException, GroupSendingNotAllowedException {
|
||||
var g = context.getGroupHelper().getGroup(groupId);
|
||||
if (g == null) {
|
||||
throw new GroupNotFoundException(groupId);
|
||||
@ -575,6 +575,10 @@ public class SendHelper {
|
||||
if (!g.isMember(account.getSelfRecipientId())) {
|
||||
throw new NotAGroupMemberException(groupId, g.getTitle());
|
||||
}
|
||||
if (g.isTerminated()) {
|
||||
// Other clients drop messages sent to a terminated group.
|
||||
throw new GroupSendingNotAllowedException(groupId, g.getTitle());
|
||||
}
|
||||
if (!g.isProfileSharingEnabled()) {
|
||||
g.setProfileSharingEnabled(true);
|
||||
account.getGroupStore().updateGroup(g);
|
||||
|
||||
@ -4,13 +4,17 @@ import org.asamk.signal.manager.api.GroupIdV1;
|
||||
import org.asamk.signal.manager.api.GroupIdV2;
|
||||
import org.asamk.signal.manager.api.Pair;
|
||||
import org.asamk.signal.manager.api.Profile;
|
||||
import org.asamk.signal.manager.api.StickerPackId;
|
||||
import org.asamk.signal.manager.internal.SignalDependencies;
|
||||
import org.asamk.signal.manager.storage.SignalAccount;
|
||||
import org.asamk.signal.manager.storage.recipients.RecipientId;
|
||||
import org.asamk.signal.manager.storage.stickers.StickerPack;
|
||||
import org.asamk.signal.manager.syncStorage.AccountRecordProcessor;
|
||||
import org.asamk.signal.manager.syncStorage.ContactRecordProcessor;
|
||||
import org.asamk.signal.manager.syncStorage.GroupV1RecordProcessor;
|
||||
import org.asamk.signal.manager.syncStorage.GroupV2RecordProcessor;
|
||||
import org.asamk.signal.manager.syncStorage.StickerPackRecordProcessor;
|
||||
import org.asamk.signal.manager.syncStorage.StorageSyncLoopDetector;
|
||||
import org.asamk.signal.manager.syncStorage.StorageSyncModels;
|
||||
import org.asamk.signal.manager.syncStorage.StorageSyncValidations;
|
||||
import org.asamk.signal.manager.syncStorage.WriteOperationResult;
|
||||
@ -52,16 +56,19 @@ public class StorageHelper {
|
||||
private static final List<Integer> KNOWN_TYPES = List.of(ManifestRecord.Identifier.Type.CONTACT.getValue(),
|
||||
ManifestRecord.Identifier.Type.GROUPV1.getValue(),
|
||||
ManifestRecord.Identifier.Type.GROUPV2.getValue(),
|
||||
ManifestRecord.Identifier.Type.ACCOUNT.getValue());
|
||||
ManifestRecord.Identifier.Type.ACCOUNT.getValue(),
|
||||
ManifestRecord.Identifier.Type.STICKER_PACK.getValue());
|
||||
|
||||
private final SignalAccount account;
|
||||
private final SignalDependencies dependencies;
|
||||
private final Context context;
|
||||
private final StorageSyncLoopDetector storageSyncLoopDetector;
|
||||
|
||||
public StorageHelper(final Context context) {
|
||||
this.account = context.getAccount();
|
||||
this.dependencies = context.getDependencies();
|
||||
this.context = context;
|
||||
this.storageSyncLoopDetector = new StorageSyncLoopDetector(account::isMultiDevice);
|
||||
}
|
||||
|
||||
public void syncDataWithStorage() throws IOException {
|
||||
@ -80,6 +87,7 @@ public class StorageHelper {
|
||||
final var storageServiceRepository = dependencies.getStorageServiceRepository();
|
||||
final var result = storageServiceRepository.getStorageManifestIfDifferentVersion(storageKey,
|
||||
localManifestVersion);
|
||||
final var fetchedRemoteManifest = result instanceof ManifestIfDifferentVersionResult.DifferentVersion;
|
||||
|
||||
var needsForcePush = false;
|
||||
final var remoteManifest = switch (result) {
|
||||
@ -120,6 +128,7 @@ public class StorageHelper {
|
||||
logger.trace("Adding missing storageIds to local data");
|
||||
account.getRecipientStore().setMissingStorageIds();
|
||||
account.getGroupStore().setMissingStorageIds();
|
||||
account.getStickerStore().setMissingStorageIds();
|
||||
|
||||
var needsMultiDeviceSync = false;
|
||||
|
||||
@ -141,7 +150,10 @@ public class StorageHelper {
|
||||
needsForcePush = true;
|
||||
} else {
|
||||
try {
|
||||
needsMultiDeviceSync = writeToStorage(storageKey, remoteManifest, needsForcePush);
|
||||
needsMultiDeviceSync = writeToStorage(storageKey,
|
||||
remoteManifest,
|
||||
needsForcePush,
|
||||
fetchedRemoteManifest);
|
||||
} catch (RetryLaterException e) {
|
||||
// TODO retry later
|
||||
return;
|
||||
@ -222,11 +234,14 @@ public class StorageHelper {
|
||||
final var updated = account.getRecipientStore()
|
||||
.removeStorageIdsFromLocalOnlyUnregisteredRecipients(connection,
|
||||
oldUnregisteredLocalOnlyIds);
|
||||
final var updatedStickers = account.getStickerStore()
|
||||
.removeStorageIdsFromLocalOnlyDeletedStickerPacks(connection, oldUnregisteredLocalOnlyIds);
|
||||
|
||||
if (updated > 0) {
|
||||
if (updated > 0 || updatedStickers > 0) {
|
||||
logger.warn(
|
||||
"Found {} records that were deleted remotely but only marked unregistered locally. Removed those from local store.",
|
||||
updated);
|
||||
"Found {} recipients and {} sticker packs that were deleted remotely but only marked deleted locally. Removed those from local store.",
|
||||
updated,
|
||||
updatedStickers);
|
||||
}
|
||||
}
|
||||
|
||||
@ -280,7 +295,8 @@ public class StorageHelper {
|
||||
private boolean writeToStorage(
|
||||
final StorageKey storageKey,
|
||||
final SignalStorageManifest remoteManifest,
|
||||
final boolean needsForcePush
|
||||
final boolean needsForcePush,
|
||||
final boolean fetchedRemoteManifest
|
||||
) throws IOException, RetryLaterException {
|
||||
final WriteOperationResult remoteWriteOperation;
|
||||
try (final var connection = account.getAccountDatabase().getConnection()) {
|
||||
@ -318,6 +334,19 @@ public class StorageHelper {
|
||||
|
||||
if (remoteWriteOperation.isEmpty()) {
|
||||
logger.debug("No remote writes needed. Still at version: {}", remoteManifest.version);
|
||||
storageSyncLoopDetector.onConverged();
|
||||
return false;
|
||||
}
|
||||
|
||||
final var loopCheck = storageSyncLoopDetector.onWriteAttempt(remoteWriteOperation,
|
||||
fetchedRemoteManifest,
|
||||
false);
|
||||
if (loopCheck instanceof StorageSyncLoopDetector.Decision.Denied denied) {
|
||||
logger.warn(
|
||||
"Skipping remote write, another device is likely undoing it. Cause: {}, level: {}. WriteOperationResult :: {}",
|
||||
denied.cause(),
|
||||
denied.level(),
|
||||
remoteWriteOperation);
|
||||
return false;
|
||||
}
|
||||
|
||||
@ -336,11 +365,18 @@ public class StorageHelper {
|
||||
remoteWriteOperation.deletes());
|
||||
switch (result) {
|
||||
case WriteStorageRecordsResult.ConflictError ignored -> {
|
||||
storageSyncLoopDetector.onWriteFailed();
|
||||
logger.debug("Hit a conflict when trying to resolve the conflict! Retrying.");
|
||||
throw new RetryLaterException();
|
||||
}
|
||||
case WriteStorageRecordsResult.NetworkError networkError -> throw networkError.getException();
|
||||
case WriteStorageRecordsResult.StatusCodeError statusCodeError -> throw statusCodeError.getException();
|
||||
case WriteStorageRecordsResult.NetworkError networkError -> {
|
||||
storageSyncLoopDetector.onWriteFailed();
|
||||
throw networkError.getException();
|
||||
}
|
||||
case WriteStorageRecordsResult.StatusCodeError statusCodeError -> {
|
||||
storageSyncLoopDetector.onWriteFailed();
|
||||
throw statusCodeError.getException();
|
||||
}
|
||||
case WriteStorageRecordsResult.Success ignored -> {
|
||||
logger.debug("Saved new manifest. Now at version: {}", remoteWriteOperation.manifest().version);
|
||||
storeManifestLocally(remoteWriteOperation.manifest());
|
||||
@ -366,6 +402,7 @@ public class StorageHelper {
|
||||
final Map<RecipientId, StorageId> newContactStorageIds;
|
||||
final Map<GroupIdV1, StorageId> newGroupV1StorageIds;
|
||||
final Map<GroupIdV2, StorageId> newGroupV2StorageIds;
|
||||
final Map<StickerPackId, StorageId> newStickerPackStorageIds;
|
||||
|
||||
try (final var connection = account.getAccountDatabase().getConnection()) {
|
||||
connection.setAutoCommit(false);
|
||||
@ -412,6 +449,19 @@ public class StorageHelper {
|
||||
new StorageRecord.Builder().groupV2(record).build()));
|
||||
}
|
||||
|
||||
final var stickerPacks = account.getStickerStore()
|
||||
.getStickerPacks(connection)
|
||||
.stream()
|
||||
.filter(pack -> pack.storageId() != null)
|
||||
.toList();
|
||||
newStickerPackStorageIds = generateStickerPackStorageIds(stickerPacks);
|
||||
for (final var stickerPack : stickerPacks) {
|
||||
final var storageId = newStickerPackStorageIds.get(stickerPack.packId());
|
||||
final var record = StorageSyncModels.localToRemoteRecord(stickerPack);
|
||||
newStorageRecords.add(new SignalStorageRecord(storageId,
|
||||
new StorageRecord.Builder().stickerPack(record).build()));
|
||||
}
|
||||
|
||||
connection.commit();
|
||||
} catch (SQLException e) {
|
||||
throw new RuntimeException("Failed to sync remote storage", e);
|
||||
@ -462,6 +512,7 @@ public class StorageHelper {
|
||||
connection.setAutoCommit(false);
|
||||
account.getRecipientStore().updateStorageIds(connection, newContactStorageIds);
|
||||
account.getGroupStore().updateStorageIds(connection, newGroupV1StorageIds, newGroupV2StorageIds);
|
||||
account.getStickerStore().updateStorageIds(connection, newStickerPackStorageIds);
|
||||
|
||||
// delete all unknown storage ids
|
||||
account.getUnknownStorageIdStore().deleteAllUnknownStorageIds(connection);
|
||||
@ -494,6 +545,14 @@ public class StorageHelper {
|
||||
_ -> StorageId.forGroupV2(KeyUtils.createRawStorageId())));
|
||||
}
|
||||
|
||||
private Map<StickerPackId, StorageId> generateStickerPackStorageIds(
|
||||
final List<StickerPack> stickerPacks
|
||||
) {
|
||||
return stickerPacks.stream()
|
||||
.collect(Collectors.toMap(stickerPack -> stickerPack.packId(),
|
||||
_ -> StorageId.forStickerPack(KeyUtils.createRawStorageId())));
|
||||
}
|
||||
|
||||
private void storeManifestLocally(
|
||||
final SignalStorageManifest remoteManifest
|
||||
) {
|
||||
@ -533,6 +592,7 @@ public class StorageHelper {
|
||||
storageIds.addAll(account.getUnknownStorageIdStore().getUnknownStorageIds(connection));
|
||||
storageIds.addAll(account.getGroupStore().getStorageIds(connection));
|
||||
storageIds.addAll(account.getRecipientStore().getStorageIds(connection));
|
||||
storageIds.addAll(account.getStickerStore().getStorageIds(connection));
|
||||
storageIds.add(account.getRecipientStore().getSelfStorageId(connection));
|
||||
return storageIds;
|
||||
}
|
||||
@ -581,6 +641,14 @@ public class StorageHelper {
|
||||
account.getUsernameLink());
|
||||
yield new SignalStorageRecord(storageId, new StorageRecord.Builder().account(record).build());
|
||||
}
|
||||
case ManifestRecord.Identifier.Type.STICKER_PACK -> {
|
||||
final var stickerPack = account.getStickerStore().getStickerPack(connection, storageId);
|
||||
if (stickerPack == null) {
|
||||
throw new AssertionError("Missing local sticker pack model for storage id: " + storageId);
|
||||
}
|
||||
final var record = StorageSyncModels.localToRemoteRecord(stickerPack);
|
||||
yield new SignalStorageRecord(storageId, new StorageRecord.Builder().stickerPack(record).build());
|
||||
}
|
||||
case null, default -> {
|
||||
throw new AssertionError("Got unknown local storage record type: " + storageId);
|
||||
}
|
||||
@ -646,6 +714,14 @@ public class StorageHelper {
|
||||
final var groupV1RecordProcessor = new GroupV1RecordProcessor(account, connection);
|
||||
final var groupV2RecordProcessor = new GroupV2RecordProcessor(account, connection);
|
||||
final var contactRecordProcessor = new ContactRecordProcessor(account, connection, context.getJobExecutor());
|
||||
final var stickerPackRecordProcessor = new StickerPackRecordProcessor(account, connection);
|
||||
|
||||
final var contactRecords = records.stream()
|
||||
.filter(record -> record.getProto().contact != null)
|
||||
.map(record -> StorageRecordConvertersKt.toSignalContactRecord(record.getProto().contact,
|
||||
record.getId()))
|
||||
.toList();
|
||||
contactRecordProcessor.prepare(contactRecords);
|
||||
|
||||
for (final var record : records) {
|
||||
if (record.getProto().account != null) {
|
||||
@ -664,6 +740,10 @@ public class StorageHelper {
|
||||
logger.debug("Reading record {} of type contact", record.getId());
|
||||
contactRecordProcessor.process(StorageRecordConvertersKt.toSignalContactRecord(record.getProto().contact,
|
||||
record.getId()));
|
||||
} else if (record.getProto().stickerPack != null) {
|
||||
logger.debug("Reading record {} of type stickerPack", record.getId());
|
||||
stickerPackRecordProcessor.process(StorageRecordConvertersKt.toSignalStickerPackRecord(record.getProto().stickerPack,
|
||||
record.getId()));
|
||||
} else {
|
||||
unknownRecords.add(record.getId());
|
||||
}
|
||||
@ -672,6 +752,7 @@ public class StorageHelper {
|
||||
processedRecords.addAll(groupV1RecordProcessor.getUpdatedStorageIds());
|
||||
processedRecords.addAll(groupV2RecordProcessor.getUpdatedStorageIds());
|
||||
processedRecords.addAll(contactRecordProcessor.getUpdatedStorageIds());
|
||||
processedRecords.addAll(stickerPackRecordProcessor.getUpdatedStorageIds());
|
||||
|
||||
return new Pair<>(unknownRecords, processedRecords);
|
||||
}
|
||||
|
||||
@ -237,18 +237,19 @@ public class SyncHelper {
|
||||
final var address = account.getRecipientAddressResolver().resolveRecipientAddress(record.first());
|
||||
if (address.aci().isPresent() || address.number().isPresent()) {
|
||||
addresses.add(new BlockedListMessage.Individual(address.aci().orElse(null),
|
||||
address.number().orElse(null)));
|
||||
address.number().orElse(null),
|
||||
record.second().blockedAt()));
|
||||
}
|
||||
}
|
||||
}
|
||||
var groupIds = new ArrayList<byte[]>();
|
||||
var groups = new ArrayList<BlockedListMessage.Group>();
|
||||
for (var record : account.getGroupStore().getGroups()) {
|
||||
if (record.isBlocked()) {
|
||||
groupIds.add(record.getGroupId().serialize());
|
||||
groups.add(new BlockedListMessage.Group(record.getGroupId().serialize(), record.getBlockedAt()));
|
||||
}
|
||||
}
|
||||
return context.getSendHelper()
|
||||
.sendSyncMessage(SignalServiceSyncMessage.forBlocked(new BlockedListMessage(addresses, groupIds)));
|
||||
.sendSyncMessage(SignalServiceSyncMessage.forBlocked(new BlockedListMessage(addresses, groups)));
|
||||
}
|
||||
|
||||
public SendMessageResult sendVerifiedMessage(
|
||||
|
||||
@ -98,8 +98,10 @@ import org.asamk.signal.manager.util.StickerUtils;
|
||||
import org.signal.core.models.ServiceId;
|
||||
import org.signal.core.models.ServiceId.ACI;
|
||||
import org.signal.core.models.ServiceId.PNI;
|
||||
import org.signal.core.util.Base64;
|
||||
import org.signal.core.util.Hex;
|
||||
import org.signal.core.util.crypto.DeviceName;
|
||||
import org.signal.core.util.crypto.DeviceNameCipher;
|
||||
import org.signal.libsignal.net.LinkedDevice;
|
||||
import org.signal.libsignal.protocol.InvalidMessageException;
|
||||
import org.signal.libsignal.protocol.NoSessionException;
|
||||
import org.signal.libsignal.usernames.BaseUsernameException;
|
||||
@ -120,10 +122,8 @@ import org.whispersystems.signalservice.api.messages.calls.HangupMessage;
|
||||
import org.whispersystems.signalservice.api.messages.calls.IceUpdateMessage;
|
||||
import org.whispersystems.signalservice.api.messages.calls.OfferMessage;
|
||||
import org.whispersystems.signalservice.api.messages.calls.SignalServiceCallMessage;
|
||||
import org.whispersystems.signalservice.api.messages.multidevice.DeviceInfo;
|
||||
import org.whispersystems.signalservice.api.push.ServiceIdType;
|
||||
import org.whispersystems.signalservice.api.push.exceptions.CdsiResourceExhaustedException;
|
||||
import org.whispersystems.signalservice.api.util.DeviceNameUtil;
|
||||
import org.whispersystems.signalservice.api.util.StreamDetails;
|
||||
import org.whispersystems.signalservice.internal.util.Util;
|
||||
|
||||
@ -160,6 +160,7 @@ import okio.Utf8;
|
||||
|
||||
import static org.asamk.signal.manager.config.ServiceConfig.MAX_MESSAGE_SIZE_BYTES;
|
||||
import static org.asamk.signal.manager.util.Utils.handleResponseException;
|
||||
import static org.asamk.signal.manager.util.Utils.handleResponseExceptionSuspend;
|
||||
import static org.signal.core.util.StringExtensionsKt.splitByByteLength;
|
||||
|
||||
public class ManagerImpl implements Manager {
|
||||
@ -483,23 +484,26 @@ public class ManagerImpl implements Manager {
|
||||
|
||||
@Override
|
||||
public List<Device> getLinkedDevices() throws IOException {
|
||||
var devices = handleResponseException(dependencies.getLinkDeviceApi().getDevices());
|
||||
final List<LinkedDevice> devices = handleResponseExceptionSuspend(cont -> dependencies.getLinkDeviceApi()
|
||||
.getDevices(cont));
|
||||
account.setMultiDevice(devices.size() > 1);
|
||||
var identityKey = account.getAciIdentityKeyPair().getPrivateKey();
|
||||
var identityKey = account.getAciIdentityKeyPair();
|
||||
return devices.stream().map(d -> {
|
||||
String deviceName = d.getName();
|
||||
if (deviceName != null) {
|
||||
String deviceName = null;
|
||||
if (d.getEncryptedName() != null && d.getEncryptedName().length > 0) {
|
||||
try {
|
||||
deviceName = DeviceNameUtil.decryptDeviceName(deviceName, identityKey);
|
||||
} catch (IOException e) {
|
||||
deviceName = new String(DeviceNameCipher.decryptDeviceName(DeviceName.ADAPTER.decode(d.getEncryptedName()),
|
||||
identityKey), StandardCharsets.UTF_8);
|
||||
} catch (Exception e) {
|
||||
logger.debug("Failed to decrypt device name, maybe plain text?", e);
|
||||
deviceName = new String(d.getEncryptedName(), StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
final var createdAt = getPlaintextCreatedAt(d);
|
||||
return new Device(d.getId(),
|
||||
deviceName,
|
||||
createdAt == null ? 0 : createdAt,
|
||||
d.getLastSeen(),
|
||||
d.getLastSeen().toEpochMilli(),
|
||||
d.getId() == account.getDeviceId());
|
||||
}).toList();
|
||||
}
|
||||
@ -519,7 +523,7 @@ public class ManagerImpl implements Manager {
|
||||
}
|
||||
}
|
||||
|
||||
private Long getPlaintextCreatedAt(DeviceInfo d) {
|
||||
private Long getPlaintextCreatedAt(LinkedDevice d) {
|
||||
final var DECRYPTION_INFO = "deviceCreatedAt";
|
||||
var identityKey = account.getAciIdentityKeyPair().getPrivateKey();
|
||||
try {
|
||||
@ -527,8 +531,9 @@ public class ManagerImpl implements Manager {
|
||||
var associatedData = new ByteArrayOutputStream();
|
||||
associatedData.write(d.getId());
|
||||
associatedData.write(ByteBuffer.allocate(4).putInt(d.getRegistrationId()).array());
|
||||
var createdAtPlaintext = identityKey.open(Base64.decode(d.getCreatedAtCiphertext()
|
||||
.getBytes(StandardCharsets.UTF_8)), DECRYPTION_INFO, associatedData.toByteArray());
|
||||
var createdAtPlaintext = identityKey.open(d.getCreatedAtCiphertext(),
|
||||
DECRYPTION_INFO,
|
||||
associatedData.toByteArray());
|
||||
return ByteBuffer.wrap(createdAtPlaintext).getLong();
|
||||
} catch (Exception e) {
|
||||
logger.warn("Failed while reading the protobuf.", e);
|
||||
@ -605,6 +610,11 @@ public class ManagerImpl implements Manager {
|
||||
context.getGroupHelper().deleteGroup(groupId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public SendGroupMessageResults terminateGroup(GroupId groupId) throws IOException, GroupNotFoundException, NotAGroupMemberException {
|
||||
return context.getGroupHelper().terminateGroup(groupId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Pair<GroupId, SendGroupMessageResults> createGroup(
|
||||
String name,
|
||||
@ -907,6 +917,10 @@ public class ManagerImpl implements Manager {
|
||||
if (!(groupInfo instanceof GroupInfoV2 groupInfoV2)) {
|
||||
throw new IOException("Stories are only supported for V2 groups");
|
||||
}
|
||||
if (groupInfoV2.isTerminated()) {
|
||||
// Other clients drop messages sent to a terminated group.
|
||||
throw new IOException("Cannot send a story to a group that has been terminated");
|
||||
}
|
||||
|
||||
final var uploadedAttachment = context.getAttachmentHelper().uploadAttachment(attachment);
|
||||
final var groupContext = SignalServiceGroupV2.newBuilder(groupInfoV2.getMasterKey())
|
||||
@ -1701,8 +1715,15 @@ public class ManagerImpl implements Manager {
|
||||
}
|
||||
|
||||
final var contact = account.getContactStore().getContact(recipientId);
|
||||
if (contact != null && !Util.isEmpty(contact.getName())) {
|
||||
return contact.getName();
|
||||
if (contact != null) {
|
||||
final var nickname = contact.getDisplayNickname();
|
||||
if (!Util.isEmpty(nickname)) {
|
||||
return nickname;
|
||||
}
|
||||
|
||||
if (!Util.isEmpty(contact.getName())) {
|
||||
return contact.getName();
|
||||
}
|
||||
}
|
||||
|
||||
final var profile = context.getProfileHelper().getRecipientProfile(recipientId);
|
||||
|
||||
@ -19,34 +19,57 @@ package org.asamk.signal.manager.internal;
|
||||
import org.asamk.signal.manager.Manager;
|
||||
import org.asamk.signal.manager.ProvisioningManager;
|
||||
import org.asamk.signal.manager.Settings;
|
||||
import org.asamk.signal.manager.api.DeviceLinkUrl;
|
||||
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.storage.SignalAccount;
|
||||
import org.asamk.signal.manager.storage.accounts.AccountsStore;
|
||||
import org.asamk.signal.manager.util.KeyUtils;
|
||||
import org.signal.core.models.AccountEntropyPool;
|
||||
import org.signal.core.models.ServiceId.ACI;
|
||||
import org.signal.core.models.ServiceId.PNI;
|
||||
import org.signal.core.models.backup.MediaRootBackupKey;
|
||||
import org.signal.core.util.crypto.DeviceNameCipher;
|
||||
import org.signal.libsignal.protocol.IdentityKey;
|
||||
import org.signal.libsignal.protocol.IdentityKeyPair;
|
||||
import org.signal.libsignal.protocol.ecc.ECPrivateKey;
|
||||
import org.signal.libsignal.zkgroup.profiles.ProfileKey;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
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.push.ServiceIdType;
|
||||
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
|
||||
import org.whispersystems.signalservice.api.push.exceptions.AuthorizationFailedException;
|
||||
import org.whispersystems.signalservice.api.registration.ProvisioningApi;
|
||||
import org.whispersystems.signalservice.api.util.DeviceNameUtil;
|
||||
import org.whispersystems.signalservice.internal.push.ProvisioningSocket;
|
||||
import org.whispersystems.signalservice.internal.push.PushServiceSocket;
|
||||
import org.whispersystems.signalservice.internal.util.DynamicCredentialsProvider;
|
||||
import org.whispersystems.signalservice.internal.crypto.SecondaryProvisioningCipher;
|
||||
import org.whispersystems.signalservice.internal.push.ProvisionMessage;
|
||||
|
||||
import java.io.Closeable;
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.URISyntaxException;
|
||||
import java.nio.channels.OverlappingFileLockException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import static org.asamk.signal.manager.util.KeyUtils.generatePreKeysForType;
|
||||
import kotlin.ResultKt;
|
||||
import kotlin.Unit;
|
||||
import kotlin.coroutines.Continuation;
|
||||
import kotlin.coroutines.EmptyCoroutineContext;
|
||||
import kotlin.coroutines.intrinsics.IntrinsicsKt;
|
||||
import kotlin.jvm.functions.Function3;
|
||||
import kotlinx.coroutines.BuildersKt;
|
||||
import kotlinx.coroutines.CoroutineScope;
|
||||
|
||||
public class ProvisioningManagerImpl implements ProvisioningManager {
|
||||
import static org.asamk.signal.manager.util.KeyUtils.generatePreKeysForType;
|
||||
import static org.asamk.signal.manager.util.Utils.handleResponseException;
|
||||
|
||||
public class ProvisioningManagerImpl implements ProvisioningManager, Closeable {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(ProvisioningManagerImpl.class);
|
||||
|
||||
@ -56,9 +79,10 @@ public class ProvisioningManagerImpl implements ProvisioningManager {
|
||||
private final Consumer<Manager> newManagerListener;
|
||||
private final AccountsStore accountsStore;
|
||||
|
||||
private final ProvisioningApi provisioningApi;
|
||||
private final IdentityKeyPair tempIdentityKey;
|
||||
private final String password;
|
||||
private final CompletableFuture<String> urlFuture = new CompletableFuture<>();
|
||||
private final CompletableFuture<SecondaryProvisioningCipher.ProvisioningDecryptResult<ProvisionMessage>> messageFuture = new CompletableFuture<>();
|
||||
private final Closeable socketHandle;
|
||||
|
||||
public ProvisioningManagerImpl(
|
||||
PathConfig pathConfig,
|
||||
@ -73,35 +97,58 @@ public class ProvisioningManagerImpl implements ProvisioningManager {
|
||||
this.newManagerListener = newManagerListener;
|
||||
this.accountsStore = accountsStore;
|
||||
|
||||
tempIdentityKey = KeyUtils.generateIdentityKeyPair();
|
||||
final IdentityKeyPair tempIdentityKey = KeyUtils.generateIdentityKeyPair();
|
||||
password = KeyUtils.createPassword();
|
||||
final var credentialsProvider = new DynamicCredentialsProvider(null,
|
||||
null,
|
||||
null,
|
||||
password,
|
||||
SignalServiceAddress.DEFAULT_DEVICE_ID);
|
||||
final var pushServiceSocket = new PushServiceSocket(serviceEnvironmentConfig.signalServiceConfiguration(),
|
||||
credentialsProvider,
|
||||
userAgent,
|
||||
ServiceConfig.AUTOMATIC_NETWORK_RETRY);
|
||||
final var provisioningSocket = new ProvisioningSocket(serviceEnvironmentConfig.signalServiceConfiguration(),
|
||||
userAgent);
|
||||
this.provisioningApi = new ProvisioningApi(pushServiceSocket, provisioningSocket, credentialsProvider);
|
||||
|
||||
socketHandle = ProvisioningSocket.Companion.start(new ProvisioningSocket.Mode.Link(false),
|
||||
tempIdentityKey,
|
||||
serviceEnvironmentConfig.signalServiceConfiguration(),
|
||||
(id, t) -> {
|
||||
urlFuture.completeExceptionally(t);
|
||||
messageFuture.completeExceptionally(t);
|
||||
},
|
||||
new ProvisioningBlock());
|
||||
}
|
||||
|
||||
@Override
|
||||
public URI getDeviceLinkUri() throws TimeoutException, IOException {
|
||||
var deviceUuid = provisioningApi.getNewDeviceUuid();
|
||||
|
||||
return new DeviceLinkUrl(deviceUuid, tempIdentityKey.getPublicKey().getPublicKey()).createDeviceLinkUri();
|
||||
try {
|
||||
var url = urlFuture.get(30, TimeUnit.SECONDS);
|
||||
return new URI(url);
|
||||
} catch (java.util.concurrent.TimeoutException e) {
|
||||
throw new TimeoutException("Timed out waiting for provisioning URL");
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IOException("Interrupted while waiting for provisioning URL", e);
|
||||
} catch (ExecutionException e) {
|
||||
throw new IOException("Failed to get provisioning URL", e.getCause());
|
||||
} catch (URISyntaxException e) {
|
||||
throw new IOException("Invalid provisioning URL", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String finishDeviceLink(String deviceName) throws IOException, TimeoutException, UserAlreadyExistsException {
|
||||
var ret = provisioningApi.getNewDeviceRegistration(tempIdentityKey);
|
||||
var number = ret.getNumber();
|
||||
var aci = ret.getAci();
|
||||
var pni = ret.getPni();
|
||||
SecondaryProvisioningCipher.ProvisioningDecryptResult<ProvisionMessage> decryptResult;
|
||||
try {
|
||||
decryptResult = messageFuture.get(120, TimeUnit.SECONDS);
|
||||
} catch (java.util.concurrent.TimeoutException e) {
|
||||
throw new TimeoutException("Timed out waiting for provisioning message");
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IOException("Interrupted while waiting for provisioning message", e);
|
||||
} catch (ExecutionException e) {
|
||||
throw new IOException("Failed to receive provisioning message", e.getCause());
|
||||
}
|
||||
|
||||
if (!(decryptResult instanceof SecondaryProvisioningCipher.ProvisioningDecryptResult.Success<ProvisionMessage> success)) {
|
||||
throw new IOException("Failed to decrypt provisioning message");
|
||||
}
|
||||
var msg = success.getMessage();
|
||||
|
||||
var number = msg.number;
|
||||
var aci = ACI.parseOrThrow(msg.aci, msg.aciBinary);
|
||||
var pni = PNI.parseOrThrow(msg.pni, msg.pniBinary);
|
||||
|
||||
logger.info("Received link information from {}, linking in progress ...", number);
|
||||
|
||||
@ -120,21 +167,42 @@ public class ProvisioningManagerImpl implements ProvisioningManager {
|
||||
accountsStore.updateAccount(accountPath, number, aci);
|
||||
}
|
||||
|
||||
final IdentityKeyPair aciIdentity;
|
||||
final IdentityKeyPair pniIdentity;
|
||||
final ProfileKey profileKey;
|
||||
try {
|
||||
aciIdentity = new IdentityKeyPair(new IdentityKey(msg.aciIdentityKeyPublic.toByteArray()),
|
||||
new ECPrivateKey(msg.aciIdentityKeyPrivate.toByteArray()));
|
||||
pniIdentity = new IdentityKeyPair(new IdentityKey(msg.pniIdentityKeyPublic.toByteArray()),
|
||||
new ECPrivateKey(msg.pniIdentityKeyPrivate.toByteArray()));
|
||||
profileKey = msg.profileKey == null
|
||||
? KeyUtils.createProfileKey()
|
||||
: new ProfileKey(msg.profileKey.toByteArray());
|
||||
} catch (Exception e) {
|
||||
throw new IOException("Invalid key material in provisioning message", e);
|
||||
}
|
||||
|
||||
var encryptedDeviceName = deviceName == null
|
||||
? null
|
||||
: DeviceNameUtil.encryptDeviceName(deviceName, ret.getAciIdentity().getPrivateKey());
|
||||
// Create new account with the synced identity
|
||||
var profileKey = ret.getProfileKey() == null ? KeyUtils.createProfileKey() : ret.getProfileKey();
|
||||
: DeviceNameCipher.encryptDeviceName(deviceName.getBytes(StandardCharsets.UTF_8), aciIdentity);
|
||||
var accountEntropyPool = msg.accountEntropyPool == null ? null : new AccountEntropyPool(msg.accountEntropyPool);
|
||||
var mediaRootBackupKey = msg.mediaRootBackupKey == null
|
||||
? null
|
||||
: new MediaRootBackupKey(msg.mediaRootBackupKey.toByteArray());
|
||||
|
||||
SignalAccount account = null;
|
||||
var cleanUpPartialAccountOnFailure = false;
|
||||
var linkingFinished = false;
|
||||
try {
|
||||
if (!accountExists) {
|
||||
account = SignalAccount.createLinkedAccount(pathConfig.dataPath(),
|
||||
accountPath,
|
||||
serviceEnvironmentConfig.type(),
|
||||
Settings.DEFAULT);
|
||||
cleanUpPartialAccountOnFailure = true;
|
||||
} else {
|
||||
account = SignalAccount.load(pathConfig.dataPath(), accountPath, true, Settings.DEFAULT);
|
||||
cleanUpPartialAccountOnFailure = false;
|
||||
}
|
||||
|
||||
account.setProvisioningData(number,
|
||||
@ -142,24 +210,43 @@ public class ProvisioningManagerImpl implements ProvisioningManager {
|
||||
pni,
|
||||
password,
|
||||
encryptedDeviceName,
|
||||
ret.getAciIdentity(),
|
||||
ret.getPniIdentity(),
|
||||
aciIdentity,
|
||||
pniIdentity,
|
||||
profileKey,
|
||||
ret.getAccountEntropyPool(),
|
||||
ret.getMediaRootBackupKey());
|
||||
accountEntropyPool,
|
||||
msg.authCredentialSalt == null ? null : msg.authCredentialSalt.toByteArray(),
|
||||
mediaRootBackupKey);
|
||||
|
||||
account.getConfigurationStore().setReadReceipts(ret.isReadReceipts());
|
||||
if (msg.readReceipts != null) {
|
||||
account.getConfigurationStore().setReadReceipts(msg.readReceipts);
|
||||
}
|
||||
|
||||
final var aciPreKeys = generatePreKeysForType(account.getAccountData(ServiceIdType.ACI));
|
||||
final var pniPreKeys = generatePreKeysForType(account.getAccountData(ServiceIdType.PNI));
|
||||
|
||||
logger.debug("Finishing new device registration");
|
||||
var deviceId = provisioningApi.finishNewDeviceRegistration(ret.getProvisioningCode(),
|
||||
account.getAccountAttributes(null),
|
||||
aciPreKeys,
|
||||
pniPreKeys);
|
||||
final var attrs = account.getAccountAttributes(null);
|
||||
final var deviceAttributes = new DeviceAttributes(attrs.getFetchesMessages(),
|
||||
attrs.getRegistrationId(),
|
||||
attrs.getPniRegistrationId(),
|
||||
attrs.getName(),
|
||||
attrs.getCapabilities());
|
||||
final var unauthAccountManager = SignalServiceAccountManager.createWithStaticCredentials(
|
||||
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);
|
||||
linkingFinished = true;
|
||||
|
||||
ManagerImpl m = null;
|
||||
try {
|
||||
@ -196,6 +283,12 @@ public class ProvisioningManagerImpl implements ProvisioningManager {
|
||||
m.close();
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
if (!linkingFinished && cleanUpPartialAccountOnFailure && account != null) {
|
||||
cleanupPartialAccount(account, accountPath, e);
|
||||
account = null;
|
||||
}
|
||||
throw e;
|
||||
} finally {
|
||||
if (account != null) {
|
||||
account.close();
|
||||
@ -203,6 +296,33 @@ public class ProvisioningManagerImpl implements ProvisioningManager {
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() throws IOException {
|
||||
socketHandle.close();
|
||||
}
|
||||
|
||||
private void cleanupPartialAccount(final SignalAccount account, final String accountPath, final Exception cause) {
|
||||
logger.warn("Link attempt failed before registration completed, removing partial account state for {}.",
|
||||
accountPath,
|
||||
cause);
|
||||
try {
|
||||
account.deleteAccountData();
|
||||
} catch (IOException cleanupError) {
|
||||
logger.warn("Failed to delete partial account data for {}: {}",
|
||||
accountPath,
|
||||
cleanupError.getMessage(),
|
||||
cleanupError);
|
||||
}
|
||||
try {
|
||||
accountsStore.removeAccount(accountPath);
|
||||
} catch (RuntimeException cleanupError) {
|
||||
logger.warn("Failed to remove partial account entry for {}: {}",
|
||||
accountPath,
|
||||
cleanupError.getMessage(),
|
||||
cleanupError);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean canRelinkExistingAccount(final String accountPath) throws IOException {
|
||||
final SignalAccount signalAccount;
|
||||
try {
|
||||
@ -216,6 +336,10 @@ public class ProvisioningManagerImpl implements ProvisioningManager {
|
||||
}
|
||||
|
||||
try (signalAccount) {
|
||||
if (signalAccount.getDeviceId() <= 0) {
|
||||
logger.debug("Account has invalid deviceId {}, allowing relink.", signalAccount.getDeviceId());
|
||||
return true;
|
||||
}
|
||||
if (signalAccount.isPrimaryDevice()) {
|
||||
logger.debug("Account is a primary device.");
|
||||
return false;
|
||||
@ -243,4 +367,36 @@ public class ProvisioningManagerImpl implements ProvisioningManager {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private class ProvisioningBlock implements Function3<CoroutineScope, ProvisioningSocket<ProvisionMessage>, Continuation<? super Unit>, Object> {
|
||||
|
||||
@Override
|
||||
public Object invoke(
|
||||
CoroutineScope scope,
|
||||
ProvisioningSocket<ProvisionMessage> socket,
|
||||
Continuation<? super Unit> cont
|
||||
) {
|
||||
Thread.ofVirtual().start(() -> {
|
||||
try {
|
||||
urlFuture.complete(BuildersKt.runBlocking(EmptyCoroutineContext.INSTANCE,
|
||||
(s, c) -> socket.getProvisioningUrl(c)));
|
||||
messageFuture.complete(BuildersKt.runBlocking(EmptyCoroutineContext.INSTANCE,
|
||||
(s, c) -> socket.getProvisioningMessageDecryptResult(c)));
|
||||
cont.resumeWith(Unit.INSTANCE);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
failBoth(new RuntimeException(e), cont);
|
||||
} catch (Throwable t) {
|
||||
failBoth(t, cont);
|
||||
}
|
||||
});
|
||||
return IntrinsicsKt.getCOROUTINE_SUSPENDED();
|
||||
}
|
||||
|
||||
private void failBoth(Throwable t, Continuation<? super Unit> cont) {
|
||||
urlFuture.completeExceptionally(t);
|
||||
messageFuture.completeExceptionally(t);
|
||||
cont.resumeWith(ResultKt.createFailure(t));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -355,6 +355,7 @@ public class SignalDependencies {
|
||||
() -> preKeyRepository = new PreKeyRepository(getKeysApi(),
|
||||
dataStore.aci(),
|
||||
localProtocolAddress,
|
||||
getSessionLock(),
|
||||
Runnable::run));
|
||||
}
|
||||
|
||||
|
||||
@ -9,6 +9,7 @@ import org.whispersystems.signalservice.api.websocket.HealthMonitor;
|
||||
import org.whispersystems.signalservice.api.websocket.SignalWebSocket;
|
||||
import org.whispersystems.signalservice.api.websocket.WebSocketConnectionState;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.concurrent.Executor;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
@ -103,6 +104,18 @@ final class SignalWebSocketHealthMonitor implements HealthMonitor {
|
||||
logger.info("Received alerts: {}", String.join(", ", strings));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onServerTimestamp(final long serverTimestamp, final boolean isIdentifiedWebsocket) {
|
||||
final var skew = skewFrom(serverTimestamp);
|
||||
if (skew.compareTo(Duration.ofDays(1)) > 0) {
|
||||
logger.warn("Local clock is off from the server by {}, which exceeds the allowed limit..", skew);
|
||||
}
|
||||
}
|
||||
|
||||
private Duration skewFrom(long serverTime) {
|
||||
return Duration.ofMillis(Math.abs(System.currentTimeMillis() - serverTime));
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends periodic heartbeats/keep-alives over the WebSocket to prevent connection timeouts. If
|
||||
* the WebSocket fails to get a return heartbeat after [KEEP_ALIVE_TIMEOUT] seconds, it is forced to be recreated.
|
||||
|
||||
@ -33,7 +33,7 @@ import java.util.UUID;
|
||||
public class AccountDatabase extends Database {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(AccountDatabase.class);
|
||||
private static final long DATABASE_VERSION = 28;
|
||||
private static final long DATABASE_VERSION = 31;
|
||||
|
||||
private AccountDatabase(final HikariDataSource dataSource) {
|
||||
super(logger, DATABASE_VERSION, dataSource);
|
||||
@ -623,6 +623,36 @@ public class AccountDatabase extends Database {
|
||||
""");
|
||||
}
|
||||
}
|
||||
if (oldVersion < 29) {
|
||||
logger.debug("Updating database: Adding sticker storage sync columns");
|
||||
try (final var statement = connection.createStatement()) {
|
||||
statement.executeUpdate("""
|
||||
ALTER TABLE sticker ADD COLUMN position INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE sticker ADD COLUMN deleted_timestamp INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE sticker ADD COLUMN storage_id BLOB;
|
||||
ALTER TABLE sticker ADD COLUMN storage_record BLOB;
|
||||
CREATE UNIQUE INDEX sticker_storage_id_index ON sticker (storage_id);
|
||||
""");
|
||||
}
|
||||
}
|
||||
if (oldVersion < 30) {
|
||||
logger.debug("Updating database: Create pni_signature_verified column");
|
||||
try (final var statement = connection.createStatement()) {
|
||||
statement.executeUpdate("""
|
||||
ALTER TABLE recipient ADD pni_signature_verified INTEGER NOT NULL DEFAULT FALSE;
|
||||
""");
|
||||
}
|
||||
}
|
||||
if (oldVersion < 31) {
|
||||
logger.debug("Updating database: Add blocked-at timestamps");
|
||||
try (final var statement = connection.createStatement()) {
|
||||
statement.executeUpdate("""
|
||||
ALTER TABLE recipient ADD blocked_at INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE group_v1 ADD blocked_at INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE group_v2 ADD blocked_at INTEGER NOT NULL DEFAULT 0;
|
||||
""");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void createUuidMappingTable(
|
||||
|
||||
@ -116,7 +116,7 @@ public class SignalAccount implements Closeable {
|
||||
private static final Logger logger = LoggerFactory.getLogger(SignalAccount.class);
|
||||
|
||||
private static final int MINIMUM_STORAGE_VERSION = 1;
|
||||
private static final int CURRENT_STORAGE_VERSION = 10;
|
||||
private static final int CURRENT_STORAGE_VERSION = 11;
|
||||
|
||||
private final Object LOCK = new Object();
|
||||
|
||||
@ -141,6 +141,7 @@ public class SignalAccount implements Closeable {
|
||||
private MasterKey pinMasterKey;
|
||||
private StorageKey storageKey;
|
||||
private AccountEntropyPool accountEntropyPool;
|
||||
private byte[] authCredentialSalt;
|
||||
private MediaRootBackupKey mediaRootBackupKey;
|
||||
private ProfileKey profileKey;
|
||||
|
||||
@ -153,6 +154,10 @@ public class SignalAccount implements Closeable {
|
||||
private final KeyValueEntry<Long> lastReceiveTimestamp = new KeyValueEntry<>("last-receive-timestamp",
|
||||
long.class,
|
||||
0L);
|
||||
private final KeyValueEntry<Long> lastAppliedPniChangeServerTimestamp = new KeyValueEntry<>(
|
||||
"last-applied-pni-change-server-timestamp",
|
||||
long.class,
|
||||
0L);
|
||||
private final KeyValueEntry<Boolean> needsToRetryFailedMessages = new KeyValueEntry<>("retry-failed-messages",
|
||||
Boolean.class,
|
||||
true);
|
||||
@ -292,11 +297,12 @@ public class SignalAccount implements Closeable {
|
||||
final ACI aci,
|
||||
final PNI pni,
|
||||
final String password,
|
||||
final String encryptedDeviceName,
|
||||
final byte[] encryptedDeviceName,
|
||||
final IdentityKeyPair aciIdentity,
|
||||
final IdentityKeyPair pniIdentity,
|
||||
final ProfileKey profileKey,
|
||||
final AccountEntropyPool accountEntropyPool,
|
||||
final byte[] authCredentialSalt,
|
||||
final MediaRootBackupKey mediaRootBackupKey
|
||||
) {
|
||||
this.deviceId = 0;
|
||||
@ -307,12 +313,13 @@ public class SignalAccount implements Closeable {
|
||||
getRecipientTrustedResolver().resolveSelfRecipientTrusted(getSelfRecipientAddress());
|
||||
this.password = password;
|
||||
this.profileKey = profileKey;
|
||||
this.encryptedDeviceName = encryptedDeviceName;
|
||||
this.encryptedDeviceName = org.signal.core.util.Base64.encodeWithoutPadding(encryptedDeviceName);
|
||||
this.aciAccountData.setIdentityKeyPair(aciIdentity);
|
||||
this.pniAccountData.setIdentityKeyPair(pniIdentity);
|
||||
this.registered = false;
|
||||
this.isMultiDevice = true;
|
||||
setLastReceiveTimestamp(0L);
|
||||
setLastAppliedPniChangeServerTimestamp(0L);
|
||||
if (accountEntropyPool != null) {
|
||||
this.pinMasterKey = null;
|
||||
this.accountEntropyPool = accountEntropyPool;
|
||||
@ -320,6 +327,7 @@ public class SignalAccount implements Closeable {
|
||||
this.pinMasterKey = null;
|
||||
this.accountEntropyPool = null;
|
||||
}
|
||||
this.authCredentialSalt = authCredentialSalt;
|
||||
this.mediaRootBackupKey = mediaRootBackupKey;
|
||||
getKeyValueStore().storeEntry(storageManifestVersion, -1L);
|
||||
this.setStorageManifest(null);
|
||||
@ -356,6 +364,7 @@ public class SignalAccount implements Closeable {
|
||||
) {
|
||||
this.pinMasterKey = masterKey;
|
||||
this.accountEntropyPool = null;
|
||||
this.authCredentialSalt = null;
|
||||
getKeyValueStore().storeEntry(storageManifestVersion, -1L);
|
||||
this.setStorageManifest(null);
|
||||
this.storageKey = null;
|
||||
@ -368,6 +377,7 @@ public class SignalAccount implements Closeable {
|
||||
init();
|
||||
this.registrationLockPin = pin;
|
||||
setLastReceiveTimestamp(0L);
|
||||
setLastAppliedPniChangeServerTimestamp(0L);
|
||||
save();
|
||||
|
||||
setPreKeys(ServiceIdType.ACI, aciPreKeys);
|
||||
@ -520,6 +530,9 @@ public class SignalAccount implements Closeable {
|
||||
if (storage.accountEntropyPool != null) {
|
||||
accountEntropyPool = new AccountEntropyPool(storage.accountEntropyPool);
|
||||
}
|
||||
if (storage.authCredentialSalt != null) {
|
||||
authCredentialSalt = base64.decode(storage.authCredentialSalt);
|
||||
}
|
||||
if (storage.mediaRootBackupKey != null) {
|
||||
mediaRootBackupKey = new MediaRootBackupKey(base64.decode(storage.mediaRootBackupKey));
|
||||
}
|
||||
@ -900,6 +913,7 @@ public class SignalAccount implements Closeable {
|
||||
0,
|
||||
false,
|
||||
contact.blocked,
|
||||
0,
|
||||
contact.archived,
|
||||
false,
|
||||
false,
|
||||
@ -1008,6 +1022,7 @@ public class SignalAccount implements Closeable {
|
||||
pinMasterKey == null ? null : base64.encodeToString(pinMasterKey.serialize()),
|
||||
storageKey == null ? null : base64.encodeToString(storageKey.serialize()),
|
||||
accountEntropyPool == null ? null : accountEntropyPool.getValue(),
|
||||
authCredentialSalt == null ? null : base64.encodeToString(authCredentialSalt),
|
||||
mediaRootBackupKey == null ? null : base64.encodeToString(mediaRootBackupKey.getValue()),
|
||||
profileKey == null ? null : base64.encodeToString(profileKey.serialize()),
|
||||
usernameLink == null ? null : base64.encodeToString(usernameLink.getEntropy()),
|
||||
@ -1222,6 +1237,11 @@ public class SignalAccount implements Closeable {
|
||||
return pniAccountData.getSignalServiceAccountDataStore();
|
||||
}
|
||||
|
||||
@Override
|
||||
public SignalServiceAccountDataStore pniOrNull() {
|
||||
return getPni() != null ? pniAccountData.getSignalServiceAccountDataStore() : null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isMultiDevice() {
|
||||
return SignalAccount.this.isMultiDevice();
|
||||
@ -1642,6 +1662,10 @@ public class SignalAccount implements Closeable {
|
||||
save();
|
||||
}
|
||||
|
||||
public byte[] getAuthCredentialSalt() {
|
||||
return authCredentialSalt;
|
||||
}
|
||||
|
||||
public String getRecoveryPassword() {
|
||||
final var masterKey = getPinBackedMasterKey();
|
||||
if (masterKey == null) {
|
||||
@ -1725,7 +1749,7 @@ public class SignalAccount implements Closeable {
|
||||
}
|
||||
|
||||
public boolean isRegistered() {
|
||||
return registered;
|
||||
return registered && deviceId > 0;
|
||||
}
|
||||
|
||||
public void setRegistered(final boolean registered) {
|
||||
@ -1753,6 +1777,14 @@ public class SignalAccount implements Closeable {
|
||||
getKeyValueStore().storeEntry(lastReceiveTimestamp, value);
|
||||
}
|
||||
|
||||
public long getLastAppliedPniChangeServerTimestamp() {
|
||||
return getKeyValueStore().getEntry(lastAppliedPniChangeServerTimestamp);
|
||||
}
|
||||
|
||||
public void setLastAppliedPniChangeServerTimestamp(final long value) {
|
||||
getKeyValueStore().storeEntry(lastAppliedPniChangeServerTimestamp, value);
|
||||
}
|
||||
|
||||
public void setNeedsToRetryFailedMessages(final boolean value) {
|
||||
getKeyValueStore().storeEntry(needsToRetryFailedMessages, value);
|
||||
}
|
||||
@ -1973,6 +2005,7 @@ public class SignalAccount implements Closeable {
|
||||
String pinMasterKey,
|
||||
String storageKey,
|
||||
String accountEntropyPool,
|
||||
String authCredentialSalt,
|
||||
String mediaRootBackupKey,
|
||||
String profileKey,
|
||||
String usernameLinkEntropy,
|
||||
|
||||
@ -58,6 +58,10 @@ public sealed abstract class GroupInfo permits GroupInfoV1, GroupInfoV2 {
|
||||
|
||||
public abstract void setBlocked(boolean blocked);
|
||||
|
||||
public abstract long getBlockedAt();
|
||||
|
||||
public abstract void setBlockedAt(long blockedAt);
|
||||
|
||||
public abstract boolean isProfileSharingEnabled();
|
||||
|
||||
public abstract void setProfileSharingEnabled(boolean profileSharingEnabled);
|
||||
@ -66,6 +70,8 @@ public sealed abstract class GroupInfo permits GroupInfoV1, GroupInfoV2 {
|
||||
|
||||
public abstract boolean isAnnouncementGroup();
|
||||
|
||||
public abstract boolean isTerminated();
|
||||
|
||||
public abstract GroupPermission getPermissionAddMember();
|
||||
|
||||
public abstract GroupPermission getPermissionEditDetails();
|
||||
|
||||
@ -24,6 +24,7 @@ public final class GroupInfoV1 extends GroupInfo {
|
||||
public String color;
|
||||
public int messageExpirationTime;
|
||||
public boolean blocked;
|
||||
private long blockedAt;
|
||||
public boolean archived;
|
||||
private byte[] storageRecord;
|
||||
|
||||
@ -39,6 +40,7 @@ public final class GroupInfoV1 extends GroupInfo {
|
||||
final String color,
|
||||
final int messageExpirationTime,
|
||||
final boolean blocked,
|
||||
final long blockedAt,
|
||||
final boolean archived,
|
||||
final byte[] storageRecord
|
||||
) {
|
||||
@ -49,6 +51,7 @@ public final class GroupInfoV1 extends GroupInfo {
|
||||
this.color = color;
|
||||
this.messageExpirationTime = messageExpirationTime;
|
||||
this.blocked = blocked;
|
||||
this.blockedAt = blockedAt;
|
||||
this.archived = archived;
|
||||
this.storageRecord = storageRecord;
|
||||
}
|
||||
@ -91,9 +94,24 @@ public final class GroupInfoV1 extends GroupInfo {
|
||||
|
||||
@Override
|
||||
public void setBlocked(final boolean blocked) {
|
||||
if (blocked && !this.blocked) {
|
||||
blockedAt = System.currentTimeMillis();
|
||||
} else if (!blocked) {
|
||||
blockedAt = 0;
|
||||
}
|
||||
this.blocked = blocked;
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getBlockedAt() {
|
||||
return blockedAt;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setBlockedAt(final long blockedAt) {
|
||||
this.blockedAt = blockedAt;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isProfileSharingEnabled() {
|
||||
return true;
|
||||
@ -113,6 +131,11 @@ public final class GroupInfoV1 extends GroupInfo {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isTerminated() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public GroupPermission getPermissionAddMember() {
|
||||
return GroupPermission.EVERY_MEMBER;
|
||||
|
||||
@ -23,6 +23,7 @@ public final class GroupInfoV2 extends GroupInfo {
|
||||
private final GroupMasterKey masterKey;
|
||||
private final DistributionId distributionId;
|
||||
private boolean blocked;
|
||||
private long blockedAt;
|
||||
private boolean profileSharingEnabled;
|
||||
private DecryptedGroup group;
|
||||
private byte[] storageRecord;
|
||||
@ -47,6 +48,7 @@ public final class GroupInfoV2 extends GroupInfo {
|
||||
final DecryptedGroup group,
|
||||
final DistributionId distributionId,
|
||||
final boolean blocked,
|
||||
final long blockedAt,
|
||||
final boolean profileSharingEnabled,
|
||||
final boolean permissionDenied,
|
||||
final byte[] storageRecord,
|
||||
@ -57,6 +59,7 @@ public final class GroupInfoV2 extends GroupInfo {
|
||||
this.group = group;
|
||||
this.distributionId = distributionId;
|
||||
this.blocked = blocked;
|
||||
this.blockedAt = blockedAt;
|
||||
this.profileSharingEnabled = profileSharingEnabled;
|
||||
this.permissionDenied = permissionDenied;
|
||||
this.storageRecord = storageRecord;
|
||||
@ -186,9 +189,24 @@ public final class GroupInfoV2 extends GroupInfo {
|
||||
|
||||
@Override
|
||||
public void setBlocked(final boolean blocked) {
|
||||
if (blocked && !this.blocked) {
|
||||
blockedAt = System.currentTimeMillis();
|
||||
} else if (!blocked) {
|
||||
blockedAt = 0;
|
||||
}
|
||||
this.blocked = blocked;
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getBlockedAt() {
|
||||
return blockedAt;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setBlockedAt(final long blockedAt) {
|
||||
this.blockedAt = blockedAt;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isProfileSharingEnabled() {
|
||||
return profileSharingEnabled;
|
||||
@ -211,6 +229,11 @@ public final class GroupInfoV2 extends GroupInfo {
|
||||
return this.group != null && this.group.isAnnouncementGroup == EnabledState.ENABLED;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isTerminated() {
|
||||
return this.group != null && Boolean.TRUE.equals(this.group.terminated);
|
||||
}
|
||||
|
||||
@Override
|
||||
public GroupPermission getPermissionAddMember() {
|
||||
final var accessControl = getAccessControl();
|
||||
|
||||
@ -63,6 +63,7 @@ public class GroupStore {
|
||||
distribution_id BLOB UNIQUE NOT NULL,
|
||||
endorsement_expiration_time INTEGER NOT NULL DEFAULT 0,
|
||||
blocked INTEGER NOT NULL DEFAULT FALSE,
|
||||
blocked_at INTEGER NOT NULL DEFAULT 0,
|
||||
profile_sharing INTEGER NOT NULL DEFAULT FALSE,
|
||||
permission_denied INTEGER NOT NULL DEFAULT FALSE
|
||||
) STRICT;
|
||||
@ -83,6 +84,7 @@ public class GroupStore {
|
||||
color TEXT,
|
||||
expiration_time INTEGER NOT NULL DEFAULT 0,
|
||||
blocked INTEGER NOT NULL DEFAULT FALSE,
|
||||
blocked_at INTEGER NOT NULL DEFAULT 0,
|
||||
archived INTEGER NOT NULL DEFAULT FALSE
|
||||
) STRICT;
|
||||
CREATE TABLE group_v1_member (
|
||||
@ -401,6 +403,7 @@ public class GroupStore {
|
||||
deleteGroup(connection, groupInfoV1.getGroupId());
|
||||
final var groupInfoV2 = new GroupInfoV2(groupId, groupMasterKey, recipientResolver);
|
||||
groupInfoV2.setBlocked(groupInfoV1.isBlocked());
|
||||
groupInfoV2.setBlockedAt(groupInfoV1.getBlockedAt());
|
||||
updateGroup(connection, groupInfoV2);
|
||||
logger.debug("Locally migrated group {} to group v2, id: {}",
|
||||
groupInfoV1.getGroupId().toBase64(),
|
||||
@ -614,9 +617,9 @@ public class GroupStore {
|
||||
}
|
||||
}
|
||||
final var sql = """
|
||||
INSERT INTO %s (_id, group_id, group_id_v2, name, color, expiration_time, blocked, archived, storage_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (_id) DO UPDATE SET group_id=excluded.group_id, group_id_v2=excluded.group_id_v2, name=excluded.name, color=excluded.color, expiration_time=excluded.expiration_time, blocked=excluded.blocked, archived=excluded.archived, storage_id=excluded.storage_id
|
||||
INSERT INTO %s (_id, group_id, group_id_v2, name, color, expiration_time, blocked, blocked_at, archived, storage_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (_id) DO UPDATE SET group_id=excluded.group_id, group_id_v2=excluded.group_id_v2, name=excluded.name, color=excluded.color, expiration_time=excluded.expiration_time, blocked=excluded.blocked, blocked_at=excluded.blocked_at, archived=excluded.archived, storage_id=excluded.storage_id
|
||||
RETURNING _id
|
||||
""".formatted(TABLE_GROUP_V1);
|
||||
try (final var statement = connection.prepareStatement(sql)) {
|
||||
@ -631,8 +634,9 @@ public class GroupStore {
|
||||
statement.setString(5, groupV1.color);
|
||||
statement.setLong(6, groupV1.getMessageExpirationTimer());
|
||||
statement.setBoolean(7, groupV1.isBlocked());
|
||||
statement.setBoolean(8, groupV1.archived);
|
||||
statement.setBytes(9, KeyUtils.createRawStorageId());
|
||||
statement.setLong(8, groupV1.getBlockedAt());
|
||||
statement.setBoolean(9, groupV1.archived);
|
||||
statement.setBytes(10, KeyUtils.createRawStorageId());
|
||||
final var generatedKey = Utils.executeQueryForOptional(statement, Utils::getIdMapper);
|
||||
|
||||
if (internalId == null) {
|
||||
@ -658,9 +662,9 @@ public class GroupStore {
|
||||
} else if (group instanceof GroupInfoV2 groupV2) {
|
||||
final var sql = (
|
||||
"""
|
||||
INSERT INTO %s (_id, group_id, master_key, group_data, distribution_id, blocked, permission_denied, storage_id, profile_sharing)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (_id) DO UPDATE SET group_id=excluded.group_id, master_key=excluded.master_key, group_data=excluded.group_data, distribution_id=excluded.distribution_id, blocked=excluded.blocked, permission_denied=excluded.permission_denied, storage_id=excluded.storage_id, profile_sharing=excluded.profile_sharing
|
||||
INSERT INTO %s (_id, group_id, master_key, group_data, distribution_id, blocked, blocked_at, permission_denied, storage_id, profile_sharing)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (_id) DO UPDATE SET group_id=excluded.group_id, master_key=excluded.master_key, group_data=excluded.group_data, distribution_id=excluded.distribution_id, blocked=excluded.blocked, blocked_at=excluded.blocked_at, permission_denied=excluded.permission_denied, storage_id=excluded.storage_id, profile_sharing=excluded.profile_sharing
|
||||
"""
|
||||
).formatted(TABLE_GROUP_V2);
|
||||
try (final var statement = connection.prepareStatement(sql)) {
|
||||
@ -678,9 +682,10 @@ public class GroupStore {
|
||||
}
|
||||
statement.setBytes(5, UuidUtil.toByteArray(groupV2.getDistributionId().asUuid()));
|
||||
statement.setBoolean(6, groupV2.isBlocked());
|
||||
statement.setBoolean(7, groupV2.isPermissionDenied());
|
||||
statement.setBytes(8, KeyUtils.createRawStorageId());
|
||||
statement.setBoolean(9, groupV2.isProfileSharingEnabled());
|
||||
statement.setLong(7, groupV2.getBlockedAt());
|
||||
statement.setBoolean(8, groupV2.isPermissionDenied());
|
||||
statement.setBytes(9, KeyUtils.createRawStorageId());
|
||||
statement.setBoolean(10, groupV2.isProfileSharingEnabled());
|
||||
statement.executeUpdate();
|
||||
}
|
||||
} else {
|
||||
@ -691,7 +696,7 @@ public class GroupStore {
|
||||
private List<GroupInfoV2> getGroupsV2() {
|
||||
final var sql = (
|
||||
"""
|
||||
SELECT g.group_id, g.master_key, g.group_data, g.distribution_id, g.blocked, g.profile_sharing, g.permission_denied, g.storage_record
|
||||
SELECT g.group_id, g.master_key, g.group_data, g.distribution_id, g.blocked, g.blocked_at, g.profile_sharing, g.permission_denied, g.storage_record
|
||||
FROM %s g
|
||||
"""
|
||||
).formatted(TABLE_GROUP_V2);
|
||||
@ -709,7 +714,7 @@ public class GroupStore {
|
||||
public GroupInfoV2 getGroup(Connection connection, GroupIdV2 groupIdV2) throws SQLException {
|
||||
final var sql = (
|
||||
"""
|
||||
SELECT g.group_id, g.master_key, g.group_data, g.distribution_id, g.blocked, g.profile_sharing, g.permission_denied, g.storage_record
|
||||
SELECT g.group_id, g.master_key, g.group_data, g.distribution_id, g.blocked, g.blocked_at, g.profile_sharing, g.permission_denied, g.storage_record
|
||||
FROM %s g
|
||||
WHERE g.group_id = ?
|
||||
"""
|
||||
@ -743,7 +748,7 @@ public class GroupStore {
|
||||
public GroupInfoV2 getGroupV2(Connection connection, StorageId storageId) throws SQLException {
|
||||
final var sql = (
|
||||
"""
|
||||
SELECT g.group_id, g.master_key, g.group_data, g.distribution_id, g.blocked, g.profile_sharing, g.permission_denied, g.storage_record
|
||||
SELECT g.group_id, g.master_key, g.group_data, g.distribution_id, g.blocked, g.blocked_at, g.profile_sharing, g.permission_denied, g.storage_record
|
||||
FROM %s g
|
||||
WHERE g.storage_id = ?
|
||||
"""
|
||||
@ -766,6 +771,7 @@ public class GroupStore {
|
||||
final var groupData = resultSet.getBytes("group_data");
|
||||
final var distributionId = resultSet.getBytes("distribution_id");
|
||||
final var blocked = resultSet.getBoolean("blocked");
|
||||
final var blockedAt = resultSet.getLong("blocked_at");
|
||||
final var profileSharingEnabled = resultSet.getBoolean("profile_sharing");
|
||||
final var permissionDenied = resultSet.getBoolean("permission_denied");
|
||||
final var storageRecord = resultSet.getBytes("storage_record");
|
||||
@ -774,6 +780,7 @@ public class GroupStore {
|
||||
groupData == null ? null : DecryptedGroup.ADAPTER.decode(groupData),
|
||||
DistributionId.from(UuidUtil.parseOrThrow(distributionId)),
|
||||
blocked,
|
||||
blockedAt,
|
||||
profileSharingEnabled,
|
||||
permissionDenied,
|
||||
storageRecord,
|
||||
@ -800,7 +807,7 @@ public class GroupStore {
|
||||
private List<GroupInfoV1> getGroupsV1() {
|
||||
final var sql = (
|
||||
"""
|
||||
SELECT g.group_id, g.group_id_v2, g.name, g.color, (select group_concat(gm.recipient_id) from %s gm where gm.group_id = g._id) as members, g.expiration_time, g.blocked, g.archived, g.storage_record
|
||||
SELECT g.group_id, g.group_id_v2, g.name, g.color, (select group_concat(gm.recipient_id) from %s gm where gm.group_id = g._id) as members, g.expiration_time, g.blocked, g.blocked_at, g.archived, g.storage_record
|
||||
FROM %s g
|
||||
"""
|
||||
).formatted(TABLE_GROUP_V1_MEMBER, TABLE_GROUP_V1);
|
||||
@ -818,7 +825,7 @@ public class GroupStore {
|
||||
public GroupInfoV1 getGroup(Connection connection, GroupIdV1 groupIdV1) throws SQLException {
|
||||
final var sql = (
|
||||
"""
|
||||
SELECT g.group_id, g.group_id_v2, g.name, g.color, (select group_concat(gm.recipient_id) from %s gm where gm.group_id = g._id) as members, g.expiration_time, g.blocked, g.archived, g.storage_record
|
||||
SELECT g.group_id, g.group_id_v2, g.name, g.color, (select group_concat(gm.recipient_id) from %s gm where gm.group_id = g._id) as members, g.expiration_time, g.blocked, g.blocked_at, g.archived, g.storage_record
|
||||
FROM %s g
|
||||
WHERE g.group_id = ?
|
||||
"""
|
||||
@ -852,7 +859,7 @@ public class GroupStore {
|
||||
public GroupInfoV1 getGroupV1(Connection connection, StorageId storageId) throws SQLException {
|
||||
final var sql = (
|
||||
"""
|
||||
SELECT g.group_id, g.group_id_v2, g.name, g.color, (select group_concat(gm.recipient_id) from %s gm where gm.group_id = g._id) as members, g.expiration_time, g.blocked, g.archived, g.storage_record
|
||||
SELECT g.group_id, g.group_id_v2, g.name, g.color, (select group_concat(gm.recipient_id) from %s gm where gm.group_id = g._id) as members, g.expiration_time, g.blocked, g.blocked_at, g.archived, g.storage_record
|
||||
FROM %s g
|
||||
WHERE g.storage_id = ?
|
||||
"""
|
||||
@ -882,6 +889,7 @@ public class GroupStore {
|
||||
.collect(Collectors.toSet());
|
||||
final var expirationTime = resultSet.getInt("expiration_time");
|
||||
final var blocked = resultSet.getBoolean("blocked");
|
||||
final var blockedAt = resultSet.getLong("blocked_at");
|
||||
final var archived = resultSet.getBoolean("archived");
|
||||
final var storageRecord = resultSet.getBytes("storage_record");
|
||||
return new GroupInfoV1(GroupId.v1(groupId),
|
||||
@ -891,6 +899,7 @@ public class GroupStore {
|
||||
color,
|
||||
expirationTime,
|
||||
blocked,
|
||||
blockedAt,
|
||||
archived,
|
||||
storageRecord);
|
||||
}
|
||||
@ -902,7 +911,7 @@ public class GroupStore {
|
||||
private GroupInfoV1 getGroupV1ByV2Id(Connection connection, GroupIdV2 groupIdV2) throws SQLException {
|
||||
final var sql = (
|
||||
"""
|
||||
SELECT g.group_id, g.group_id_v2, g.name, g.color, (select group_concat(gm.recipient_id) from %s gm where gm.group_id = g._id) as members, g.expiration_time, g.blocked, g.archived, g.storage_record
|
||||
SELECT g.group_id, g.group_id_v2, g.name, g.color, (select group_concat(gm.recipient_id) from %s gm where gm.group_id = g._id) as members, g.expiration_time, g.blocked, g.blocked_at, g.archived, g.storage_record
|
||||
FROM %s g
|
||||
WHERE g.group_id_v2 = ?
|
||||
"""
|
||||
|
||||
@ -59,6 +59,7 @@ public class LegacyGroupStore {
|
||||
g1.color,
|
||||
g1.messageExpirationTime,
|
||||
g1.blocked,
|
||||
0,
|
||||
g1.archived,
|
||||
null);
|
||||
}
|
||||
@ -77,6 +78,7 @@ public class LegacyGroupStore {
|
||||
loadDecryptedGroupLocked(groupId, groupCachePath),
|
||||
g2.distributionId == null ? DistributionId.create() : DistributionId.from(g2.distributionId),
|
||||
g2.blocked,
|
||||
0,
|
||||
true,
|
||||
g2.permissionDenied,
|
||||
null,
|
||||
|
||||
@ -50,6 +50,7 @@ public class LegacyRecipientStore2 {
|
||||
0,
|
||||
false,
|
||||
r.contact.blocked,
|
||||
0,
|
||||
r.contact.archived,
|
||||
r.contact.profileSharingEnabled,
|
||||
false,
|
||||
@ -100,6 +101,7 @@ public class LegacyRecipientStore2 {
|
||||
profile,
|
||||
null,
|
||||
null,
|
||||
false,
|
||||
null);
|
||||
}).collect(Collectors.toMap(Recipient::getRecipientId, r -> r));
|
||||
|
||||
|
||||
@ -25,6 +25,8 @@ public class Recipient {
|
||||
|
||||
private final Long unregisteredTimestamp;
|
||||
|
||||
private final boolean pniSignatureVerified;
|
||||
|
||||
private final byte[] storageRecord;
|
||||
|
||||
public Recipient(
|
||||
@ -36,6 +38,7 @@ public class Recipient {
|
||||
final Profile profile,
|
||||
final Boolean discoverable,
|
||||
final Long unregisteredTimestamp,
|
||||
final boolean pniSignatureVerified,
|
||||
final byte[] storageRecord
|
||||
) {
|
||||
this.recipientId = recipientId;
|
||||
@ -46,6 +49,7 @@ public class Recipient {
|
||||
this.profile = profile;
|
||||
this.discoverable = discoverable;
|
||||
this.unregisteredTimestamp = unregisteredTimestamp;
|
||||
this.pniSignatureVerified = pniSignatureVerified;
|
||||
this.storageRecord = storageRecord;
|
||||
}
|
||||
|
||||
@ -58,6 +62,7 @@ public class Recipient {
|
||||
profile = builder.profile;
|
||||
discoverable = builder.discoverable;
|
||||
unregisteredTimestamp = builder.unregisteredTimestamp;
|
||||
pniSignatureVerified = builder.pniSignatureVerified;
|
||||
storageRecord = builder.storageRecord;
|
||||
}
|
||||
|
||||
@ -73,6 +78,9 @@ public class Recipient {
|
||||
builder.profileKey = copy.getProfileKey();
|
||||
builder.expiringProfileKeyCredential = copy.getExpiringProfileKeyCredential();
|
||||
builder.profile = copy.getProfile();
|
||||
builder.discoverable = copy.getDiscoverable();
|
||||
builder.unregisteredTimestamp = copy.getUnregisteredTimestamp();
|
||||
builder.pniSignatureVerified = copy.isPniSignatureVerified();
|
||||
builder.storageRecord = copy.getStorageRecord();
|
||||
return builder;
|
||||
}
|
||||
@ -113,6 +121,10 @@ public class Recipient {
|
||||
return unregisteredTimestamp == null;
|
||||
}
|
||||
|
||||
public boolean isPniSignatureVerified() {
|
||||
return pniSignatureVerified;
|
||||
}
|
||||
|
||||
public byte[] getStorageRecord() {
|
||||
return storageRecord;
|
||||
}
|
||||
@ -127,12 +139,19 @@ public class Recipient {
|
||||
&& Objects.equals(contact, recipient.contact)
|
||||
&& Objects.equals(profileKey, recipient.profileKey)
|
||||
&& Objects.equals(expiringProfileKeyCredential, recipient.expiringProfileKeyCredential)
|
||||
&& Objects.equals(profile, recipient.profile);
|
||||
&& Objects.equals(profile, recipient.profile)
|
||||
&& pniSignatureVerified == recipient.pniSignatureVerified;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(recipientId, address, contact, profileKey, expiringProfileKeyCredential, profile);
|
||||
return Objects.hash(recipientId,
|
||||
address,
|
||||
contact,
|
||||
profileKey,
|
||||
expiringProfileKeyCredential,
|
||||
profile,
|
||||
pniSignatureVerified);
|
||||
}
|
||||
|
||||
public static final class Builder {
|
||||
@ -145,6 +164,7 @@ public class Recipient {
|
||||
private Profile profile;
|
||||
private Boolean discoverable;
|
||||
private Long unregisteredTimestamp;
|
||||
private boolean pniSignatureVerified;
|
||||
private byte[] storageRecord;
|
||||
|
||||
private Builder() {
|
||||
@ -190,6 +210,11 @@ public class Recipient {
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder withPniSignatureVerified(final boolean val) {
|
||||
pniSignatureVerified = val;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder withStorageRecord(final byte[] val) {
|
||||
storageRecord = val;
|
||||
return this;
|
||||
|
||||
@ -54,14 +54,16 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
|
||||
|
||||
private static final int MAX_RECIPIENT_CACHE_SIZE = 2000;
|
||||
|
||||
private final Map<ServiceId, RecipientWithAddress> recipientAddressCache = Collections.synchronizedMap(
|
||||
new LinkedHashMap<>(16, 0.75f, true) {
|
||||
private final Map<ServiceId, RecipientWithAddress> recipientAddressCache = Collections.synchronizedMap(new LinkedHashMap<>(
|
||||
16,
|
||||
0.75f,
|
||||
true) {
|
||||
|
||||
@Override
|
||||
protected boolean removeEldestEntry(Map.Entry<ServiceId, RecipientWithAddress> eldest) {
|
||||
return size() > MAX_RECIPIENT_CACHE_SIZE;
|
||||
}
|
||||
});
|
||||
@Override
|
||||
protected boolean removeEldestEntry(Map.Entry<ServiceId, RecipientWithAddress> eldest) {
|
||||
return size() > MAX_RECIPIENT_CACHE_SIZE;
|
||||
}
|
||||
});
|
||||
|
||||
public static void createSql(Connection connection) throws SQLException {
|
||||
// When modifying the CREATE statement here, also add a migration in AccountDatabase.java
|
||||
@ -80,6 +82,7 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
|
||||
profile_key BLOB,
|
||||
profile_key_credential BLOB,
|
||||
needs_pni_signature INTEGER NOT NULL DEFAULT FALSE,
|
||||
pni_signature_verified INTEGER NOT NULL DEFAULT FALSE,
|
||||
|
||||
given_name TEXT,
|
||||
family_name TEXT,
|
||||
@ -93,6 +96,7 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
|
||||
expiration_time_version INTEGER DEFAULT 1 NOT NULL,
|
||||
mute_until INTEGER NOT NULL DEFAULT 0,
|
||||
blocked INTEGER NOT NULL DEFAULT FALSE,
|
||||
blocked_at INTEGER NOT NULL DEFAULT 0,
|
||||
archived INTEGER NOT NULL DEFAULT FALSE,
|
||||
profile_sharing INTEGER NOT NULL DEFAULT FALSE,
|
||||
hide_story INTEGER NOT NULL DEFAULT FALSE,
|
||||
@ -348,7 +352,7 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
|
||||
public List<Pair<RecipientId, Contact>> getContacts() {
|
||||
final var sql = (
|
||||
"""
|
||||
SELECT r._id, r.given_name, r.family_name, r.nick_name, r.nick_name_given_name, r.nick_name_family_name, r.note, r.expiration_time, r.expiration_time_version, r.mute_until, r.hide_story, r.profile_sharing, r.color, r.blocked, r.archived, r.hidden, r.unregistered_timestamp
|
||||
SELECT r._id, r.given_name, r.family_name, r.nick_name, r.nick_name_given_name, r.nick_name_family_name, r.note, r.expiration_time, r.expiration_time_version, r.mute_until, r.hide_story, r.profile_sharing, r.color, r.blocked, r.blocked_at, r.archived, r.hidden, r.unregistered_timestamp
|
||||
FROM %s r
|
||||
WHERE (r.number IS NOT NULL OR r.pni IS NOT NULL OR r.aci IS NOT NULL) AND %s AND r.hidden = FALSE
|
||||
"""
|
||||
@ -372,7 +376,8 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
|
||||
SELECT r._id,
|
||||
r.number, r.aci, r.pni, r.username,
|
||||
r.profile_key, r.profile_key_credential,
|
||||
r.given_name, r.family_name, r.nick_name, r.nick_name_given_name, r.nick_name_family_name, r.note, r.expiration_time, r.expiration_time_version, r.mute_until, r.hide_story, r.profile_sharing, r.color, r.blocked, r.archived, r.hidden, r.unregistered_timestamp,
|
||||
r.pni_signature_verified,
|
||||
r.given_name, r.family_name, r.nick_name, r.nick_name_given_name, r.nick_name_family_name, r.note, r.expiration_time, r.expiration_time_version, r.mute_until, r.hide_story, r.profile_sharing, r.color, r.blocked, r.blocked_at, r.archived, r.hidden, r.unregistered_timestamp,
|
||||
r.profile_last_update_timestamp, r.profile_given_name, r.profile_family_name, r.profile_about, r.profile_about_emoji, r.profile_avatar_url_path, r.profile_mobile_coin_address, r.profile_unidentified_access_mode, r.profile_capabilities, r.profile_phone_number_sharing,
|
||||
r.discoverable,
|
||||
r.storage_record
|
||||
@ -392,7 +397,8 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
|
||||
SELECT r._id,
|
||||
r.number, r.aci, r.pni, r.username,
|
||||
r.profile_key, r.profile_key_credential,
|
||||
r.given_name, r.family_name, r.nick_name, r.nick_name_given_name, r.nick_name_family_name, r.note, r.expiration_time, r.expiration_time_version, r.mute_until, r.hide_story, r.profile_sharing, r.color, r.blocked, r.archived, r.hidden, r.unregistered_timestamp,
|
||||
r.pni_signature_verified,
|
||||
r.given_name, r.family_name, r.nick_name, r.nick_name_given_name, r.nick_name_family_name, r.note, r.expiration_time, r.expiration_time_version, r.mute_until, r.hide_story, r.profile_sharing, r.color, r.blocked, r.blocked_at, r.archived, r.hidden, r.unregistered_timestamp,
|
||||
r.profile_last_update_timestamp, r.profile_given_name, r.profile_family_name, r.profile_about, r.profile_about_emoji, r.profile_avatar_url_path, r.profile_mobile_coin_address, r.profile_unidentified_access_mode, r.profile_capabilities, r.profile_phone_number_sharing,
|
||||
r.discoverable,
|
||||
r.storage_record
|
||||
@ -441,7 +447,8 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
|
||||
SELECT r._id,
|
||||
r.number, r.aci, r.pni, r.username,
|
||||
r.profile_key, r.profile_key_credential,
|
||||
r.given_name, r.family_name, r.nick_name, r.nick_name_given_name, r.nick_name_family_name, r.note, r.expiration_time, r.expiration_time_version, r.mute_until, r.hide_story, r.profile_sharing, r.color, r.blocked, r.archived, r.hidden, r.unregistered_timestamp,
|
||||
r.pni_signature_verified,
|
||||
r.given_name, r.family_name, r.nick_name, r.nick_name_given_name, r.nick_name_family_name, r.note, r.expiration_time, r.expiration_time_version, r.mute_until, r.hide_story, r.profile_sharing, r.color, r.blocked, r.blocked_at, r.archived, r.hidden, r.unregistered_timestamp,
|
||||
r.profile_last_update_timestamp, r.profile_given_name, r.profile_family_name, r.profile_about, r.profile_about_emoji, r.profile_avatar_url_path, r.profile_mobile_coin_address, r.profile_unidentified_access_mode, r.profile_capabilities, r.profile_phone_number_sharing,
|
||||
r.discoverable,
|
||||
r.storage_record
|
||||
@ -886,7 +893,7 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
|
||||
final var sql = (
|
||||
"""
|
||||
UPDATE %s
|
||||
SET given_name = ?, family_name = ?, nick_name = ?, expiration_time = ?, expiration_time_version = ?, mute_until = ?, hide_story = ?, profile_sharing = ?, color = ?, blocked = ?, archived = ?, unregistered_timestamp = ?, nick_name_given_name = ?, nick_name_family_name = ?, note = ?, hidden = ?
|
||||
SET given_name = ?, family_name = ?, nick_name = ?, expiration_time = ?, expiration_time_version = ?, mute_until = ?, hide_story = ?, profile_sharing = ?, color = ?, blocked = ?, blocked_at = ?, archived = ?, unregistered_timestamp = ?, nick_name_given_name = ?, nick_name_family_name = ?, note = ?, hidden = ?
|
||||
WHERE _id = ?
|
||||
"""
|
||||
).formatted(TABLE_RECIPIENT);
|
||||
@ -901,25 +908,49 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
|
||||
statement.setBoolean(8, contact != null && contact.isProfileSharingEnabled());
|
||||
statement.setString(9, contact == null ? null : contact.color());
|
||||
statement.setBoolean(10, contact != null && contact.isBlocked());
|
||||
statement.setBoolean(11, contact != null && contact.isArchived());
|
||||
statement.setLong(11, contact == null ? 0 : contact.blockedAt());
|
||||
statement.setBoolean(12, contact != null && contact.isArchived());
|
||||
if (contact == null || contact.unregisteredTimestamp() == null) {
|
||||
statement.setNull(12, Types.INTEGER);
|
||||
statement.setNull(13, Types.INTEGER);
|
||||
} else {
|
||||
statement.setLong(12, contact.unregisteredTimestamp());
|
||||
statement.setLong(13, contact.unregisteredTimestamp());
|
||||
}
|
||||
statement.setString(13, contact == null ? null : contact.nickNameGivenName());
|
||||
statement.setString(14, contact == null ? null : contact.nickNameFamilyName());
|
||||
statement.setString(15, contact == null ? null : contact.note());
|
||||
statement.setBoolean(16, contact != null && contact.isHidden());
|
||||
statement.setLong(17, recipientId.id());
|
||||
statement.setString(14, contact == null ? null : contact.nickNameGivenName());
|
||||
statement.setString(15, contact == null ? null : contact.nickNameFamilyName());
|
||||
statement.setString(16, contact == null ? null : contact.note());
|
||||
statement.setBoolean(17, contact != null && contact.isHidden());
|
||||
statement.setLong(18, recipientId.id());
|
||||
statement.executeUpdate();
|
||||
}
|
||||
if (contact != null && contact.unregisteredTimestamp() != null) {
|
||||
markUnregisteredAndSplitIfNecessary(connection, recipientId);
|
||||
markUnregisteredAndSplitIfNecessary(connection, recipientId, contact.unregisteredTimestamp());
|
||||
}
|
||||
rotateStorageId(connection, recipientId);
|
||||
}
|
||||
|
||||
public void splitForStorageSyncIfNecessary(final Connection connection, final ACI aci) throws SQLException {
|
||||
final var recipient = findByServiceId(connection, aci);
|
||||
if (recipient.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
final var recipientId = recipient.get().id();
|
||||
final var address = recipient.get().address();
|
||||
if (address.pni().isEmpty() && address.number().isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.debug("Splitting {} for storage sync", recipientId);
|
||||
final var splitAddress = new RecipientAddress(Optional.empty(),
|
||||
address.pni(),
|
||||
address.number(),
|
||||
Optional.empty());
|
||||
updateRecipientAddress(connection,
|
||||
recipientId,
|
||||
new RecipientAddress(address.aci(), Optional.empty(), Optional.empty(), address.username()));
|
||||
resolveRecipientTrusted(connection, splitAddress);
|
||||
}
|
||||
|
||||
public int removeStorageIdsFromLocalOnlyUnregisteredRecipients(
|
||||
final Connection connection,
|
||||
final Collection<StorageId> storageIds
|
||||
@ -961,6 +992,69 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
|
||||
}
|
||||
}
|
||||
|
||||
public void markPniSignatureVerified(final RecipientId recipientId) {
|
||||
logger.debug("Marking {} as pni signature verified", recipientId);
|
||||
try (final var connection = database.getConnection()) {
|
||||
connection.setAutoCommit(false);
|
||||
if (storePniSignatureVerified(connection, recipientId, true)) {
|
||||
rotateStorageId(connection, recipientId);
|
||||
}
|
||||
connection.commit();
|
||||
} catch (SQLException e) {
|
||||
throw new RuntimeException("Failed update recipient store", e);
|
||||
}
|
||||
}
|
||||
|
||||
public boolean storePniSignatureVerified(
|
||||
final Connection connection,
|
||||
final RecipientId recipientId,
|
||||
final boolean value
|
||||
) throws SQLException {
|
||||
return storePniSignatureVerified(connection, recipientId, value, false);
|
||||
}
|
||||
|
||||
private boolean storePniSignatureVerified(
|
||||
final Connection connection,
|
||||
final RecipientId recipientId,
|
||||
final boolean value,
|
||||
final boolean force
|
||||
) throws SQLException {
|
||||
if (!force && isPniSignatureVerified(connection, recipientId) == value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final var sql = (
|
||||
"""
|
||||
UPDATE %s
|
||||
SET pni_signature_verified = ?
|
||||
WHERE _id = ?
|
||||
"""
|
||||
).formatted(TABLE_RECIPIENT);
|
||||
try (final var statement = connection.prepareStatement(sql)) {
|
||||
statement.setBoolean(1, value);
|
||||
statement.setLong(2, recipientId.id());
|
||||
statement.executeUpdate();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private boolean isPniSignatureVerified(
|
||||
final Connection connection,
|
||||
final RecipientId recipientId
|
||||
) throws SQLException {
|
||||
final var sql = (
|
||||
"""
|
||||
SELECT pni_signature_verified
|
||||
FROM %s
|
||||
WHERE _id = ?
|
||||
"""
|
||||
).formatted(TABLE_RECIPIENT);
|
||||
try (final var statement = connection.prepareStatement(sql)) {
|
||||
statement.setLong(1, recipientId.id());
|
||||
return Utils.executeQuerySingleRow(statement, resultSet -> resultSet.getBoolean("pni_signature_verified"));
|
||||
}
|
||||
}
|
||||
|
||||
public boolean needsPniSignature(final RecipientId recipientId) {
|
||||
try (final var connection = database.getConnection()) {
|
||||
final var sql = (
|
||||
@ -992,7 +1086,7 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
|
||||
if (recipientAddress.get().address().aci().isEmpty() || (
|
||||
contact != null && contact.unregisteredTimestamp() != null
|
||||
)) {
|
||||
markUnregisteredAndSplitIfNecessary(connection, recipientId);
|
||||
markUnregisteredAndSplitIfNecessary(connection, recipientId, System.currentTimeMillis());
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1026,7 +1120,7 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
|
||||
if (registered) {
|
||||
markRegistered(connection, recipientId);
|
||||
} else {
|
||||
markUnregisteredAndSplitIfNecessary(connection, recipientId);
|
||||
markUnregisteredAndSplitIfNecessary(connection, recipientId, System.currentTimeMillis());
|
||||
}
|
||||
connection.commit();
|
||||
} catch (SQLException e) {
|
||||
@ -1036,9 +1130,10 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
|
||||
|
||||
private void markUnregisteredAndSplitIfNecessary(
|
||||
final Connection connection,
|
||||
final RecipientId recipientId
|
||||
final RecipientId recipientId,
|
||||
final long unregisteredTimestamp
|
||||
) throws SQLException {
|
||||
markUnregistered(connection, recipientId);
|
||||
markUnregistered(connection, recipientId, unregisteredTimestamp);
|
||||
final var address = resolveRecipientAddress(connection, recipientId);
|
||||
final var needSplit = address.aci().isPresent() && address.pni().isPresent();
|
||||
logger.trace("Marking unregistered recipient {} as unregistered (and split={}): {}",
|
||||
@ -1085,7 +1180,11 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
|
||||
}
|
||||
}
|
||||
|
||||
private void markUnregistered(final Connection connection, final RecipientId recipientId) throws SQLException {
|
||||
private void markUnregistered(
|
||||
final Connection connection,
|
||||
final RecipientId recipientId,
|
||||
final long unregisteredTimestamp
|
||||
) throws SQLException {
|
||||
final var sql = (
|
||||
"""
|
||||
UPDATE %s
|
||||
@ -1094,7 +1193,7 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
|
||||
"""
|
||||
).formatted(TABLE_RECIPIENT);
|
||||
try (final var statement = connection.prepareStatement(sql)) {
|
||||
statement.setLong(1, System.currentTimeMillis());
|
||||
statement.setLong(1, unregisteredTimestamp);
|
||||
statement.setLong(2, recipientId.id());
|
||||
statement.executeUpdate();
|
||||
}
|
||||
@ -1255,6 +1354,9 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
|
||||
final List<RecipientId> toBeMergedRecipientIds
|
||||
) throws SQLException {
|
||||
for (final var toBeMergedRecipientId : toBeMergedRecipientIds) {
|
||||
if (isPniSignatureVerified(connection, toBeMergedRecipientId)) {
|
||||
storePniSignatureVerified(connection, recipientId, true, true);
|
||||
}
|
||||
recipientMergeHandler.mergeRecipients(connection, recipientId, toBeMergedRecipientId);
|
||||
deleteRecipient(connection, toBeMergedRecipientId);
|
||||
recipientAddressCache.entrySet().removeIf(e -> e.getValue().id().equals(toBeMergedRecipientId));
|
||||
@ -1349,7 +1451,12 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
|
||||
final var sql = (
|
||||
"""
|
||||
UPDATE %s
|
||||
SET number = NULL, aci = NULL, pni = NULL, username = NULL, storage_id = NULL
|
||||
SET number = NULL,
|
||||
aci = NULL,
|
||||
pni = NULL,
|
||||
username = NULL,
|
||||
storage_id = NULL,
|
||||
pni_signature_verified = FALSE
|
||||
WHERE _id = ?
|
||||
"""
|
||||
).formatted(TABLE_RECIPIENT);
|
||||
@ -1365,10 +1472,18 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
|
||||
final RecipientAddress address
|
||||
) throws SQLException {
|
||||
recipientAddressCache.entrySet().removeIf(e -> e.getValue().id().equals(recipientId));
|
||||
final var existingAddress = resolveRecipientAddress(connection, recipientId);
|
||||
final var keepPniSignatureVerified = Objects.equals(existingAddress.aci(), address.aci()) && Objects.equals(
|
||||
existingAddress.pni(),
|
||||
address.pni());
|
||||
final var sql = (
|
||||
"""
|
||||
UPDATE %s
|
||||
SET number = ?, aci = ?, pni = ?, username = ?
|
||||
SET number = ?,
|
||||
aci = ?,
|
||||
pni = ?,
|
||||
username = ?,
|
||||
pni_signature_verified = ?
|
||||
WHERE _id = ?
|
||||
"""
|
||||
).formatted(TABLE_RECIPIENT);
|
||||
@ -1377,7 +1492,8 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
|
||||
statement.setString(2, address.aci().map(ACI::toString).orElse(null));
|
||||
statement.setString(3, address.pni().map(PNI::toString).orElse(null));
|
||||
statement.setString(4, address.username().orElse(null));
|
||||
statement.setLong(5, recipientId.id());
|
||||
statement.setBoolean(5, keepPniSignatureVerified && isPniSignatureVerified(connection, recipientId));
|
||||
statement.setLong(6, recipientId.id());
|
||||
statement.executeUpdate();
|
||||
}
|
||||
rotateStorageId(connection, recipientId);
|
||||
@ -1402,9 +1518,14 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
|
||||
RecipientId toBeMergedRecipientId
|
||||
) throws SQLException {
|
||||
final var contact = getContact(connection, recipientId);
|
||||
final var toBeMergedContact = getContact(connection, toBeMergedRecipientId);
|
||||
if (contact == null) {
|
||||
final var toBeMergedContact = getContact(connection, toBeMergedRecipientId);
|
||||
storeContact(connection, recipientId, toBeMergedContact);
|
||||
} else if (toBeMergedContact != null) {
|
||||
final var mergedContact = mergeContacts(contact, toBeMergedContact);
|
||||
if (!contact.equals(mergedContact)) {
|
||||
storeContact(connection, recipientId, mergedContact);
|
||||
}
|
||||
}
|
||||
|
||||
final var profileKey = getProfileKey(connection, recipientId);
|
||||
@ -1429,6 +1550,24 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
|
||||
recipientsMerged.put(toBeMergedRecipientId.id(), recipientId.id());
|
||||
}
|
||||
|
||||
static Contact mergeContacts(final Contact primary, final Contact secondary) {
|
||||
final var profileSharingEnabled = primary.isProfileSharingEnabled() || secondary.isProfileSharingEnabled();
|
||||
return Contact.newBuilder(primary)
|
||||
.withGivenName(secondary.givenName())
|
||||
.withFamilyName(secondary.familyName())
|
||||
.withMessageExpirationTime(primary.messageExpirationTime() > 0
|
||||
? primary.messageExpirationTime()
|
||||
: secondary.messageExpirationTime())
|
||||
.withMessageExpirationTimeVersion(Math.max(primary.messageExpirationTimeVersion(),
|
||||
secondary.messageExpirationTimeVersion()))
|
||||
.withMuteUntil(primary.muteUntil() > 0 ? primary.muteUntil() : secondary.muteUntil())
|
||||
.withIsBlocked(primary.isBlocked() || secondary.isBlocked())
|
||||
.withBlockedAt(Math.max(primary.blockedAt(), secondary.blockedAt()))
|
||||
.withIsProfileSharingEnabled(profileSharingEnabled)
|
||||
.withIsHidden(profileSharingEnabled ? false : primary.isHidden())
|
||||
.build();
|
||||
}
|
||||
|
||||
private Optional<RecipientWithAddress> findByNumber(
|
||||
final Connection connection,
|
||||
final String number
|
||||
@ -1508,7 +1647,7 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
|
||||
private Contact getContact(final Connection connection, final RecipientId recipientId) throws SQLException {
|
||||
final var sql = (
|
||||
"""
|
||||
SELECT r.given_name, r.family_name, r.nick_name, r.nick_name_given_name, r.nick_name_family_name, r.note, r.expiration_time, r.expiration_time_version, r.mute_until, r.hide_story, r.profile_sharing, r.color, r.blocked, r.archived, r.hidden, r.unregistered_timestamp
|
||||
SELECT r.given_name, r.family_name, r.nick_name, r.nick_name_given_name, r.nick_name_family_name, r.note, r.expiration_time, r.expiration_time_version, r.mute_until, r.hide_story, r.profile_sharing, r.color, r.blocked, r.blocked_at, r.archived, r.hidden, r.unregistered_timestamp
|
||||
FROM %s r
|
||||
WHERE r._id = ? AND (%s)
|
||||
"""
|
||||
@ -1595,6 +1734,7 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
|
||||
getProfileFromResultSet(resultSet),
|
||||
getDiscoverableFromResultSet(resultSet),
|
||||
getUnregisteredTimestampFromResultSet(resultSet),
|
||||
resultSet.getBoolean("pni_signature_verified"),
|
||||
getStorageRecordFromResultSet(resultSet));
|
||||
}
|
||||
|
||||
@ -1612,6 +1752,7 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
|
||||
resultSet.getLong("mute_until"),
|
||||
resultSet.getBoolean("hide_story"),
|
||||
resultSet.getBoolean("blocked"),
|
||||
resultSet.getLong("blocked_at"),
|
||||
resultSet.getBoolean("archived"),
|
||||
resultSet.getBoolean("profile_sharing"),
|
||||
resultSet.getBoolean("hidden"),
|
||||
|
||||
@ -410,7 +410,7 @@ public class SessionStore implements SignalServiceSessionStore {
|
||||
}
|
||||
|
||||
private static boolean isActive(SessionRecord record) {
|
||||
return record != null && record.hasSenderChain(0.0);
|
||||
return record != null && record.hasSenderChain();
|
||||
}
|
||||
|
||||
record Key(String address, int deviceId) {}
|
||||
|
||||
@ -1,10 +1,29 @@
|
||||
package org.asamk.signal.manager.storage.stickers;
|
||||
|
||||
import org.asamk.signal.manager.api.StickerPackId;
|
||||
import org.whispersystems.signalservice.api.storage.StorageId;
|
||||
|
||||
public record StickerPack(long internalId, StickerPackId packId, byte[] packKey, boolean isInstalled) {
|
||||
public record StickerPack(
|
||||
long internalId,
|
||||
StickerPackId packId,
|
||||
byte[] packKey,
|
||||
boolean isInstalled,
|
||||
int position,
|
||||
long deletedTimestamp,
|
||||
StorageId storageId,
|
||||
byte[] storageRecord
|
||||
) {
|
||||
|
||||
public StickerPack(
|
||||
final long internalId,
|
||||
final StickerPackId packId,
|
||||
final byte[] packKey,
|
||||
final boolean isInstalled
|
||||
) {
|
||||
this(internalId, packId, packKey, isInstalled, 0, 0, null, null);
|
||||
}
|
||||
|
||||
public StickerPack(final StickerPackId packId, final byte[] packKey) {
|
||||
this(-1, packId, packKey, false);
|
||||
this(-1, packId, packKey, false, 0, 0, null, null);
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,14 +3,19 @@ package org.asamk.signal.manager.storage.stickers;
|
||||
import org.asamk.signal.manager.api.StickerPackId;
|
||||
import org.asamk.signal.manager.storage.Database;
|
||||
import org.asamk.signal.manager.storage.Utils;
|
||||
import org.asamk.signal.manager.util.KeyUtils;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.whispersystems.signalservice.api.storage.SignalStickerPackRecord;
|
||||
import org.whispersystems.signalservice.api.storage.StorageId;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Types;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
public class StickerStore {
|
||||
|
||||
@ -27,7 +32,11 @@ public class StickerStore {
|
||||
_id INTEGER PRIMARY KEY,
|
||||
pack_id BLOB UNIQUE NOT NULL,
|
||||
pack_key BLOB NOT NULL,
|
||||
installed INTEGER NOT NULL DEFAULT FALSE
|
||||
installed INTEGER NOT NULL DEFAULT FALSE,
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
deleted_timestamp INTEGER NOT NULL DEFAULT 0,
|
||||
storage_id BLOB UNIQUE,
|
||||
storage_record BLOB
|
||||
) STRICT;
|
||||
""");
|
||||
}
|
||||
@ -38,55 +47,103 @@ public class StickerStore {
|
||||
}
|
||||
|
||||
public List<StickerPack> getStickerPacks() {
|
||||
final var sql = (
|
||||
"""
|
||||
SELECT s._id, s.pack_id, s.pack_key, s.installed
|
||||
FROM %s s
|
||||
"""
|
||||
).formatted(TABLE_STICKER);
|
||||
try (final var connection = database.getConnection()) {
|
||||
try (final var statement = connection.prepareStatement(sql)) {
|
||||
try (var result = Utils.executeQueryForStream(statement, this::getStickerPackFromResultSet)) {
|
||||
return result.toList();
|
||||
}
|
||||
}
|
||||
return getStickerPacks(connection);
|
||||
} catch (SQLException e) {
|
||||
throw new RuntimeException("Failed read from sticker store", e);
|
||||
}
|
||||
}
|
||||
|
||||
public StickerPack getStickerPack(StickerPackId packId) {
|
||||
public List<StickerPack> getStickerPacks(final Connection connection) throws SQLException {
|
||||
final var sql = (
|
||||
"""
|
||||
SELECT s._id, s.pack_id, s.pack_key, s.installed
|
||||
SELECT s._id, s.pack_id, s.pack_key, s.installed, s.position, s.deleted_timestamp, s.storage_id, s.storage_record
|
||||
FROM %s s
|
||||
"""
|
||||
).formatted(TABLE_STICKER);
|
||||
try (final var statement = connection.prepareStatement(sql)) {
|
||||
try (var result = Utils.executeQueryForStream(statement, this::getStickerPackFromResultSet)) {
|
||||
return result.toList();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public StickerPack getStickerPack(StickerPackId packId) {
|
||||
try (final var connection = database.getConnection()) {
|
||||
return getStickerPack(connection, packId);
|
||||
} catch (SQLException e) {
|
||||
throw new RuntimeException("Failed read from sticker store", e);
|
||||
}
|
||||
}
|
||||
|
||||
public StickerPack getStickerPack(Connection connection, StickerPackId packId) throws SQLException {
|
||||
final var sql = (
|
||||
"""
|
||||
SELECT s._id, s.pack_id, s.pack_key, s.installed, s.position, s.deleted_timestamp, s.storage_id, s.storage_record
|
||||
FROM %s s
|
||||
WHERE s.pack_id = ?
|
||||
"""
|
||||
).formatted(TABLE_STICKER);
|
||||
try (final var connection = database.getConnection()) {
|
||||
try (final var statement = connection.prepareStatement(sql)) {
|
||||
statement.setBytes(1, packId.serialize());
|
||||
return Utils.executeQueryForOptional(statement, this::getStickerPackFromResultSet).orElse(null);
|
||||
}
|
||||
} catch (SQLException e) {
|
||||
throw new RuntimeException("Failed read from sticker store", e);
|
||||
try (final var statement = connection.prepareStatement(sql)) {
|
||||
statement.setBytes(1, packId.serialize());
|
||||
return Utils.executeQueryForOptional(statement, this::getStickerPackFromResultSet).orElse(null);
|
||||
}
|
||||
}
|
||||
|
||||
public StickerPack getStickerPack(Connection connection, StorageId storageId) throws SQLException {
|
||||
final var sql = (
|
||||
"""
|
||||
SELECT s._id, s.pack_id, s.pack_key, s.installed, s.position, s.deleted_timestamp, s.storage_id, s.storage_record
|
||||
FROM %s s
|
||||
WHERE s.storage_id = ?
|
||||
"""
|
||||
).formatted(TABLE_STICKER);
|
||||
try (final var statement = connection.prepareStatement(sql)) {
|
||||
statement.setBytes(1, storageId.getRaw());
|
||||
return Utils.executeQueryForOptional(statement, this::getStickerPackFromResultSet).orElse(null);
|
||||
}
|
||||
}
|
||||
|
||||
public void addStickerPack(StickerPack stickerPack) {
|
||||
final var sql = (
|
||||
"""
|
||||
INSERT INTO %s (pack_id, pack_key, installed)
|
||||
VALUES (?, ?, ?)
|
||||
INSERT INTO %s (pack_id, pack_key, installed, position, deleted_timestamp, storage_id, storage_record)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
"""
|
||||
).formatted(TABLE_STICKER);
|
||||
try (final var connection = database.getConnection()) {
|
||||
connection.setAutoCommit(false);
|
||||
var storageId = stickerPack.storageId();
|
||||
if (storageId == null && (stickerPack.isInstalled() || stickerPack.deletedTimestamp() > 0)) {
|
||||
storageId = StorageId.forStickerPack(KeyUtils.createRawStorageId());
|
||||
}
|
||||
|
||||
final var position = stickerPack.isInstalled() ? Math.max(stickerPack.position(),
|
||||
getNextPosition(connection)) : 0;
|
||||
var deletedTimestamp = stickerPack.deletedTimestamp();
|
||||
if (!stickerPack.isInstalled() && deletedTimestamp == 0 && storageId != null) {
|
||||
deletedTimestamp = System.currentTimeMillis();
|
||||
}
|
||||
|
||||
try (final var statement = connection.prepareStatement(sql)) {
|
||||
statement.setBytes(1, stickerPack.packId().serialize());
|
||||
statement.setBytes(2, stickerPack.packKey());
|
||||
statement.setBoolean(3, stickerPack.isInstalled());
|
||||
statement.setInt(4, position);
|
||||
statement.setLong(5, deletedTimestamp);
|
||||
if (storageId == null) {
|
||||
statement.setNull(6, Types.BLOB);
|
||||
} else {
|
||||
statement.setBytes(6, storageId.getRaw());
|
||||
}
|
||||
if (stickerPack.storageRecord() == null) {
|
||||
statement.setNull(7, Types.BLOB);
|
||||
} else {
|
||||
statement.setBytes(7, stickerPack.storageRecord());
|
||||
}
|
||||
statement.executeUpdate();
|
||||
}
|
||||
connection.commit();
|
||||
} catch (SQLException e) {
|
||||
throw new RuntimeException("Failed update sticker store", e);
|
||||
}
|
||||
@ -96,28 +153,279 @@ public class StickerStore {
|
||||
final var sql = (
|
||||
"""
|
||||
UPDATE %s
|
||||
SET installed = ?
|
||||
SET installed = ?, position = ?, deleted_timestamp = ?, storage_id = ?
|
||||
WHERE pack_id = ?
|
||||
"""
|
||||
).formatted(TABLE_STICKER);
|
||||
try (final var connection = database.getConnection()) {
|
||||
connection.setAutoCommit(false);
|
||||
final var existing = getStickerPack(connection, stickerPackId);
|
||||
if (existing == null || existing.isInstalled() == installed) {
|
||||
connection.commit();
|
||||
return;
|
||||
}
|
||||
|
||||
final var newStorageId = StorageId.forStickerPack(KeyUtils.createRawStorageId());
|
||||
final var position = installed ? getNextPosition(connection) : 0;
|
||||
final var deletedTimestamp = installed ? 0 : System.currentTimeMillis();
|
||||
|
||||
try (final var statement = connection.prepareStatement(sql)) {
|
||||
statement.setBytes(1, stickerPackId.serialize());
|
||||
statement.setBoolean(2, installed);
|
||||
statement.setBoolean(1, installed);
|
||||
statement.setInt(2, position);
|
||||
statement.setLong(3, deletedTimestamp);
|
||||
statement.setBytes(4, newStorageId.getRaw());
|
||||
statement.setBytes(5, stickerPackId.serialize());
|
||||
statement.executeUpdate();
|
||||
}
|
||||
connection.commit();
|
||||
} catch (SQLException e) {
|
||||
throw new RuntimeException("Failed update sticker store", e);
|
||||
}
|
||||
}
|
||||
|
||||
public List<StorageId> getStorageIds(final Connection connection) throws SQLException {
|
||||
final var sql = (
|
||||
"""
|
||||
SELECT s.storage_id
|
||||
FROM %s s
|
||||
WHERE s.storage_id IS NOT NULL
|
||||
"""
|
||||
).formatted(TABLE_STICKER);
|
||||
try (final var statement = connection.prepareStatement(sql)) {
|
||||
return Utils.executeQueryForStream(statement, this::getStorageIdFromResultSet).toList();
|
||||
}
|
||||
}
|
||||
|
||||
public void updateStorageId(
|
||||
final Connection connection,
|
||||
final StickerPackId packId,
|
||||
final StorageId storageId
|
||||
) throws SQLException {
|
||||
final var sql = (
|
||||
"""
|
||||
UPDATE %s
|
||||
SET storage_id = ?
|
||||
WHERE pack_id = ?
|
||||
"""
|
||||
).formatted(TABLE_STICKER);
|
||||
try (final var statement = connection.prepareStatement(sql)) {
|
||||
statement.setBytes(1, storageId.getRaw());
|
||||
statement.setBytes(2, packId.serialize());
|
||||
statement.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
public void updateStorageIds(
|
||||
final Connection connection,
|
||||
final Map<StickerPackId, StorageId> storageIdMap
|
||||
) throws SQLException {
|
||||
final var sql = (
|
||||
"""
|
||||
UPDATE %s
|
||||
SET storage_id = ?
|
||||
WHERE pack_id = ?
|
||||
"""
|
||||
).formatted(TABLE_STICKER);
|
||||
try (final var statement = connection.prepareStatement(sql)) {
|
||||
for (final var entry : storageIdMap.entrySet()) {
|
||||
statement.setBytes(1, entry.getValue().getRaw());
|
||||
statement.setBytes(2, entry.getKey().serialize());
|
||||
statement.executeUpdate();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public StorageId getStorageId(final Connection connection, final StickerPackId packId) throws SQLException {
|
||||
final var sql = (
|
||||
"""
|
||||
SELECT s.storage_id
|
||||
FROM %s s
|
||||
WHERE s.pack_id = ? AND s.storage_id IS NOT NULL
|
||||
"""
|
||||
).formatted(TABLE_STICKER);
|
||||
try (final var statement = connection.prepareStatement(sql)) {
|
||||
statement.setBytes(1, packId.serialize());
|
||||
final var storageId = Utils.executeQueryForOptional(statement, this::getStorageIdFromResultSet);
|
||||
if (storageId.isPresent()) {
|
||||
return storageId.get();
|
||||
}
|
||||
}
|
||||
|
||||
final var newStorageId = StorageId.forStickerPack(KeyUtils.createRawStorageId());
|
||||
updateStorageId(connection, packId, newStorageId);
|
||||
return newStorageId;
|
||||
}
|
||||
|
||||
public void storeStorageRecord(
|
||||
final Connection connection,
|
||||
final StickerPackId packId,
|
||||
final StorageId storageId,
|
||||
final byte[] storageRecord
|
||||
) throws SQLException {
|
||||
final var clearSql = (
|
||||
"""
|
||||
UPDATE %s
|
||||
SET storage_id = NULL
|
||||
WHERE storage_id = ?
|
||||
"""
|
||||
).formatted(TABLE_STICKER);
|
||||
try (final var statement = connection.prepareStatement(clearSql)) {
|
||||
statement.setBytes(1, storageId.getRaw());
|
||||
statement.executeUpdate();
|
||||
}
|
||||
|
||||
final var updateSql = (
|
||||
"""
|
||||
UPDATE %s
|
||||
SET storage_id = ?, storage_record = ?
|
||||
WHERE pack_id = ?
|
||||
"""
|
||||
).formatted(TABLE_STICKER);
|
||||
try (final var statement = connection.prepareStatement(updateSql)) {
|
||||
statement.setBytes(1, storageId.getRaw());
|
||||
if (storageRecord == null) {
|
||||
statement.setNull(2, Types.BLOB);
|
||||
} else {
|
||||
statement.setBytes(2, storageRecord);
|
||||
}
|
||||
statement.setBytes(3, packId.serialize());
|
||||
statement.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
public void setMissingStorageIds() {
|
||||
final var selectSql = (
|
||||
"""
|
||||
SELECT s.pack_id
|
||||
FROM %s s
|
||||
WHERE s.storage_id IS NULL AND s.installed = TRUE
|
||||
"""
|
||||
).formatted(TABLE_STICKER);
|
||||
final var updateSql = (
|
||||
"""
|
||||
UPDATE %s
|
||||
SET storage_id = ?
|
||||
WHERE pack_id = ?
|
||||
"""
|
||||
).formatted(TABLE_STICKER);
|
||||
|
||||
try (final var connection = database.getConnection()) {
|
||||
connection.setAutoCommit(false);
|
||||
try (final var selectStatement = connection.prepareStatement(selectSql)) {
|
||||
final var packIds = Utils.executeQueryForStream(selectStatement,
|
||||
resultSet -> StickerPackId.deserialize(resultSet.getBytes("pack_id"))).toList();
|
||||
try (final var updateStatement = connection.prepareStatement(updateSql)) {
|
||||
for (final var packId : packIds) {
|
||||
updateStatement.setBytes(1, KeyUtils.createRawStorageId());
|
||||
updateStatement.setBytes(2, packId.serialize());
|
||||
updateStatement.executeUpdate();
|
||||
}
|
||||
}
|
||||
}
|
||||
connection.commit();
|
||||
} catch (SQLException e) {
|
||||
throw new RuntimeException("Failed update sticker store", e);
|
||||
}
|
||||
}
|
||||
|
||||
public int removeStorageIdsFromLocalOnlyDeletedStickerPacks(
|
||||
final Connection connection,
|
||||
final Collection<StorageId> storageIds
|
||||
) throws SQLException {
|
||||
final var sql = (
|
||||
"""
|
||||
UPDATE %s
|
||||
SET storage_id = NULL
|
||||
WHERE storage_id = ? AND installed = FALSE AND deleted_timestamp > 0
|
||||
"""
|
||||
).formatted(TABLE_STICKER);
|
||||
var count = 0;
|
||||
try (final var statement = connection.prepareStatement(sql)) {
|
||||
for (final var storageId : storageIds) {
|
||||
statement.setBytes(1, storageId.getRaw());
|
||||
count += statement.executeUpdate();
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
public void upsertFromStorageSync(
|
||||
final Connection connection,
|
||||
final SignalStickerPackRecord record
|
||||
) throws SQLException {
|
||||
final var remote = record.getProto();
|
||||
final var packId = StickerPackId.deserialize(remote.packId.toByteArray());
|
||||
final var deleted = remote.deletedAtTimestamp > 0;
|
||||
final var packKey = remote.packKey.toByteArray();
|
||||
final var storageRecord = remote.encode();
|
||||
|
||||
final var current = getStickerPack(connection, packId);
|
||||
|
||||
if (current == null) {
|
||||
final var insertSql = (
|
||||
"""
|
||||
INSERT INTO %s (pack_id, pack_key, installed, position, deleted_timestamp, storage_id, storage_record)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
"""
|
||||
).formatted(TABLE_STICKER);
|
||||
try (final var statement = connection.prepareStatement(insertSql)) {
|
||||
statement.setBytes(1, packId.serialize());
|
||||
statement.setBytes(2, packKey);
|
||||
statement.setBoolean(3, !deleted);
|
||||
statement.setInt(4, deleted ? 0 : remote.position);
|
||||
statement.setLong(5, remote.deletedAtTimestamp);
|
||||
statement.setBytes(6, record.getId().getRaw());
|
||||
statement.setBytes(7, storageRecord);
|
||||
statement.executeUpdate();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (packKey.length > 0) {
|
||||
final var updateSql = (
|
||||
"""
|
||||
UPDATE %s
|
||||
SET pack_key = ?, installed = ?, position = ?, deleted_timestamp = ?, storage_id = ?, storage_record = ?
|
||||
WHERE pack_id = ?
|
||||
"""
|
||||
).formatted(TABLE_STICKER);
|
||||
try (final var statement = connection.prepareStatement(updateSql)) {
|
||||
statement.setBytes(1, packKey);
|
||||
statement.setBoolean(2, !deleted);
|
||||
statement.setInt(3, deleted ? 0 : remote.position);
|
||||
statement.setLong(4, remote.deletedAtTimestamp);
|
||||
statement.setBytes(5, record.getId().getRaw());
|
||||
statement.setBytes(6, storageRecord);
|
||||
statement.setBytes(7, packId.serialize());
|
||||
statement.executeUpdate();
|
||||
}
|
||||
} else {
|
||||
final var updateSql = (
|
||||
"""
|
||||
UPDATE %s
|
||||
SET installed = ?, position = ?, deleted_timestamp = ?, storage_id = ?, storage_record = ?
|
||||
WHERE pack_id = ?
|
||||
"""
|
||||
).formatted(TABLE_STICKER);
|
||||
try (final var statement = connection.prepareStatement(updateSql)) {
|
||||
statement.setBoolean(1, !deleted);
|
||||
statement.setInt(2, deleted ? 0 : remote.position);
|
||||
statement.setLong(3, remote.deletedAtTimestamp);
|
||||
statement.setBytes(4, record.getId().getRaw());
|
||||
statement.setBytes(5, storageRecord);
|
||||
statement.setBytes(6, packId.serialize());
|
||||
statement.executeUpdate();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void addLegacyStickers(Collection<StickerPack> stickerPacks) {
|
||||
logger.debug("Migrating legacy stickers to database");
|
||||
long start = System.nanoTime();
|
||||
final var sql = (
|
||||
"""
|
||||
INSERT INTO %s (pack_id, pack_key, installed)
|
||||
VALUES (?, ?, ?)
|
||||
INSERT INTO %s (pack_id, pack_key, installed, position, deleted_timestamp, storage_id, storage_record)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
"""
|
||||
).formatted(TABLE_STICKER);
|
||||
try (final var connection = database.getConnection()) {
|
||||
@ -125,11 +433,23 @@ public class StickerStore {
|
||||
try (final var statement = connection.prepareStatement("DELETE FROM %s".formatted(TABLE_STICKER))) {
|
||||
statement.executeUpdate();
|
||||
}
|
||||
var installedPosition = 0;
|
||||
try (final var statement = connection.prepareStatement(sql)) {
|
||||
for (final var sticker : stickerPacks) {
|
||||
final var storageId = sticker.isInstalled()
|
||||
? StorageId.forStickerPack(KeyUtils.createRawStorageId())
|
||||
: null;
|
||||
statement.setBytes(1, sticker.packId().serialize());
|
||||
statement.setBytes(2, sticker.packKey());
|
||||
statement.setBoolean(3, sticker.isInstalled());
|
||||
statement.setInt(4, sticker.isInstalled() ? installedPosition++ : 0);
|
||||
statement.setLong(5, 0);
|
||||
if (storageId == null) {
|
||||
statement.setNull(6, Types.BLOB);
|
||||
} else {
|
||||
statement.setBytes(6, storageId.getRaw());
|
||||
}
|
||||
statement.setNull(7, Types.BLOB);
|
||||
statement.executeUpdate();
|
||||
}
|
||||
}
|
||||
@ -145,6 +465,36 @@ public class StickerStore {
|
||||
final var packId = resultSet.getBytes("pack_id");
|
||||
final var packKey = resultSet.getBytes("pack_key");
|
||||
final var installed = resultSet.getBoolean("installed");
|
||||
return new StickerPack(internalId, StickerPackId.deserialize(packId), packKey, installed);
|
||||
final var position = resultSet.getInt("position");
|
||||
final var deletedTimestamp = resultSet.getLong("deleted_timestamp");
|
||||
final var storageIdBytes = resultSet.getBytes("storage_id");
|
||||
final var storageId = storageIdBytes == null ? null : StorageId.forStickerPack(storageIdBytes);
|
||||
final var storageRecord = resultSet.getBytes("storage_record");
|
||||
return new StickerPack(internalId,
|
||||
StickerPackId.deserialize(packId),
|
||||
packKey,
|
||||
installed,
|
||||
position,
|
||||
deletedTimestamp,
|
||||
storageId,
|
||||
storageRecord);
|
||||
}
|
||||
|
||||
private StorageId getStorageIdFromResultSet(final ResultSet resultSet) throws SQLException {
|
||||
final var storageId = resultSet.getBytes("storage_id");
|
||||
return StorageId.forStickerPack(storageId);
|
||||
}
|
||||
|
||||
private int getNextPosition(final Connection connection) throws SQLException {
|
||||
final var sql = (
|
||||
"""
|
||||
SELECT IFNULL(MAX(position) + 1, 0) AS next_position
|
||||
FROM %s
|
||||
WHERE installed = TRUE
|
||||
"""
|
||||
).formatted(TABLE_STICKER);
|
||||
try (final var statement = connection.prepareStatement(sql)) {
|
||||
return Utils.executeQuerySingleRow(statement, resultSet -> resultSet.getInt("next_position"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -24,6 +24,7 @@ import org.whispersystems.signalservice.internal.storage.protos.ContactRecord.Id
|
||||
import java.sql.Connection;
|
||||
import java.sql.SQLException;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collection;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.regex.Pattern;
|
||||
@ -56,6 +57,29 @@ public class ContactRecordProcessor extends DefaultStorageRecordProcessor<Signal
|
||||
this.selfNumber = account.getNumber();
|
||||
}
|
||||
|
||||
public void prepare(final Collection<SignalContactRecord> remoteRecords) throws SQLException {
|
||||
for (final var remoteRecord : remoteRecords) {
|
||||
if (isInvalid(remoteRecord)) {
|
||||
continue;
|
||||
}
|
||||
final var remote = remoteRecord.getProto();
|
||||
final var aci = ACI.parseOrNull(remote.aci, remote.aciBinary);
|
||||
final var pni = PNI.parseOrNull(remote.pni, remote.pniBinary);
|
||||
if (shouldSplitForStorageSync(remote.unregisteredAtTimestamp, aci, pni, remote.e164)) {
|
||||
account.getRecipientStore().splitForStorageSyncIfNecessary(connection, aci);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static boolean shouldSplitForStorageSync(
|
||||
final long unregisteredAtTimestamp,
|
||||
final ACI aci,
|
||||
final PNI pni,
|
||||
final String e164
|
||||
) {
|
||||
return unregisteredAtTimestamp > 0 && aci != null && pni == null && e164.isEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
* Error cases:
|
||||
* - You can't have a contact record without an ACI or PNI.
|
||||
@ -205,6 +229,7 @@ public class ContactRecordProcessor extends DefaultStorageRecordProcessor<Signal
|
||||
.identityState(identityState)
|
||||
.identityKey(identityKey)
|
||||
.blocked(remote.blocked)
|
||||
.blockedAtTimestamp(remote.blockedAtTimestamp)
|
||||
.whitelisted(remote.whitelisted)
|
||||
.archived(remote.archived)
|
||||
.markedUnread(remote.markedUnread)
|
||||
@ -283,7 +308,9 @@ public class ContactRecordProcessor extends DefaultStorageRecordProcessor<Signal
|
||||
final var contactNickGivenName = contact == null ? null : contact.nickNameGivenName();
|
||||
final var contactNickFamilyName = contact == null ? null : contact.nickNameFamilyName();
|
||||
final var contactNote = contact == null ? null : contact.note();
|
||||
final var blockedAt = contact == null ? 0 : contact.blockedAt();
|
||||
if (blocked != contactProto.blocked
|
||||
|| blockedAt != contactProto.blockedAtTimestamp
|
||||
|| profileShared != contactProto.whitelisted
|
||||
|| archived != contactProto.archived
|
||||
|| hidden != contactProto.hidden
|
||||
@ -301,6 +328,7 @@ public class ContactRecordProcessor extends DefaultStorageRecordProcessor<Signal
|
||||
logger.debug("Storing new or updated contact {}", recipientId);
|
||||
final var contactBuilder = contact == null ? Contact.newBuilder() : Contact.newBuilder(contact);
|
||||
final var newContact = contactBuilder.withIsBlocked(contactProto.blocked)
|
||||
.withBlockedAt(contactProto.blocked ? contactProto.blockedAtTimestamp : 0)
|
||||
.withIsProfileSharingEnabled(contactProto.whitelisted)
|
||||
.withIsArchived(contactProto.archived)
|
||||
.withIsHidden(contactProto.hidden)
|
||||
@ -357,6 +385,8 @@ public class ContactRecordProcessor extends DefaultStorageRecordProcessor<Signal
|
||||
logger.warn("Received invalid contact identity key from storage");
|
||||
}
|
||||
}
|
||||
account.getRecipientStore()
|
||||
.storePniSignatureVerified(connection, recipientId, contactProto.pniSignatureVerified);
|
||||
account.getRecipientStore()
|
||||
.storeStorageRecord(connection, recipientId, contactRecord.getId(), contactProto.encode());
|
||||
}
|
||||
|
||||
@ -114,6 +114,7 @@ public final class GroupV1RecordProcessor extends DefaultStorageRecordProcessor<
|
||||
final var group = account.getGroupStore().getOrCreateGroupV1(connection, groupIdV1);
|
||||
if (group != null) {
|
||||
group.setBlocked(groupV1Proto.blocked);
|
||||
group.setBlockedAt(0);
|
||||
account.getGroupStore().updateGroup(connection, group);
|
||||
account.getGroupStore()
|
||||
.storeStorageRecord(connection, group.getGroupId(), groupV1Record.getId(), groupV1Proto.encode());
|
||||
|
||||
@ -56,6 +56,7 @@ public final class GroupV2RecordProcessor extends DefaultStorageRecordProcessor<
|
||||
final var mergedBuilder = remote.newBuilder()
|
||||
.masterKey(remote.masterKey)
|
||||
.blocked(remote.blocked)
|
||||
.blockedAtTimestamp(remote.blockedAtTimestamp)
|
||||
.whitelisted(remote.whitelisted)
|
||||
.archived(remote.archived)
|
||||
.markedUnread(remote.markedUnread)
|
||||
@ -93,6 +94,7 @@ public final class GroupV2RecordProcessor extends DefaultStorageRecordProcessor<
|
||||
|
||||
final var group = account.getGroupStore().getGroupOrPartialMigrate(connection, groupMasterKey);
|
||||
group.setBlocked(groupV2Proto.blocked);
|
||||
group.setBlockedAt(groupV2Proto.blocked ? groupV2Proto.blockedAtTimestamp : 0);
|
||||
group.setProfileSharingEnabled(groupV2Proto.whitelisted);
|
||||
account.getGroupStore().updateGroup(connection, group);
|
||||
account.getGroupStore()
|
||||
|
||||
@ -0,0 +1,86 @@
|
||||
package org.asamk.signal.manager.syncStorage;
|
||||
|
||||
import org.asamk.signal.manager.api.StickerPackId;
|
||||
import org.asamk.signal.manager.storage.SignalAccount;
|
||||
import org.asamk.signal.manager.util.KeyUtils;
|
||||
import org.whispersystems.signalservice.api.storage.SignalStickerPackRecord;
|
||||
import org.whispersystems.signalservice.api.storage.StorageId;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.SQLException;
|
||||
import java.util.Optional;
|
||||
|
||||
public class StickerPackRecordProcessor extends DefaultStorageRecordProcessor<SignalStickerPackRecord> {
|
||||
|
||||
private static final int PACK_ID_LENGTH = 16;
|
||||
private static final int PACK_KEY_LENGTH = 32;
|
||||
|
||||
private final SignalAccount account;
|
||||
private final Connection connection;
|
||||
|
||||
public StickerPackRecordProcessor(final SignalAccount account, final Connection connection) {
|
||||
this.account = account;
|
||||
this.connection = connection;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int compare(final SignalStickerPackRecord lhs, final SignalStickerPackRecord rhs) {
|
||||
return lhs.getProto().packId.equals(rhs.getProto().packId) ? 0 : 1;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected boolean isInvalid(final SignalStickerPackRecord remote) {
|
||||
return remote.getProto().packId.size() != PACK_ID_LENGTH || (
|
||||
remote.getProto().deletedAtTimestamp == 0 && remote.getProto().packKey.size() != PACK_KEY_LENGTH
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Optional<SignalStickerPackRecord> getMatching(final SignalStickerPackRecord remote) throws SQLException {
|
||||
final var packId = StickerPackId.deserialize(remote.getProto().packId.toByteArray());
|
||||
final var local = account.getStickerStore().getStickerPack(connection, packId);
|
||||
|
||||
if (local == null || (!local.isInstalled() && local.deletedTimestamp() == 0)) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
final StorageId storageId;
|
||||
if (local.storageId() != null) {
|
||||
storageId = local.storageId();
|
||||
} else {
|
||||
storageId = StorageId.forStickerPack(KeyUtils.createRawStorageId());
|
||||
account.getStickerStore().updateStorageId(connection, packId, storageId);
|
||||
}
|
||||
|
||||
return Optional.of(new SignalStickerPackRecord(storageId, StorageSyncModels.localToRemoteRecord(local)));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected SignalStickerPackRecord merge(
|
||||
final SignalStickerPackRecord remoteRecord,
|
||||
final SignalStickerPackRecord localRecord
|
||||
) {
|
||||
final var remote = remoteRecord.getProto();
|
||||
final var local = localRecord.getProto();
|
||||
|
||||
if (shouldKeepLocalDeletion(remote.deletedAtTimestamp, local.deletedAtTimestamp)) {
|
||||
return localRecord;
|
||||
}
|
||||
|
||||
return remoteRecord;
|
||||
}
|
||||
|
||||
static boolean shouldKeepLocalDeletion(final long remoteDeletedAt, final long localDeletedAt) {
|
||||
return remoteDeletedAt > 0 && localDeletedAt > 0 && localDeletedAt < remoteDeletedAt;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void insertLocal(final SignalStickerPackRecord record) throws SQLException {
|
||||
account.getStickerStore().upsertFromStorageSync(connection, record);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void updateLocal(final StorageRecordUpdate<SignalStickerPackRecord> update) throws SQLException {
|
||||
account.getStickerStore().upsertFromStorageSync(connection, update.newRecord());
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,139 @@
|
||||
package org.asamk.signal.manager.syncStorage;
|
||||
|
||||
import org.asamk.signal.manager.util.LeakyBucket;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.function.BooleanSupplier;
|
||||
|
||||
public final class StorageSyncLoopDetector {
|
||||
|
||||
private static final int FINGERPRINT_HISTORY = 3;
|
||||
|
||||
private final BooleanSupplier isMultiDevice;
|
||||
private final List<Integer> recentFingerprints = new ArrayList<>();
|
||||
private final LeakyBucket contentBucket = new LeakyBucket(3,
|
||||
Duration.ofHours(1).toMillis(),
|
||||
new InMemoryBucketState());
|
||||
private final LeakyBucket rateBucket = new LeakyBucket(100,
|
||||
Duration.ofMinutes(10).toMillis(),
|
||||
new InMemoryBucketState());
|
||||
|
||||
public StorageSyncLoopDetector(final BooleanSupplier isMultiDevice) {
|
||||
this.isMultiDevice = isMultiDevice;
|
||||
}
|
||||
|
||||
public synchronized Decision onWriteAttempt(
|
||||
final WriteOperationResult write,
|
||||
final boolean fetchedRemoteManifest,
|
||||
final boolean isRetry
|
||||
) {
|
||||
return onWriteAttempt(write, fetchedRemoteManifest, isRetry, System.currentTimeMillis());
|
||||
}
|
||||
|
||||
synchronized Decision onWriteAttempt(
|
||||
final WriteOperationResult write,
|
||||
final boolean fetchedRemoteManifest,
|
||||
final boolean isRetry,
|
||||
final long now
|
||||
) {
|
||||
if (!isMultiDevice.getAsBoolean() || isRetry) {
|
||||
return Decision.Allowed.INSTANCE;
|
||||
}
|
||||
|
||||
final var fingerprint = fingerprint(write);
|
||||
final var chargeContent = fetchedRemoteManifest && fingerprint != null && recentFingerprints.contains(
|
||||
fingerprint);
|
||||
|
||||
if (chargeContent && !contentBucket.hasRoom(now)) {
|
||||
return new Decision.Denied(Cause.REPEATED_PAYLOAD, contentBucket.level(now));
|
||||
}
|
||||
if (fetchedRemoteManifest && !rateBucket.hasRoom(now)) {
|
||||
return new Decision.Denied(Cause.WRITE_RATE, rateBucket.level(now));
|
||||
}
|
||||
|
||||
if (chargeContent) {
|
||||
contentBucket.use(now);
|
||||
}
|
||||
if (fetchedRemoteManifest) {
|
||||
rateBucket.use(now);
|
||||
}
|
||||
if (fingerprint != null) {
|
||||
remember(fingerprint);
|
||||
}
|
||||
|
||||
return Decision.Allowed.INSTANCE;
|
||||
}
|
||||
|
||||
public synchronized void onWriteFailed() {
|
||||
onWriteFailed(System.currentTimeMillis());
|
||||
}
|
||||
|
||||
synchronized void onWriteFailed(final long now) {
|
||||
contentBucket.refund(now);
|
||||
rateBucket.refund(now);
|
||||
}
|
||||
|
||||
public synchronized void onConverged() {
|
||||
contentBucket.clear();
|
||||
}
|
||||
|
||||
private void remember(final int fingerprint) {
|
||||
recentFingerprints.remove(Integer.valueOf(fingerprint));
|
||||
recentFingerprints.addFirst(fingerprint);
|
||||
if (recentFingerprints.size() > FINGERPRINT_HISTORY) {
|
||||
recentFingerprints.removeLast();
|
||||
}
|
||||
}
|
||||
|
||||
private static Integer fingerprint(final WriteOperationResult write) {
|
||||
if (write.inserts().isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return write.inserts()
|
||||
.stream()
|
||||
.map(record -> Arrays.hashCode(record.getProto().encode()))
|
||||
.sorted()
|
||||
.toList()
|
||||
.hashCode();
|
||||
}
|
||||
|
||||
private static final class InMemoryBucketState implements LeakyBucket.State {
|
||||
|
||||
private int level;
|
||||
private long levelUpdatedAt;
|
||||
|
||||
@Override
|
||||
public int level() {
|
||||
return level;
|
||||
}
|
||||
|
||||
@Override
|
||||
public long levelUpdatedAt() {
|
||||
return levelUpdatedAt;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void update(final int level, final long levelUpdatedAt) {
|
||||
this.level = level;
|
||||
this.levelUpdatedAt = levelUpdatedAt;
|
||||
}
|
||||
}
|
||||
|
||||
public enum Cause {
|
||||
REPEATED_PAYLOAD,
|
||||
WRITE_RATE
|
||||
}
|
||||
|
||||
public sealed interface Decision {
|
||||
|
||||
enum Allowed implements Decision {
|
||||
INSTANCE
|
||||
}
|
||||
|
||||
record Denied(Cause cause, int level) implements Decision {}
|
||||
}
|
||||
}
|
||||
@ -8,6 +8,7 @@ import org.asamk.signal.manager.storage.groups.GroupInfoV1;
|
||||
import org.asamk.signal.manager.storage.groups.GroupInfoV2;
|
||||
import org.asamk.signal.manager.storage.identities.IdentityInfo;
|
||||
import org.asamk.signal.manager.storage.recipients.Recipient;
|
||||
import org.asamk.signal.manager.storage.stickers.StickerPack;
|
||||
import org.signal.core.models.ServiceId.ACI;
|
||||
import org.signal.core.models.ServiceId.PNI;
|
||||
import org.signal.core.util.UuidUtil;
|
||||
@ -16,12 +17,14 @@ import org.whispersystems.signalservice.api.storage.SignalAccountRecord;
|
||||
import org.whispersystems.signalservice.api.storage.SignalContactRecord;
|
||||
import org.whispersystems.signalservice.api.storage.SignalGroupV1Record;
|
||||
import org.whispersystems.signalservice.api.storage.SignalGroupV2Record;
|
||||
import org.whispersystems.signalservice.api.storage.SignalStickerPackRecord;
|
||||
import org.whispersystems.signalservice.internal.storage.protos.AccountRecord;
|
||||
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.IdentityState;
|
||||
import org.whispersystems.signalservice.internal.storage.protos.GroupV1Record;
|
||||
import org.whispersystems.signalservice.internal.storage.protos.GroupV2Record;
|
||||
import org.whispersystems.signalservice.internal.storage.protos.StickerPackRecord;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.SQLException;
|
||||
@ -93,11 +96,11 @@ public final class StorageSyncModels {
|
||||
public static ContactRecord localToRemoteRecord(Recipient recipient, IdentityInfo identity) {
|
||||
final var address = recipient.getAddress();
|
||||
final var aciPresent = address.aci().isPresent();
|
||||
final var pniPresent = address.pni().isPresent();
|
||||
|
||||
final var builder = SignalContactRecord.Companion.newBuilder(recipient.getStorageRecord())
|
||||
.e164(pniPresent ? address.number().orElse("") : "")
|
||||
.e164(address.number().orElse(""))
|
||||
.username(address.username().orElse(""))
|
||||
.pniSignatureVerified(recipient.isPniSignatureVerified())
|
||||
.profileKey(recipient.getProfileKey() == null
|
||||
? ByteString.EMPTY
|
||||
: ByteString.of(recipient.getProfileKey().serialize()));
|
||||
@ -120,6 +123,7 @@ public final class StorageSyncModels {
|
||||
.nickname(getNicknameRemoteRecord(recipient.getContact()))
|
||||
.note(emptyIfNull(recipient.getContact().note()))
|
||||
.blocked(recipient.getContact().isBlocked())
|
||||
.blockedAtTimestamp(recipient.getContact().blockedAt())
|
||||
.whitelisted(recipient.getContact().isProfileSharingEnabled())
|
||||
.mutedUntilTimestamp(recipient.getContact().muteUntil())
|
||||
.hideStory(recipient.getContact().hideStory())
|
||||
@ -158,10 +162,28 @@ public final class StorageSyncModels {
|
||||
final var builder = SignalGroupV2Record.Companion.newBuilder(group.getStorageRecord());
|
||||
builder.masterKey(ByteString.of(group.getMasterKey().serialize()));
|
||||
builder.blocked(group.isBlocked());
|
||||
builder.blockedAtTimestamp(group.getBlockedAt());
|
||||
builder.whitelisted(group.isProfileSharingEnabled());
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
public static StickerPackRecord localToRemoteRecord(StickerPack stickerPack) {
|
||||
final var builder = SignalStickerPackRecord.Companion.newBuilder(stickerPack.storageRecord());
|
||||
builder.packId(ByteString.of(stickerPack.packId().serialize()));
|
||||
|
||||
if (stickerPack.deletedTimestamp() > 0) {
|
||||
builder.packKey(ByteString.EMPTY);
|
||||
builder.position(0);
|
||||
builder.deletedAtTimestamp(stickerPack.deletedTimestamp());
|
||||
} else {
|
||||
builder.packKey(ByteString.of(stickerPack.packKey()));
|
||||
builder.position(stickerPack.position());
|
||||
builder.deletedAtTimestamp(0);
|
||||
}
|
||||
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
public static TrustLevel remoteToLocal(IdentityState identityState) {
|
||||
return switch (identityState) {
|
||||
case DEFAULT -> TrustLevel.TRUSTED_UNVERIFIED;
|
||||
|
||||
@ -168,6 +168,11 @@ public final class StorageSyncValidations {
|
||||
throw new DuplicateCallLinkError();
|
||||
}
|
||||
|
||||
ids = manifest.getStorageIdsByType().get(ManifestRecord.Identifier.Type.STICKER_PACK.getValue());
|
||||
if (ids.size() != new HashSet<>(ids).size()) {
|
||||
throw new DuplicateStickerPackError();
|
||||
}
|
||||
|
||||
throw new DuplicateRawIdAcrossTypesError();
|
||||
}
|
||||
|
||||
@ -217,6 +222,8 @@ public final class StorageSyncValidations {
|
||||
|
||||
private static final class DuplicateInsertInWriteError extends Error {}
|
||||
|
||||
private static final class DuplicateStickerPackError extends Error {}
|
||||
|
||||
private static final class InsertNotPresentInFullIdSetError extends Error {}
|
||||
|
||||
private static final class DeletePresentInFullIdSetError extends Error {}
|
||||
|
||||
@ -1,28 +1,44 @@
|
||||
package org.asamk.signal.manager.util;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentStream;
|
||||
import org.whispersystems.signalservice.api.push.exceptions.ResumeLocationInvalidException;
|
||||
import org.whispersystems.signalservice.api.util.StreamDetails;
|
||||
import org.whispersystems.signalservice.internal.push.http.ResumableUploadSpec;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
|
||||
public class AttachmentUtils {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(AttachmentUtils.class);
|
||||
|
||||
// Images are fully buffered in memory to probe their dimensions, so cap how large a file we'll do this for.
|
||||
private static final long MAX_DIMENSION_PROBE_SIZE = 20 * 1024 * 1024;
|
||||
|
||||
public static SignalServiceAttachmentStream createAttachmentStream(
|
||||
StreamDetails streamDetails,
|
||||
Optional<String> name,
|
||||
boolean voiceNote,
|
||||
ResumableUploadSpec resumableUploadSpec
|
||||
) throws ResumeLocationInvalidException {
|
||||
) throws ResumeLocationInvalidException, IOException {
|
||||
final var uploadTimestamp = System.currentTimeMillis();
|
||||
final var probedStream = probeImageDimensions(streamDetails);
|
||||
return SignalServiceAttachmentStream.newStreamBuilder()
|
||||
.withStream(streamDetails.getStream())
|
||||
.withStream(probedStream.inputStream())
|
||||
.withContentType(streamDetails.getContentType())
|
||||
.withLength(streamDetails.getLength())
|
||||
.withFileName(name.orElse(null))
|
||||
.withVoiceNote(voiceNote)
|
||||
.withWidth(probedStream.width())
|
||||
.withHeight(probedStream.height())
|
||||
.withUploadTimestamp(uploadTimestamp)
|
||||
.withResumableUploadSpec(resumableUploadSpec)
|
||||
.withUuid(UUID.randomUUID())
|
||||
@ -33,7 +49,55 @@ public class AttachmentUtils {
|
||||
StreamDetails streamDetails,
|
||||
Optional<String> name,
|
||||
ResumableUploadSpec resumableUploadSpec
|
||||
) throws ResumeLocationInvalidException {
|
||||
) throws ResumeLocationInvalidException, IOException {
|
||||
return createAttachmentStream(streamDetails, name, false, resumableUploadSpec);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the attachment's dimensions if it's an image, so recipients don't get a square-cropped thumbnail.
|
||||
* Falls back to width/height 0 (today's behavior) if the content isn't an image, is too large to probe
|
||||
* cheaply, or fails to parse.
|
||||
*/
|
||||
private static ProbedStream probeImageDimensions(StreamDetails streamDetails) throws IOException {
|
||||
final var contentType = streamDetails.getContentType();
|
||||
final var length = streamDetails.getLength();
|
||||
if (contentType == null
|
||||
|| !contentType.startsWith("image/")
|
||||
|| length <= 0
|
||||
|| length > MAX_DIMENSION_PROBE_SIZE) {
|
||||
return new ProbedStream(streamDetails.getStream(), 0, 0);
|
||||
}
|
||||
|
||||
final var stream = streamDetails.getStream();
|
||||
var width = 0;
|
||||
var height = 0;
|
||||
if (stream instanceof FileInputStream fis) {
|
||||
try {
|
||||
final var image = ImageIO.read(fis);
|
||||
if (image != null) {
|
||||
width = image.getWidth();
|
||||
height = image.getHeight();
|
||||
}
|
||||
fis.getChannel().position(0);
|
||||
} catch (IOException | LinkageError e) {
|
||||
logger.debug("Failed to probe image dimensions, sending without width/height: {}", e.getMessage());
|
||||
fis.getChannel().position(0);
|
||||
}
|
||||
return new ProbedStream(fis, width, height);
|
||||
}
|
||||
|
||||
final var bytes = stream.readAllBytes();
|
||||
try {
|
||||
final var image = ImageIO.read(new ByteArrayInputStream(bytes));
|
||||
if (image != null) {
|
||||
width = image.getWidth();
|
||||
height = image.getHeight();
|
||||
}
|
||||
} catch (IOException | LinkageError e) {
|
||||
logger.debug("Failed to probe image dimensions, sending without width/height: {}", e.getMessage());
|
||||
}
|
||||
return new ProbedStream(new ByteArrayInputStream(bytes), width, height);
|
||||
}
|
||||
|
||||
private record ProbedStream(InputStream inputStream, int width, int height) {}
|
||||
}
|
||||
|
||||
@ -0,0 +1,60 @@
|
||||
package org.asamk.signal.manager.util;
|
||||
|
||||
public final class LeakyBucket {
|
||||
|
||||
private final int capacity;
|
||||
private final long dripIntervalMillis;
|
||||
private final State state;
|
||||
|
||||
public LeakyBucket(final int capacity, final long dripIntervalMillis, final State state) {
|
||||
this.capacity = capacity;
|
||||
this.dripIntervalMillis = dripIntervalMillis;
|
||||
this.state = state;
|
||||
}
|
||||
|
||||
public int level(final long now) {
|
||||
return calculateStateForCurrentTime(now).level();
|
||||
}
|
||||
|
||||
public boolean hasRoom(final long now) {
|
||||
return level(now) < capacity;
|
||||
}
|
||||
|
||||
public void use(final long now) {
|
||||
final var currentState = calculateStateForCurrentTime(now);
|
||||
state.update(currentState.level() + 1, currentState.levelUpdatedAt());
|
||||
}
|
||||
|
||||
public void refund(final long now) {
|
||||
final var currentState = calculateStateForCurrentTime(now);
|
||||
state.update(Math.max(currentState.level() - 1, 0), currentState.levelUpdatedAt());
|
||||
}
|
||||
|
||||
public void clear() {
|
||||
state.update(0, 0);
|
||||
}
|
||||
|
||||
private Snapshot calculateStateForCurrentTime(final long now) {
|
||||
final var level = state.level();
|
||||
final var levelUpdatedAt = state.levelUpdatedAt();
|
||||
final var elapsed = now - levelUpdatedAt;
|
||||
|
||||
if (level <= 0 || elapsed < 0) {
|
||||
return new Snapshot(0, now);
|
||||
}
|
||||
|
||||
final var drips = elapsed / dripIntervalMillis;
|
||||
return new Snapshot((int) Math.max(level - drips, 0), levelUpdatedAt + dripIntervalMillis * drips);
|
||||
}
|
||||
|
||||
private record Snapshot(int level, long levelUpdatedAt) {}
|
||||
|
||||
public interface State {
|
||||
|
||||
int level();
|
||||
|
||||
long levelUpdatedAt();
|
||||
|
||||
void update(int level, long levelUpdatedAt);
|
||||
}
|
||||
}
|
||||
@ -17,7 +17,6 @@ import java.io.ByteArrayInputStream;
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.math.BigInteger;
|
||||
import java.net.Proxy;
|
||||
import java.net.ProxySelector;
|
||||
@ -39,6 +38,9 @@ import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
import java.util.stream.StreamSupport;
|
||||
|
||||
import kotlin.coroutines.Continuation;
|
||||
import kotlin.coroutines.EmptyCoroutineContext;
|
||||
import kotlinx.coroutines.BuildersKt;
|
||||
import okio.ByteString;
|
||||
|
||||
public class Utils {
|
||||
@ -53,7 +55,7 @@ public class Utils {
|
||||
}
|
||||
|
||||
public static StreamDetails createStreamDetailsFromFile(final File file) throws IOException {
|
||||
final InputStream stream = new FileInputStream(file);
|
||||
final var stream = new FileInputStream(file);
|
||||
final var size = file.length();
|
||||
final var mime = MimeUtils.getFileMimeType(file).orElse(MimeUtils.OCTET_STREAM);
|
||||
return new StreamDetails(stream, mime, size);
|
||||
@ -161,6 +163,24 @@ public class Utils {
|
||||
return NetworkResultUtil.toBasicLegacy(response);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
public static <T> T runSuspendBlocking(final Function<Continuation<? super T>, Object> call) {
|
||||
try {
|
||||
return (T) BuildersKt.runBlocking(EmptyCoroutineContext.INSTANCE,
|
||||
(scope, cont) -> call.apply((Continuation<? super T>) cont));
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new RuntimeException("Interrupted while waiting for suspend function", e);
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
public static <T, E extends BadRequestError> T handleResponseExceptionSuspend(
|
||||
final Function<Continuation<? super RequestResult>, Object> call
|
||||
) throws IOException {
|
||||
return handleResponseException((RequestResult<T, E>) runSuspendBlocking((Function) call));
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
public static <T, E extends BadRequestError> T handleResponseException(final RequestResult<T, E> result) throws IOException {
|
||||
if (result instanceof RequestResult.Success<?> success) {
|
||||
|
||||
@ -0,0 +1,104 @@
|
||||
package org.asamk.signal.manager.storage.groups;
|
||||
|
||||
import org.asamk.signal.manager.api.Group;
|
||||
import org.asamk.signal.manager.api.GroupId;
|
||||
import org.asamk.signal.manager.groups.GroupUtils;
|
||||
import org.asamk.signal.manager.storage.recipients.RecipientAddress;
|
||||
import org.asamk.signal.manager.storage.recipients.RecipientId;
|
||||
import org.asamk.signal.manager.storage.recipients.RecipientResolver;
|
||||
import org.asamk.signal.manager.storage.recipients.TestRecipientId;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.signal.core.models.ServiceId;
|
||||
import org.signal.libsignal.zkgroup.InvalidInputException;
|
||||
import org.signal.libsignal.zkgroup.groups.GroupMasterKey;
|
||||
import org.signal.storageservice.storage.protos.groups.local.DecryptedGroup;
|
||||
import org.whispersystems.signalservice.api.push.DistributionId;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class GroupInfoTerminatedTest {
|
||||
|
||||
private static final RecipientResolver UNUSED_RESOLVER = new RecipientResolver() {
|
||||
@Override
|
||||
public RecipientId resolveRecipient(final RecipientAddress address) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public RecipientId resolveRecipient(final long recipientId) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public RecipientId resolveRecipient(final String identifier) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public RecipientId resolveRecipient(final ServiceId serviceId) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
};
|
||||
|
||||
private static GroupMasterKey masterKey() {
|
||||
final var bytes = new byte[32];
|
||||
for (int i = 0; i < bytes.length; i++) {
|
||||
bytes[i] = (byte) (i + 1);
|
||||
}
|
||||
try {
|
||||
return new GroupMasterKey(bytes);
|
||||
} catch (InvalidInputException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
}
|
||||
|
||||
private static GroupInfoV2 groupV2(final DecryptedGroup group) {
|
||||
final var masterKey = masterKey();
|
||||
return new GroupInfoV2(GroupUtils.getGroupIdV2(masterKey),
|
||||
masterKey,
|
||||
group,
|
||||
DistributionId.create(),
|
||||
false,
|
||||
0,
|
||||
false,
|
||||
false,
|
||||
null,
|
||||
UNUSED_RESOLVER);
|
||||
}
|
||||
|
||||
@Test
|
||||
void v1GroupsAreNeverTerminated() {
|
||||
final var group = new GroupInfoV1(GroupId.v1(new byte[16]));
|
||||
assertFalse(group.isTerminated());
|
||||
}
|
||||
|
||||
@Test
|
||||
void v2ReadsTerminatedFlagFromDecryptedGroup() {
|
||||
assertTrue(groupV2(new DecryptedGroup.Builder().terminated(true).build()).isTerminated());
|
||||
assertFalse(groupV2(new DecryptedGroup.Builder().terminated(false).build()).isTerminated());
|
||||
assertFalse(groupV2(new DecryptedGroup.Builder().build()).isTerminated());
|
||||
}
|
||||
|
||||
@Test
|
||||
void v2IsNotTerminatedWhenGroupStateMissing() {
|
||||
final var masterKey = masterKey();
|
||||
final var group = new GroupInfoV2(GroupUtils.getGroupIdV2(masterKey), masterKey, UNUSED_RESOLVER);
|
||||
assertFalse(group.isTerminated());
|
||||
}
|
||||
|
||||
@Test
|
||||
void groupApiRecordCarriesTerminatedFromModel() {
|
||||
final RecipientId self = TestRecipientId.createTestId(1);
|
||||
|
||||
final var terminated = Group.from(groupV2(new DecryptedGroup.Builder().terminated(true).build()),
|
||||
recipientId -> null,
|
||||
self);
|
||||
assertTrue(terminated.isTerminated());
|
||||
|
||||
final var live = Group.from(groupV2(new DecryptedGroup.Builder().terminated(false).build()),
|
||||
recipientId -> null,
|
||||
self);
|
||||
assertFalse(live.isTerminated());
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,93 @@
|
||||
package org.asamk.signal.manager.storage.recipients;
|
||||
|
||||
import org.asamk.signal.manager.api.Contact;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class RecipientStoreTest {
|
||||
|
||||
@Test
|
||||
void mergeContactsUsesAndroidMergePolicy() {
|
||||
final var primary = Contact.newBuilder()
|
||||
.withGivenName("Primary given")
|
||||
.withFamilyName("Primary family")
|
||||
.withNickName("Primary system nickname")
|
||||
.withNickNameGivenName("Primary nickname given")
|
||||
.withNickNameFamilyName("Primary nickname family")
|
||||
.withNote("Primary note")
|
||||
.withColor("Primary color")
|
||||
.withMessageExpirationTimeVersion(2)
|
||||
.withHideStory(true)
|
||||
.withIsBlocked(false)
|
||||
.withBlockedAt(100)
|
||||
.withIsArchived(true)
|
||||
.withIsHidden(true)
|
||||
.withUnregisteredTimestamp(300L)
|
||||
.build();
|
||||
final var secondary = Contact.newBuilder()
|
||||
.withGivenName("Secondary given")
|
||||
.withFamilyName("Secondary family")
|
||||
.withNickName("Secondary system nickname")
|
||||
.withNickNameGivenName("Secondary nickname given")
|
||||
.withNickNameFamilyName("Secondary nickname family")
|
||||
.withNote("Secondary note")
|
||||
.withColor("Secondary color")
|
||||
.withMessageExpirationTime(60)
|
||||
.withMessageExpirationTimeVersion(3)
|
||||
.withMuteUntil(400)
|
||||
.withIsBlocked(true)
|
||||
.withBlockedAt(200)
|
||||
.withIsProfileSharingEnabled(true)
|
||||
.withIsHidden(true)
|
||||
.withUnregisteredTimestamp(500L)
|
||||
.build();
|
||||
|
||||
final var merged = RecipientStore.mergeContacts(primary, secondary);
|
||||
|
||||
assertEquals("Secondary given", merged.givenName());
|
||||
assertEquals("Secondary family", merged.familyName());
|
||||
assertEquals("Primary system nickname", merged.nickName());
|
||||
assertEquals("Primary nickname given", merged.nickNameGivenName());
|
||||
assertEquals("Primary nickname family", merged.nickNameFamilyName());
|
||||
assertEquals("Primary note", merged.note());
|
||||
assertEquals("Primary color", merged.color());
|
||||
assertEquals(60, merged.messageExpirationTime());
|
||||
assertEquals(3, merged.messageExpirationTimeVersion());
|
||||
assertEquals(400, merged.muteUntil());
|
||||
assertTrue(merged.hideStory());
|
||||
assertTrue(merged.isBlocked());
|
||||
assertEquals(200, merged.blockedAt());
|
||||
assertTrue(merged.isArchived());
|
||||
assertTrue(merged.isProfileSharingEnabled());
|
||||
assertFalse(merged.isHidden());
|
||||
assertEquals(300L, merged.unregisteredTimestamp());
|
||||
}
|
||||
|
||||
@Test
|
||||
void mergeContactsPrefersConfiguredPrimaryValues() {
|
||||
final var primary = Contact.newBuilder()
|
||||
.withColor("Primary color")
|
||||
.withMessageExpirationTime(30)
|
||||
.withMessageExpirationTimeVersion(4)
|
||||
.withMuteUntil(100)
|
||||
.withIsHidden(true)
|
||||
.build();
|
||||
final var secondary = Contact.newBuilder()
|
||||
.withColor("Secondary color")
|
||||
.withMessageExpirationTime(60)
|
||||
.withMessageExpirationTimeVersion(3)
|
||||
.withMuteUntil(200)
|
||||
.build();
|
||||
|
||||
final var merged = RecipientStore.mergeContacts(primary, secondary);
|
||||
|
||||
assertEquals("Primary color", merged.color());
|
||||
assertEquals(30, merged.messageExpirationTime());
|
||||
assertEquals(4, merged.messageExpirationTimeVersion());
|
||||
assertEquals(100, merged.muteUntil());
|
||||
assertTrue(merged.isHidden());
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,33 @@
|
||||
package org.asamk.signal.manager.syncStorage;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.signal.core.models.ServiceId.ACI;
|
||||
import org.signal.core.models.ServiceId.PNI;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class StorageRecordProcessorTest {
|
||||
|
||||
@Test
|
||||
void splitsOnlyUnregisteredAciOnlyRecords() {
|
||||
final var aci = ACI.from(UUID.randomUUID());
|
||||
final var pni = PNI.from(UUID.randomUUID());
|
||||
|
||||
assertTrue(ContactRecordProcessor.shouldSplitForStorageSync(1, aci, null, ""));
|
||||
assertFalse(ContactRecordProcessor.shouldSplitForStorageSync(0, aci, null, ""));
|
||||
assertFalse(ContactRecordProcessor.shouldSplitForStorageSync(1, null, null, ""));
|
||||
assertFalse(ContactRecordProcessor.shouldSplitForStorageSync(1, aci, pni, ""));
|
||||
assertFalse(ContactRecordProcessor.shouldSplitForStorageSync(1, aci, null, "+12025550123"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void keepsOlderLocalStickerDeletion() {
|
||||
assertTrue(StickerPackRecordProcessor.shouldKeepLocalDeletion(200, 100));
|
||||
assertFalse(StickerPackRecordProcessor.shouldKeepLocalDeletion(100, 200));
|
||||
assertFalse(StickerPackRecordProcessor.shouldKeepLocalDeletion(200, 0));
|
||||
assertFalse(StickerPackRecordProcessor.shouldKeepLocalDeletion(0, 100));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,102 @@
|
||||
package org.asamk.signal.manager.syncStorage;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.whispersystems.signalservice.api.storage.SignalStorageRecord;
|
||||
import org.whispersystems.signalservice.api.storage.StorageId;
|
||||
import org.whispersystems.signalservice.internal.storage.protos.StorageRecord;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
|
||||
|
||||
class StorageSyncLoopDetectorTest {
|
||||
|
||||
private static final long NOW = 1_700_000_000_000L;
|
||||
|
||||
@Test
|
||||
void repeatedPayloadIsDeniedAfterThreeCharges() {
|
||||
final var detector = new StorageSyncLoopDetector(() -> true);
|
||||
final var write = writeWithInsert(1);
|
||||
|
||||
assertAllowed(detector.onWriteAttempt(write, true, false, NOW));
|
||||
for (var index = 0; index < 3; index++) {
|
||||
assertAllowed(detector.onWriteAttempt(write, true, false, NOW));
|
||||
}
|
||||
|
||||
assertEquals(new StorageSyncLoopDetector.Decision.Denied(StorageSyncLoopDetector.Cause.REPEATED_PAYLOAD, 3),
|
||||
detector.onWriteAttempt(write, true, false, NOW));
|
||||
}
|
||||
|
||||
@Test
|
||||
void storageIdsAreExcludedFromPayloadFingerprint() {
|
||||
final var detector = new StorageSyncLoopDetector(() -> true);
|
||||
|
||||
assertAllowed(detector.onWriteAttempt(writeWithInsert(1), true, false, NOW));
|
||||
assertAllowed(detector.onWriteAttempt(writeWithInsert(2), true, false, NOW));
|
||||
assertAllowed(detector.onWriteAttempt(writeWithInsert(3), true, false, NOW));
|
||||
assertAllowed(detector.onWriteAttempt(writeWithInsert(4), true, false, NOW));
|
||||
|
||||
assertInstanceOf(StorageSyncLoopDetector.Decision.Denied.class,
|
||||
detector.onWriteAttempt(writeWithInsert(5), true, false, NOW));
|
||||
}
|
||||
|
||||
@Test
|
||||
void convergenceClearsTheContentBucket() {
|
||||
final var detector = new StorageSyncLoopDetector(() -> true);
|
||||
final var write = writeWithInsert(1);
|
||||
for (var index = 0; index < 4; index++) {
|
||||
assertAllowed(detector.onWriteAttempt(write, true, false, NOW));
|
||||
}
|
||||
|
||||
detector.onConverged();
|
||||
|
||||
assertAllowed(detector.onWriteAttempt(write, true, false, NOW));
|
||||
}
|
||||
|
||||
@Test
|
||||
void failedWritesAreRefunded() {
|
||||
final var detector = new StorageSyncLoopDetector(() -> true);
|
||||
final var write = writeWithInsert(1);
|
||||
|
||||
for (var index = 0; index < 20; index++) {
|
||||
assertAllowed(detector.onWriteAttempt(write, true, false, NOW));
|
||||
detector.onWriteFailed(NOW);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void rateBucketLimitsDeleteOnlyWrites() {
|
||||
final var detector = new StorageSyncLoopDetector(() -> true);
|
||||
final var write = new WriteOperationResult(null, List.of(), List.of(new byte[]{1}));
|
||||
|
||||
for (var index = 0; index < 100; index++) {
|
||||
assertAllowed(detector.onWriteAttempt(write, true, false, NOW));
|
||||
}
|
||||
|
||||
assertEquals(new StorageSyncLoopDetector.Decision.Denied(StorageSyncLoopDetector.Cause.WRITE_RATE, 100),
|
||||
detector.onWriteAttempt(write, true, false, NOW));
|
||||
}
|
||||
|
||||
@Test
|
||||
void retriesAndSingleDeviceWritesAreExempt() {
|
||||
final var retryDetector = new StorageSyncLoopDetector(() -> true);
|
||||
final var singleDeviceDetector = new StorageSyncLoopDetector(() -> false);
|
||||
final var write = writeWithInsert(1);
|
||||
|
||||
for (var index = 0; index < 20; index++) {
|
||||
assertAllowed(retryDetector.onWriteAttempt(write, true, true, NOW));
|
||||
assertAllowed(singleDeviceDetector.onWriteAttempt(write, true, false, NOW));
|
||||
}
|
||||
}
|
||||
|
||||
private static WriteOperationResult writeWithInsert(final int storageIdByte) {
|
||||
final var storageId = StorageId.forType(new byte[]{(byte) storageIdByte}, 99);
|
||||
final var record = new SignalStorageRecord(storageId, new StorageRecord.Builder().build());
|
||||
return new WriteOperationResult(null, List.of(record), List.of());
|
||||
}
|
||||
|
||||
private static void assertAllowed(final StorageSyncLoopDetector.Decision decision) {
|
||||
assertEquals(StorageSyncLoopDetector.Decision.Allowed.INSTANCE, decision);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,52 @@
|
||||
package org.asamk.signal.manager.util;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.whispersystems.signalservice.api.util.StreamDetails;
|
||||
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.util.Optional;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
class AttachmentUtilsTest {
|
||||
|
||||
@Test
|
||||
public void createAttachmentStream_setsWidthAndHeightForImage() throws Exception {
|
||||
final var imageBytes = pngBytes(37, 21);
|
||||
final var streamDetails = new StreamDetails(new ByteArrayInputStream(imageBytes),
|
||||
"image/png",
|
||||
imageBytes.length);
|
||||
|
||||
final var attachment = AttachmentUtils.createAttachmentStream(streamDetails, Optional.of("meme.png"), null);
|
||||
|
||||
assertEquals(37, attachment.getWidth());
|
||||
assertEquals(21, attachment.getHeight());
|
||||
assertArrayEquals(imageBytes, attachment.getInputStream().readAllBytes());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void createAttachmentStream_leavesWidthAndHeightZeroForNonImage() throws Exception {
|
||||
final var bytes = "not an image".getBytes();
|
||||
final var streamDetails = new StreamDetails(new ByteArrayInputStream(bytes),
|
||||
"application/octet-stream",
|
||||
bytes.length);
|
||||
|
||||
final var attachment = AttachmentUtils.createAttachmentStream(streamDetails, Optional.of("file.bin"), null);
|
||||
|
||||
assertEquals(0, attachment.getWidth());
|
||||
assertEquals(0, attachment.getHeight());
|
||||
assertArrayEquals(bytes, attachment.getInputStream().readAllBytes());
|
||||
}
|
||||
|
||||
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 out = new ByteArrayOutputStream();
|
||||
ImageIO.write(image, "png", out);
|
||||
return out.toByteArray();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,66 @@
|
||||
package org.asamk.signal.manager.util;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class LeakyBucketTest {
|
||||
|
||||
private static final long NOW = 1_700_000_000_000L;
|
||||
|
||||
@Test
|
||||
void dripsLevelsAndCarriesPartialIntervals() {
|
||||
final var state = new TestState();
|
||||
final var bucket = new LeakyBucket(3, Duration.ofHours(1).toMillis(), state);
|
||||
|
||||
bucket.use(NOW);
|
||||
bucket.use(NOW);
|
||||
bucket.use(NOW);
|
||||
|
||||
assertEquals(2, bucket.level(NOW + Duration.ofMinutes(90).toMillis()));
|
||||
assertEquals(1, bucket.level(NOW + Duration.ofMinutes(121).toMillis()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void refundNeverDropsBelowZero() {
|
||||
final var bucket = new LeakyBucket(1, 1_000, new TestState());
|
||||
|
||||
bucket.refund(NOW);
|
||||
|
||||
assertEquals(0, bucket.level(NOW));
|
||||
assertTrue(bucket.hasRoom(NOW));
|
||||
}
|
||||
|
||||
@Test
|
||||
void clockMovingBackwardsRefillsTheBucket() {
|
||||
final var bucket = new LeakyBucket(1, 1_000, new TestState());
|
||||
bucket.use(NOW);
|
||||
|
||||
assertTrue(bucket.hasRoom(NOW - 1));
|
||||
}
|
||||
|
||||
private static final class TestState implements LeakyBucket.State {
|
||||
|
||||
private int level;
|
||||
private long levelUpdatedAt;
|
||||
|
||||
@Override
|
||||
public int level() {
|
||||
return level;
|
||||
}
|
||||
|
||||
@Override
|
||||
public long levelUpdatedAt() {
|
||||
return levelUpdatedAt;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void update(final int level, final long levelUpdatedAt) {
|
||||
this.level = level;
|
||||
this.levelUpdatedAt = levelUpdatedAt;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1 +1 @@
|
||||
0.96.3
|
||||
0.99.1
|
||||
|
||||
@ -452,6 +452,7 @@ Groups have the following (case-sensitive) properties:
|
||||
* IsBlocked<b> : true=member will not receive group messages; false=not blocked
|
||||
* IsMember<b> (read-only) : always true (object path exists only for group members)
|
||||
* IsAdmin<b> (read-only) : true=member has admin privileges; false=not admin
|
||||
* IsTerminated<b> (read-only) : true=group was permanently ended by an admin; nobody (not even an admin) can send messages or start calls, sending is rejected locally
|
||||
* MessageExpirationTimer<i> : int32 representing message expiration time for group
|
||||
* Members<as> (read-only) : String array of group members' phone numbers
|
||||
* PendingMembers<as> (read-only) : String array of pending members' phone numbers
|
||||
@ -502,6 +503,11 @@ Exceptions: Failure
|
||||
quitGroup() -> <>::
|
||||
Exceptions: Failure, LastGroupAdmin
|
||||
|
||||
terminateGroup() -> <>::
|
||||
Permanently terminate the group for all members (requires admin privileges). Afterwards nobody can send messages or start calls; members keep access to the existing history.
|
||||
|
||||
Exceptions: Failure
|
||||
|
||||
removeAdmins(recipients<as>) -> <>::
|
||||
* recipients : String array of phone numbers
|
||||
|
||||
|
||||
@ -786,6 +786,16 @@ Specify the recipient group ID in base64 encoding.
|
||||
*--delete*::
|
||||
Delete local group data completely after quitting group.
|
||||
|
||||
=== terminateGroup
|
||||
|
||||
Permanently terminate a group for all members.
|
||||
This requires admin privileges.
|
||||
Afterwards no member (not even an admin) can send messages or start calls in the group; members keep access to the existing message history.
|
||||
Only supported for Signal group v2 groups.
|
||||
|
||||
*-g* GROUP, *--group-id* GROUP::
|
||||
Specify the group ID in base64 encoding.
|
||||
|
||||
=== listGroups
|
||||
|
||||
Show a list of known groups and related information.
|
||||
|
||||
@ -213,6 +213,7 @@ run_main -a "$NUMBER_2" send "$NUMBER_1" -m hi
|
||||
run_main -a "$NUMBER_2" send "$NUMBER_1" -m hii
|
||||
run_main -a "$NUMBER_1" updateAccount --discoverable-by-number=false
|
||||
run_main -a "$NUMBER_1" receive
|
||||
run_main -a "$NUMBER_1" send "$NUMBER_2" -m hi
|
||||
run_main -a "$NUMBER_2" receive
|
||||
run_main -a "$NUMBER_2" send "$NUMBER_1" -m hi
|
||||
run_main -a "$NUMBER_2" send "$NUMBER_1" -m hii
|
||||
|
||||
@ -595,6 +595,7 @@ public interface Signal extends DBusInterface {
|
||||
@DBusProperty(name = "IsBlocked", type = Boolean.class)
|
||||
@DBusProperty(name = "IsMember", type = Boolean.class, access = DBusProperty.Access.READ)
|
||||
@DBusProperty(name = "IsAdmin", type = Boolean.class, access = DBusProperty.Access.READ)
|
||||
@DBusProperty(name = "IsTerminated", type = Boolean.class, access = DBusProperty.Access.READ)
|
||||
@DBusProperty(name = "MessageExpirationTimer", type = Integer.class)
|
||||
@DBusProperty(name = "Members", type = String[].class, access = DBusProperty.Access.READ)
|
||||
@DBusProperty(name = "PendingMembers", type = String[].class, access = DBusProperty.Access.READ)
|
||||
@ -611,6 +612,8 @@ public interface Signal extends DBusInterface {
|
||||
|
||||
void deleteGroup() throws Error.Failure;
|
||||
|
||||
void terminateGroup() throws Error.Failure;
|
||||
|
||||
void addMembers(List<String> recipients) throws Error.Failure;
|
||||
|
||||
void removeMembers(List<String> recipients) throws Error.Failure;
|
||||
|
||||
@ -8,7 +8,7 @@ public class BaseConfig {
|
||||
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"))
|
||||
.orElse("Signal-Android/8.15.0");
|
||||
.orElse("Signal-Android/8.21.1");
|
||||
static final String USER_AGENT_SIGNAL_CLI = PROJECT_NAME == null
|
||||
? "signal-cli"
|
||||
: PROJECT_NAME + "/" + PROJECT_VERSION;
|
||||
|
||||
@ -61,6 +61,7 @@ public class Commands {
|
||||
addCommand(new SubmitRateLimitChallengeCommand());
|
||||
addCommand(new StartChangeNumberCommand());
|
||||
addCommand(new StartLinkCommand());
|
||||
addCommand(new TerminateGroupCommand());
|
||||
addCommand(new TrustCommand());
|
||||
addCommand(new UnblockCommand());
|
||||
addCommand(new UnregisterCommand());
|
||||
|
||||
@ -79,12 +79,13 @@ public class ListGroupsCommand implements JsonRpcLocalCommand {
|
||||
final var groupInviteLink = group.groupInviteLinkUrl();
|
||||
|
||||
writer.println(
|
||||
"Id: {} Name: {} Description: {} Active: {} Blocked: {} Members: {} Pending members: {} Requesting members: {} Banned: {} Message expiration: {} Link: {}",
|
||||
"Id: {} Name: {} Description: {} Active: {} Blocked: {} Terminated: {} Members: {} Pending members: {} Requesting members: {} Banned: {} Message expiration: {} Link: {}",
|
||||
group.groupId().toBase64(),
|
||||
group.title(),
|
||||
group.description(),
|
||||
group.isMember(),
|
||||
group.isBlocked(),
|
||||
group.isTerminated(),
|
||||
resolveMembers(group.members()),
|
||||
resolveMemberAddress(group.pendingMembers()),
|
||||
resolveMemberAddress(group.requestingMembers()),
|
||||
@ -92,11 +93,12 @@ public class ListGroupsCommand implements JsonRpcLocalCommand {
|
||||
group.messageExpirationTimer() == 0 ? "disabled" : group.messageExpirationTimer() + "s",
|
||||
groupInviteLink == null ? '-' : groupInviteLink.getUrl());
|
||||
} else {
|
||||
writer.println("Id: {} Name: {} Active: {} Blocked: {}",
|
||||
writer.println("Id: {} Name: {} Active: {} Blocked: {} Terminated: {}",
|
||||
group.groupId().toBase64(),
|
||||
group.title(),
|
||||
group.isMember(),
|
||||
group.isBlocked());
|
||||
group.isBlocked(),
|
||||
group.isTerminated());
|
||||
}
|
||||
}
|
||||
|
||||
@ -133,7 +135,8 @@ public class ListGroupsCommand implements JsonRpcLocalCommand {
|
||||
group.permissionAddMember().name(),
|
||||
group.permissionEditDetails().name(),
|
||||
group.permissionSendMessage().name(),
|
||||
groupInviteLink == null ? null : groupInviteLink.getUrl());
|
||||
groupInviteLink == null ? null : groupInviteLink.getUrl(),
|
||||
group.isTerminated());
|
||||
}).toList();
|
||||
jsonWriter.write(jsonGroups);
|
||||
}
|
||||
@ -161,7 +164,8 @@ public class ListGroupsCommand implements JsonRpcLocalCommand {
|
||||
String permissionAddMember,
|
||||
String permissionEditDetails,
|
||||
String permissionSendMessage,
|
||||
String groupInviteLink
|
||||
String groupInviteLink,
|
||||
boolean isTerminated
|
||||
) {}
|
||||
|
||||
private record JsonGroupMemberAddress(String number, String uuid) {}
|
||||
|
||||
@ -33,9 +33,7 @@ public class SendStoryCommand implements JsonRpcLocalCommand {
|
||||
subparser.addArgument("-a", "--attachment")
|
||||
.required(true)
|
||||
.help("Specify the file path to the image or video to post as a story.");
|
||||
subparser.addArgument("--no-replies")
|
||||
.action(Arguments.storeTrue())
|
||||
.help("Disable replies on this story.");
|
||||
subparser.addArgument("--no-replies").action(Arguments.storeTrue()).help("Disable replies on this story.");
|
||||
subparser.addArgument("-g", "--group-id")
|
||||
.help("Specify a group to post the story to. Without this, posts to My Story.");
|
||||
}
|
||||
|
||||
@ -42,7 +42,8 @@ public class SubmitRateLimitChallengeCommand implements JsonRpcLocalCommand {
|
||||
throw new IOErrorException("Submit challenge error: " + e.getMessage(), e);
|
||||
} catch (CaptchaRejectedException e) {
|
||||
throw new CaptchaRejectedErrorException(
|
||||
"Captcha rejected, it may be outdated, already used or solved from a different IP address.", e);
|
||||
"Captcha rejected, it may be outdated, already used or solved from a different IP address.",
|
||||
e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,54 @@
|
||||
package org.asamk.signal.commands;
|
||||
|
||||
import net.sourceforge.argparse4j.inf.Namespace;
|
||||
import net.sourceforge.argparse4j.inf.Subparser;
|
||||
|
||||
import org.asamk.signal.commands.exceptions.CommandException;
|
||||
import org.asamk.signal.commands.exceptions.IOErrorException;
|
||||
import org.asamk.signal.commands.exceptions.UserErrorException;
|
||||
import org.asamk.signal.manager.Manager;
|
||||
import org.asamk.signal.manager.api.GroupNotFoundException;
|
||||
import org.asamk.signal.manager.api.NotAGroupMemberException;
|
||||
import org.asamk.signal.output.OutputWriter;
|
||||
import org.asamk.signal.util.CommandUtil;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
import static org.asamk.signal.util.SendMessageResultUtils.outputResult;
|
||||
|
||||
public class TerminateGroupCommand implements JsonRpcLocalCommand {
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return "terminateGroup";
|
||||
}
|
||||
|
||||
@Override
|
||||
public void attachToSubparser(final Subparser subparser) {
|
||||
subparser.help(
|
||||
"Permanently terminate a group for all members. Requires admin privileges; afterwards nobody can send messages or start calls.");
|
||||
subparser.addArgument("-g", "--group-id", "--group").required(true).help("Specify the group ID.");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handleCommand(
|
||||
final Namespace ns,
|
||||
final Manager m,
|
||||
final OutputWriter outputWriter
|
||||
) throws CommandException {
|
||||
final var groupId = CommandUtil.getGroupId(ns.getString("group-id"));
|
||||
|
||||
try {
|
||||
final var results = m.terminateGroup(groupId);
|
||||
outputResult(outputWriter, results);
|
||||
} catch (IOException e) {
|
||||
throw new IOErrorException("Failed to send message: "
|
||||
+ e.getMessage()
|
||||
+ " ("
|
||||
+ e.getClass().getSimpleName()
|
||||
+ ")", e);
|
||||
} catch (GroupNotFoundException | NotAGroupMemberException e) {
|
||||
throw new UserErrorException("Failed to terminate group: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -327,6 +327,21 @@ public class DbusManagerImpl implements Manager {
|
||||
group.deleteGroup();
|
||||
}
|
||||
|
||||
@Override
|
||||
public SendGroupMessageResults terminateGroup(
|
||||
final GroupId groupId
|
||||
) throws IOException, GroupNotFoundException, NotAGroupMemberException {
|
||||
final var group = getRemoteObject(signal.getGroup(groupId.serialize()), Signal.Group.class);
|
||||
try {
|
||||
group.terminateGroup();
|
||||
} catch (Signal.Error.GroupNotFound e) {
|
||||
throw new GroupNotFoundException(groupId);
|
||||
} catch (Signal.Error.NotAGroupMember e) {
|
||||
throw new NotAGroupMemberException(groupId, group.Get("org.asamk.Signal.Group", "Name"));
|
||||
}
|
||||
return new SendGroupMessageResults(0, List.of());
|
||||
}
|
||||
|
||||
@Override
|
||||
public Pair<GroupId, SendGroupMessageResults> createGroup(
|
||||
final String name,
|
||||
@ -548,11 +563,7 @@ public class DbusManagerImpl implements Manager {
|
||||
}
|
||||
|
||||
@Override
|
||||
public SendMessageResults sendStory(
|
||||
String attachment,
|
||||
boolean allowsReplies,
|
||||
Optional<GroupId> groupId
|
||||
) {
|
||||
public SendMessageResults sendStory(String attachment, boolean allowsReplies, Optional<GroupId> groupId) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@ -828,6 +839,7 @@ public class DbusManagerImpl implements Manager {
|
||||
0,
|
||||
false,
|
||||
contactBlocked,
|
||||
0,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
@ -875,7 +887,8 @@ public class DbusManagerImpl implements Manager {
|
||||
GroupPermission.valueOf((String) group.get("PermissionEditDetails").getValue()),
|
||||
GroupPermission.valueOf((String) group.get("PermissionSendMessage").getValue()),
|
||||
(boolean) group.get("IsMember").getValue(),
|
||||
(boolean) group.get("IsAdmin").getValue());
|
||||
(boolean) group.get("IsAdmin").getValue(),
|
||||
group.get("IsTerminated") != null && (boolean) group.get("IsTerminated").getValue());
|
||||
} catch (GroupInviteLinkUrl.InvalidGroupLinkException | GroupInviteLinkUrl.UnknownGroupLinkVersionException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
|
||||
@ -1308,6 +1308,7 @@ public class DbusSignalImpl implements Signal, AutoCloseable {
|
||||
new DbusProperty<>("IsBlocked", () -> getGroup().isBlocked(), this::setIsBlocked),
|
||||
new DbusProperty<>("IsMember", () -> getGroup().isMember()),
|
||||
new DbusProperty<>("IsAdmin", () -> getGroup().isAdmin()),
|
||||
new DbusProperty<>("IsTerminated", () -> getGroup().isTerminated()),
|
||||
new DbusProperty<>("MessageExpirationTimer",
|
||||
() -> getGroup().messageExpirationTimer(),
|
||||
this::setMessageExpirationTime),
|
||||
@ -1375,6 +1376,19 @@ public class DbusSignalImpl implements Signal, AutoCloseable {
|
||||
updateGroups();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void terminateGroup() throws Error.Failure {
|
||||
try {
|
||||
m.terminateGroup(groupId);
|
||||
} catch (GroupNotFoundException e) {
|
||||
throw new Error.GroupNotFound(e.getMessage());
|
||||
} catch (NotAGroupMemberException e) {
|
||||
throw new Error.NotAGroupMember(e.getMessage());
|
||||
} catch (IOException e) {
|
||||
throw new Error.Failure(e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void addMembers(final List<String> recipients) throws Error.Failure {
|
||||
final var memberIdentifiers = getSingleRecipientIdentifiers(recipients, m.getSelfNumber());
|
||||
|
||||
@ -76,7 +76,8 @@ public class HttpServerHandler implements AutoCloseable {
|
||||
// If we're listening on any local address (0.0.0.0 or ::), skip Host header validation
|
||||
final var addr = address == null ? null : address.getAddress();
|
||||
if (addr != null && addr.isAnyLocalAddress()) {
|
||||
logger.warn("HTTP server has no authentication; Host header validation DISABLED because listening on {}", address);
|
||||
logger.warn("HTTP server has no authentication; Host header validation DISABLED because listening on {}",
|
||||
address);
|
||||
} else {
|
||||
logger.warn("HTTP server has no authentication; Host header is pinned to {}", allowedHosts);
|
||||
}
|
||||
@ -114,7 +115,8 @@ public class HttpServerHandler implements AutoCloseable {
|
||||
private void handleRpcEndpoint(HttpExchange httpExchange) throws IOException {
|
||||
if (!isHostAllowed(httpExchange)) {
|
||||
logger.warn("Rejected RPC request with invalid Host header: {} from {}",
|
||||
httpExchange.getRequestHeaders().getFirst("Host"), httpExchange.getRemoteAddress());
|
||||
httpExchange.getRequestHeaders().getFirst("Host"),
|
||||
httpExchange.getRemoteAddress());
|
||||
sendResponse(421, null, httpExchange);
|
||||
return;
|
||||
}
|
||||
@ -167,7 +169,8 @@ public class HttpServerHandler implements AutoCloseable {
|
||||
private void handleEventsEndpoint(HttpExchange httpExchange) throws IOException {
|
||||
if (!isHostAllowed(httpExchange)) {
|
||||
logger.warn("Rejected Events request with invalid Host header: {} from {}",
|
||||
httpExchange.getRequestHeaders().getFirst("Host"), httpExchange.getRemoteAddress());
|
||||
httpExchange.getRequestHeaders().getFirst("Host"),
|
||||
httpExchange.getRemoteAddress());
|
||||
sendResponse(421, null, httpExchange);
|
||||
return;
|
||||
}
|
||||
|
||||
@ -1,10 +1,11 @@
|
||||
package org.asamk.signal.json;
|
||||
|
||||
import io.micronaut.jsonschema.JsonSchema;
|
||||
import org.asamk.signal.manager.api.MessageEnvelope;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
import io.micronaut.jsonschema.JsonSchema;
|
||||
|
||||
@JsonSchema(title = "AdminDelete")
|
||||
public record JsonAdminDelete(
|
||||
@Deprecated String targetAuthor, String targetAuthorNumber, String targetAuthorUuid, long targetSentTimestamp
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
package org.asamk.signal.json;
|
||||
|
||||
import io.micronaut.jsonschema.JsonSchema;
|
||||
|
||||
import org.asamk.signal.manager.api.MessageEnvelope;
|
||||
|
||||
import io.micronaut.jsonschema.JsonSchema;
|
||||
|
||||
@JsonSchema(title = "Attachment")
|
||||
record JsonAttachment(
|
||||
String contentType,
|
||||
@ -13,7 +13,8 @@ record JsonAttachment(
|
||||
Integer width,
|
||||
Integer height,
|
||||
String caption,
|
||||
Long uploadTimestamp
|
||||
Long uploadTimestamp,
|
||||
boolean isVoiceNote
|
||||
) {
|
||||
|
||||
static JsonAttachment from(MessageEnvelope.Data.Attachment attachment) {
|
||||
@ -32,6 +33,7 @@ record JsonAttachment(
|
||||
width,
|
||||
height,
|
||||
caption,
|
||||
uploadTimestamp);
|
||||
uploadTimestamp,
|
||||
attachment.isVoiceNote());
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
package org.asamk.signal.json;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import io.micronaut.jsonschema.JsonSchema;
|
||||
|
||||
import org.asamk.signal.manager.api.MessageEnvelope;
|
||||
|
||||
@ -9,6 +8,8 @@ import java.math.BigInteger;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
|
||||
import io.micronaut.jsonschema.JsonSchema;
|
||||
|
||||
import static org.asamk.signal.manager.util.Utils.callIdUnsigned;
|
||||
|
||||
@JsonSchema(title = "CallMessage")
|
||||
|
||||
@ -1,10 +1,11 @@
|
||||
package org.asamk.signal.json;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import io.micronaut.jsonschema.JsonSchema;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import io.micronaut.jsonschema.JsonSchema;
|
||||
|
||||
@JsonSchema(title = "Contact")
|
||||
public record JsonContact(
|
||||
String number,
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
package org.asamk.signal.json;
|
||||
|
||||
import io.micronaut.jsonschema.JsonSchema;
|
||||
|
||||
import org.asamk.signal.manager.api.MessageEnvelope;
|
||||
import org.asamk.signal.util.Util;
|
||||
|
||||
import io.micronaut.jsonschema.JsonSchema;
|
||||
|
||||
@JsonSchema(title = "ContactAddress")
|
||||
public record JsonContactAddress(
|
||||
String type,
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
package org.asamk.signal.json;
|
||||
|
||||
import io.micronaut.jsonschema.JsonSchema;
|
||||
|
||||
import org.asamk.signal.manager.api.MessageEnvelope;
|
||||
|
||||
import io.micronaut.jsonschema.JsonSchema;
|
||||
|
||||
@JsonSchema(title = "ContactAvatar")
|
||||
public record JsonContactAvatar(JsonAttachment attachment, boolean isProfile) {
|
||||
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
package org.asamk.signal.json;
|
||||
|
||||
import io.micronaut.jsonschema.JsonSchema;
|
||||
|
||||
import org.asamk.signal.manager.api.MessageEnvelope;
|
||||
import org.asamk.signal.util.Util;
|
||||
|
||||
import io.micronaut.jsonschema.JsonSchema;
|
||||
|
||||
@JsonSchema(title = "ContactEmail")
|
||||
public record JsonContactEmail(String value, String type, String label) {
|
||||
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
package org.asamk.signal.json;
|
||||
|
||||
import io.micronaut.jsonschema.JsonSchema;
|
||||
|
||||
import org.asamk.signal.manager.api.MessageEnvelope;
|
||||
import org.asamk.signal.util.Util;
|
||||
|
||||
import io.micronaut.jsonschema.JsonSchema;
|
||||
|
||||
@JsonSchema(title = "ContactName")
|
||||
public record JsonContactName(
|
||||
String nickname, String given, String family, String prefix, String suffix, String middle
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
package org.asamk.signal.json;
|
||||
|
||||
import io.micronaut.jsonschema.JsonSchema;
|
||||
|
||||
import org.asamk.signal.manager.api.MessageEnvelope;
|
||||
import org.asamk.signal.util.Util;
|
||||
|
||||
import io.micronaut.jsonschema.JsonSchema;
|
||||
|
||||
@JsonSchema(title = "ContactPhone")
|
||||
public record JsonContactPhone(String value, String type, String label) {
|
||||
|
||||
|
||||
@ -1,13 +1,14 @@
|
||||
package org.asamk.signal.json;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import io.micronaut.jsonschema.JsonSchema;
|
||||
|
||||
import org.asamk.signal.manager.Manager;
|
||||
import org.asamk.signal.manager.api.MessageEnvelope;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import io.micronaut.jsonschema.JsonSchema;
|
||||
|
||||
@JsonSchema(title = "DataMessage")
|
||||
record JsonDataMessage(
|
||||
long timestamp,
|
||||
@ -15,6 +16,10 @@ record JsonDataMessage(
|
||||
Integer expiresInSeconds,
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL) Boolean isExpirationUpdate,
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL) Boolean viewOnce,
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL) JsonGroupCallUpdate groupCallUpdate,
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL) Boolean isEndSession,
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL) Boolean isProfileKeyUpdate,
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL) Boolean hasProfileKey,
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL) JsonReaction reaction,
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL) JsonQuote quote,
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL) JsonPayment payment,
|
||||
@ -46,6 +51,10 @@ record JsonDataMessage(
|
||||
final var expiresInSeconds = dataMessage.expiresInSeconds();
|
||||
final var isExpirationUpdate = dataMessage.isExpirationUpdate();
|
||||
final var viewOnce = dataMessage.isViewOnce();
|
||||
final var groupCallUpdate = dataMessage.groupCallUpdate().map(JsonGroupCallUpdate::from).orElse(null);
|
||||
final var isEndSession = dataMessage.isEndSession();
|
||||
final var isProfileKeyUpdate = dataMessage.isProfileKeyUpdate();
|
||||
final var hasProfileKey = dataMessage.hasProfileKey();
|
||||
final var reaction = dataMessage.reaction().map(JsonReaction::from).orElse(null);
|
||||
final var quote = dataMessage.quote().isPresent() ? JsonQuote.from(dataMessage.quote().get()) : null;
|
||||
final var payment = dataMessage.payment().isPresent() ? JsonPayment.from(dataMessage.payment().get()) : null;
|
||||
@ -85,6 +94,10 @@ record JsonDataMessage(
|
||||
expiresInSeconds,
|
||||
isExpirationUpdate,
|
||||
viewOnce,
|
||||
groupCallUpdate,
|
||||
isEndSession,
|
||||
isProfileKeyUpdate,
|
||||
hasProfileKey,
|
||||
reaction,
|
||||
quote,
|
||||
payment,
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
package org.asamk.signal.json;
|
||||
|
||||
import io.micronaut.jsonschema.JsonSchema;
|
||||
|
||||
import org.asamk.signal.manager.Manager;
|
||||
import org.asamk.signal.manager.api.MessageEnvelope;
|
||||
|
||||
import io.micronaut.jsonschema.JsonSchema;
|
||||
|
||||
@JsonSchema(title = "EditMessage")
|
||||
record JsonEditMessage(long targetSentTimestamp, JsonDataMessage dataMessage) {
|
||||
|
||||
|
||||
13
src/main/java/org/asamk/signal/json/JsonGroupCallUpdate.java
Normal file
13
src/main/java/org/asamk/signal/json/JsonGroupCallUpdate.java
Normal file
@ -0,0 +1,13 @@
|
||||
package org.asamk.signal.json;
|
||||
|
||||
import org.asamk.signal.manager.api.MessageEnvelope;
|
||||
|
||||
import io.micronaut.jsonschema.JsonSchema;
|
||||
|
||||
@JsonSchema(title = "GroupCallUpdate")
|
||||
record JsonGroupCallUpdate(String eraId) {
|
||||
|
||||
static JsonGroupCallUpdate from(MessageEnvelope.Data.GroupCallUpdate groupCallUpdate) {
|
||||
return new JsonGroupCallUpdate(groupCallUpdate.eraId());
|
||||
}
|
||||
}
|
||||
@ -1,10 +1,10 @@
|
||||
package org.asamk.signal.json;
|
||||
|
||||
import io.micronaut.jsonschema.JsonSchema;
|
||||
|
||||
import org.asamk.signal.manager.Manager;
|
||||
import org.asamk.signal.manager.api.MessageEnvelope;
|
||||
|
||||
import io.micronaut.jsonschema.JsonSchema;
|
||||
|
||||
@JsonSchema(title = "GroupInfo")
|
||||
record JsonGroupInfo(String groupId, String groupName, int revision, String type) {
|
||||
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
package org.asamk.signal.json;
|
||||
|
||||
import io.micronaut.jsonschema.JsonSchema;
|
||||
|
||||
import org.asamk.signal.manager.api.MessageEnvelope;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
import io.micronaut.jsonschema.JsonSchema;
|
||||
|
||||
@JsonSchema(title = "Mention")
|
||||
public record JsonMention(@Deprecated String name, String number, String uuid, int start, int length) {
|
||||
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
package org.asamk.signal.json;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import io.micronaut.jsonschema.JsonSchema;
|
||||
|
||||
import org.asamk.signal.manager.Manager;
|
||||
import org.asamk.signal.manager.api.MessageEnvelope;
|
||||
@ -11,6 +10,8 @@ import org.asamk.signal.manager.api.UntrustedIdentityException;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
import io.micronaut.jsonschema.JsonSchema;
|
||||
|
||||
@JsonSchema(title = "MessageEnvelope")
|
||||
public record JsonMessageEnvelope(
|
||||
@Deprecated String source,
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
package org.asamk.signal.json;
|
||||
|
||||
import io.micronaut.jsonschema.JsonSchema;
|
||||
|
||||
import org.asamk.signal.manager.api.MessageEnvelope;
|
||||
|
||||
import io.micronaut.jsonschema.JsonSchema;
|
||||
|
||||
@JsonSchema(title = "Payment")
|
||||
public record JsonPayment(String note, byte[] receipt) {
|
||||
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
package org.asamk.signal.json;
|
||||
|
||||
import io.micronaut.jsonschema.JsonSchema;
|
||||
|
||||
import org.asamk.signal.manager.api.MessageEnvelope;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
import io.micronaut.jsonschema.JsonSchema;
|
||||
|
||||
@JsonSchema(title = "PinMessage")
|
||||
public record JsonPinMessage(
|
||||
@Deprecated String targetAuthor,
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
package org.asamk.signal.json;
|
||||
|
||||
import io.micronaut.jsonschema.JsonSchema;
|
||||
|
||||
import org.asamk.signal.manager.api.MessageEnvelope;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import io.micronaut.jsonschema.JsonSchema;
|
||||
|
||||
@JsonSchema(title = "PollCreate")
|
||||
public record JsonPollCreate(
|
||||
String question, boolean allowMultiple, List<String> options
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
package org.asamk.signal.json;
|
||||
|
||||
import io.micronaut.jsonschema.JsonSchema;
|
||||
|
||||
import org.asamk.signal.manager.api.MessageEnvelope;
|
||||
|
||||
import io.micronaut.jsonschema.JsonSchema;
|
||||
|
||||
@JsonSchema(title = "PollTerminate")
|
||||
public record JsonPollTerminate(long targetSentTimestamp) {
|
||||
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
package org.asamk.signal.json;
|
||||
|
||||
import io.micronaut.jsonschema.JsonSchema;
|
||||
|
||||
import org.asamk.signal.manager.api.MessageEnvelope;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import io.micronaut.jsonschema.JsonSchema;
|
||||
|
||||
@JsonSchema(title = "PollVote")
|
||||
public record JsonPollVote(
|
||||
@Deprecated String author,
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
package org.asamk.signal.json;
|
||||
|
||||
import io.micronaut.jsonschema.JsonSchema;
|
||||
|
||||
import org.asamk.signal.manager.api.MessageEnvelope;
|
||||
|
||||
import io.micronaut.jsonschema.JsonSchema;
|
||||
|
||||
@JsonSchema(title = "Preview")
|
||||
public record JsonPreview(String url, String title, String description, JsonAttachment image) {
|
||||
|
||||
|
||||
@ -1,13 +1,14 @@
|
||||
package org.asamk.signal.json;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import io.micronaut.jsonschema.JsonSchema;
|
||||
|
||||
import org.asamk.signal.manager.api.MessageEnvelope;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import io.micronaut.jsonschema.JsonSchema;
|
||||
|
||||
@JsonSchema(title = "Quote")
|
||||
public record JsonQuote(
|
||||
long id,
|
||||
|
||||
@ -1,10 +1,11 @@
|
||||
package org.asamk.signal.json;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import io.micronaut.jsonschema.JsonSchema;
|
||||
|
||||
import org.asamk.signal.manager.api.MessageEnvelope;
|
||||
|
||||
import io.micronaut.jsonschema.JsonSchema;
|
||||
|
||||
@JsonSchema(title = "QuotedAttachment")
|
||||
public record JsonQuotedAttachment(
|
||||
String contentType, String filename, @JsonInclude(JsonInclude.Include.NON_NULL) JsonAttachment thumbnail
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
package org.asamk.signal.json;
|
||||
|
||||
import io.micronaut.jsonschema.JsonSchema;
|
||||
|
||||
import org.asamk.signal.manager.api.MessageEnvelope;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
import io.micronaut.jsonschema.JsonSchema;
|
||||
|
||||
@JsonSchema(title = "Reaction")
|
||||
public record JsonReaction(
|
||||
String emoji,
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
package org.asamk.signal.json;
|
||||
|
||||
import io.micronaut.jsonschema.JsonSchema;
|
||||
|
||||
import org.asamk.signal.manager.api.MessageEnvelope;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import io.micronaut.jsonschema.JsonSchema;
|
||||
|
||||
@JsonSchema(title = "ReceiptMessage")
|
||||
record JsonReceiptMessage(long when, boolean isDelivery, boolean isRead, boolean isViewed, List<Long> timestamps) {
|
||||
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
package org.asamk.signal.json;
|
||||
|
||||
import io.micronaut.jsonschema.JsonSchema;
|
||||
|
||||
import org.asamk.signal.manager.api.RecipientAddress;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
import io.micronaut.jsonschema.JsonSchema;
|
||||
|
||||
@JsonSchema(title = "RecipientAddress")
|
||||
public record JsonRecipientAddress(String uuid, String number, String username) {
|
||||
|
||||
|
||||
@ -1,11 +1,12 @@
|
||||
package org.asamk.signal.json;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import io.micronaut.jsonschema.JsonSchema;
|
||||
|
||||
import org.asamk.signal.manager.api.GroupId;
|
||||
import org.asamk.signal.manager.api.SendMessageResult;
|
||||
|
||||
import io.micronaut.jsonschema.JsonSchema;
|
||||
|
||||
@JsonSchema(title = "SendMessageResult")
|
||||
public record JsonSendMessageResult(
|
||||
JsonRecipientAddress recipientAddress,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user