Compare commits

..

35 Commits

Author SHA1 Message Date
AsamK
3c17e01c37 Fix storage sync issues 2026-08-21 21:39:51 +02:00
AsamK
9605c94f09 Improve contact merging 2026-08-21 20:47:35 +02:00
AsamK
d2f5696df1 Update libsignal-service 2026-08-21 20:35:42 +02:00
AsamK
81e513d6b7 Implement storage sync loop detector 2026-08-21 18:46:03 +02:00
AsamK
dbf11fd006 Update graalvm build tools 2026-08-21 18:37:35 +02:00
Bazyli Brzóska
7e802166a3
fix(json): expose data message metadata (#2104) 2026-08-17 12:40:27 +02:00
Bazyli Brzóska
0f88410465
feat(json): expose voice-note attachment metadata (#2100)
Include isVoiceNote in receive JSON and JSON-RPC attachment payloads. Add regression coverage for true and false serialization.
2026-08-10 10:38:27 +02:00
Javair Ratliff
13819c8f09
Support the GroupsV2 "end group" (terminate) feature (#2098)
* Show and enforce the terminated state of a group

A group admin can permanently terminate a group. Afterwards the group is
read-only for everyone, not even an admin can send messages or start calls;
members keep access to the existing history. Other clients silently drop
messages sent to a terminated group.

Read the DecryptedGroup.terminated flag through GroupInfo.isTerminated() and:

- surface it in listGroups (json and plain text) and as the DBus IsTerminated
  group property
- refuse to send to a terminated group locally (messages, reactions, typing and
  group stories) rather than send one that other clients ignore
- drop incoming messages addressed to a terminated group

* Add terminateGroup command to terminate a group for everyone

Let a group admin permanently terminate a GroupV2 group via the
TerminateGroupAction (Groups.proto field 28, change epoch 7) the pinned library
already exposes as GroupsV2Operations.createTerminateGroup().

- GroupV2Helper.terminateGroup builds and commits the change
- GroupHelper.terminateGroup resolves/refreshes the group (v2 only), sends the
  update to members and syncs storage, with the same conflict-retry as
  updateGroup
- exposed through Manager.terminateGroup, the terminateGroup CLI/JSON-RPC command
  and the DBus Group.terminateGroup method

---------

Co-authored-by: FailSpy <FailSpy@users.noreply.github.com>
2026-08-08 17:16:49 +02:00
AsamK
85580d1ff6 Fix graalvm issue with image dimension probing
Fixes #2096
2026-08-04 13:16:58 +02:00
AsamK
f8ea8ed314 Update graalvm build tools 2026-08-04 13:14:29 +02:00
AsamK
472784e614 Replace unmaintained release actions 2026-08-02 08:45:09 +02:00
AsamK
db97731f63 Update redhat-actions in workflow 2026-08-02 08:45:09 +02:00
AsamK
ff9f2028ae Prepare next release 2026-08-02 08:45:09 +02:00
AsamK
9ee5262876 Prepare next release 2026-08-01 10:07:44 +02:00
AsamK
b01b6b370d Fix build pipeline 2026-08-01 09:38:01 +02:00
AsamK
890f798a03 Bump version to 0.14.7 2026-08-01 09:30:18 +02:00
AsamK
3c319f8b4a Reformat files 2026-08-01 09:24:09 +02:00
AsamK
fd3f572499 Use nickname for displaying recipient names 2026-08-01 09:21:52 +02:00
AsamK
854cb35d15 Explicitly store pni signature verified 2026-07-31 13:20:34 +02:00
AsamK
e78521bb5c Always set e164 in sync if available 2026-07-31 13:09:00 +02:00
AsamK
86ad7bf53f Always set the read receipts setting during provisioning if available 2026-07-31 13:08:13 +02:00
AsamK
de5daf0ad4 Cleanup incomplete account data after unsuccessful link
Fixes #2080
2026-07-31 09:01:08 +02:00
AsamK
ecc76ec52b Improve handling of pni change number messages 2026-07-30 22:44:06 +02:00
AsamK
6fa1d1bf90 Fix marking sender as needs pni signature 2026-07-30 22:44:06 +02:00
AsamK
d29f6c1641 Fix removing self from pending group invite via PNI 2026-07-30 22:44:06 +02:00
AsamK
b3e3eda4f7 Use upstream provisioning functionality 2026-07-30 22:44:06 +02:00
AsamK
1e59c6f6cc Readd job to update github dependency graph 2026-07-30 17:39:40 +02:00
AsamK
457658aca0 Update dependencies 2026-07-29 22:33:49 +02:00
AsamK
2cf5e25e9e Implement sticker pack sync 2026-07-29 22:33:49 +02:00
AsamK
151b42c6e9 Update libsignal-service 2026-07-29 22:33:48 +02:00
AsamK
91138452bb Update graalvm build tools 2026-07-29 19:20:31 +02:00
AsamK
304ffb0e33 Add special handling for file input stream attachments 2026-07-29 19:20:22 +02:00
mikesimone
987832099d
Set width/height on outgoing image attachments (#2093)
createAttachmentStream() never populated SignalServiceAttachmentStream's
width/height fields, even though the Builder has supported them since at
least the currently pinned libsignal-service version. Without them, other
Signal clients render outgoing image attachments as a square-cropped
thumbnail instead of their actual aspect ratio.

Probe dimensions via ImageIO for image/* attachments up to 20MiB (buffered
in memory only for that check) and pass them through to the builder.
Falls back to width/height 0 (today's behavior) for non-images, oversized
files, or anything ImageIO can't parse.
2026-07-29 19:07:06 +02:00
Arnout Engelen
64a629002d
docs: clarify register vs link (#2094)
This wasn't obvious to me, happy I asked in #2092 :)
2026-07-26 18:47:30 +02:00
AsamK
c26e0632c5 Prepare next release 2026-07-13 09:12:11 +02:00
117 changed files with 3246 additions and 396 deletions

27
.github/workflows/dependency-graph.yml vendored Normal file
View 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

View File

@ -35,55 +35,19 @@ jobs:
mv ./signal-cli-archive-${{ env.ARCHIVE_JAVA_VERSION }}/* . mv ./signal-cli-archive-${{ env.ARCHIVE_JAVA_VERSION }}/* .
echo "version=$(cat VERSION)" >> $GITHUB_OUTPUT echo "version=$(cat VERSION)" >> $GITHUB_OUTPUT
- name: Create release - name: Create draft release and upload assets
id: create_release
uses: actions/create-release@v1
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with: run: |
tag_name: v${{ steps.version.outputs.version }} # note: added `v` gh release create "v${{ steps.version.outputs.version }}" \
release_name: v${{ steps.version.outputs.version }} # note: added `v` --repo "${{ github.repository }}" \
draft: true --title "v${{ steps.version.outputs.version }}" \
--draft \
- name: Upload archive --verify-tag \
uses: actions/upload-release-asset@v1 "signal-cli-${{ steps.version.outputs.version }}.tar.gz" \
env: "signal-cli-${{ steps.version.outputs.version }}-Linux-native.tar.gz" \
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} "signal-cli-${{ steps.version.outputs.version }}-Linux-client.tar.gz" \
with: "signal-cli-${{ steps.version.outputs.version }}-json-schemas.tar.gz"
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
build-container: build-container:
needs: release needs: release
@ -104,7 +68,7 @@ jobs:
- name: Build Image - name: Build Image
id: build_image id: build_image
uses: redhat-actions/buildah-build@v2 uses: redhat-actions/buildah-build@v3
with: with:
image: ${{ env.IMAGE_NAME }} image: ${{ env.IMAGE_NAME }}
tags: latest ${{ github.sha }} ${{ needs.release.outputs.version }} tags: latest ${{ github.sha }} ${{ needs.release.outputs.version }}
@ -112,7 +76,7 @@ jobs:
oci: true oci: true
- name: Push To GHCR - name: Push To GHCR
uses: redhat-actions/push-to-registry@v2 uses: redhat-actions/push-to-registry@v3
id: push id: push
with: with:
image: ${{ steps.build_image.outputs.image }} image: ${{ steps.build_image.outputs.image }}
@ -145,7 +109,7 @@ jobs:
- name: Build Image - name: Build Image
id: build_image id: build_image
uses: redhat-actions/buildah-build@v2 uses: redhat-actions/buildah-build@v3
with: with:
image: ${{ env.IMAGE_NAME }} image: ${{ env.IMAGE_NAME }}
tags: latest-native ${{ github.sha }}-native ${{ needs.release.outputs.version }}-native tags: latest-native ${{ github.sha }}-native ${{ needs.release.outputs.version }}-native
@ -153,7 +117,7 @@ jobs:
oci: true oci: true
- name: Push To GHCR - name: Push To GHCR
uses: redhat-actions/push-to-registry@v2 uses: redhat-actions/push-to-registry@v3
id: push id: push
with: with:
image: ${{ steps.build_image.outputs.image }} image: ${{ steps.build_image.outputs.image }}
@ -186,7 +150,7 @@ jobs:
- name: Build Image - name: Build Image
id: build_image id: build_image
uses: redhat-actions/buildah-build@v2 uses: redhat-actions/buildah-build@v3
with: with:
image: ${{ env.IMAGE_NAME }} image: ${{ env.IMAGE_NAME }}
tags: latest-client ${{ github.sha }}-client ${{ needs.release.outputs.version }}-client tags: latest-client ${{ github.sha }}-client ${{ needs.release.outputs.version }}-client
@ -194,7 +158,7 @@ jobs:
oci: true oci: true
- name: Push To GHCR - name: Push To GHCR
uses: redhat-actions/push-to-registry@v2 uses: redhat-actions/push-to-registry@v3
id: push id: push
with: with:
image: ${{ steps.build_image.outputs.image }} image: ${{ steps.build_image.outputs.image }}

View File

@ -1,5 +1,24 @@
# Changelog # 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 ## [0.14.6] - 2026-07-12
### Added ### Added

View File

@ -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 should start with a "+" sign. (See [Wikipedia](https://en.wikipedia.org/wiki/List_of_country_calling_codes) for a list
of all country codes.) of all country codes.)
* 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) * 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 signal-cli -a ACCOUNT register
You can register Signal using a landline number. In this case, you need to follow the procedure below: You can register Signal using a landline number. In this case, you need to follow the procedure below:

View File

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

View File

@ -45,6 +45,9 @@
<content_attribute id="social-chat">intense</content_attribute> <content_attribute id="social-chat">intense</content_attribute>
</content_rating> </content_rating>
<releases> <releases>
<release version="0.14.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"> <release version="0.14.6" date="2026-07-12">
<url type="details">https://github.com/AsamK/signal-cli/releases/tag/v0.14.6</url> <url type="details">https://github.com/AsamK/signal-cli/releases/tag/v0.14.6</url>
</release> </release>

View File

@ -1,13 +1,14 @@
[versions] [versions]
slf4j = "2.0.18" slf4j = "2.0.18"
junit = "6.1.0" coroutines = "1.10.2"
micronaut-json-schema = "2.0.1" junit = "6.1.2"
micronaut-core = "5.0.0" micronaut-json-schema = "2.1.0"
signal-service = "2.15.3_unofficial_149" micronaut-core = "5.1.10"
signal-service = "2.15.3_unofficial_152"
[libraries] [libraries]
bouncycastle = "org.bouncycastle:bcprov-jdk18on:1.84" bouncycastle = "org.bouncycastle:bcprov-jdk18on:1.85"
jackson-databind = "com.fasterxml.jackson.core:jackson-databind:2.21.5" jackson-databind = "com.fasterxml.jackson.core:jackson-databind:2.22.1"
argparse4j = "net.sourceforge.argparse4j:argparse4j:0.9.0" argparse4j = "net.sourceforge.argparse4j:argparse4j:0.9.0"
dbusjava = "com.github.hypfvieh:dbus-java-transport-native-unixsocket:5.0.0" dbusjava = "com.github.hypfvieh:dbus-java-transport-native-unixsocket:5.0.0"
zxing = "com.google.zxing:core:3.5.4" zxing = "com.google.zxing:core:3.5.4"
@ -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" } micronaut-inject-java = { module = "io.micronaut:micronaut-inject-java", version.ref = "micronaut-core" }
slf4j-api = { module = "org.slf4j:slf4j-api", version.ref = "slf4j" } slf4j-api = { module = "org.slf4j:slf4j-api", version.ref = "slf4j" }
slf4j-jul = { module = "org.slf4j:jul-to-slf4j", version.ref = "slf4j" } slf4j-jul = { module = "org.slf4j:jul-to-slf4j", version.ref = "slf4j" }
logback = "ch.qos.logback:logback-classic:1.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" } signalnetwork = { module = "com.github.turasa:signal-network", version.ref = "signal-service" }
sqlite = "org.xerial:sqlite-jdbc:3.53.1.0" sqlite = "org.xerial:sqlite-jdbc:3.53.2.1"
hikari = "com.zaxxer:HikariCP:7.0.2" hikari = "com.zaxxer:HikariCP:7.1.0"
junit-jupiter-bom = { module = "org.junit:junit-bom", version.ref = "junit" } junit-jupiter-bom = { module = "org.junit:junit-bom", version.ref = "junit" }
junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", 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" } junit-launcher = { module = "org.junit.platform:junit-platform-launcher", version.ref = "junit" }

View File

@ -30,6 +30,7 @@ dependencies {
implementation(libs.slf4j.api) implementation(libs.slf4j.api)
implementation(libs.sqlite) implementation(libs.sqlite)
implementation(libs.hikari) implementation(libs.hikari)
compileOnly(libs.kotlinx.coroutines.core)
testImplementation(libs.junit.jupiter) testImplementation(libs.junit.jupiter)
testImplementation(platform(libs.junit.jupiter.bom)) testImplementation(platform(libs.junit.jupiter.bom))

View File

@ -184,6 +184,10 @@ public interface Manager extends Closeable {
void deleteGroup(GroupId groupId) throws IOException; void deleteGroup(GroupId groupId) throws IOException;
SendGroupMessageResults terminateGroup(
GroupId groupId
) throws IOException, GroupNotFoundException, NotAGroupMemberException;
Pair<GroupId, SendGroupMessageResults> createGroup( Pair<GroupId, SendGroupMessageResults> createGroup(
String name, String name,
Set<RecipientIdentifier.Single> members, Set<RecipientIdentifier.Single> members,

View File

@ -15,6 +15,7 @@ public record Contact(
long muteUntil, long muteUntil,
boolean hideStory, boolean hideStory,
boolean isBlocked, boolean isBlocked,
long blockedAt,
boolean isArchived, boolean isArchived,
boolean isProfileSharingEnabled, boolean isProfileSharingEnabled,
boolean isHidden, boolean isHidden,
@ -34,6 +35,7 @@ public record Contact(
builder.muteUntil, builder.muteUntil,
builder.hideStory, builder.hideStory,
builder.isBlocked, builder.isBlocked,
builder.blockedAt,
builder.isArchived, builder.isArchived,
builder.isProfileSharingEnabled, builder.isProfileSharingEnabled,
builder.isHidden, builder.isHidden,
@ -58,6 +60,7 @@ public record Contact(
builder.muteUntil = copy.muteUntil(); builder.muteUntil = copy.muteUntil();
builder.hideStory = copy.hideStory(); builder.hideStory = copy.hideStory();
builder.isBlocked = copy.isBlocked(); builder.isBlocked = copy.isBlocked();
builder.blockedAt = copy.blockedAt();
builder.isArchived = copy.isArchived(); builder.isArchived = copy.isArchived();
builder.isProfileSharingEnabled = copy.isProfileSharingEnabled(); builder.isProfileSharingEnabled = copy.isProfileSharingEnabled();
builder.isHidden = copy.isHidden(); builder.isHidden = copy.isHidden();
@ -80,6 +83,21 @@ public record Contact(
return givenName + " " + familyName; 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 { public static final class Builder {
private String givenName; private String givenName;
@ -94,6 +112,7 @@ public record Contact(
private long muteUntil; private long muteUntil;
private boolean hideStory; private boolean hideStory;
private boolean isBlocked; private boolean isBlocked;
private long blockedAt;
private boolean isArchived; private boolean isArchived;
private boolean isProfileSharingEnabled; private boolean isProfileSharingEnabled;
private boolean isHidden; private boolean isHidden;
@ -162,10 +181,20 @@ public record Contact(
} }
public Builder withIsBlocked(final boolean val) { public Builder withIsBlocked(final boolean val) {
if (val && !isBlocked) {
blockedAt = System.currentTimeMillis();
} else if (!val) {
blockedAt = 0;
}
isBlocked = val; isBlocked = val;
return this; return this;
} }
public Builder withBlockedAt(final long val) {
blockedAt = val;
return this;
}
public Builder withIsArchived(final boolean val) { public Builder withIsArchived(final boolean val) {
isArchived = val; isArchived = val;
return this; return this;

View File

@ -22,7 +22,8 @@ public record Group(
GroupPermission permissionEditDetails, GroupPermission permissionEditDetails,
GroupPermission permissionSendMessage, GroupPermission permissionSendMessage,
boolean isMember, boolean isMember,
boolean isAdmin boolean isAdmin,
boolean isTerminated
) { ) {
public static Group from( public static Group from(
@ -59,6 +60,7 @@ public record Group(
groupInfo.getPermissionEditDetails(), groupInfo.getPermissionEditDetails(),
groupInfo.getPermissionSendMessage(), groupInfo.getPermissionSendMessage(),
groupInfo.isMember(selfRecipientId), groupInfo.isMember(selfRecipientId),
groupInfo.isAdmin(selfRecipientId)); groupInfo.isAdmin(selfRecipientId),
groupInfo.isTerminated());
} }
} }

View File

@ -739,7 +739,10 @@ public record MessageEnvelope(
null, null,
d.getE164(), d.getE164(),
null)) null))
.toList(), blockedListMessage.groupIds.stream().map(GroupId::unknownVersion).toList()); .toList(),
blockedListMessage.groups.stream()
.map(group -> GroupId.unknownVersion(group.getGroupId()))
.toList());
} }
} }

View File

@ -3,15 +3,15 @@ package org.asamk.signal.manager.config;
import org.signal.libsignal.net.Network.Environment; import org.signal.libsignal.net.Network.Environment;
import org.signal.libsignal.protocol.InvalidKeyException; import org.signal.libsignal.protocol.InvalidKeyException;
import org.signal.libsignal.protocol.ecc.ECPublicKey; import org.signal.libsignal.protocol.ecc.ECPublicKey;
import org.whispersystems.signalservice.api.push.TrustStore; import org.signal.network.config.HttpProxy;
import org.whispersystems.signalservice.internal.configuration.HttpProxy; import org.signal.network.config.SignalCdnUrl;
import org.whispersystems.signalservice.internal.configuration.SignalCdnUrl; import org.signal.network.config.SignalCdsiUrl;
import org.whispersystems.signalservice.internal.configuration.SignalCdsiUrl; import org.signal.network.config.SignalProxy;
import org.whispersystems.signalservice.internal.configuration.SignalProxy; import org.signal.network.config.SignalServiceConfiguration;
import org.whispersystems.signalservice.internal.configuration.SignalServiceConfiguration; import org.signal.network.config.SignalServiceUrl;
import org.whispersystems.signalservice.internal.configuration.SignalServiceUrl; import org.signal.network.config.SignalStorageUrl;
import org.whispersystems.signalservice.internal.configuration.SignalStorageUrl; import org.signal.network.config.SignalSvr2Url;
import org.whispersystems.signalservice.internal.configuration.SignalSvr2Url; import org.signal.network.config.TrustStore;
import java.util.Base64; import java.util.Base64;
import java.util.List; import java.util.List;

View File

@ -32,7 +32,13 @@ public class ServiceConfig {
final var attachmentBackfill = !isPrimaryDevice; final var attachmentBackfill = !isPrimaryDevice;
final var spqr = true; final var spqr = true;
final var usernameSyncChangeMessage = !isPrimaryDevice; 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( public static ServiceEnvironmentConfig getServiceEnvironmentConfig(

View File

@ -3,7 +3,7 @@ package org.asamk.signal.manager.config;
import org.asamk.signal.manager.api.ServiceEnvironment; import org.asamk.signal.manager.api.ServiceEnvironment;
import org.signal.libsignal.net.Network; import org.signal.libsignal.net.Network;
import org.signal.libsignal.protocol.ecc.ECPublicKey; import org.signal.libsignal.protocol.ecc.ECPublicKey;
import org.whispersystems.signalservice.internal.configuration.SignalServiceConfiguration; import org.signal.network.config.SignalServiceConfiguration;
import java.util.List; import java.util.List;

View File

@ -3,15 +3,15 @@ package org.asamk.signal.manager.config;
import org.signal.libsignal.net.Network; import org.signal.libsignal.net.Network;
import org.signal.libsignal.protocol.InvalidKeyException; import org.signal.libsignal.protocol.InvalidKeyException;
import org.signal.libsignal.protocol.ecc.ECPublicKey; import org.signal.libsignal.protocol.ecc.ECPublicKey;
import org.whispersystems.signalservice.api.push.TrustStore; import org.signal.network.config.HttpProxy;
import org.whispersystems.signalservice.internal.configuration.HttpProxy; import org.signal.network.config.SignalCdnUrl;
import org.whispersystems.signalservice.internal.configuration.SignalCdnUrl; import org.signal.network.config.SignalCdsiUrl;
import org.whispersystems.signalservice.internal.configuration.SignalCdsiUrl; import org.signal.network.config.SignalProxy;
import org.whispersystems.signalservice.internal.configuration.SignalProxy; import org.signal.network.config.SignalServiceConfiguration;
import org.whispersystems.signalservice.internal.configuration.SignalServiceConfiguration; import org.signal.network.config.SignalServiceUrl;
import org.whispersystems.signalservice.internal.configuration.SignalServiceUrl; import org.signal.network.config.SignalStorageUrl;
import org.whispersystems.signalservice.internal.configuration.SignalStorageUrl; import org.signal.network.config.SignalSvr2Url;
import org.whispersystems.signalservice.internal.configuration.SignalSvr2Url; import org.signal.network.config.TrustStore;
import java.util.Base64; import java.util.Base64;
import java.util.List; import java.util.List;

View File

@ -1,6 +1,6 @@
package org.asamk.signal.manager.config; package org.asamk.signal.manager.config;
import org.whispersystems.signalservice.api.push.TrustStore; import org.signal.network.config.TrustStore;
import java.io.InputStream; import java.io.InputStream;

View File

@ -17,6 +17,7 @@ import org.asamk.signal.manager.util.Utils;
import org.signal.core.models.ServiceId.ACI; import org.signal.core.models.ServiceId.ACI;
import org.signal.core.models.ServiceId.PNI; import org.signal.core.models.ServiceId.PNI;
import org.signal.core.util.Base64; import org.signal.core.util.Base64;
import org.signal.core.util.crypto.DeviceNameCipher;
import org.signal.libsignal.protocol.IdentityKeyPair; import org.signal.libsignal.protocol.IdentityKeyPair;
import org.signal.libsignal.protocol.InvalidKeyException; import org.signal.libsignal.protocol.InvalidKeyException;
import org.signal.libsignal.protocol.NoSessionException; 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.account.ChangePhoneNumberRequest;
import org.whispersystems.signalservice.api.crypto.UntrustedIdentityException; import org.whispersystems.signalservice.api.crypto.UntrustedIdentityException;
import org.whispersystems.signalservice.api.link.LinkedDeviceVerificationCodeResponse; 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.ServiceIdType;
import org.whispersystems.signalservice.api.push.SignalServiceAddress; import org.whispersystems.signalservice.api.push.SignalServiceAddress;
import org.whispersystems.signalservice.api.push.SignedPreKeyEntity; 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.AlreadyVerifiedException;
import org.whispersystems.signalservice.api.push.exceptions.AuthorizationFailedException; import org.whispersystems.signalservice.api.push.exceptions.AuthorizationFailedException;
import org.whispersystems.signalservice.api.push.exceptions.DeprecatedVersionException; 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.DeviceLimitExceededException;
import org.whispersystems.signalservice.internal.push.KyberPreKeyEntity; import org.whispersystems.signalservice.internal.push.KyberPreKeyEntity;
import org.whispersystems.signalservice.internal.push.OutgoingPushMessage; 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 org.whispersystems.signalservice.internal.push.exceptions.MismatchedDevicesException;
import java.io.IOException; import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Objects; 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.config.ServiceConfig.PREKEY_MAXIMUM_ID;
import static org.asamk.signal.manager.util.Utils.handleResponseException; import static org.asamk.signal.manager.util.Utils.handleResponseException;
import static org.asamk.signal.manager.util.Utils.handleResponseExceptionSuspend;
import static org.whispersystems.signalservice.internal.util.Util.isEmpty; import static org.whispersystems.signalservice.internal.util.Util.isEmpty;
public class AccountHelper { public class AccountHelper {
@ -321,10 +325,14 @@ public class AccountHelper {
return; 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 if (pniChangeNumber.identityKeyPair != null
&& pniChangeNumber.registrationId != null && pniChangeNumber.registrationId != null
&& pniChangeNumber.signedPreKey != null) { && pniChangeNumber.signedPreKey != null) {
@ -338,10 +346,19 @@ public class AccountHelper {
pniChangeNumber.lastResortKyberPreKey != null pniChangeNumber.lastResortKyberPreKey != null
? new KyberPreKeyRecord(pniChangeNumber.lastResortKyberPreKey.toByteArray()) ? new KyberPreKeyRecord(pniChangeNumber.lastResortKyberPreKey.toByteArray())
: null); : 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) { } catch (Exception e) {
logger.warn("Failed to handle change number message", e); logger.warn("Failed to handle change number message", e);
} }
} }
return false;
} }
public static final int USERNAME_MIN_LENGTH = 3; public static final int USERNAME_MIN_LENGTH = 3;
@ -383,13 +400,17 @@ public class AccountHelper {
} }
private void reserveUsername(final List<Username> candidates) throws IOException { 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) { 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 usernameHash = handleResponseException(dependencies.getAccountApi().reserveUsername(candidateHashes));
final var hashIndex = candidateHashes.indexOf(response.getUsernameHash()); final var hashIndex = candidateHashes.stream()
.filter(candidateHash -> Arrays.equals(candidateHash, usernameHash))
.findFirst()
.map(candidateHashes::indexOf)
.orElse(-1);
if (hashIndex == -1) { if (hashIndex == -1) {
logger.warn("[reserveUsername] The response hash could not be found in our set of candidateHashes."); logger.warn("[reserveUsername] The response hash could not be found in our set of candidateHashes.");
throw new IOException("Unexpected username response"); throw new IOException("Unexpected username response");
@ -482,8 +503,7 @@ public class AccountHelper {
final var usernameLink = account.getUsernameLink(); final var usernameLink = account.getUsernameLink();
if (usernameLink == null) { if (usernameLink == null) {
handleResponseException(dependencies.getAccountApi() handleResponseException(dependencies.getAccountApi().reserveUsername(List.of(username.getHash())));
.reserveUsername(List.of(Base64.encodeUrlSafeWithoutPadding(username.getHash()))));
logger.debug("[reserveUsername] Successfully reserved existing username."); logger.debug("[reserveUsername] Successfully reserved existing username.");
final var linkComponents = confirmUsernameAndCreateNewLink(username); final var linkComponents = confirmUsernameAndCreateNewLink(username);
account.setUsernameLink(linkComponents); account.setUsernameLink(linkComponents);
@ -508,28 +528,34 @@ public class AccountHelper {
} }
} }
@SuppressWarnings("unchecked")
public void deleteUsername() throws IOException { public void deleteUsername() throws IOException {
handleResponseException(dependencies.getAccountApi().deleteUsername()); handleResponseException(dependencies.getAccountApi().deleteUsernameHash());
account.setUsernameLink(null); account.setUsernameLink(null);
account.setUsername(null); account.setUsername(null);
logger.debug("[deleteUsername] Successfully deleted the username."); logger.debug("[deleteUsername] Successfully deleted the username.");
} }
public void setDeviceName(String deviceName) { public void setDeviceName(String deviceName) {
final var privateKey = account.getAciIdentityKeyPair().getPrivateKey(); final var encryptedDeviceName = getEncryptedDeviceName(deviceName);
final var encryptedDeviceName = DeviceNameUtil.encryptDeviceName(deviceName, privateKey); account.setEncryptedDeviceName(Base64.encodeWithoutPadding(encryptedDeviceName));
account.setEncryptedDeviceName(encryptedDeviceName);
} }
public void setDeviceName(int deviceId, String deviceName) throws IOException { public void setDeviceName(int deviceId, String deviceName) throws IOException {
final var privateKey = account.getAciIdentityKeyPair().getPrivateKey(); final var encryptedDeviceName = getEncryptedDeviceName(deviceName);
final var encryptedDeviceName = DeviceNameUtil.encryptDeviceName(deviceName, privateKey); handleResponseExceptionSuspend(cont -> dependencies.getLinkDeviceApi()
handleResponseException(dependencies.getLinkDeviceApi().setDeviceName(encryptedDeviceName, deviceId)); .setDeviceName(encryptedDeviceName, deviceId, cont));
context.getSyncHelper().sendDeviceNameChange(deviceId); 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 { 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 deviceId = account.getDeviceId();
final var device = devices.stream().filter(d -> d.id == deviceId).findFirst(); final var device = devices.stream().filter(d -> d.id == deviceId).findFirst();
if (device.isPresent()) { if (device.isPresent()) {
@ -563,14 +589,16 @@ public class AccountHelper {
account.getOrCreatePinMasterKey(), account.getOrCreatePinMasterKey(),
account.getOrCreateMediaRootBackupKey(), account.getOrCreateMediaRootBackupKey(),
verificationCode.getVerificationCode(), verificationCode.getVerificationCode(),
null)); null,
account.getAuthCredentialSalt()));
account.setMultiDevice(true); account.setMultiDevice(true);
context.getJobExecutor().enqueueJob(new SyncStorageJob()); context.getJobExecutor().enqueueJob(new SyncStorageJob());
} }
public void removeLinkedDevices(int deviceId) throws IOException { public void removeLinkedDevices(int deviceId) throws IOException {
handleResponseException(dependencies.getLinkDeviceApi().removeDevice(deviceId)); handleResponseExceptionSuspend(cont -> dependencies.getLinkDeviceApi().removeDevice(deviceId, cont));
var devices = handleResponseException(dependencies.getLinkDeviceApi().getDevices()); final List<DeviceInfo> devices = handleResponseExceptionSuspend(cont -> dependencies.getLinkDeviceApi()
.getDevices(cont));
account.setMultiDevice(devices.size() > 1); account.setMultiDevice(devices.size() > 1);
} }
@ -578,16 +606,14 @@ public class AccountHelper {
var masterKey = account.getOrCreatePinMasterKey(); var masterKey = account.getOrCreatePinMasterKey();
context.getPinHelper().migrateRegistrationLockPin(account.getRegistrationLockPin(), masterKey); context.getPinHelper().migrateRegistrationLockPin(account.getRegistrationLockPin(), masterKey);
handleResponseException(dependencies.getAccountApi() handleResponseException(dependencies.getAccountApi().enableRegistrationLock(masterKey));
.enableRegistrationLock(masterKey.deriveRegistrationLock()));
} }
public void setRegistrationPin(String pin) throws IOException { public void setRegistrationPin(String pin) throws IOException {
var masterKey = account.getOrCreatePinMasterKey(); var masterKey = account.getOrCreatePinMasterKey();
context.getPinHelper().setRegistrationLockPin(pin, masterKey); context.getPinHelper().setRegistrationLockPin(pin, masterKey);
handleResponseException(dependencies.getAccountApi() handleResponseException(dependencies.getAccountApi().enableRegistrationLock(masterKey));
.enableRegistrationLock(masterKey.deriveRegistrationLock()));
account.setRegistrationLockPin(pin); account.setRegistrationLockPin(pin);
updateAccountAttributes(); updateAccountAttributes();
@ -605,7 +631,7 @@ public class AccountHelper {
// When setting an empty GCM id, the Signal-Server also sets the fetchesMessages property to false. // 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 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. // 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); account.setRegistered(false);
unregisteredListener.call(); unregisteredListener.call();

View File

@ -85,12 +85,18 @@ public class ContactHelper {
} }
public void setContactBlocked(RecipientId recipientId, boolean blocked) { 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); var contact = account.getContactStore().getContact(recipientId);
final var builder = contact == null ? Contact.newBuilder() : Contact.newBuilder(contact); final var builder = contact == null ? Contact.newBuilder() : Contact.newBuilder(contact);
if (blocked) { if (blocked) {
builder.withIsProfileSharingEnabled(false); 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) { public void setContactProfileSharing(RecipientId recipientId, boolean profileSharing) {

View File

@ -363,6 +363,28 @@ public class GroupHelper {
return results; 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 { public void updateGroupProfileKey(GroupIdV2 groupId) throws GroupNotFoundException, NotAGroupMemberException, IOException {
var group = getGroupForUpdating(groupId); var group = getGroupForUpdating(groupId);
@ -437,12 +459,21 @@ public class GroupHelper {
} }
public void setGroupBlocked(final GroupId groupId, final boolean blocked) throws GroupNotFoundException { 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); var group = getGroup(groupId);
if (group == null) { if (group == null) {
throw new GroupNotFoundException(groupId); throw new GroupNotFoundException(groupId);
} }
group.setBlocked(blocked); group.setBlocked(blocked);
group.setBlockedAt(blocked ? blockedAt : 0);
account.getGroupStore().updateGroup(group); account.getGroupStore().updateGroup(group);
context.getJobExecutor().enqueueJob(new SyncStorageJob()); context.getJobExecutor().enqueueJob(new SyncStorageJob());
} }

View File

@ -264,6 +264,9 @@ class GroupV2Helper {
var pendingMembersList = groupInfoV2.getGroup().pendingMembers; var pendingMembersList = groupInfoV2.getGroup().pendingMembers;
final var selfAci = getSelfAci(); final var selfAci = getSelfAci();
var selfPendingMember = DecryptedGroupUtil.findPendingByServiceId(pendingMembersList, selfAci); var selfPendingMember = DecryptedGroupUtil.findPendingByServiceId(pendingMembersList, selfAci);
if (selfPendingMember.isEmpty()) {
selfPendingMember = DecryptedGroupUtil.findPendingByServiceId(pendingMembersList, getSelfPni());
}
if (selfPendingMember.isPresent()) { if (selfPendingMember.isPresent()) {
return revokeInvites(groupInfoV2, Set.of(selfPendingMember.get())); return revokeInvites(groupInfoV2, Set.of(selfPendingMember.get()));
@ -540,6 +543,14 @@ class GroupV2Helper {
return commitChange(groupInfoV2, change); 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( Pair<DecryptedGroup, GroupChangeResponse> setMemberLabels(
GroupInfoV2 groupInfoV2, GroupInfoV2 groupInfoV2,
String labelEmoji, String labelEmoji,

View File

@ -340,9 +340,9 @@ public final class IncomingMessageHandler {
} }
if (account.getPni().equals(destination.serviceId)) { if (account.getPni().equals(destination.serviceId)) {
account.getRecipientStore().markNeedsPniSignature(destination.recipientId, true); account.getRecipientStore().markNeedsPniSignature(sender, true);
} else if (account.getAci().equals(destination.serviceId)) { } else if (account.getAci().equals(destination.serviceId)) {
account.getRecipientStore().markNeedsPniSignature(destination.recipientId, false); account.getRecipientStore().markNeedsPniSignature(sender, false);
} }
if (content.getReceiptMessage().isPresent()) { if (content.getReceiptMessage().isPresent()) {
@ -498,10 +498,11 @@ public final class IncomingMessageHandler {
} }
logger.debug("Verified association of ACI {} with PNI {}", aci, pni); logger.debug("Verified association of ACI {} with PNI {}", aci, pni);
account.getRecipientTrustedResolver() final var recipientId = account.getRecipientTrustedResolver()
.resolveRecipientTrusted(Optional.of(ACI.from(aci.getRawUuid())), .resolveRecipientTrusted(Optional.of(ACI.from(aci.getRawUuid())),
Optional.of(pni), Optional.of(pni),
senderAddress.getNumber()); senderAddress.getNumber());
account.getRecipientStore().markPniSignatureVerified(recipientId);
return true; return true;
} }
@ -625,13 +626,12 @@ public final class IncomingMessageHandler {
for (var individual : blockedListMessage.individuals) { for (var individual : blockedListMessage.individuals) {
final var address = new RecipientAddress(individual.getAci(), individual.getE164()); final var address = new RecipientAddress(individual.getAci(), individual.getE164());
final var recipientId = account.getRecipientResolver().resolveRecipient(address); 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() for (var group : blockedListMessage.groups) {
.map(GroupId::unknownVersion) final var groupId = GroupId.unknownVersion(group.getGroupId());
.collect(Collectors.toSet())) {
try { try {
context.getGroupHelper().setGroupBlocked(groupId, true); context.getGroupHelper().setGroupBlocked(groupId, true, group.getBlockedAt());
} catch (GroupNotFoundException e) { } catch (GroupNotFoundException e) {
logger.warn("BlockedListMessage contained groupID that was not found in GroupStore: {}", logger.warn("BlockedListMessage contained groupID that was not found in GroupStore: {}",
groupId.toBase64()); groupId.toBase64());
@ -717,11 +717,30 @@ public final class IncomingMessageHandler {
} }
if (syncMessage.getPniChangeNumber().isPresent()) { if (syncMessage.getPniChangeNumber().isPresent()) {
final var pniChangeNumber = syncMessage.getPniChangeNumber().get(); final var pniChangeNumber = syncMessage.getPniChangeNumber().get();
logger.debug("Received PNI change number sync message, applying."); if (account.isPrimaryDevice()) {
final var updatedPniString = envelope.getUpdatedPni(); logger.warn("Received PNI change number sync message on primary device, ignoring.");
if (updatedPniString != null && !updatedPniString.isEmpty()) { } else if (sender.deviceId() != SignalServiceAddress.DEFAULT_DEVICE_ID) {
final var updatedPni = ServiceId.PNI.parseOrThrow(updatedPniString); logger.warn("Received PNI change number sync message from non-primary device {}, ignoring.",
context.getAccountHelper().handlePniChangeNumberMessage(pniChangeNumber, updatedPni); 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()) { if (syncMessage.getDeviceNameChange().isPresent()) {
@ -817,6 +836,15 @@ public final class IncomingMessageHandler {
return true; 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)) { if (group.isAnnouncementGroup() && !group.isAdmin(recipientId)) {
return message == null return message == null
|| message.getBody().isPresent() || message.getBody().isPresent()

View File

@ -12,6 +12,7 @@ import org.asamk.signal.manager.storage.recipients.RecipientAddress;
import org.signal.core.models.ServiceId.ACI; import org.signal.core.models.ServiceId.ACI;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.whispersystems.signalservice.api.messages.EnvelopeResponse;
import org.whispersystems.signalservice.api.messages.SignalServiceEnvelope; import org.whispersystems.signalservice.api.messages.SignalServiceEnvelope;
import org.whispersystems.signalservice.api.websocket.SignalWebSocket; import org.whispersystems.signalservice.api.websocket.SignalWebSocket;
import org.whispersystems.signalservice.api.websocket.WebSocketConnectionState; import org.whispersystems.signalservice.api.websocket.WebSocketConnectionState;
@ -147,15 +148,19 @@ public class ReceiveHelper {
logger.debug("Retrieved {} envelopes!", batch.size()); logger.debug("Retrieved {} envelopes!", batch.size());
isWaitingForMessage = false; isWaitingForMessage = false;
for (final var it : batch) { for (final var it : batch) {
SignalServiceEnvelope envelope1 = new SignalServiceEnvelope(it.getEnvelope(), if (it instanceof EnvelopeResponse.Unparseable) {
it.getServerDeliveredTimestamp()); logger.warn("Received unparseable envelope from server, ignoring.");
final var sourceServiceId = envelope1.getSourceServiceId(); } else if (it instanceof EnvelopeResponse.Parsed parsed) {
final var recipientId = sourceServiceId == null SignalServiceEnvelope envelope1 = new SignalServiceEnvelope(parsed.getEnvelope(),
? null parsed.getServerDeliveredTimestamp());
: account.getRecipientResolver().resolveRecipient(sourceServiceId); final var sourceServiceId = envelope1.getSourceServiceId();
logger.trace("Storing new message from {}", recipientId); final var recipientId = sourceServiceId == null
// store message on disk, before acknowledging receipt to the server ? null
cachedMessage[0] = account.getMessageCache().cacheMessage(envelope1, recipientId); : 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 { try {
signalWebSocket.sendAck(it); signalWebSocket.sendAck(it);
} catch (IOException e) { } catch (IOException e) {
@ -167,6 +172,9 @@ public class ReceiveHelper {
backOffCounter = 0; backOffCounter = 0;
if (queueNotEmpty) { if (queueNotEmpty) {
if (cachedMessage[0] == null) {
continue;
}
if (remainingMessages > 0) { if (remainingMessages > 0) {
remainingMessages -= 1; remainingMessages -= 1;
} }

View File

@ -567,7 +567,7 @@ public class SendHelper {
return results; 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); var g = context.getGroupHelper().getGroup(groupId);
if (g == null) { if (g == null) {
throw new GroupNotFoundException(groupId); throw new GroupNotFoundException(groupId);
@ -575,6 +575,10 @@ public class SendHelper {
if (!g.isMember(account.getSelfRecipientId())) { if (!g.isMember(account.getSelfRecipientId())) {
throw new NotAGroupMemberException(groupId, g.getTitle()); 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()) { if (!g.isProfileSharingEnabled()) {
g.setProfileSharingEnabled(true); g.setProfileSharingEnabled(true);
account.getGroupStore().updateGroup(g); account.getGroupStore().updateGroup(g);

View File

@ -4,13 +4,17 @@ import org.asamk.signal.manager.api.GroupIdV1;
import org.asamk.signal.manager.api.GroupIdV2; import org.asamk.signal.manager.api.GroupIdV2;
import org.asamk.signal.manager.api.Pair; import org.asamk.signal.manager.api.Pair;
import org.asamk.signal.manager.api.Profile; 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.internal.SignalDependencies;
import org.asamk.signal.manager.storage.SignalAccount; import org.asamk.signal.manager.storage.SignalAccount;
import org.asamk.signal.manager.storage.recipients.RecipientId; 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.AccountRecordProcessor;
import org.asamk.signal.manager.syncStorage.ContactRecordProcessor; import org.asamk.signal.manager.syncStorage.ContactRecordProcessor;
import org.asamk.signal.manager.syncStorage.GroupV1RecordProcessor; import org.asamk.signal.manager.syncStorage.GroupV1RecordProcessor;
import org.asamk.signal.manager.syncStorage.GroupV2RecordProcessor; 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.StorageSyncModels;
import org.asamk.signal.manager.syncStorage.StorageSyncValidations; import org.asamk.signal.manager.syncStorage.StorageSyncValidations;
import org.asamk.signal.manager.syncStorage.WriteOperationResult; 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(), private static final List<Integer> KNOWN_TYPES = List.of(ManifestRecord.Identifier.Type.CONTACT.getValue(),
ManifestRecord.Identifier.Type.GROUPV1.getValue(), ManifestRecord.Identifier.Type.GROUPV1.getValue(),
ManifestRecord.Identifier.Type.GROUPV2.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 SignalAccount account;
private final SignalDependencies dependencies; private final SignalDependencies dependencies;
private final Context context; private final Context context;
private final StorageSyncLoopDetector storageSyncLoopDetector;
public StorageHelper(final Context context) { public StorageHelper(final Context context) {
this.account = context.getAccount(); this.account = context.getAccount();
this.dependencies = context.getDependencies(); this.dependencies = context.getDependencies();
this.context = context; this.context = context;
this.storageSyncLoopDetector = new StorageSyncLoopDetector(account::isMultiDevice);
} }
public void syncDataWithStorage() throws IOException { public void syncDataWithStorage() throws IOException {
@ -80,6 +87,7 @@ public class StorageHelper {
final var storageServiceRepository = dependencies.getStorageServiceRepository(); final var storageServiceRepository = dependencies.getStorageServiceRepository();
final var result = storageServiceRepository.getStorageManifestIfDifferentVersion(storageKey, final var result = storageServiceRepository.getStorageManifestIfDifferentVersion(storageKey,
localManifestVersion); localManifestVersion);
final var fetchedRemoteManifest = result instanceof ManifestIfDifferentVersionResult.DifferentVersion;
var needsForcePush = false; var needsForcePush = false;
final var remoteManifest = switch (result) { final var remoteManifest = switch (result) {
@ -120,6 +128,7 @@ public class StorageHelper {
logger.trace("Adding missing storageIds to local data"); logger.trace("Adding missing storageIds to local data");
account.getRecipientStore().setMissingStorageIds(); account.getRecipientStore().setMissingStorageIds();
account.getGroupStore().setMissingStorageIds(); account.getGroupStore().setMissingStorageIds();
account.getStickerStore().setMissingStorageIds();
var needsMultiDeviceSync = false; var needsMultiDeviceSync = false;
@ -141,7 +150,10 @@ public class StorageHelper {
needsForcePush = true; needsForcePush = true;
} else { } else {
try { try {
needsMultiDeviceSync = writeToStorage(storageKey, remoteManifest, needsForcePush); needsMultiDeviceSync = writeToStorage(storageKey,
remoteManifest,
needsForcePush,
fetchedRemoteManifest);
} catch (RetryLaterException e) { } catch (RetryLaterException e) {
// TODO retry later // TODO retry later
return; return;
@ -222,11 +234,14 @@ public class StorageHelper {
final var updated = account.getRecipientStore() final var updated = account.getRecipientStore()
.removeStorageIdsFromLocalOnlyUnregisteredRecipients(connection, .removeStorageIdsFromLocalOnlyUnregisteredRecipients(connection,
oldUnregisteredLocalOnlyIds); oldUnregisteredLocalOnlyIds);
final var updatedStickers = account.getStickerStore()
.removeStorageIdsFromLocalOnlyDeletedStickerPacks(connection, oldUnregisteredLocalOnlyIds);
if (updated > 0) { if (updated > 0 || updatedStickers > 0) {
logger.warn( logger.warn(
"Found {} records that were deleted remotely but only marked unregistered locally. Removed those from local store.", "Found {} recipients and {} sticker packs that were deleted remotely but only marked deleted locally. Removed those from local store.",
updated); updated,
updatedStickers);
} }
} }
@ -280,7 +295,8 @@ public class StorageHelper {
private boolean writeToStorage( private boolean writeToStorage(
final StorageKey storageKey, final StorageKey storageKey,
final SignalStorageManifest remoteManifest, final SignalStorageManifest remoteManifest,
final boolean needsForcePush final boolean needsForcePush,
final boolean fetchedRemoteManifest
) throws IOException, RetryLaterException { ) throws IOException, RetryLaterException {
final WriteOperationResult remoteWriteOperation; final WriteOperationResult remoteWriteOperation;
try (final var connection = account.getAccountDatabase().getConnection()) { try (final var connection = account.getAccountDatabase().getConnection()) {
@ -318,6 +334,19 @@ public class StorageHelper {
if (remoteWriteOperation.isEmpty()) { if (remoteWriteOperation.isEmpty()) {
logger.debug("No remote writes needed. Still at version: {}", remoteManifest.version); 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; return false;
} }
@ -336,11 +365,18 @@ public class StorageHelper {
remoteWriteOperation.deletes()); remoteWriteOperation.deletes());
switch (result) { switch (result) {
case WriteStorageRecordsResult.ConflictError ignored -> { case WriteStorageRecordsResult.ConflictError ignored -> {
storageSyncLoopDetector.onWriteFailed();
logger.debug("Hit a conflict when trying to resolve the conflict! Retrying."); logger.debug("Hit a conflict when trying to resolve the conflict! Retrying.");
throw new RetryLaterException(); throw new RetryLaterException();
} }
case WriteStorageRecordsResult.NetworkError networkError -> throw networkError.getException(); case WriteStorageRecordsResult.NetworkError networkError -> {
case WriteStorageRecordsResult.StatusCodeError statusCodeError -> throw statusCodeError.getException(); storageSyncLoopDetector.onWriteFailed();
throw networkError.getException();
}
case WriteStorageRecordsResult.StatusCodeError statusCodeError -> {
storageSyncLoopDetector.onWriteFailed();
throw statusCodeError.getException();
}
case WriteStorageRecordsResult.Success ignored -> { case WriteStorageRecordsResult.Success ignored -> {
logger.debug("Saved new manifest. Now at version: {}", remoteWriteOperation.manifest().version); logger.debug("Saved new manifest. Now at version: {}", remoteWriteOperation.manifest().version);
storeManifestLocally(remoteWriteOperation.manifest()); storeManifestLocally(remoteWriteOperation.manifest());
@ -366,6 +402,7 @@ public class StorageHelper {
final Map<RecipientId, StorageId> newContactStorageIds; final Map<RecipientId, StorageId> newContactStorageIds;
final Map<GroupIdV1, StorageId> newGroupV1StorageIds; final Map<GroupIdV1, StorageId> newGroupV1StorageIds;
final Map<GroupIdV2, StorageId> newGroupV2StorageIds; final Map<GroupIdV2, StorageId> newGroupV2StorageIds;
final Map<StickerPackId, StorageId> newStickerPackStorageIds;
try (final var connection = account.getAccountDatabase().getConnection()) { try (final var connection = account.getAccountDatabase().getConnection()) {
connection.setAutoCommit(false); connection.setAutoCommit(false);
@ -412,6 +449,19 @@ public class StorageHelper {
new StorageRecord.Builder().groupV2(record).build())); 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(); connection.commit();
} catch (SQLException e) { } catch (SQLException e) {
throw new RuntimeException("Failed to sync remote storage", e); throw new RuntimeException("Failed to sync remote storage", e);
@ -462,6 +512,7 @@ public class StorageHelper {
connection.setAutoCommit(false); connection.setAutoCommit(false);
account.getRecipientStore().updateStorageIds(connection, newContactStorageIds); account.getRecipientStore().updateStorageIds(connection, newContactStorageIds);
account.getGroupStore().updateStorageIds(connection, newGroupV1StorageIds, newGroupV2StorageIds); account.getGroupStore().updateStorageIds(connection, newGroupV1StorageIds, newGroupV2StorageIds);
account.getStickerStore().updateStorageIds(connection, newStickerPackStorageIds);
// delete all unknown storage ids // delete all unknown storage ids
account.getUnknownStorageIdStore().deleteAllUnknownStorageIds(connection); account.getUnknownStorageIdStore().deleteAllUnknownStorageIds(connection);
@ -494,6 +545,14 @@ public class StorageHelper {
_ -> StorageId.forGroupV2(KeyUtils.createRawStorageId()))); _ -> 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( private void storeManifestLocally(
final SignalStorageManifest remoteManifest final SignalStorageManifest remoteManifest
) { ) {
@ -533,6 +592,7 @@ public class StorageHelper {
storageIds.addAll(account.getUnknownStorageIdStore().getUnknownStorageIds(connection)); storageIds.addAll(account.getUnknownStorageIdStore().getUnknownStorageIds(connection));
storageIds.addAll(account.getGroupStore().getStorageIds(connection)); storageIds.addAll(account.getGroupStore().getStorageIds(connection));
storageIds.addAll(account.getRecipientStore().getStorageIds(connection)); storageIds.addAll(account.getRecipientStore().getStorageIds(connection));
storageIds.addAll(account.getStickerStore().getStorageIds(connection));
storageIds.add(account.getRecipientStore().getSelfStorageId(connection)); storageIds.add(account.getRecipientStore().getSelfStorageId(connection));
return storageIds; return storageIds;
} }
@ -581,6 +641,14 @@ public class StorageHelper {
account.getUsernameLink()); account.getUsernameLink());
yield new SignalStorageRecord(storageId, new StorageRecord.Builder().account(record).build()); 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 -> { case null, default -> {
throw new AssertionError("Got unknown local storage record type: " + storageId); 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 groupV1RecordProcessor = new GroupV1RecordProcessor(account, connection);
final var groupV2RecordProcessor = new GroupV2RecordProcessor(account, connection); final var groupV2RecordProcessor = new GroupV2RecordProcessor(account, connection);
final var contactRecordProcessor = new ContactRecordProcessor(account, connection, context.getJobExecutor()); 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) { for (final var record : records) {
if (record.getProto().account != null) { if (record.getProto().account != null) {
@ -664,6 +740,10 @@ public class StorageHelper {
logger.debug("Reading record {} of type contact", record.getId()); logger.debug("Reading record {} of type contact", record.getId());
contactRecordProcessor.process(StorageRecordConvertersKt.toSignalContactRecord(record.getProto().contact, contactRecordProcessor.process(StorageRecordConvertersKt.toSignalContactRecord(record.getProto().contact,
record.getId())); record.getId()));
} else if (record.getProto().stickerPack != null) {
logger.debug("Reading record {} of type stickerPack", record.getId());
stickerPackRecordProcessor.process(StorageRecordConvertersKt.toSignalStickerPackRecord(record.getProto().stickerPack,
record.getId()));
} else { } else {
unknownRecords.add(record.getId()); unknownRecords.add(record.getId());
} }
@ -672,6 +752,7 @@ public class StorageHelper {
processedRecords.addAll(groupV1RecordProcessor.getUpdatedStorageIds()); processedRecords.addAll(groupV1RecordProcessor.getUpdatedStorageIds());
processedRecords.addAll(groupV2RecordProcessor.getUpdatedStorageIds()); processedRecords.addAll(groupV2RecordProcessor.getUpdatedStorageIds());
processedRecords.addAll(contactRecordProcessor.getUpdatedStorageIds()); processedRecords.addAll(contactRecordProcessor.getUpdatedStorageIds());
processedRecords.addAll(stickerPackRecordProcessor.getUpdatedStorageIds());
return new Pair<>(unknownRecords, processedRecords); return new Pair<>(unknownRecords, processedRecords);
} }

View File

@ -237,18 +237,19 @@ public class SyncHelper {
final var address = account.getRecipientAddressResolver().resolveRecipientAddress(record.first()); final var address = account.getRecipientAddressResolver().resolveRecipientAddress(record.first());
if (address.aci().isPresent() || address.number().isPresent()) { if (address.aci().isPresent() || address.number().isPresent()) {
addresses.add(new BlockedListMessage.Individual(address.aci().orElse(null), 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()) { for (var record : account.getGroupStore().getGroups()) {
if (record.isBlocked()) { if (record.isBlocked()) {
groupIds.add(record.getGroupId().serialize()); groups.add(new BlockedListMessage.Group(record.getGroupId().serialize(), record.getBlockedAt()));
} }
} }
return context.getSendHelper() return context.getSendHelper()
.sendSyncMessage(SignalServiceSyncMessage.forBlocked(new BlockedListMessage(addresses, groupIds))); .sendSyncMessage(SignalServiceSyncMessage.forBlocked(new BlockedListMessage(addresses, groups)));
} }
public SendMessageResult sendVerifiedMessage( public SendMessageResult sendVerifiedMessage(

View File

@ -98,8 +98,10 @@ import org.asamk.signal.manager.util.StickerUtils;
import org.signal.core.models.ServiceId; import org.signal.core.models.ServiceId;
import org.signal.core.models.ServiceId.ACI; import org.signal.core.models.ServiceId.ACI;
import org.signal.core.models.ServiceId.PNI; import org.signal.core.models.ServiceId.PNI;
import org.signal.core.util.Base64;
import org.signal.core.util.Hex; 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.InvalidMessageException;
import org.signal.libsignal.protocol.NoSessionException; import org.signal.libsignal.protocol.NoSessionException;
import org.signal.libsignal.usernames.BaseUsernameException; 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.IceUpdateMessage;
import org.whispersystems.signalservice.api.messages.calls.OfferMessage; import org.whispersystems.signalservice.api.messages.calls.OfferMessage;
import org.whispersystems.signalservice.api.messages.calls.SignalServiceCallMessage; import org.whispersystems.signalservice.api.messages.calls.SignalServiceCallMessage;
import org.whispersystems.signalservice.api.messages.multidevice.DeviceInfo;
import org.whispersystems.signalservice.api.push.ServiceIdType; import org.whispersystems.signalservice.api.push.ServiceIdType;
import org.whispersystems.signalservice.api.push.exceptions.CdsiResourceExhaustedException; import org.whispersystems.signalservice.api.push.exceptions.CdsiResourceExhaustedException;
import org.whispersystems.signalservice.api.util.DeviceNameUtil;
import org.whispersystems.signalservice.api.util.StreamDetails; import org.whispersystems.signalservice.api.util.StreamDetails;
import org.whispersystems.signalservice.internal.util.Util; 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.config.ServiceConfig.MAX_MESSAGE_SIZE_BYTES;
import static org.asamk.signal.manager.util.Utils.handleResponseException; import static org.asamk.signal.manager.util.Utils.handleResponseException;
import static org.asamk.signal.manager.util.Utils.handleResponseExceptionSuspend;
import static org.signal.core.util.StringExtensionsKt.splitByByteLength; import static org.signal.core.util.StringExtensionsKt.splitByByteLength;
public class ManagerImpl implements Manager { public class ManagerImpl implements Manager {
@ -483,23 +484,26 @@ public class ManagerImpl implements Manager {
@Override @Override
public List<Device> getLinkedDevices() throws IOException { 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); account.setMultiDevice(devices.size() > 1);
var identityKey = account.getAciIdentityKeyPair().getPrivateKey(); var identityKey = account.getAciIdentityKeyPair();
return devices.stream().map(d -> { return devices.stream().map(d -> {
String deviceName = d.getName(); String deviceName = null;
if (deviceName != null) { if (d.getEncryptedName() != null && d.getEncryptedName().length > 0) {
try { try {
deviceName = DeviceNameUtil.decryptDeviceName(deviceName, identityKey); deviceName = new String(DeviceNameCipher.decryptDeviceName(DeviceName.ADAPTER.decode(d.getEncryptedName()),
} catch (IOException e) { identityKey), StandardCharsets.UTF_8);
} catch (Exception e) {
logger.debug("Failed to decrypt device name, maybe plain text?", 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); final var createdAt = getPlaintextCreatedAt(d);
return new Device(d.getId(), return new Device(d.getId(),
deviceName, deviceName,
createdAt == null ? 0 : createdAt, createdAt == null ? 0 : createdAt,
d.getLastSeen(), d.getLastSeen().toEpochMilli(),
d.getId() == account.getDeviceId()); d.getId() == account.getDeviceId());
}).toList(); }).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"; final var DECRYPTION_INFO = "deviceCreatedAt";
var identityKey = account.getAciIdentityKeyPair().getPrivateKey(); var identityKey = account.getAciIdentityKeyPair().getPrivateKey();
try { try {
@ -527,8 +531,9 @@ public class ManagerImpl implements Manager {
var associatedData = new ByteArrayOutputStream(); var associatedData = new ByteArrayOutputStream();
associatedData.write(d.getId()); associatedData.write(d.getId());
associatedData.write(ByteBuffer.allocate(4).putInt(d.getRegistrationId()).array()); associatedData.write(ByteBuffer.allocate(4).putInt(d.getRegistrationId()).array());
var createdAtPlaintext = identityKey.open(Base64.decode(d.getCreatedAtCiphertext() var createdAtPlaintext = identityKey.open(d.getCreatedAtCiphertext(),
.getBytes(StandardCharsets.UTF_8)), DECRYPTION_INFO, associatedData.toByteArray()); DECRYPTION_INFO,
associatedData.toByteArray());
return ByteBuffer.wrap(createdAtPlaintext).getLong(); return ByteBuffer.wrap(createdAtPlaintext).getLong();
} catch (Exception e) { } catch (Exception e) {
logger.warn("Failed while reading the protobuf.", e); logger.warn("Failed while reading the protobuf.", e);
@ -605,6 +610,11 @@ public class ManagerImpl implements Manager {
context.getGroupHelper().deleteGroup(groupId); context.getGroupHelper().deleteGroup(groupId);
} }
@Override
public SendGroupMessageResults terminateGroup(GroupId groupId) throws IOException, GroupNotFoundException, NotAGroupMemberException {
return context.getGroupHelper().terminateGroup(groupId);
}
@Override @Override
public Pair<GroupId, SendGroupMessageResults> createGroup( public Pair<GroupId, SendGroupMessageResults> createGroup(
String name, String name,
@ -907,6 +917,10 @@ public class ManagerImpl implements Manager {
if (!(groupInfo instanceof GroupInfoV2 groupInfoV2)) { if (!(groupInfo instanceof GroupInfoV2 groupInfoV2)) {
throw new IOException("Stories are only supported for V2 groups"); 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 uploadedAttachment = context.getAttachmentHelper().uploadAttachment(attachment);
final var groupContext = SignalServiceGroupV2.newBuilder(groupInfoV2.getMasterKey()) final var groupContext = SignalServiceGroupV2.newBuilder(groupInfoV2.getMasterKey())
@ -1701,8 +1715,15 @@ public class ManagerImpl implements Manager {
} }
final var contact = account.getContactStore().getContact(recipientId); final var contact = account.getContactStore().getContact(recipientId);
if (contact != null && !Util.isEmpty(contact.getName())) { if (contact != null) {
return contact.getName(); 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); final var profile = context.getProfileHelper().getRecipientProfile(recipientId);

View File

@ -19,34 +19,57 @@ package org.asamk.signal.manager.internal;
import org.asamk.signal.manager.Manager; import org.asamk.signal.manager.Manager;
import org.asamk.signal.manager.ProvisioningManager; import org.asamk.signal.manager.ProvisioningManager;
import org.asamk.signal.manager.Settings; import org.asamk.signal.manager.Settings;
import org.asamk.signal.manager.api.DeviceLinkUrl;
import org.asamk.signal.manager.api.UserAlreadyExistsException; import org.asamk.signal.manager.api.UserAlreadyExistsException;
import org.asamk.signal.manager.config.ServiceConfig; import org.asamk.signal.manager.config.ServiceConfig;
import org.asamk.signal.manager.config.ServiceEnvironmentConfig; import org.asamk.signal.manager.config.ServiceEnvironmentConfig;
import org.asamk.signal.manager.storage.SignalAccount; import org.asamk.signal.manager.storage.SignalAccount;
import org.asamk.signal.manager.storage.accounts.AccountsStore; import org.asamk.signal.manager.storage.accounts.AccountsStore;
import org.asamk.signal.manager.util.KeyUtils; 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.IdentityKeyPair;
import org.signal.libsignal.protocol.ecc.ECPrivateKey;
import org.signal.libsignal.zkgroup.profiles.ProfileKey;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.whispersystems.signalservice.api.SignalServiceAccountManager;
import org.whispersystems.signalservice.api.account.DeviceAttributes;
import org.whispersystems.signalservice.api.provisioning.ProvisioningSocket;
import org.whispersystems.signalservice.api.push.ServiceIdType; import org.whispersystems.signalservice.api.push.ServiceIdType;
import org.whispersystems.signalservice.api.push.SignalServiceAddress; import org.whispersystems.signalservice.api.push.SignalServiceAddress;
import org.whispersystems.signalservice.api.push.exceptions.AuthorizationFailedException; import org.whispersystems.signalservice.api.push.exceptions.AuthorizationFailedException;
import org.whispersystems.signalservice.api.registration.ProvisioningApi; import org.whispersystems.signalservice.internal.crypto.SecondaryProvisioningCipher;
import org.whispersystems.signalservice.api.util.DeviceNameUtil; import org.whispersystems.signalservice.internal.push.ProvisionMessage;
import org.whispersystems.signalservice.internal.push.ProvisioningSocket;
import org.whispersystems.signalservice.internal.push.PushServiceSocket;
import org.whispersystems.signalservice.internal.util.DynamicCredentialsProvider;
import java.io.Closeable;
import java.io.IOException; import java.io.IOException;
import java.net.URI; import java.net.URI;
import java.net.URISyntaxException;
import java.nio.channels.OverlappingFileLockException; 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.concurrent.TimeoutException;
import java.util.function.Consumer; 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); 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 Consumer<Manager> newManagerListener;
private final AccountsStore accountsStore; private final AccountsStore accountsStore;
private final ProvisioningApi provisioningApi;
private final IdentityKeyPair tempIdentityKey;
private final String password; 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( public ProvisioningManagerImpl(
PathConfig pathConfig, PathConfig pathConfig,
@ -73,35 +97,58 @@ public class ProvisioningManagerImpl implements ProvisioningManager {
this.newManagerListener = newManagerListener; this.newManagerListener = newManagerListener;
this.accountsStore = accountsStore; this.accountsStore = accountsStore;
tempIdentityKey = KeyUtils.generateIdentityKeyPair(); final IdentityKeyPair tempIdentityKey = KeyUtils.generateIdentityKeyPair();
password = KeyUtils.createPassword(); password = KeyUtils.createPassword();
final var credentialsProvider = new DynamicCredentialsProvider(null,
null, socketHandle = ProvisioningSocket.Companion.start(new ProvisioningSocket.Mode.Link(false),
null, tempIdentityKey,
password, serviceEnvironmentConfig.signalServiceConfiguration(),
SignalServiceAddress.DEFAULT_DEVICE_ID); (id, t) -> {
final var pushServiceSocket = new PushServiceSocket(serviceEnvironmentConfig.signalServiceConfiguration(), urlFuture.completeExceptionally(t);
credentialsProvider, messageFuture.completeExceptionally(t);
userAgent, },
ServiceConfig.AUTOMATIC_NETWORK_RETRY); new ProvisioningBlock());
final var provisioningSocket = new ProvisioningSocket(serviceEnvironmentConfig.signalServiceConfiguration(),
userAgent);
this.provisioningApi = new ProvisioningApi(pushServiceSocket, provisioningSocket, credentialsProvider);
} }
@Override @Override
public URI getDeviceLinkUri() throws TimeoutException, IOException { public URI getDeviceLinkUri() throws TimeoutException, IOException {
var deviceUuid = provisioningApi.getNewDeviceUuid(); try {
var url = urlFuture.get(30, TimeUnit.SECONDS);
return new DeviceLinkUrl(deviceUuid, tempIdentityKey.getPublicKey().getPublicKey()).createDeviceLinkUri(); 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 @Override
public String finishDeviceLink(String deviceName) throws IOException, TimeoutException, UserAlreadyExistsException { public String finishDeviceLink(String deviceName) throws IOException, TimeoutException, UserAlreadyExistsException {
var ret = provisioningApi.getNewDeviceRegistration(tempIdentityKey); SecondaryProvisioningCipher.ProvisioningDecryptResult<ProvisionMessage> decryptResult;
var number = ret.getNumber(); try {
var aci = ret.getAci(); decryptResult = messageFuture.get(120, TimeUnit.SECONDS);
var pni = ret.getPni(); } 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); logger.info("Received link information from {}, linking in progress ...", number);
@ -120,21 +167,42 @@ public class ProvisioningManagerImpl implements ProvisioningManager {
accountsStore.updateAccount(accountPath, number, aci); 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 var encryptedDeviceName = deviceName == null
? null ? null
: DeviceNameUtil.encryptDeviceName(deviceName, ret.getAciIdentity().getPrivateKey()); : DeviceNameCipher.encryptDeviceName(deviceName.getBytes(StandardCharsets.UTF_8), aciIdentity);
// Create new account with the synced identity var accountEntropyPool = msg.accountEntropyPool == null ? null : new AccountEntropyPool(msg.accountEntropyPool);
var profileKey = ret.getProfileKey() == null ? KeyUtils.createProfileKey() : ret.getProfileKey(); var mediaRootBackupKey = msg.mediaRootBackupKey == null
? null
: new MediaRootBackupKey(msg.mediaRootBackupKey.toByteArray());
SignalAccount account = null; SignalAccount account = null;
var cleanUpPartialAccountOnFailure = false;
var linkingFinished = false;
try { try {
if (!accountExists) { if (!accountExists) {
account = SignalAccount.createLinkedAccount(pathConfig.dataPath(), account = SignalAccount.createLinkedAccount(pathConfig.dataPath(),
accountPath, accountPath,
serviceEnvironmentConfig.type(), serviceEnvironmentConfig.type(),
Settings.DEFAULT); Settings.DEFAULT);
cleanUpPartialAccountOnFailure = true;
} else { } else {
account = SignalAccount.load(pathConfig.dataPath(), accountPath, true, Settings.DEFAULT); account = SignalAccount.load(pathConfig.dataPath(), accountPath, true, Settings.DEFAULT);
cleanUpPartialAccountOnFailure = false;
} }
account.setProvisioningData(number, account.setProvisioningData(number,
@ -142,24 +210,43 @@ public class ProvisioningManagerImpl implements ProvisioningManager {
pni, pni,
password, password,
encryptedDeviceName, encryptedDeviceName,
ret.getAciIdentity(), aciIdentity,
ret.getPniIdentity(), pniIdentity,
profileKey, profileKey,
ret.getAccountEntropyPool(), accountEntropyPool,
ret.getMediaRootBackupKey()); 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 aciPreKeys = generatePreKeysForType(account.getAccountData(ServiceIdType.ACI));
final var pniPreKeys = generatePreKeysForType(account.getAccountData(ServiceIdType.PNI)); final var pniPreKeys = generatePreKeysForType(account.getAccountData(ServiceIdType.PNI));
logger.debug("Finishing new device registration"); logger.debug("Finishing new device registration");
var deviceId = provisioningApi.finishNewDeviceRegistration(ret.getProvisioningCode(), final var attrs = account.getAccountAttributes(null);
account.getAccountAttributes(null), final var deviceAttributes = new DeviceAttributes(attrs.getFetchesMessages(),
aciPreKeys, attrs.getRegistrationId(),
pniPreKeys); 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); account.finishLinking(deviceId, aciPreKeys, pniPreKeys);
linkingFinished = true;
ManagerImpl m = null; ManagerImpl m = null;
try { try {
@ -196,6 +283,12 @@ public class ProvisioningManagerImpl implements ProvisioningManager {
m.close(); m.close();
} }
} }
} catch (Exception e) {
if (!linkingFinished && cleanUpPartialAccountOnFailure && account != null) {
cleanupPartialAccount(account, accountPath, e);
account = null;
}
throw e;
} finally { } finally {
if (account != null) { if (account != null) {
account.close(); 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 { private boolean canRelinkExistingAccount(final String accountPath) throws IOException {
final SignalAccount signalAccount; final SignalAccount signalAccount;
try { try {
@ -216,6 +336,10 @@ public class ProvisioningManagerImpl implements ProvisioningManager {
} }
try (signalAccount) { try (signalAccount) {
if (signalAccount.getDeviceId() <= 0) {
logger.debug("Account has invalid deviceId {}, allowing relink.", signalAccount.getDeviceId());
return true;
}
if (signalAccount.isPrimaryDevice()) { if (signalAccount.isPrimaryDevice()) {
logger.debug("Account is a primary device."); logger.debug("Account is a primary device.");
return false; return false;
@ -243,4 +367,36 @@ public class ProvisioningManagerImpl implements ProvisioningManager {
return false; 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));
}
}
} }

View File

@ -355,6 +355,7 @@ public class SignalDependencies {
() -> preKeyRepository = new PreKeyRepository(getKeysApi(), () -> preKeyRepository = new PreKeyRepository(getKeysApi(),
dataStore.aci(), dataStore.aci(),
localProtocolAddress, localProtocolAddress,
getSessionLock(),
Runnable::run)); Runnable::run));
} }

View File

@ -9,6 +9,7 @@ import org.whispersystems.signalservice.api.websocket.HealthMonitor;
import org.whispersystems.signalservice.api.websocket.SignalWebSocket; import org.whispersystems.signalservice.api.websocket.SignalWebSocket;
import org.whispersystems.signalservice.api.websocket.WebSocketConnectionState; import org.whispersystems.signalservice.api.websocket.WebSocketConnectionState;
import java.time.Duration;
import java.util.concurrent.Executor; import java.util.concurrent.Executor;
import java.util.concurrent.Executors; import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
@ -103,6 +104,18 @@ final class SignalWebSocketHealthMonitor implements HealthMonitor {
logger.info("Received alerts: {}", String.join(", ", strings)); 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 * 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. * the WebSocket fails to get a return heartbeat after [KEEP_ALIVE_TIMEOUT] seconds, it is forced to be recreated.

View File

@ -33,7 +33,7 @@ import java.util.UUID;
public class AccountDatabase extends Database { public class AccountDatabase extends Database {
private static final Logger logger = LoggerFactory.getLogger(AccountDatabase.class); 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) { private AccountDatabase(final HikariDataSource dataSource) {
super(logger, DATABASE_VERSION, 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( private static void createUuidMappingTable(

View File

@ -116,7 +116,7 @@ public class SignalAccount implements Closeable {
private static final Logger logger = LoggerFactory.getLogger(SignalAccount.class); private static final Logger logger = LoggerFactory.getLogger(SignalAccount.class);
private static final int MINIMUM_STORAGE_VERSION = 1; 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(); private final Object LOCK = new Object();
@ -141,6 +141,7 @@ public class SignalAccount implements Closeable {
private MasterKey pinMasterKey; private MasterKey pinMasterKey;
private StorageKey storageKey; private StorageKey storageKey;
private AccountEntropyPool accountEntropyPool; private AccountEntropyPool accountEntropyPool;
private byte[] authCredentialSalt;
private MediaRootBackupKey mediaRootBackupKey; private MediaRootBackupKey mediaRootBackupKey;
private ProfileKey profileKey; private ProfileKey profileKey;
@ -153,6 +154,10 @@ public class SignalAccount implements Closeable {
private final KeyValueEntry<Long> lastReceiveTimestamp = new KeyValueEntry<>("last-receive-timestamp", private final KeyValueEntry<Long> lastReceiveTimestamp = new KeyValueEntry<>("last-receive-timestamp",
long.class, long.class,
0L); 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", private final KeyValueEntry<Boolean> needsToRetryFailedMessages = new KeyValueEntry<>("retry-failed-messages",
Boolean.class, Boolean.class,
true); true);
@ -292,11 +297,12 @@ public class SignalAccount implements Closeable {
final ACI aci, final ACI aci,
final PNI pni, final PNI pni,
final String password, final String password,
final String encryptedDeviceName, final byte[] encryptedDeviceName,
final IdentityKeyPair aciIdentity, final IdentityKeyPair aciIdentity,
final IdentityKeyPair pniIdentity, final IdentityKeyPair pniIdentity,
final ProfileKey profileKey, final ProfileKey profileKey,
final AccountEntropyPool accountEntropyPool, final AccountEntropyPool accountEntropyPool,
final byte[] authCredentialSalt,
final MediaRootBackupKey mediaRootBackupKey final MediaRootBackupKey mediaRootBackupKey
) { ) {
this.deviceId = 0; this.deviceId = 0;
@ -307,12 +313,13 @@ public class SignalAccount implements Closeable {
getRecipientTrustedResolver().resolveSelfRecipientTrusted(getSelfRecipientAddress()); getRecipientTrustedResolver().resolveSelfRecipientTrusted(getSelfRecipientAddress());
this.password = password; this.password = password;
this.profileKey = profileKey; this.profileKey = profileKey;
this.encryptedDeviceName = encryptedDeviceName; this.encryptedDeviceName = org.signal.core.util.Base64.encodeWithoutPadding(encryptedDeviceName);
this.aciAccountData.setIdentityKeyPair(aciIdentity); this.aciAccountData.setIdentityKeyPair(aciIdentity);
this.pniAccountData.setIdentityKeyPair(pniIdentity); this.pniAccountData.setIdentityKeyPair(pniIdentity);
this.registered = false; this.registered = false;
this.isMultiDevice = true; this.isMultiDevice = true;
setLastReceiveTimestamp(0L); setLastReceiveTimestamp(0L);
setLastAppliedPniChangeServerTimestamp(0L);
if (accountEntropyPool != null) { if (accountEntropyPool != null) {
this.pinMasterKey = null; this.pinMasterKey = null;
this.accountEntropyPool = accountEntropyPool; this.accountEntropyPool = accountEntropyPool;
@ -320,6 +327,7 @@ public class SignalAccount implements Closeable {
this.pinMasterKey = null; this.pinMasterKey = null;
this.accountEntropyPool = null; this.accountEntropyPool = null;
} }
this.authCredentialSalt = authCredentialSalt;
this.mediaRootBackupKey = mediaRootBackupKey; this.mediaRootBackupKey = mediaRootBackupKey;
getKeyValueStore().storeEntry(storageManifestVersion, -1L); getKeyValueStore().storeEntry(storageManifestVersion, -1L);
this.setStorageManifest(null); this.setStorageManifest(null);
@ -356,6 +364,7 @@ public class SignalAccount implements Closeable {
) { ) {
this.pinMasterKey = masterKey; this.pinMasterKey = masterKey;
this.accountEntropyPool = null; this.accountEntropyPool = null;
this.authCredentialSalt = null;
getKeyValueStore().storeEntry(storageManifestVersion, -1L); getKeyValueStore().storeEntry(storageManifestVersion, -1L);
this.setStorageManifest(null); this.setStorageManifest(null);
this.storageKey = null; this.storageKey = null;
@ -368,6 +377,7 @@ public class SignalAccount implements Closeable {
init(); init();
this.registrationLockPin = pin; this.registrationLockPin = pin;
setLastReceiveTimestamp(0L); setLastReceiveTimestamp(0L);
setLastAppliedPniChangeServerTimestamp(0L);
save(); save();
setPreKeys(ServiceIdType.ACI, aciPreKeys); setPreKeys(ServiceIdType.ACI, aciPreKeys);
@ -520,6 +530,9 @@ public class SignalAccount implements Closeable {
if (storage.accountEntropyPool != null) { if (storage.accountEntropyPool != null) {
accountEntropyPool = new AccountEntropyPool(storage.accountEntropyPool); accountEntropyPool = new AccountEntropyPool(storage.accountEntropyPool);
} }
if (storage.authCredentialSalt != null) {
authCredentialSalt = base64.decode(storage.authCredentialSalt);
}
if (storage.mediaRootBackupKey != null) { if (storage.mediaRootBackupKey != null) {
mediaRootBackupKey = new MediaRootBackupKey(base64.decode(storage.mediaRootBackupKey)); mediaRootBackupKey = new MediaRootBackupKey(base64.decode(storage.mediaRootBackupKey));
} }
@ -900,6 +913,7 @@ public class SignalAccount implements Closeable {
0, 0,
false, false,
contact.blocked, contact.blocked,
0,
contact.archived, contact.archived,
false, false,
false, false,
@ -1008,6 +1022,7 @@ public class SignalAccount implements Closeable {
pinMasterKey == null ? null : base64.encodeToString(pinMasterKey.serialize()), pinMasterKey == null ? null : base64.encodeToString(pinMasterKey.serialize()),
storageKey == null ? null : base64.encodeToString(storageKey.serialize()), storageKey == null ? null : base64.encodeToString(storageKey.serialize()),
accountEntropyPool == null ? null : accountEntropyPool.getValue(), accountEntropyPool == null ? null : accountEntropyPool.getValue(),
authCredentialSalt == null ? null : base64.encodeToString(authCredentialSalt),
mediaRootBackupKey == null ? null : base64.encodeToString(mediaRootBackupKey.getValue()), mediaRootBackupKey == null ? null : base64.encodeToString(mediaRootBackupKey.getValue()),
profileKey == null ? null : base64.encodeToString(profileKey.serialize()), profileKey == null ? null : base64.encodeToString(profileKey.serialize()),
usernameLink == null ? null : base64.encodeToString(usernameLink.getEntropy()), usernameLink == null ? null : base64.encodeToString(usernameLink.getEntropy()),
@ -1222,6 +1237,11 @@ public class SignalAccount implements Closeable {
return pniAccountData.getSignalServiceAccountDataStore(); return pniAccountData.getSignalServiceAccountDataStore();
} }
@Override
public SignalServiceAccountDataStore pniOrNull() {
return getPni() != null ? pniAccountData.getSignalServiceAccountDataStore() : null;
}
@Override @Override
public boolean isMultiDevice() { public boolean isMultiDevice() {
return SignalAccount.this.isMultiDevice(); return SignalAccount.this.isMultiDevice();
@ -1642,6 +1662,10 @@ public class SignalAccount implements Closeable {
save(); save();
} }
public byte[] getAuthCredentialSalt() {
return authCredentialSalt;
}
public String getRecoveryPassword() { public String getRecoveryPassword() {
final var masterKey = getPinBackedMasterKey(); final var masterKey = getPinBackedMasterKey();
if (masterKey == null) { if (masterKey == null) {
@ -1725,7 +1749,7 @@ public class SignalAccount implements Closeable {
} }
public boolean isRegistered() { public boolean isRegistered() {
return registered; return registered && deviceId > 0;
} }
public void setRegistered(final boolean registered) { public void setRegistered(final boolean registered) {
@ -1753,6 +1777,14 @@ public class SignalAccount implements Closeable {
getKeyValueStore().storeEntry(lastReceiveTimestamp, value); 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) { public void setNeedsToRetryFailedMessages(final boolean value) {
getKeyValueStore().storeEntry(needsToRetryFailedMessages, value); getKeyValueStore().storeEntry(needsToRetryFailedMessages, value);
} }
@ -1973,6 +2005,7 @@ public class SignalAccount implements Closeable {
String pinMasterKey, String pinMasterKey,
String storageKey, String storageKey,
String accountEntropyPool, String accountEntropyPool,
String authCredentialSalt,
String mediaRootBackupKey, String mediaRootBackupKey,
String profileKey, String profileKey,
String usernameLinkEntropy, String usernameLinkEntropy,

View File

@ -58,6 +58,10 @@ public sealed abstract class GroupInfo permits GroupInfoV1, GroupInfoV2 {
public abstract void setBlocked(boolean blocked); public abstract void setBlocked(boolean blocked);
public abstract long getBlockedAt();
public abstract void setBlockedAt(long blockedAt);
public abstract boolean isProfileSharingEnabled(); public abstract boolean isProfileSharingEnabled();
public abstract void setProfileSharingEnabled(boolean profileSharingEnabled); 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 isAnnouncementGroup();
public abstract boolean isTerminated();
public abstract GroupPermission getPermissionAddMember(); public abstract GroupPermission getPermissionAddMember();
public abstract GroupPermission getPermissionEditDetails(); public abstract GroupPermission getPermissionEditDetails();

View File

@ -24,6 +24,7 @@ public final class GroupInfoV1 extends GroupInfo {
public String color; public String color;
public int messageExpirationTime; public int messageExpirationTime;
public boolean blocked; public boolean blocked;
private long blockedAt;
public boolean archived; public boolean archived;
private byte[] storageRecord; private byte[] storageRecord;
@ -39,6 +40,7 @@ public final class GroupInfoV1 extends GroupInfo {
final String color, final String color,
final int messageExpirationTime, final int messageExpirationTime,
final boolean blocked, final boolean blocked,
final long blockedAt,
final boolean archived, final boolean archived,
final byte[] storageRecord final byte[] storageRecord
) { ) {
@ -49,6 +51,7 @@ public final class GroupInfoV1 extends GroupInfo {
this.color = color; this.color = color;
this.messageExpirationTime = messageExpirationTime; this.messageExpirationTime = messageExpirationTime;
this.blocked = blocked; this.blocked = blocked;
this.blockedAt = blockedAt;
this.archived = archived; this.archived = archived;
this.storageRecord = storageRecord; this.storageRecord = storageRecord;
} }
@ -91,9 +94,24 @@ public final class GroupInfoV1 extends GroupInfo {
@Override @Override
public void setBlocked(final boolean blocked) { public void setBlocked(final boolean blocked) {
if (blocked && !this.blocked) {
blockedAt = System.currentTimeMillis();
} else if (!blocked) {
blockedAt = 0;
}
this.blocked = blocked; this.blocked = blocked;
} }
@Override
public long getBlockedAt() {
return blockedAt;
}
@Override
public void setBlockedAt(final long blockedAt) {
this.blockedAt = blockedAt;
}
@Override @Override
public boolean isProfileSharingEnabled() { public boolean isProfileSharingEnabled() {
return true; return true;
@ -113,6 +131,11 @@ public final class GroupInfoV1 extends GroupInfo {
return false; return false;
} }
@Override
public boolean isTerminated() {
return false;
}
@Override @Override
public GroupPermission getPermissionAddMember() { public GroupPermission getPermissionAddMember() {
return GroupPermission.EVERY_MEMBER; return GroupPermission.EVERY_MEMBER;

View File

@ -23,6 +23,7 @@ public final class GroupInfoV2 extends GroupInfo {
private final GroupMasterKey masterKey; private final GroupMasterKey masterKey;
private final DistributionId distributionId; private final DistributionId distributionId;
private boolean blocked; private boolean blocked;
private long blockedAt;
private boolean profileSharingEnabled; private boolean profileSharingEnabled;
private DecryptedGroup group; private DecryptedGroup group;
private byte[] storageRecord; private byte[] storageRecord;
@ -47,6 +48,7 @@ public final class GroupInfoV2 extends GroupInfo {
final DecryptedGroup group, final DecryptedGroup group,
final DistributionId distributionId, final DistributionId distributionId,
final boolean blocked, final boolean blocked,
final long blockedAt,
final boolean profileSharingEnabled, final boolean profileSharingEnabled,
final boolean permissionDenied, final boolean permissionDenied,
final byte[] storageRecord, final byte[] storageRecord,
@ -57,6 +59,7 @@ public final class GroupInfoV2 extends GroupInfo {
this.group = group; this.group = group;
this.distributionId = distributionId; this.distributionId = distributionId;
this.blocked = blocked; this.blocked = blocked;
this.blockedAt = blockedAt;
this.profileSharingEnabled = profileSharingEnabled; this.profileSharingEnabled = profileSharingEnabled;
this.permissionDenied = permissionDenied; this.permissionDenied = permissionDenied;
this.storageRecord = storageRecord; this.storageRecord = storageRecord;
@ -186,9 +189,24 @@ public final class GroupInfoV2 extends GroupInfo {
@Override @Override
public void setBlocked(final boolean blocked) { public void setBlocked(final boolean blocked) {
if (blocked && !this.blocked) {
blockedAt = System.currentTimeMillis();
} else if (!blocked) {
blockedAt = 0;
}
this.blocked = blocked; this.blocked = blocked;
} }
@Override
public long getBlockedAt() {
return blockedAt;
}
@Override
public void setBlockedAt(final long blockedAt) {
this.blockedAt = blockedAt;
}
@Override @Override
public boolean isProfileSharingEnabled() { public boolean isProfileSharingEnabled() {
return profileSharingEnabled; return profileSharingEnabled;
@ -211,6 +229,11 @@ public final class GroupInfoV2 extends GroupInfo {
return this.group != null && this.group.isAnnouncementGroup == EnabledState.ENABLED; return this.group != null && this.group.isAnnouncementGroup == EnabledState.ENABLED;
} }
@Override
public boolean isTerminated() {
return this.group != null && Boolean.TRUE.equals(this.group.terminated);
}
@Override @Override
public GroupPermission getPermissionAddMember() { public GroupPermission getPermissionAddMember() {
final var accessControl = getAccessControl(); final var accessControl = getAccessControl();

View File

@ -63,6 +63,7 @@ public class GroupStore {
distribution_id BLOB UNIQUE NOT NULL, distribution_id BLOB UNIQUE NOT NULL,
endorsement_expiration_time INTEGER NOT NULL DEFAULT 0, endorsement_expiration_time INTEGER NOT NULL DEFAULT 0,
blocked INTEGER NOT NULL DEFAULT FALSE, blocked INTEGER NOT NULL DEFAULT FALSE,
blocked_at INTEGER NOT NULL DEFAULT 0,
profile_sharing INTEGER NOT NULL DEFAULT FALSE, profile_sharing INTEGER NOT NULL DEFAULT FALSE,
permission_denied INTEGER NOT NULL DEFAULT FALSE permission_denied INTEGER NOT NULL DEFAULT FALSE
) STRICT; ) STRICT;
@ -83,6 +84,7 @@ public class GroupStore {
color TEXT, color TEXT,
expiration_time INTEGER NOT NULL DEFAULT 0, expiration_time INTEGER NOT NULL DEFAULT 0,
blocked INTEGER NOT NULL DEFAULT FALSE, blocked INTEGER NOT NULL DEFAULT FALSE,
blocked_at INTEGER NOT NULL DEFAULT 0,
archived INTEGER NOT NULL DEFAULT FALSE archived INTEGER NOT NULL DEFAULT FALSE
) STRICT; ) STRICT;
CREATE TABLE group_v1_member ( CREATE TABLE group_v1_member (
@ -401,6 +403,7 @@ public class GroupStore {
deleteGroup(connection, groupInfoV1.getGroupId()); deleteGroup(connection, groupInfoV1.getGroupId());
final var groupInfoV2 = new GroupInfoV2(groupId, groupMasterKey, recipientResolver); final var groupInfoV2 = new GroupInfoV2(groupId, groupMasterKey, recipientResolver);
groupInfoV2.setBlocked(groupInfoV1.isBlocked()); groupInfoV2.setBlocked(groupInfoV1.isBlocked());
groupInfoV2.setBlockedAt(groupInfoV1.getBlockedAt());
updateGroup(connection, groupInfoV2); updateGroup(connection, groupInfoV2);
logger.debug("Locally migrated group {} to group v2, id: {}", logger.debug("Locally migrated group {} to group v2, id: {}",
groupInfoV1.getGroupId().toBase64(), groupInfoV1.getGroupId().toBase64(),
@ -614,9 +617,9 @@ public class GroupStore {
} }
} }
final var sql = """ final var sql = """
INSERT INTO %s (_id, group_id, group_id_v2, name, color, expiration_time, blocked, archived, storage_id) INSERT INTO %s (_id, group_id, group_id_v2, name, color, expiration_time, blocked, blocked_at, archived, storage_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) 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 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 RETURNING _id
""".formatted(TABLE_GROUP_V1); """.formatted(TABLE_GROUP_V1);
try (final var statement = connection.prepareStatement(sql)) { try (final var statement = connection.prepareStatement(sql)) {
@ -631,8 +634,9 @@ public class GroupStore {
statement.setString(5, groupV1.color); statement.setString(5, groupV1.color);
statement.setLong(6, groupV1.getMessageExpirationTimer()); statement.setLong(6, groupV1.getMessageExpirationTimer());
statement.setBoolean(7, groupV1.isBlocked()); statement.setBoolean(7, groupV1.isBlocked());
statement.setBoolean(8, groupV1.archived); statement.setLong(8, groupV1.getBlockedAt());
statement.setBytes(9, KeyUtils.createRawStorageId()); statement.setBoolean(9, groupV1.archived);
statement.setBytes(10, KeyUtils.createRawStorageId());
final var generatedKey = Utils.executeQueryForOptional(statement, Utils::getIdMapper); final var generatedKey = Utils.executeQueryForOptional(statement, Utils::getIdMapper);
if (internalId == null) { if (internalId == null) {
@ -658,9 +662,9 @@ public class GroupStore {
} else if (group instanceof GroupInfoV2 groupV2) { } else if (group instanceof GroupInfoV2 groupV2) {
final var sql = ( final var sql = (
""" """
INSERT INTO %s (_id, group_id, master_key, group_data, distribution_id, blocked, permission_denied, storage_id, profile_sharing) INSERT INTO %s (_id, group_id, master_key, group_data, distribution_id, blocked, blocked_at, permission_denied, storage_id, profile_sharing)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) 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 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); ).formatted(TABLE_GROUP_V2);
try (final var statement = connection.prepareStatement(sql)) { try (final var statement = connection.prepareStatement(sql)) {
@ -678,9 +682,10 @@ public class GroupStore {
} }
statement.setBytes(5, UuidUtil.toByteArray(groupV2.getDistributionId().asUuid())); statement.setBytes(5, UuidUtil.toByteArray(groupV2.getDistributionId().asUuid()));
statement.setBoolean(6, groupV2.isBlocked()); statement.setBoolean(6, groupV2.isBlocked());
statement.setBoolean(7, groupV2.isPermissionDenied()); statement.setLong(7, groupV2.getBlockedAt());
statement.setBytes(8, KeyUtils.createRawStorageId()); statement.setBoolean(8, groupV2.isPermissionDenied());
statement.setBoolean(9, groupV2.isProfileSharingEnabled()); statement.setBytes(9, KeyUtils.createRawStorageId());
statement.setBoolean(10, groupV2.isProfileSharingEnabled());
statement.executeUpdate(); statement.executeUpdate();
} }
} else { } else {
@ -691,7 +696,7 @@ public class GroupStore {
private List<GroupInfoV2> getGroupsV2() { private List<GroupInfoV2> getGroupsV2() {
final var sql = ( 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 FROM %s g
""" """
).formatted(TABLE_GROUP_V2); ).formatted(TABLE_GROUP_V2);
@ -709,7 +714,7 @@ public class GroupStore {
public GroupInfoV2 getGroup(Connection connection, GroupIdV2 groupIdV2) throws SQLException { public GroupInfoV2 getGroup(Connection connection, GroupIdV2 groupIdV2) throws SQLException {
final var sql = ( 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 FROM %s g
WHERE g.group_id = ? WHERE g.group_id = ?
""" """
@ -743,7 +748,7 @@ public class GroupStore {
public GroupInfoV2 getGroupV2(Connection connection, StorageId storageId) throws SQLException { public GroupInfoV2 getGroupV2(Connection connection, StorageId storageId) throws SQLException {
final var sql = ( 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 FROM %s g
WHERE g.storage_id = ? WHERE g.storage_id = ?
""" """
@ -766,6 +771,7 @@ public class GroupStore {
final var groupData = resultSet.getBytes("group_data"); final var groupData = resultSet.getBytes("group_data");
final var distributionId = resultSet.getBytes("distribution_id"); final var distributionId = resultSet.getBytes("distribution_id");
final var blocked = resultSet.getBoolean("blocked"); final var blocked = resultSet.getBoolean("blocked");
final var blockedAt = resultSet.getLong("blocked_at");
final var profileSharingEnabled = resultSet.getBoolean("profile_sharing"); final var profileSharingEnabled = resultSet.getBoolean("profile_sharing");
final var permissionDenied = resultSet.getBoolean("permission_denied"); final var permissionDenied = resultSet.getBoolean("permission_denied");
final var storageRecord = resultSet.getBytes("storage_record"); final var storageRecord = resultSet.getBytes("storage_record");
@ -774,6 +780,7 @@ public class GroupStore {
groupData == null ? null : DecryptedGroup.ADAPTER.decode(groupData), groupData == null ? null : DecryptedGroup.ADAPTER.decode(groupData),
DistributionId.from(UuidUtil.parseOrThrow(distributionId)), DistributionId.from(UuidUtil.parseOrThrow(distributionId)),
blocked, blocked,
blockedAt,
profileSharingEnabled, profileSharingEnabled,
permissionDenied, permissionDenied,
storageRecord, storageRecord,
@ -800,7 +807,7 @@ public class GroupStore {
private List<GroupInfoV1> getGroupsV1() { private List<GroupInfoV1> getGroupsV1() {
final var sql = ( 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 FROM %s g
""" """
).formatted(TABLE_GROUP_V1_MEMBER, TABLE_GROUP_V1); ).formatted(TABLE_GROUP_V1_MEMBER, TABLE_GROUP_V1);
@ -818,7 +825,7 @@ public class GroupStore {
public GroupInfoV1 getGroup(Connection connection, GroupIdV1 groupIdV1) throws SQLException { public GroupInfoV1 getGroup(Connection connection, GroupIdV1 groupIdV1) throws SQLException {
final var sql = ( 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 FROM %s g
WHERE g.group_id = ? WHERE g.group_id = ?
""" """
@ -852,7 +859,7 @@ public class GroupStore {
public GroupInfoV1 getGroupV1(Connection connection, StorageId storageId) throws SQLException { public GroupInfoV1 getGroupV1(Connection connection, StorageId storageId) throws SQLException {
final var sql = ( 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 FROM %s g
WHERE g.storage_id = ? WHERE g.storage_id = ?
""" """
@ -882,6 +889,7 @@ public class GroupStore {
.collect(Collectors.toSet()); .collect(Collectors.toSet());
final var expirationTime = resultSet.getInt("expiration_time"); final var expirationTime = resultSet.getInt("expiration_time");
final var blocked = resultSet.getBoolean("blocked"); final var blocked = resultSet.getBoolean("blocked");
final var blockedAt = resultSet.getLong("blocked_at");
final var archived = resultSet.getBoolean("archived"); final var archived = resultSet.getBoolean("archived");
final var storageRecord = resultSet.getBytes("storage_record"); final var storageRecord = resultSet.getBytes("storage_record");
return new GroupInfoV1(GroupId.v1(groupId), return new GroupInfoV1(GroupId.v1(groupId),
@ -891,6 +899,7 @@ public class GroupStore {
color, color,
expirationTime, expirationTime,
blocked, blocked,
blockedAt,
archived, archived,
storageRecord); storageRecord);
} }
@ -902,7 +911,7 @@ public class GroupStore {
private GroupInfoV1 getGroupV1ByV2Id(Connection connection, GroupIdV2 groupIdV2) throws SQLException { private GroupInfoV1 getGroupV1ByV2Id(Connection connection, GroupIdV2 groupIdV2) throws SQLException {
final var sql = ( 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 FROM %s g
WHERE g.group_id_v2 = ? WHERE g.group_id_v2 = ?
""" """

View File

@ -59,6 +59,7 @@ public class LegacyGroupStore {
g1.color, g1.color,
g1.messageExpirationTime, g1.messageExpirationTime,
g1.blocked, g1.blocked,
0,
g1.archived, g1.archived,
null); null);
} }
@ -77,6 +78,7 @@ public class LegacyGroupStore {
loadDecryptedGroupLocked(groupId, groupCachePath), loadDecryptedGroupLocked(groupId, groupCachePath),
g2.distributionId == null ? DistributionId.create() : DistributionId.from(g2.distributionId), g2.distributionId == null ? DistributionId.create() : DistributionId.from(g2.distributionId),
g2.blocked, g2.blocked,
0,
true, true,
g2.permissionDenied, g2.permissionDenied,
null, null,

View File

@ -50,6 +50,7 @@ public class LegacyRecipientStore2 {
0, 0,
false, false,
r.contact.blocked, r.contact.blocked,
0,
r.contact.archived, r.contact.archived,
r.contact.profileSharingEnabled, r.contact.profileSharingEnabled,
false, false,
@ -100,6 +101,7 @@ public class LegacyRecipientStore2 {
profile, profile,
null, null,
null, null,
false,
null); null);
}).collect(Collectors.toMap(Recipient::getRecipientId, r -> r)); }).collect(Collectors.toMap(Recipient::getRecipientId, r -> r));

View File

@ -25,6 +25,8 @@ public class Recipient {
private final Long unregisteredTimestamp; private final Long unregisteredTimestamp;
private final boolean pniSignatureVerified;
private final byte[] storageRecord; private final byte[] storageRecord;
public Recipient( public Recipient(
@ -36,6 +38,7 @@ public class Recipient {
final Profile profile, final Profile profile,
final Boolean discoverable, final Boolean discoverable,
final Long unregisteredTimestamp, final Long unregisteredTimestamp,
final boolean pniSignatureVerified,
final byte[] storageRecord final byte[] storageRecord
) { ) {
this.recipientId = recipientId; this.recipientId = recipientId;
@ -46,6 +49,7 @@ public class Recipient {
this.profile = profile; this.profile = profile;
this.discoverable = discoverable; this.discoverable = discoverable;
this.unregisteredTimestamp = unregisteredTimestamp; this.unregisteredTimestamp = unregisteredTimestamp;
this.pniSignatureVerified = pniSignatureVerified;
this.storageRecord = storageRecord; this.storageRecord = storageRecord;
} }
@ -58,6 +62,7 @@ public class Recipient {
profile = builder.profile; profile = builder.profile;
discoverable = builder.discoverable; discoverable = builder.discoverable;
unregisteredTimestamp = builder.unregisteredTimestamp; unregisteredTimestamp = builder.unregisteredTimestamp;
pniSignatureVerified = builder.pniSignatureVerified;
storageRecord = builder.storageRecord; storageRecord = builder.storageRecord;
} }
@ -73,6 +78,9 @@ public class Recipient {
builder.profileKey = copy.getProfileKey(); builder.profileKey = copy.getProfileKey();
builder.expiringProfileKeyCredential = copy.getExpiringProfileKeyCredential(); builder.expiringProfileKeyCredential = copy.getExpiringProfileKeyCredential();
builder.profile = copy.getProfile(); builder.profile = copy.getProfile();
builder.discoverable = copy.getDiscoverable();
builder.unregisteredTimestamp = copy.getUnregisteredTimestamp();
builder.pniSignatureVerified = copy.isPniSignatureVerified();
builder.storageRecord = copy.getStorageRecord(); builder.storageRecord = copy.getStorageRecord();
return builder; return builder;
} }
@ -113,6 +121,10 @@ public class Recipient {
return unregisteredTimestamp == null; return unregisteredTimestamp == null;
} }
public boolean isPniSignatureVerified() {
return pniSignatureVerified;
}
public byte[] getStorageRecord() { public byte[] getStorageRecord() {
return storageRecord; return storageRecord;
} }
@ -127,12 +139,19 @@ public class Recipient {
&& Objects.equals(contact, recipient.contact) && Objects.equals(contact, recipient.contact)
&& Objects.equals(profileKey, recipient.profileKey) && Objects.equals(profileKey, recipient.profileKey)
&& Objects.equals(expiringProfileKeyCredential, recipient.expiringProfileKeyCredential) && Objects.equals(expiringProfileKeyCredential, recipient.expiringProfileKeyCredential)
&& Objects.equals(profile, recipient.profile); && Objects.equals(profile, recipient.profile)
&& pniSignatureVerified == recipient.pniSignatureVerified;
} }
@Override @Override
public int hashCode() { 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 { public static final class Builder {
@ -145,6 +164,7 @@ public class Recipient {
private Profile profile; private Profile profile;
private Boolean discoverable; private Boolean discoverable;
private Long unregisteredTimestamp; private Long unregisteredTimestamp;
private boolean pniSignatureVerified;
private byte[] storageRecord; private byte[] storageRecord;
private Builder() { private Builder() {
@ -190,6 +210,11 @@ public class Recipient {
return this; return this;
} }
public Builder withPniSignatureVerified(final boolean val) {
pniSignatureVerified = val;
return this;
}
public Builder withStorageRecord(final byte[] val) { public Builder withStorageRecord(final byte[] val) {
storageRecord = val; storageRecord = val;
return this; return this;

View File

@ -54,14 +54,16 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
private static final int MAX_RECIPIENT_CACHE_SIZE = 2000; private static final int MAX_RECIPIENT_CACHE_SIZE = 2000;
private final Map<ServiceId, RecipientWithAddress> recipientAddressCache = Collections.synchronizedMap( private final Map<ServiceId, RecipientWithAddress> recipientAddressCache = Collections.synchronizedMap(new LinkedHashMap<>(
new LinkedHashMap<>(16, 0.75f, true) { 16,
0.75f,
true) {
@Override @Override
protected boolean removeEldestEntry(Map.Entry<ServiceId, RecipientWithAddress> eldest) { protected boolean removeEldestEntry(Map.Entry<ServiceId, RecipientWithAddress> eldest) {
return size() > MAX_RECIPIENT_CACHE_SIZE; return size() > MAX_RECIPIENT_CACHE_SIZE;
} }
}); });
public static void createSql(Connection connection) throws SQLException { public static void createSql(Connection connection) throws SQLException {
// When modifying the CREATE statement here, also add a migration in AccountDatabase.java // 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 BLOB,
profile_key_credential BLOB, profile_key_credential BLOB,
needs_pni_signature INTEGER NOT NULL DEFAULT FALSE, needs_pni_signature INTEGER NOT NULL DEFAULT FALSE,
pni_signature_verified INTEGER NOT NULL DEFAULT FALSE,
given_name TEXT, given_name TEXT,
family_name TEXT, family_name TEXT,
@ -93,6 +96,7 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
expiration_time_version INTEGER DEFAULT 1 NOT NULL, expiration_time_version INTEGER DEFAULT 1 NOT NULL,
mute_until INTEGER NOT NULL DEFAULT 0, mute_until INTEGER NOT NULL DEFAULT 0,
blocked INTEGER NOT NULL DEFAULT FALSE, blocked INTEGER NOT NULL DEFAULT FALSE,
blocked_at INTEGER NOT NULL DEFAULT 0,
archived INTEGER NOT NULL DEFAULT FALSE, archived INTEGER NOT NULL DEFAULT FALSE,
profile_sharing INTEGER NOT NULL DEFAULT FALSE, profile_sharing INTEGER NOT NULL DEFAULT FALSE,
hide_story 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() { public List<Pair<RecipientId, Contact>> getContacts() {
final var sql = ( 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 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 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, SELECT r._id,
r.number, r.aci, r.pni, r.username, r.number, r.aci, r.pni, r.username,
r.profile_key, r.profile_key_credential, 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.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.discoverable,
r.storage_record r.storage_record
@ -392,7 +397,8 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
SELECT r._id, SELECT r._id,
r.number, r.aci, r.pni, r.username, r.number, r.aci, r.pni, r.username,
r.profile_key, r.profile_key_credential, 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.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.discoverable,
r.storage_record r.storage_record
@ -441,7 +447,8 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
SELECT r._id, SELECT r._id,
r.number, r.aci, r.pni, r.username, r.number, r.aci, r.pni, r.username,
r.profile_key, r.profile_key_credential, 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.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.discoverable,
r.storage_record r.storage_record
@ -886,7 +893,7 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
final var sql = ( final var sql = (
""" """
UPDATE %s 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 = ? WHERE _id = ?
""" """
).formatted(TABLE_RECIPIENT); ).formatted(TABLE_RECIPIENT);
@ -901,25 +908,49 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
statement.setBoolean(8, contact != null && contact.isProfileSharingEnabled()); statement.setBoolean(8, contact != null && contact.isProfileSharingEnabled());
statement.setString(9, contact == null ? null : contact.color()); statement.setString(9, contact == null ? null : contact.color());
statement.setBoolean(10, contact != null && contact.isBlocked()); 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) { if (contact == null || contact.unregisteredTimestamp() == null) {
statement.setNull(12, Types.INTEGER); statement.setNull(13, Types.INTEGER);
} else { } 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.nickNameGivenName());
statement.setString(14, contact == null ? null : contact.nickNameFamilyName()); statement.setString(15, contact == null ? null : contact.nickNameFamilyName());
statement.setString(15, contact == null ? null : contact.note()); statement.setString(16, contact == null ? null : contact.note());
statement.setBoolean(16, contact != null && contact.isHidden()); statement.setBoolean(17, contact != null && contact.isHidden());
statement.setLong(17, recipientId.id()); statement.setLong(18, recipientId.id());
statement.executeUpdate(); statement.executeUpdate();
} }
if (contact != null && contact.unregisteredTimestamp() != null) { if (contact != null && contact.unregisteredTimestamp() != null) {
markUnregisteredAndSplitIfNecessary(connection, recipientId); markUnregisteredAndSplitIfNecessary(connection, recipientId, contact.unregisteredTimestamp());
} }
rotateStorageId(connection, recipientId); 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( public int removeStorageIdsFromLocalOnlyUnregisteredRecipients(
final Connection connection, final Connection connection,
final Collection<StorageId> storageIds 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) { public boolean needsPniSignature(final RecipientId recipientId) {
try (final var connection = database.getConnection()) { try (final var connection = database.getConnection()) {
final var sql = ( final var sql = (
@ -992,7 +1086,7 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
if (recipientAddress.get().address().aci().isEmpty() || ( if (recipientAddress.get().address().aci().isEmpty() || (
contact != null && contact.unregisteredTimestamp() != null 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) { if (registered) {
markRegistered(connection, recipientId); markRegistered(connection, recipientId);
} else { } else {
markUnregisteredAndSplitIfNecessary(connection, recipientId); markUnregisteredAndSplitIfNecessary(connection, recipientId, System.currentTimeMillis());
} }
connection.commit(); connection.commit();
} catch (SQLException e) { } catch (SQLException e) {
@ -1036,9 +1130,10 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
private void markUnregisteredAndSplitIfNecessary( private void markUnregisteredAndSplitIfNecessary(
final Connection connection, final Connection connection,
final RecipientId recipientId final RecipientId recipientId,
final long unregisteredTimestamp
) throws SQLException { ) throws SQLException {
markUnregistered(connection, recipientId); markUnregistered(connection, recipientId, unregisteredTimestamp);
final var address = resolveRecipientAddress(connection, recipientId); final var address = resolveRecipientAddress(connection, recipientId);
final var needSplit = address.aci().isPresent() && address.pni().isPresent(); final var needSplit = address.aci().isPresent() && address.pni().isPresent();
logger.trace("Marking unregistered recipient {} as unregistered (and split={}): {}", 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 = ( final var sql = (
""" """
UPDATE %s UPDATE %s
@ -1094,7 +1193,7 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
""" """
).formatted(TABLE_RECIPIENT); ).formatted(TABLE_RECIPIENT);
try (final var statement = connection.prepareStatement(sql)) { try (final var statement = connection.prepareStatement(sql)) {
statement.setLong(1, System.currentTimeMillis()); statement.setLong(1, unregisteredTimestamp);
statement.setLong(2, recipientId.id()); statement.setLong(2, recipientId.id());
statement.executeUpdate(); statement.executeUpdate();
} }
@ -1255,6 +1354,9 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
final List<RecipientId> toBeMergedRecipientIds final List<RecipientId> toBeMergedRecipientIds
) throws SQLException { ) throws SQLException {
for (final var toBeMergedRecipientId : toBeMergedRecipientIds) { for (final var toBeMergedRecipientId : toBeMergedRecipientIds) {
if (isPniSignatureVerified(connection, toBeMergedRecipientId)) {
storePniSignatureVerified(connection, recipientId, true, true);
}
recipientMergeHandler.mergeRecipients(connection, recipientId, toBeMergedRecipientId); recipientMergeHandler.mergeRecipients(connection, recipientId, toBeMergedRecipientId);
deleteRecipient(connection, toBeMergedRecipientId); deleteRecipient(connection, toBeMergedRecipientId);
recipientAddressCache.entrySet().removeIf(e -> e.getValue().id().equals(toBeMergedRecipientId)); recipientAddressCache.entrySet().removeIf(e -> e.getValue().id().equals(toBeMergedRecipientId));
@ -1349,7 +1451,12 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
final var sql = ( final var sql = (
""" """
UPDATE %s 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 = ? WHERE _id = ?
""" """
).formatted(TABLE_RECIPIENT); ).formatted(TABLE_RECIPIENT);
@ -1365,10 +1472,18 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
final RecipientAddress address final RecipientAddress address
) throws SQLException { ) throws SQLException {
recipientAddressCache.entrySet().removeIf(e -> e.getValue().id().equals(recipientId)); 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 = ( final var sql = (
""" """
UPDATE %s UPDATE %s
SET number = ?, aci = ?, pni = ?, username = ? SET number = ?,
aci = ?,
pni = ?,
username = ?,
pni_signature_verified = ?
WHERE _id = ? WHERE _id = ?
""" """
).formatted(TABLE_RECIPIENT); ).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(2, address.aci().map(ACI::toString).orElse(null));
statement.setString(3, address.pni().map(PNI::toString).orElse(null)); statement.setString(3, address.pni().map(PNI::toString).orElse(null));
statement.setString(4, address.username().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(); statement.executeUpdate();
} }
rotateStorageId(connection, recipientId); rotateStorageId(connection, recipientId);
@ -1402,9 +1518,14 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
RecipientId toBeMergedRecipientId RecipientId toBeMergedRecipientId
) throws SQLException { ) throws SQLException {
final var contact = getContact(connection, recipientId); final var contact = getContact(connection, recipientId);
final var toBeMergedContact = getContact(connection, toBeMergedRecipientId);
if (contact == null) { if (contact == null) {
final var toBeMergedContact = getContact(connection, toBeMergedRecipientId);
storeContact(connection, recipientId, toBeMergedContact); 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); final var profileKey = getProfileKey(connection, recipientId);
@ -1429,6 +1550,24 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
recipientsMerged.put(toBeMergedRecipientId.id(), recipientId.id()); 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( private Optional<RecipientWithAddress> findByNumber(
final Connection connection, final Connection connection,
final String number 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 { private Contact getContact(final Connection connection, final RecipientId recipientId) throws SQLException {
final var sql = ( 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 FROM %s r
WHERE r._id = ? AND (%s) WHERE r._id = ? AND (%s)
""" """
@ -1595,6 +1734,7 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
getProfileFromResultSet(resultSet), getProfileFromResultSet(resultSet),
getDiscoverableFromResultSet(resultSet), getDiscoverableFromResultSet(resultSet),
getUnregisteredTimestampFromResultSet(resultSet), getUnregisteredTimestampFromResultSet(resultSet),
resultSet.getBoolean("pni_signature_verified"),
getStorageRecordFromResultSet(resultSet)); getStorageRecordFromResultSet(resultSet));
} }
@ -1612,6 +1752,7 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
resultSet.getLong("mute_until"), resultSet.getLong("mute_until"),
resultSet.getBoolean("hide_story"), resultSet.getBoolean("hide_story"),
resultSet.getBoolean("blocked"), resultSet.getBoolean("blocked"),
resultSet.getLong("blocked_at"),
resultSet.getBoolean("archived"), resultSet.getBoolean("archived"),
resultSet.getBoolean("profile_sharing"), resultSet.getBoolean("profile_sharing"),
resultSet.getBoolean("hidden"), resultSet.getBoolean("hidden"),

View File

@ -410,7 +410,7 @@ public class SessionStore implements SignalServiceSessionStore {
} }
private static boolean isActive(SessionRecord record) { private static boolean isActive(SessionRecord record) {
return record != null && record.hasSenderChain(0.0); return record != null && record.hasSenderChain();
} }
record Key(String address, int deviceId) {} record Key(String address, int deviceId) {}

View File

@ -1,10 +1,29 @@
package org.asamk.signal.manager.storage.stickers; package org.asamk.signal.manager.storage.stickers;
import org.asamk.signal.manager.api.StickerPackId; 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) { public StickerPack(final StickerPackId packId, final byte[] packKey) {
this(-1, packId, packKey, false); this(-1, packId, packKey, false, 0, 0, null, null);
} }
} }

View File

@ -3,14 +3,19 @@ package org.asamk.signal.manager.storage.stickers;
import org.asamk.signal.manager.api.StickerPackId; import org.asamk.signal.manager.api.StickerPackId;
import org.asamk.signal.manager.storage.Database; import org.asamk.signal.manager.storage.Database;
import org.asamk.signal.manager.storage.Utils; import org.asamk.signal.manager.storage.Utils;
import org.asamk.signal.manager.util.KeyUtils;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.whispersystems.signalservice.api.storage.SignalStickerPackRecord;
import org.whispersystems.signalservice.api.storage.StorageId;
import java.sql.Connection; import java.sql.Connection;
import java.sql.ResultSet; import java.sql.ResultSet;
import java.sql.SQLException; import java.sql.SQLException;
import java.sql.Types;
import java.util.Collection; import java.util.Collection;
import java.util.List; import java.util.List;
import java.util.Map;
public class StickerStore { public class StickerStore {
@ -27,7 +32,11 @@ public class StickerStore {
_id INTEGER PRIMARY KEY, _id INTEGER PRIMARY KEY,
pack_id BLOB UNIQUE NOT NULL, pack_id BLOB UNIQUE NOT NULL,
pack_key BLOB 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; ) STRICT;
"""); """);
} }
@ -38,55 +47,103 @@ public class StickerStore {
} }
public List<StickerPack> getStickerPacks() { 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 connection = database.getConnection()) {
try (final var statement = connection.prepareStatement(sql)) { return getStickerPacks(connection);
try (var result = Utils.executeQueryForStream(statement, this::getStickerPackFromResultSet)) {
return result.toList();
}
}
} catch (SQLException e) { } catch (SQLException e) {
throw new RuntimeException("Failed read from sticker store", 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 = ( 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 FROM %s s
WHERE s.pack_id = ? WHERE s.pack_id = ?
""" """
).formatted(TABLE_STICKER); ).formatted(TABLE_STICKER);
try (final var connection = database.getConnection()) { try (final var statement = connection.prepareStatement(sql)) {
try (final var statement = connection.prepareStatement(sql)) { statement.setBytes(1, packId.serialize());
statement.setBytes(1, packId.serialize()); return Utils.executeQueryForOptional(statement, this::getStickerPackFromResultSet).orElse(null);
return Utils.executeQueryForOptional(statement, this::getStickerPackFromResultSet).orElse(null); }
} }
} catch (SQLException e) {
throw new RuntimeException("Failed read from sticker store", e); 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) { public void addStickerPack(StickerPack stickerPack) {
final var sql = ( final var sql = (
""" """
INSERT INTO %s (pack_id, pack_key, installed) INSERT INTO %s (pack_id, pack_key, installed, position, deleted_timestamp, storage_id, storage_record)
VALUES (?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?)
""" """
).formatted(TABLE_STICKER); ).formatted(TABLE_STICKER);
try (final var connection = database.getConnection()) { 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)) { try (final var statement = connection.prepareStatement(sql)) {
statement.setBytes(1, stickerPack.packId().serialize()); statement.setBytes(1, stickerPack.packId().serialize());
statement.setBytes(2, stickerPack.packKey()); statement.setBytes(2, stickerPack.packKey());
statement.setBoolean(3, stickerPack.isInstalled()); 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(); statement.executeUpdate();
} }
connection.commit();
} catch (SQLException e) { } catch (SQLException e) {
throw new RuntimeException("Failed update sticker store", e); throw new RuntimeException("Failed update sticker store", e);
} }
@ -96,28 +153,279 @@ public class StickerStore {
final var sql = ( final var sql = (
""" """
UPDATE %s UPDATE %s
SET installed = ? SET installed = ?, position = ?, deleted_timestamp = ?, storage_id = ?
WHERE pack_id = ? WHERE pack_id = ?
""" """
).formatted(TABLE_STICKER); ).formatted(TABLE_STICKER);
try (final var connection = database.getConnection()) { 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)) { try (final var statement = connection.prepareStatement(sql)) {
statement.setBytes(1, stickerPackId.serialize()); statement.setBoolean(1, installed);
statement.setBoolean(2, installed); statement.setInt(2, position);
statement.setLong(3, deletedTimestamp);
statement.setBytes(4, newStorageId.getRaw());
statement.setBytes(5, stickerPackId.serialize());
statement.executeUpdate(); statement.executeUpdate();
} }
connection.commit();
} catch (SQLException e) { } catch (SQLException e) {
throw new RuntimeException("Failed update sticker store", 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) { void addLegacyStickers(Collection<StickerPack> stickerPacks) {
logger.debug("Migrating legacy stickers to database"); logger.debug("Migrating legacy stickers to database");
long start = System.nanoTime(); long start = System.nanoTime();
final var sql = ( final var sql = (
""" """
INSERT INTO %s (pack_id, pack_key, installed) INSERT INTO %s (pack_id, pack_key, installed, position, deleted_timestamp, storage_id, storage_record)
VALUES (?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?)
""" """
).formatted(TABLE_STICKER); ).formatted(TABLE_STICKER);
try (final var connection = database.getConnection()) { 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))) { try (final var statement = connection.prepareStatement("DELETE FROM %s".formatted(TABLE_STICKER))) {
statement.executeUpdate(); statement.executeUpdate();
} }
var installedPosition = 0;
try (final var statement = connection.prepareStatement(sql)) { try (final var statement = connection.prepareStatement(sql)) {
for (final var sticker : stickerPacks) { for (final var sticker : stickerPacks) {
final var storageId = sticker.isInstalled()
? StorageId.forStickerPack(KeyUtils.createRawStorageId())
: null;
statement.setBytes(1, sticker.packId().serialize()); statement.setBytes(1, sticker.packId().serialize());
statement.setBytes(2, sticker.packKey()); statement.setBytes(2, sticker.packKey());
statement.setBoolean(3, sticker.isInstalled()); 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(); statement.executeUpdate();
} }
} }
@ -145,6 +465,36 @@ public class StickerStore {
final var packId = resultSet.getBytes("pack_id"); final var packId = resultSet.getBytes("pack_id");
final var packKey = resultSet.getBytes("pack_key"); final var packKey = resultSet.getBytes("pack_key");
final var installed = resultSet.getBoolean("installed"); 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"));
}
} }
} }

View File

@ -24,6 +24,7 @@ import org.whispersystems.signalservice.internal.storage.protos.ContactRecord.Id
import java.sql.Connection; import java.sql.Connection;
import java.sql.SQLException; import java.sql.SQLException;
import java.util.Arrays; import java.util.Arrays;
import java.util.Collection;
import java.util.Objects; import java.util.Objects;
import java.util.Optional; import java.util.Optional;
import java.util.regex.Pattern; import java.util.regex.Pattern;
@ -56,6 +57,29 @@ public class ContactRecordProcessor extends DefaultStorageRecordProcessor<Signal
this.selfNumber = account.getNumber(); 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: * Error cases:
* - You can't have a contact record without an ACI or PNI. * - You can't have a contact record without an ACI or PNI.
@ -205,6 +229,7 @@ public class ContactRecordProcessor extends DefaultStorageRecordProcessor<Signal
.identityState(identityState) .identityState(identityState)
.identityKey(identityKey) .identityKey(identityKey)
.blocked(remote.blocked) .blocked(remote.blocked)
.blockedAtTimestamp(remote.blockedAtTimestamp)
.whitelisted(remote.whitelisted) .whitelisted(remote.whitelisted)
.archived(remote.archived) .archived(remote.archived)
.markedUnread(remote.markedUnread) .markedUnread(remote.markedUnread)
@ -283,7 +308,9 @@ public class ContactRecordProcessor extends DefaultStorageRecordProcessor<Signal
final var contactNickGivenName = contact == null ? null : contact.nickNameGivenName(); final var contactNickGivenName = contact == null ? null : contact.nickNameGivenName();
final var contactNickFamilyName = contact == null ? null : contact.nickNameFamilyName(); final var contactNickFamilyName = contact == null ? null : contact.nickNameFamilyName();
final var contactNote = contact == null ? null : contact.note(); final var contactNote = contact == null ? null : contact.note();
final var blockedAt = contact == null ? 0 : contact.blockedAt();
if (blocked != contactProto.blocked if (blocked != contactProto.blocked
|| blockedAt != contactProto.blockedAtTimestamp
|| profileShared != contactProto.whitelisted || profileShared != contactProto.whitelisted
|| archived != contactProto.archived || archived != contactProto.archived
|| hidden != contactProto.hidden || hidden != contactProto.hidden
@ -301,6 +328,7 @@ public class ContactRecordProcessor extends DefaultStorageRecordProcessor<Signal
logger.debug("Storing new or updated contact {}", recipientId); logger.debug("Storing new or updated contact {}", recipientId);
final var contactBuilder = contact == null ? Contact.newBuilder() : Contact.newBuilder(contact); final var contactBuilder = contact == null ? Contact.newBuilder() : Contact.newBuilder(contact);
final var newContact = contactBuilder.withIsBlocked(contactProto.blocked) final var newContact = contactBuilder.withIsBlocked(contactProto.blocked)
.withBlockedAt(contactProto.blocked ? contactProto.blockedAtTimestamp : 0)
.withIsProfileSharingEnabled(contactProto.whitelisted) .withIsProfileSharingEnabled(contactProto.whitelisted)
.withIsArchived(contactProto.archived) .withIsArchived(contactProto.archived)
.withIsHidden(contactProto.hidden) .withIsHidden(contactProto.hidden)
@ -357,6 +385,8 @@ public class ContactRecordProcessor extends DefaultStorageRecordProcessor<Signal
logger.warn("Received invalid contact identity key from storage"); logger.warn("Received invalid contact identity key from storage");
} }
} }
account.getRecipientStore()
.storePniSignatureVerified(connection, recipientId, contactProto.pniSignatureVerified);
account.getRecipientStore() account.getRecipientStore()
.storeStorageRecord(connection, recipientId, contactRecord.getId(), contactProto.encode()); .storeStorageRecord(connection, recipientId, contactRecord.getId(), contactProto.encode());
} }

View File

@ -114,6 +114,7 @@ public final class GroupV1RecordProcessor extends DefaultStorageRecordProcessor<
final var group = account.getGroupStore().getOrCreateGroupV1(connection, groupIdV1); final var group = account.getGroupStore().getOrCreateGroupV1(connection, groupIdV1);
if (group != null) { if (group != null) {
group.setBlocked(groupV1Proto.blocked); group.setBlocked(groupV1Proto.blocked);
group.setBlockedAt(0);
account.getGroupStore().updateGroup(connection, group); account.getGroupStore().updateGroup(connection, group);
account.getGroupStore() account.getGroupStore()
.storeStorageRecord(connection, group.getGroupId(), groupV1Record.getId(), groupV1Proto.encode()); .storeStorageRecord(connection, group.getGroupId(), groupV1Record.getId(), groupV1Proto.encode());

View File

@ -56,6 +56,7 @@ public final class GroupV2RecordProcessor extends DefaultStorageRecordProcessor<
final var mergedBuilder = remote.newBuilder() final var mergedBuilder = remote.newBuilder()
.masterKey(remote.masterKey) .masterKey(remote.masterKey)
.blocked(remote.blocked) .blocked(remote.blocked)
.blockedAtTimestamp(remote.blockedAtTimestamp)
.whitelisted(remote.whitelisted) .whitelisted(remote.whitelisted)
.archived(remote.archived) .archived(remote.archived)
.markedUnread(remote.markedUnread) .markedUnread(remote.markedUnread)
@ -93,6 +94,7 @@ public final class GroupV2RecordProcessor extends DefaultStorageRecordProcessor<
final var group = account.getGroupStore().getGroupOrPartialMigrate(connection, groupMasterKey); final var group = account.getGroupStore().getGroupOrPartialMigrate(connection, groupMasterKey);
group.setBlocked(groupV2Proto.blocked); group.setBlocked(groupV2Proto.blocked);
group.setBlockedAt(groupV2Proto.blocked ? groupV2Proto.blockedAtTimestamp : 0);
group.setProfileSharingEnabled(groupV2Proto.whitelisted); group.setProfileSharingEnabled(groupV2Proto.whitelisted);
account.getGroupStore().updateGroup(connection, group); account.getGroupStore().updateGroup(connection, group);
account.getGroupStore() account.getGroupStore()

View File

@ -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());
}
}

View File

@ -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 {}
}
}

View File

@ -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.groups.GroupInfoV2;
import org.asamk.signal.manager.storage.identities.IdentityInfo; import org.asamk.signal.manager.storage.identities.IdentityInfo;
import org.asamk.signal.manager.storage.recipients.Recipient; 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.ACI;
import org.signal.core.models.ServiceId.PNI; import org.signal.core.models.ServiceId.PNI;
import org.signal.core.util.UuidUtil; 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.SignalContactRecord;
import org.whispersystems.signalservice.api.storage.SignalGroupV1Record; import org.whispersystems.signalservice.api.storage.SignalGroupV1Record;
import org.whispersystems.signalservice.api.storage.SignalGroupV2Record; import org.whispersystems.signalservice.api.storage.SignalGroupV2Record;
import org.whispersystems.signalservice.api.storage.SignalStickerPackRecord;
import org.whispersystems.signalservice.internal.storage.protos.AccountRecord; import org.whispersystems.signalservice.internal.storage.protos.AccountRecord;
import org.whispersystems.signalservice.internal.storage.protos.AccountRecord.UsernameLink; import org.whispersystems.signalservice.internal.storage.protos.AccountRecord.UsernameLink;
import org.whispersystems.signalservice.internal.storage.protos.ContactRecord; import org.whispersystems.signalservice.internal.storage.protos.ContactRecord;
import org.whispersystems.signalservice.internal.storage.protos.ContactRecord.IdentityState; import org.whispersystems.signalservice.internal.storage.protos.ContactRecord.IdentityState;
import org.whispersystems.signalservice.internal.storage.protos.GroupV1Record; import org.whispersystems.signalservice.internal.storage.protos.GroupV1Record;
import org.whispersystems.signalservice.internal.storage.protos.GroupV2Record; import org.whispersystems.signalservice.internal.storage.protos.GroupV2Record;
import org.whispersystems.signalservice.internal.storage.protos.StickerPackRecord;
import java.sql.Connection; import java.sql.Connection;
import java.sql.SQLException; import java.sql.SQLException;
@ -93,11 +96,11 @@ public final class StorageSyncModels {
public static ContactRecord localToRemoteRecord(Recipient recipient, IdentityInfo identity) { public static ContactRecord localToRemoteRecord(Recipient recipient, IdentityInfo identity) {
final var address = recipient.getAddress(); final var address = recipient.getAddress();
final var aciPresent = address.aci().isPresent(); final var aciPresent = address.aci().isPresent();
final var pniPresent = address.pni().isPresent();
final var builder = SignalContactRecord.Companion.newBuilder(recipient.getStorageRecord()) final var builder = SignalContactRecord.Companion.newBuilder(recipient.getStorageRecord())
.e164(pniPresent ? address.number().orElse("") : "") .e164(address.number().orElse(""))
.username(address.username().orElse("")) .username(address.username().orElse(""))
.pniSignatureVerified(recipient.isPniSignatureVerified())
.profileKey(recipient.getProfileKey() == null .profileKey(recipient.getProfileKey() == null
? ByteString.EMPTY ? ByteString.EMPTY
: ByteString.of(recipient.getProfileKey().serialize())); : ByteString.of(recipient.getProfileKey().serialize()));
@ -120,6 +123,7 @@ public final class StorageSyncModels {
.nickname(getNicknameRemoteRecord(recipient.getContact())) .nickname(getNicknameRemoteRecord(recipient.getContact()))
.note(emptyIfNull(recipient.getContact().note())) .note(emptyIfNull(recipient.getContact().note()))
.blocked(recipient.getContact().isBlocked()) .blocked(recipient.getContact().isBlocked())
.blockedAtTimestamp(recipient.getContact().blockedAt())
.whitelisted(recipient.getContact().isProfileSharingEnabled()) .whitelisted(recipient.getContact().isProfileSharingEnabled())
.mutedUntilTimestamp(recipient.getContact().muteUntil()) .mutedUntilTimestamp(recipient.getContact().muteUntil())
.hideStory(recipient.getContact().hideStory()) .hideStory(recipient.getContact().hideStory())
@ -158,10 +162,28 @@ public final class StorageSyncModels {
final var builder = SignalGroupV2Record.Companion.newBuilder(group.getStorageRecord()); final var builder = SignalGroupV2Record.Companion.newBuilder(group.getStorageRecord());
builder.masterKey(ByteString.of(group.getMasterKey().serialize())); builder.masterKey(ByteString.of(group.getMasterKey().serialize()));
builder.blocked(group.isBlocked()); builder.blocked(group.isBlocked());
builder.blockedAtTimestamp(group.getBlockedAt());
builder.whitelisted(group.isProfileSharingEnabled()); builder.whitelisted(group.isProfileSharingEnabled());
return builder.build(); 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) { public static TrustLevel remoteToLocal(IdentityState identityState) {
return switch (identityState) { return switch (identityState) {
case DEFAULT -> TrustLevel.TRUSTED_UNVERIFIED; case DEFAULT -> TrustLevel.TRUSTED_UNVERIFIED;

View File

@ -168,6 +168,11 @@ public final class StorageSyncValidations {
throw new DuplicateCallLinkError(); 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(); throw new DuplicateRawIdAcrossTypesError();
} }
@ -217,6 +222,8 @@ public final class StorageSyncValidations {
private static final class DuplicateInsertInWriteError extends Error {} 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 InsertNotPresentInFullIdSetError extends Error {}
private static final class DeletePresentInFullIdSetError extends Error {} private static final class DeletePresentInFullIdSetError extends Error {}

View File

@ -1,28 +1,44 @@
package org.asamk.signal.manager.util; 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.messages.SignalServiceAttachmentStream;
import org.whispersystems.signalservice.api.push.exceptions.ResumeLocationInvalidException; import org.whispersystems.signalservice.api.push.exceptions.ResumeLocationInvalidException;
import org.whispersystems.signalservice.api.util.StreamDetails; import org.whispersystems.signalservice.api.util.StreamDetails;
import org.whispersystems.signalservice.internal.push.http.ResumableUploadSpec; 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.Optional;
import java.util.UUID; import java.util.UUID;
import javax.imageio.ImageIO;
public class AttachmentUtils { 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( public static SignalServiceAttachmentStream createAttachmentStream(
StreamDetails streamDetails, StreamDetails streamDetails,
Optional<String> name, Optional<String> name,
boolean voiceNote, boolean voiceNote,
ResumableUploadSpec resumableUploadSpec ResumableUploadSpec resumableUploadSpec
) throws ResumeLocationInvalidException { ) throws ResumeLocationInvalidException, IOException {
final var uploadTimestamp = System.currentTimeMillis(); final var uploadTimestamp = System.currentTimeMillis();
final var probedStream = probeImageDimensions(streamDetails);
return SignalServiceAttachmentStream.newStreamBuilder() return SignalServiceAttachmentStream.newStreamBuilder()
.withStream(streamDetails.getStream()) .withStream(probedStream.inputStream())
.withContentType(streamDetails.getContentType()) .withContentType(streamDetails.getContentType())
.withLength(streamDetails.getLength()) .withLength(streamDetails.getLength())
.withFileName(name.orElse(null)) .withFileName(name.orElse(null))
.withVoiceNote(voiceNote) .withVoiceNote(voiceNote)
.withWidth(probedStream.width())
.withHeight(probedStream.height())
.withUploadTimestamp(uploadTimestamp) .withUploadTimestamp(uploadTimestamp)
.withResumableUploadSpec(resumableUploadSpec) .withResumableUploadSpec(resumableUploadSpec)
.withUuid(UUID.randomUUID()) .withUuid(UUID.randomUUID())
@ -33,7 +49,55 @@ public class AttachmentUtils {
StreamDetails streamDetails, StreamDetails streamDetails,
Optional<String> name, Optional<String> name,
ResumableUploadSpec resumableUploadSpec ResumableUploadSpec resumableUploadSpec
) throws ResumeLocationInvalidException { ) throws ResumeLocationInvalidException, IOException {
return createAttachmentStream(streamDetails, name, false, resumableUploadSpec); 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) {}
} }

View File

@ -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);
}
}

View File

@ -17,7 +17,6 @@ import java.io.ByteArrayInputStream;
import java.io.File; import java.io.File;
import java.io.FileInputStream; import java.io.FileInputStream;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream;
import java.math.BigInteger; import java.math.BigInteger;
import java.net.Proxy; import java.net.Proxy;
import java.net.ProxySelector; import java.net.ProxySelector;
@ -39,6 +38,9 @@ import java.util.stream.Collectors;
import java.util.stream.Stream; import java.util.stream.Stream;
import java.util.stream.StreamSupport; import java.util.stream.StreamSupport;
import kotlin.coroutines.Continuation;
import kotlin.coroutines.EmptyCoroutineContext;
import kotlinx.coroutines.BuildersKt;
import okio.ByteString; import okio.ByteString;
public class Utils { public class Utils {
@ -53,7 +55,7 @@ public class Utils {
} }
public static StreamDetails createStreamDetailsFromFile(final File file) throws IOException { 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 size = file.length();
final var mime = MimeUtils.getFileMimeType(file).orElse(MimeUtils.OCTET_STREAM); final var mime = MimeUtils.getFileMimeType(file).orElse(MimeUtils.OCTET_STREAM);
return new StreamDetails(stream, mime, size); return new StreamDetails(stream, mime, size);
@ -161,6 +163,24 @@ public class Utils {
return NetworkResultUtil.toBasicLegacy(response); 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") @SuppressWarnings("unchecked")
public static <T, E extends BadRequestError> T handleResponseException(final RequestResult<T, E> result) throws IOException { public static <T, E extends BadRequestError> T handleResponseException(final RequestResult<T, E> result) throws IOException {
if (result instanceof RequestResult.Success<?> success) { if (result instanceof RequestResult.Success<?> success) {

View File

@ -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());
}
}

View File

@ -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());
}
}

View File

@ -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));
}
}

View File

@ -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);
}
}

View File

@ -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();
}
}

View File

@ -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;
}
}
}

View File

@ -1 +1 @@
0.96.3 0.99.1

View File

@ -452,6 +452,7 @@ Groups have the following (case-sensitive) properties:
* IsBlocked<b> : true=member will not receive group messages; false=not blocked * 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) * 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 * 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 * MessageExpirationTimer<i> : int32 representing message expiration time for group
* Members<as> (read-only) : String array of group members' phone numbers * Members<as> (read-only) : String array of group members' phone numbers
* PendingMembers<as> (read-only) : String array of pending members' phone numbers * PendingMembers<as> (read-only) : String array of pending members' phone numbers
@ -502,6 +503,11 @@ Exceptions: Failure
quitGroup() -> <>:: quitGroup() -> <>::
Exceptions: Failure, LastGroupAdmin 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>) -> <>:: removeAdmins(recipients<as>) -> <>::
* recipients : String array of phone numbers * recipients : String array of phone numbers

View File

@ -786,6 +786,16 @@ Specify the recipient group ID in base64 encoding.
*--delete*:: *--delete*::
Delete local group data completely after quitting group. 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 === listGroups
Show a list of known groups and related information. Show a list of known groups and related information.

View File

@ -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_2" send "$NUMBER_1" -m hii
run_main -a "$NUMBER_1" updateAccount --discoverable-by-number=false run_main -a "$NUMBER_1" updateAccount --discoverable-by-number=false
run_main -a "$NUMBER_1" receive 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" receive
run_main -a "$NUMBER_2" send "$NUMBER_1" -m hi run_main -a "$NUMBER_2" send "$NUMBER_1" -m hi
run_main -a "$NUMBER_2" send "$NUMBER_1" -m hii run_main -a "$NUMBER_2" send "$NUMBER_1" -m hii

View File

@ -595,6 +595,7 @@ public interface Signal extends DBusInterface {
@DBusProperty(name = "IsBlocked", type = Boolean.class) @DBusProperty(name = "IsBlocked", type = Boolean.class)
@DBusProperty(name = "IsMember", type = Boolean.class, access = DBusProperty.Access.READ) @DBusProperty(name = "IsMember", type = Boolean.class, access = DBusProperty.Access.READ)
@DBusProperty(name = "IsAdmin", 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 = "MessageExpirationTimer", type = Integer.class)
@DBusProperty(name = "Members", type = String[].class, access = DBusProperty.Access.READ) @DBusProperty(name = "Members", type = String[].class, access = DBusProperty.Access.READ)
@DBusProperty(name = "PendingMembers", 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 deleteGroup() throws Error.Failure;
void terminateGroup() throws Error.Failure;
void addMembers(List<String> recipients) throws Error.Failure; void addMembers(List<String> recipients) throws Error.Failure;
void removeMembers(List<String> recipients) throws Error.Failure; void removeMembers(List<String> recipients) throws Error.Failure;

View File

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

View File

@ -61,6 +61,7 @@ public class Commands {
addCommand(new SubmitRateLimitChallengeCommand()); addCommand(new SubmitRateLimitChallengeCommand());
addCommand(new StartChangeNumberCommand()); addCommand(new StartChangeNumberCommand());
addCommand(new StartLinkCommand()); addCommand(new StartLinkCommand());
addCommand(new TerminateGroupCommand());
addCommand(new TrustCommand()); addCommand(new TrustCommand());
addCommand(new UnblockCommand()); addCommand(new UnblockCommand());
addCommand(new UnregisterCommand()); addCommand(new UnregisterCommand());

View File

@ -79,12 +79,13 @@ public class ListGroupsCommand implements JsonRpcLocalCommand {
final var groupInviteLink = group.groupInviteLinkUrl(); final var groupInviteLink = group.groupInviteLinkUrl();
writer.println( 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.groupId().toBase64(),
group.title(), group.title(),
group.description(), group.description(),
group.isMember(), group.isMember(),
group.isBlocked(), group.isBlocked(),
group.isTerminated(),
resolveMembers(group.members()), resolveMembers(group.members()),
resolveMemberAddress(group.pendingMembers()), resolveMemberAddress(group.pendingMembers()),
resolveMemberAddress(group.requestingMembers()), resolveMemberAddress(group.requestingMembers()),
@ -92,11 +93,12 @@ public class ListGroupsCommand implements JsonRpcLocalCommand {
group.messageExpirationTimer() == 0 ? "disabled" : group.messageExpirationTimer() + "s", group.messageExpirationTimer() == 0 ? "disabled" : group.messageExpirationTimer() + "s",
groupInviteLink == null ? '-' : groupInviteLink.getUrl()); groupInviteLink == null ? '-' : groupInviteLink.getUrl());
} else { } else {
writer.println("Id: {} Name: {} Active: {} Blocked: {}", writer.println("Id: {} Name: {} Active: {} Blocked: {} Terminated: {}",
group.groupId().toBase64(), group.groupId().toBase64(),
group.title(), group.title(),
group.isMember(), group.isMember(),
group.isBlocked()); group.isBlocked(),
group.isTerminated());
} }
} }
@ -133,7 +135,8 @@ public class ListGroupsCommand implements JsonRpcLocalCommand {
group.permissionAddMember().name(), group.permissionAddMember().name(),
group.permissionEditDetails().name(), group.permissionEditDetails().name(),
group.permissionSendMessage().name(), group.permissionSendMessage().name(),
groupInviteLink == null ? null : groupInviteLink.getUrl()); groupInviteLink == null ? null : groupInviteLink.getUrl(),
group.isTerminated());
}).toList(); }).toList();
jsonWriter.write(jsonGroups); jsonWriter.write(jsonGroups);
} }
@ -161,7 +164,8 @@ public class ListGroupsCommand implements JsonRpcLocalCommand {
String permissionAddMember, String permissionAddMember,
String permissionEditDetails, String permissionEditDetails,
String permissionSendMessage, String permissionSendMessage,
String groupInviteLink String groupInviteLink,
boolean isTerminated
) {} ) {}
private record JsonGroupMemberAddress(String number, String uuid) {} private record JsonGroupMemberAddress(String number, String uuid) {}

View File

@ -33,9 +33,7 @@ public class SendStoryCommand implements JsonRpcLocalCommand {
subparser.addArgument("-a", "--attachment") subparser.addArgument("-a", "--attachment")
.required(true) .required(true)
.help("Specify the file path to the image or video to post as a story."); .help("Specify the file path to the image or video to post as a story.");
subparser.addArgument("--no-replies") subparser.addArgument("--no-replies").action(Arguments.storeTrue()).help("Disable replies on this story.");
.action(Arguments.storeTrue())
.help("Disable replies on this story.");
subparser.addArgument("-g", "--group-id") subparser.addArgument("-g", "--group-id")
.help("Specify a group to post the story to. Without this, posts to My Story."); .help("Specify a group to post the story to. Without this, posts to My Story.");
} }

View File

@ -42,7 +42,8 @@ public class SubmitRateLimitChallengeCommand implements JsonRpcLocalCommand {
throw new IOErrorException("Submit challenge error: " + e.getMessage(), e); throw new IOErrorException("Submit challenge error: " + e.getMessage(), e);
} catch (CaptchaRejectedException e) { } catch (CaptchaRejectedException e) {
throw new CaptchaRejectedErrorException( 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);
} }
} }
} }

View File

@ -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());
}
}
}

View File

@ -327,6 +327,21 @@ public class DbusManagerImpl implements Manager {
group.deleteGroup(); 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 @Override
public Pair<GroupId, SendGroupMessageResults> createGroup( public Pair<GroupId, SendGroupMessageResults> createGroup(
final String name, final String name,
@ -548,11 +563,7 @@ public class DbusManagerImpl implements Manager {
} }
@Override @Override
public SendMessageResults sendStory( public SendMessageResults sendStory(String attachment, boolean allowsReplies, Optional<GroupId> groupId) {
String attachment,
boolean allowsReplies,
Optional<GroupId> groupId
) {
throw new UnsupportedOperationException(); throw new UnsupportedOperationException();
} }
@ -828,6 +839,7 @@ public class DbusManagerImpl implements Manager {
0, 0,
false, false,
contactBlocked, contactBlocked,
0,
false, false,
false, false,
false, false,
@ -875,7 +887,8 @@ public class DbusManagerImpl implements Manager {
GroupPermission.valueOf((String) group.get("PermissionEditDetails").getValue()), GroupPermission.valueOf((String) group.get("PermissionEditDetails").getValue()),
GroupPermission.valueOf((String) group.get("PermissionSendMessage").getValue()), GroupPermission.valueOf((String) group.get("PermissionSendMessage").getValue()),
(boolean) group.get("IsMember").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) { } catch (GroupInviteLinkUrl.InvalidGroupLinkException | GroupInviteLinkUrl.UnknownGroupLinkVersionException e) {
throw new AssertionError(e); throw new AssertionError(e);
} }

View File

@ -1308,6 +1308,7 @@ public class DbusSignalImpl implements Signal, AutoCloseable {
new DbusProperty<>("IsBlocked", () -> getGroup().isBlocked(), this::setIsBlocked), new DbusProperty<>("IsBlocked", () -> getGroup().isBlocked(), this::setIsBlocked),
new DbusProperty<>("IsMember", () -> getGroup().isMember()), new DbusProperty<>("IsMember", () -> getGroup().isMember()),
new DbusProperty<>("IsAdmin", () -> getGroup().isAdmin()), new DbusProperty<>("IsAdmin", () -> getGroup().isAdmin()),
new DbusProperty<>("IsTerminated", () -> getGroup().isTerminated()),
new DbusProperty<>("MessageExpirationTimer", new DbusProperty<>("MessageExpirationTimer",
() -> getGroup().messageExpirationTimer(), () -> getGroup().messageExpirationTimer(),
this::setMessageExpirationTime), this::setMessageExpirationTime),
@ -1375,6 +1376,19 @@ public class DbusSignalImpl implements Signal, AutoCloseable {
updateGroups(); 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 @Override
public void addMembers(final List<String> recipients) throws Error.Failure { public void addMembers(final List<String> recipients) throws Error.Failure {
final var memberIdentifiers = getSingleRecipientIdentifiers(recipients, m.getSelfNumber()); final var memberIdentifiers = getSingleRecipientIdentifiers(recipients, m.getSelfNumber());

View File

@ -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 // 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(); final var addr = address == null ? null : address.getAddress();
if (addr != null && addr.isAnyLocalAddress()) { 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 { } else {
logger.warn("HTTP server has no authentication; Host header is pinned to {}", allowedHosts); 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 { private void handleRpcEndpoint(HttpExchange httpExchange) throws IOException {
if (!isHostAllowed(httpExchange)) { if (!isHostAllowed(httpExchange)) {
logger.warn("Rejected RPC request with invalid Host header: {} from {}", 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); sendResponse(421, null, httpExchange);
return; return;
} }
@ -167,7 +169,8 @@ public class HttpServerHandler implements AutoCloseable {
private void handleEventsEndpoint(HttpExchange httpExchange) throws IOException { private void handleEventsEndpoint(HttpExchange httpExchange) throws IOException {
if (!isHostAllowed(httpExchange)) { if (!isHostAllowed(httpExchange)) {
logger.warn("Rejected Events request with invalid Host header: {} from {}", 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); sendResponse(421, null, httpExchange);
return; return;
} }

View File

@ -1,10 +1,11 @@
package org.asamk.signal.json; package org.asamk.signal.json;
import io.micronaut.jsonschema.JsonSchema;
import org.asamk.signal.manager.api.MessageEnvelope; import org.asamk.signal.manager.api.MessageEnvelope;
import java.util.UUID; import java.util.UUID;
import io.micronaut.jsonschema.JsonSchema;
@JsonSchema(title = "AdminDelete") @JsonSchema(title = "AdminDelete")
public record JsonAdminDelete( public record JsonAdminDelete(
@Deprecated String targetAuthor, String targetAuthorNumber, String targetAuthorUuid, long targetSentTimestamp @Deprecated String targetAuthor, String targetAuthorNumber, String targetAuthorUuid, long targetSentTimestamp

View File

@ -1,9 +1,9 @@
package org.asamk.signal.json; package org.asamk.signal.json;
import io.micronaut.jsonschema.JsonSchema;
import org.asamk.signal.manager.api.MessageEnvelope; import org.asamk.signal.manager.api.MessageEnvelope;
import io.micronaut.jsonschema.JsonSchema;
@JsonSchema(title = "Attachment") @JsonSchema(title = "Attachment")
record JsonAttachment( record JsonAttachment(
String contentType, String contentType,
@ -13,7 +13,8 @@ record JsonAttachment(
Integer width, Integer width,
Integer height, Integer height,
String caption, String caption,
Long uploadTimestamp Long uploadTimestamp,
boolean isVoiceNote
) { ) {
static JsonAttachment from(MessageEnvelope.Data.Attachment attachment) { static JsonAttachment from(MessageEnvelope.Data.Attachment attachment) {
@ -32,6 +33,7 @@ record JsonAttachment(
width, width,
height, height,
caption, caption,
uploadTimestamp); uploadTimestamp,
attachment.isVoiceNote());
} }
} }

View File

@ -1,7 +1,6 @@
package org.asamk.signal.json; package org.asamk.signal.json;
import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonInclude;
import io.micronaut.jsonschema.JsonSchema;
import org.asamk.signal.manager.api.MessageEnvelope; import org.asamk.signal.manager.api.MessageEnvelope;
@ -9,6 +8,8 @@ import java.math.BigInteger;
import java.util.Base64; import java.util.Base64;
import java.util.List; import java.util.List;
import io.micronaut.jsonschema.JsonSchema;
import static org.asamk.signal.manager.util.Utils.callIdUnsigned; import static org.asamk.signal.manager.util.Utils.callIdUnsigned;
@JsonSchema(title = "CallMessage") @JsonSchema(title = "CallMessage")

View File

@ -1,10 +1,11 @@
package org.asamk.signal.json; package org.asamk.signal.json;
import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonInclude;
import io.micronaut.jsonschema.JsonSchema;
import java.util.List; import java.util.List;
import io.micronaut.jsonschema.JsonSchema;
@JsonSchema(title = "Contact") @JsonSchema(title = "Contact")
public record JsonContact( public record JsonContact(
String number, String number,

View File

@ -1,10 +1,10 @@
package org.asamk.signal.json; package org.asamk.signal.json;
import io.micronaut.jsonschema.JsonSchema;
import org.asamk.signal.manager.api.MessageEnvelope; import org.asamk.signal.manager.api.MessageEnvelope;
import org.asamk.signal.util.Util; import org.asamk.signal.util.Util;
import io.micronaut.jsonschema.JsonSchema;
@JsonSchema(title = "ContactAddress") @JsonSchema(title = "ContactAddress")
public record JsonContactAddress( public record JsonContactAddress(
String type, String type,

View File

@ -1,9 +1,9 @@
package org.asamk.signal.json; package org.asamk.signal.json;
import io.micronaut.jsonschema.JsonSchema;
import org.asamk.signal.manager.api.MessageEnvelope; import org.asamk.signal.manager.api.MessageEnvelope;
import io.micronaut.jsonschema.JsonSchema;
@JsonSchema(title = "ContactAvatar") @JsonSchema(title = "ContactAvatar")
public record JsonContactAvatar(JsonAttachment attachment, boolean isProfile) { public record JsonContactAvatar(JsonAttachment attachment, boolean isProfile) {

View File

@ -1,10 +1,10 @@
package org.asamk.signal.json; package org.asamk.signal.json;
import io.micronaut.jsonschema.JsonSchema;
import org.asamk.signal.manager.api.MessageEnvelope; import org.asamk.signal.manager.api.MessageEnvelope;
import org.asamk.signal.util.Util; import org.asamk.signal.util.Util;
import io.micronaut.jsonschema.JsonSchema;
@JsonSchema(title = "ContactEmail") @JsonSchema(title = "ContactEmail")
public record JsonContactEmail(String value, String type, String label) { public record JsonContactEmail(String value, String type, String label) {

View File

@ -1,10 +1,10 @@
package org.asamk.signal.json; package org.asamk.signal.json;
import io.micronaut.jsonschema.JsonSchema;
import org.asamk.signal.manager.api.MessageEnvelope; import org.asamk.signal.manager.api.MessageEnvelope;
import org.asamk.signal.util.Util; import org.asamk.signal.util.Util;
import io.micronaut.jsonschema.JsonSchema;
@JsonSchema(title = "ContactName") @JsonSchema(title = "ContactName")
public record JsonContactName( public record JsonContactName(
String nickname, String given, String family, String prefix, String suffix, String middle String nickname, String given, String family, String prefix, String suffix, String middle

View File

@ -1,10 +1,10 @@
package org.asamk.signal.json; package org.asamk.signal.json;
import io.micronaut.jsonschema.JsonSchema;
import org.asamk.signal.manager.api.MessageEnvelope; import org.asamk.signal.manager.api.MessageEnvelope;
import org.asamk.signal.util.Util; import org.asamk.signal.util.Util;
import io.micronaut.jsonschema.JsonSchema;
@JsonSchema(title = "ContactPhone") @JsonSchema(title = "ContactPhone")
public record JsonContactPhone(String value, String type, String label) { public record JsonContactPhone(String value, String type, String label) {

View File

@ -1,13 +1,14 @@
package org.asamk.signal.json; package org.asamk.signal.json;
import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonInclude;
import io.micronaut.jsonschema.JsonSchema;
import org.asamk.signal.manager.Manager; import org.asamk.signal.manager.Manager;
import org.asamk.signal.manager.api.MessageEnvelope; import org.asamk.signal.manager.api.MessageEnvelope;
import java.util.List; import java.util.List;
import io.micronaut.jsonschema.JsonSchema;
@JsonSchema(title = "DataMessage") @JsonSchema(title = "DataMessage")
record JsonDataMessage( record JsonDataMessage(
long timestamp, long timestamp,
@ -15,6 +16,10 @@ record JsonDataMessage(
Integer expiresInSeconds, Integer expiresInSeconds,
@JsonInclude(JsonInclude.Include.NON_NULL) Boolean isExpirationUpdate, @JsonInclude(JsonInclude.Include.NON_NULL) Boolean isExpirationUpdate,
@JsonInclude(JsonInclude.Include.NON_NULL) Boolean viewOnce, @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) JsonReaction reaction,
@JsonInclude(JsonInclude.Include.NON_NULL) JsonQuote quote, @JsonInclude(JsonInclude.Include.NON_NULL) JsonQuote quote,
@JsonInclude(JsonInclude.Include.NON_NULL) JsonPayment payment, @JsonInclude(JsonInclude.Include.NON_NULL) JsonPayment payment,
@ -46,6 +51,10 @@ record JsonDataMessage(
final var expiresInSeconds = dataMessage.expiresInSeconds(); final var expiresInSeconds = dataMessage.expiresInSeconds();
final var isExpirationUpdate = dataMessage.isExpirationUpdate(); final var isExpirationUpdate = dataMessage.isExpirationUpdate();
final var viewOnce = dataMessage.isViewOnce(); 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 reaction = dataMessage.reaction().map(JsonReaction::from).orElse(null);
final var quote = dataMessage.quote().isPresent() ? JsonQuote.from(dataMessage.quote().get()) : 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; final var payment = dataMessage.payment().isPresent() ? JsonPayment.from(dataMessage.payment().get()) : null;
@ -85,6 +94,10 @@ record JsonDataMessage(
expiresInSeconds, expiresInSeconds,
isExpirationUpdate, isExpirationUpdate,
viewOnce, viewOnce,
groupCallUpdate,
isEndSession,
isProfileKeyUpdate,
hasProfileKey,
reaction, reaction,
quote, quote,
payment, payment,

View File

@ -1,10 +1,10 @@
package org.asamk.signal.json; package org.asamk.signal.json;
import io.micronaut.jsonschema.JsonSchema;
import org.asamk.signal.manager.Manager; import org.asamk.signal.manager.Manager;
import org.asamk.signal.manager.api.MessageEnvelope; import org.asamk.signal.manager.api.MessageEnvelope;
import io.micronaut.jsonschema.JsonSchema;
@JsonSchema(title = "EditMessage") @JsonSchema(title = "EditMessage")
record JsonEditMessage(long targetSentTimestamp, JsonDataMessage dataMessage) { record JsonEditMessage(long targetSentTimestamp, JsonDataMessage dataMessage) {

View 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());
}
}

View File

@ -1,10 +1,10 @@
package org.asamk.signal.json; package org.asamk.signal.json;
import io.micronaut.jsonschema.JsonSchema;
import org.asamk.signal.manager.Manager; import org.asamk.signal.manager.Manager;
import org.asamk.signal.manager.api.MessageEnvelope; import org.asamk.signal.manager.api.MessageEnvelope;
import io.micronaut.jsonschema.JsonSchema;
@JsonSchema(title = "GroupInfo") @JsonSchema(title = "GroupInfo")
record JsonGroupInfo(String groupId, String groupName, int revision, String type) { record JsonGroupInfo(String groupId, String groupName, int revision, String type) {

View File

@ -1,11 +1,11 @@
package org.asamk.signal.json; package org.asamk.signal.json;
import io.micronaut.jsonschema.JsonSchema;
import org.asamk.signal.manager.api.MessageEnvelope; import org.asamk.signal.manager.api.MessageEnvelope;
import java.util.UUID; import java.util.UUID;
import io.micronaut.jsonschema.JsonSchema;
@JsonSchema(title = "Mention") @JsonSchema(title = "Mention")
public record JsonMention(@Deprecated String name, String number, String uuid, int start, int length) { public record JsonMention(@Deprecated String name, String number, String uuid, int start, int length) {

View File

@ -1,7 +1,6 @@
package org.asamk.signal.json; package org.asamk.signal.json;
import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonInclude;
import io.micronaut.jsonschema.JsonSchema;
import org.asamk.signal.manager.Manager; import org.asamk.signal.manager.Manager;
import org.asamk.signal.manager.api.MessageEnvelope; import org.asamk.signal.manager.api.MessageEnvelope;
@ -11,6 +10,8 @@ import org.asamk.signal.manager.api.UntrustedIdentityException;
import java.util.UUID; import java.util.UUID;
import io.micronaut.jsonschema.JsonSchema;
@JsonSchema(title = "MessageEnvelope") @JsonSchema(title = "MessageEnvelope")
public record JsonMessageEnvelope( public record JsonMessageEnvelope(
@Deprecated String source, @Deprecated String source,

View File

@ -1,9 +1,9 @@
package org.asamk.signal.json; package org.asamk.signal.json;
import io.micronaut.jsonschema.JsonSchema;
import org.asamk.signal.manager.api.MessageEnvelope; import org.asamk.signal.manager.api.MessageEnvelope;
import io.micronaut.jsonschema.JsonSchema;
@JsonSchema(title = "Payment") @JsonSchema(title = "Payment")
public record JsonPayment(String note, byte[] receipt) { public record JsonPayment(String note, byte[] receipt) {

View File

@ -1,11 +1,11 @@
package org.asamk.signal.json; package org.asamk.signal.json;
import io.micronaut.jsonschema.JsonSchema;
import org.asamk.signal.manager.api.MessageEnvelope; import org.asamk.signal.manager.api.MessageEnvelope;
import java.util.UUID; import java.util.UUID;
import io.micronaut.jsonschema.JsonSchema;
@JsonSchema(title = "PinMessage") @JsonSchema(title = "PinMessage")
public record JsonPinMessage( public record JsonPinMessage(
@Deprecated String targetAuthor, @Deprecated String targetAuthor,

View File

@ -1,11 +1,11 @@
package org.asamk.signal.json; package org.asamk.signal.json;
import io.micronaut.jsonschema.JsonSchema;
import org.asamk.signal.manager.api.MessageEnvelope; import org.asamk.signal.manager.api.MessageEnvelope;
import java.util.List; import java.util.List;
import io.micronaut.jsonschema.JsonSchema;
@JsonSchema(title = "PollCreate") @JsonSchema(title = "PollCreate")
public record JsonPollCreate( public record JsonPollCreate(
String question, boolean allowMultiple, List<String> options String question, boolean allowMultiple, List<String> options

View File

@ -1,9 +1,9 @@
package org.asamk.signal.json; package org.asamk.signal.json;
import io.micronaut.jsonschema.JsonSchema;
import org.asamk.signal.manager.api.MessageEnvelope; import org.asamk.signal.manager.api.MessageEnvelope;
import io.micronaut.jsonschema.JsonSchema;
@JsonSchema(title = "PollTerminate") @JsonSchema(title = "PollTerminate")
public record JsonPollTerminate(long targetSentTimestamp) { public record JsonPollTerminate(long targetSentTimestamp) {

View File

@ -1,12 +1,12 @@
package org.asamk.signal.json; package org.asamk.signal.json;
import io.micronaut.jsonschema.JsonSchema;
import org.asamk.signal.manager.api.MessageEnvelope; import org.asamk.signal.manager.api.MessageEnvelope;
import java.util.List; import java.util.List;
import java.util.UUID; import java.util.UUID;
import io.micronaut.jsonschema.JsonSchema;
@JsonSchema(title = "PollVote") @JsonSchema(title = "PollVote")
public record JsonPollVote( public record JsonPollVote(
@Deprecated String author, @Deprecated String author,

View File

@ -1,9 +1,9 @@
package org.asamk.signal.json; package org.asamk.signal.json;
import io.micronaut.jsonschema.JsonSchema;
import org.asamk.signal.manager.api.MessageEnvelope; import org.asamk.signal.manager.api.MessageEnvelope;
import io.micronaut.jsonschema.JsonSchema;
@JsonSchema(title = "Preview") @JsonSchema(title = "Preview")
public record JsonPreview(String url, String title, String description, JsonAttachment image) { public record JsonPreview(String url, String title, String description, JsonAttachment image) {

View File

@ -1,13 +1,14 @@
package org.asamk.signal.json; package org.asamk.signal.json;
import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonInclude;
import io.micronaut.jsonschema.JsonSchema;
import org.asamk.signal.manager.api.MessageEnvelope; import org.asamk.signal.manager.api.MessageEnvelope;
import java.util.List; import java.util.List;
import java.util.UUID; import java.util.UUID;
import io.micronaut.jsonschema.JsonSchema;
@JsonSchema(title = "Quote") @JsonSchema(title = "Quote")
public record JsonQuote( public record JsonQuote(
long id, long id,

View File

@ -1,10 +1,11 @@
package org.asamk.signal.json; package org.asamk.signal.json;
import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonInclude;
import io.micronaut.jsonschema.JsonSchema;
import org.asamk.signal.manager.api.MessageEnvelope; import org.asamk.signal.manager.api.MessageEnvelope;
import io.micronaut.jsonschema.JsonSchema;
@JsonSchema(title = "QuotedAttachment") @JsonSchema(title = "QuotedAttachment")
public record JsonQuotedAttachment( public record JsonQuotedAttachment(
String contentType, String filename, @JsonInclude(JsonInclude.Include.NON_NULL) JsonAttachment thumbnail String contentType, String filename, @JsonInclude(JsonInclude.Include.NON_NULL) JsonAttachment thumbnail

View File

@ -1,11 +1,11 @@
package org.asamk.signal.json; package org.asamk.signal.json;
import io.micronaut.jsonschema.JsonSchema;
import org.asamk.signal.manager.api.MessageEnvelope; import org.asamk.signal.manager.api.MessageEnvelope;
import java.util.UUID; import java.util.UUID;
import io.micronaut.jsonschema.JsonSchema;
@JsonSchema(title = "Reaction") @JsonSchema(title = "Reaction")
public record JsonReaction( public record JsonReaction(
String emoji, String emoji,

View File

@ -1,11 +1,11 @@
package org.asamk.signal.json; package org.asamk.signal.json;
import io.micronaut.jsonschema.JsonSchema;
import org.asamk.signal.manager.api.MessageEnvelope; import org.asamk.signal.manager.api.MessageEnvelope;
import java.util.List; import java.util.List;
import io.micronaut.jsonschema.JsonSchema;
@JsonSchema(title = "ReceiptMessage") @JsonSchema(title = "ReceiptMessage")
record JsonReceiptMessage(long when, boolean isDelivery, boolean isRead, boolean isViewed, List<Long> timestamps) { record JsonReceiptMessage(long when, boolean isDelivery, boolean isRead, boolean isViewed, List<Long> timestamps) {

View File

@ -1,11 +1,11 @@
package org.asamk.signal.json; package org.asamk.signal.json;
import io.micronaut.jsonschema.JsonSchema;
import org.asamk.signal.manager.api.RecipientAddress; import org.asamk.signal.manager.api.RecipientAddress;
import java.util.UUID; import java.util.UUID;
import io.micronaut.jsonschema.JsonSchema;
@JsonSchema(title = "RecipientAddress") @JsonSchema(title = "RecipientAddress")
public record JsonRecipientAddress(String uuid, String number, String username) { public record JsonRecipientAddress(String uuid, String number, String username) {

View File

@ -1,11 +1,12 @@
package org.asamk.signal.json; package org.asamk.signal.json;
import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonInclude;
import io.micronaut.jsonschema.JsonSchema;
import org.asamk.signal.manager.api.GroupId; import org.asamk.signal.manager.api.GroupId;
import org.asamk.signal.manager.api.SendMessageResult; import org.asamk.signal.manager.api.SendMessageResult;
import io.micronaut.jsonschema.JsonSchema;
@JsonSchema(title = "SendMessageResult") @JsonSchema(title = "SendMessageResult")
public record JsonSendMessageResult( public record JsonSendMessageResult(
JsonRecipientAddress recipientAddress, JsonRecipientAddress recipientAddress,

Some files were not shown because too many files have changed in this diff Show More