Compare commits

...

6 Commits

Author SHA1 Message Date
BarbossHack
daef4d1cf8 Add reproducible builds 2026-03-08 15:11:56 +01:00
AsamK
b498d2050a Bump version to 0.14.1 2026-03-08 12:55:39 +01:00
AsamK
27a722dc75 Add flags to update group member labels 2026-03-08 12:23:26 +01:00
AsamK
7014f629fe Show member labels in listGroups command 2026-03-08 11:55:19 +01:00
AsamK
30b57bdb3d Update graalvm build tools 2026-03-08 08:41:44 +01:00
AsamK
b94162afbc Enable spqr capability 2026-03-07 15:58:01 +01:00
42 changed files with 651 additions and 341 deletions

48
.github/workflows/build.yml vendored Normal file
View File

@ -0,0 +1,48 @@
name: build
on:
push:
branches:
- "**"
pull_request:
workflow_call:
permissions: {}
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Build
run: ./reproducible-builds/build.sh
- name: Upload build artifacts
uses: actions/upload-artifact@v7
with:
name: signal-cli-build
path: dist/*
build-client:
strategy:
matrix:
os:
- ubuntu
- macos
- windows
runs-on: ${{ matrix.os }}-latest
defaults:
run:
working-directory: ./client
steps:
- uses: actions/checkout@v6
- name: Install rust
run: rustup default stable
- name: Build client
run: cargo build --release --verbose
- name: Archive production artifacts
uses: actions/upload-artifact@v7
with:
name: signal-cli-client-${{ matrix.os }}
path: |
client/target/release/signal-cli-client
client/target/release/signal-cli-client.exe

View File

@ -1,96 +0,0 @@
name: signal-cli CI
on:
push:
branches:
- '**'
pull_request:
workflow_call:
permissions:
contents: write # to fetch code (actions/checkout) and submit dependency graph (gradle/gradle-build-action)
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
java: [ '25' ]
steps:
- uses: actions/checkout@v6
- name: Set up JDK
uses: actions/setup-java@v5
with:
distribution: 'zulu'
java-version: ${{ matrix.java }}
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v5
with:
dependency-graph: generate-and-submit
- name: Install asciidoc
run: sudo apt update && sudo apt --no-install-recommends install -y asciidoc-base
- name: Build with Gradle
run: ./gradlew --no-daemon build
- name: Build man page
run: |
cd man
make install
- name: Add man page to archive
run: |
version=$(tar tf build/distributions/signal-cli-*.tar | head -n1 | sed 's|signal-cli-\([^/]*\)/.*|\1|')
echo $version
tar --transform="flags=r;s|man|signal-cli-${version}/man|" -rf build/distributions/signal-cli-${version}.tar man/man{1,5}
- name: Compress archive
run: gzip -n -9 build/distributions/signal-cli-*.tar
- name: Archive production artifacts
uses: actions/upload-artifact@v7
with:
name: signal-cli-archive-${{ matrix.java }}
path: build/distributions/signal-cli-*.tar.gz
build-graalvm:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: graalvm/setup-graalvm@v1
with:
distribution: 'graalvm'
java-version: '25'
cache: 'gradle'
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Build with Gradle
run: ./gradlew --no-daemon nativeCompile
- name: Archive production artifacts
uses: actions/upload-artifact@v7
with:
name: signal-cli-native
path: build/native/nativeCompile/signal-cli
build-client:
strategy:
matrix:
os:
- ubuntu
- macos
- windows
runs-on: ${{ matrix.os }}-latest
defaults:
run:
working-directory: ./client
steps:
- uses: actions/checkout@v6
- name: Install rust
run: rustup default stable
- name: Build client
run: cargo build --release --verbose
- name: Archive production artifacts
uses: actions/upload-artifact@v7
with:
name: signal-cli-client-${{ matrix.os }}
path: |
client/target/release/signal-cli-client
client/target/release/signal-cli-client.exe

View File

@ -5,8 +5,7 @@ on:
tags:
- v*
permissions:
contents: write # to fetch code (actions/checkout) and create release
permissions: {}
env:
IMAGE_NAME: signal-cli
@ -15,96 +14,25 @@ env:
REGISTRY_PASSWORD: ${{ github.token }}
jobs:
build:
uses: AsamK/signal-cli/.github/workflows/build.yml@master
ci_wf:
permissions:
contents: write
uses: AsamK/signal-cli/.github/workflows/ci.yml@master
# ${{ github.repository }} not accepted here
lib_to_jar:
needs: ci_wf
release:
needs: build
runs-on: ubuntu-latest
permissions:
contents: write
outputs:
signal_cli_version: ${{ steps.cli_ver.outputs.version }}
release_id: ${{ steps.create_release.outputs.id }}
version: ${{ steps.version.outputs.version }}
steps:
- name: Download signal-cli build from CI workflow
uses: actions/download-artifact@v8
- name: Get signal-cli version
id: cli_ver
id: version
run: |
ver="${GITHUB_REF_NAME#v}"
echo "version=${ver}" >> $GITHUB_OUTPUT
- name: Extract archive
run: |
tree .
ARCHIVE_DIR=$(ls signal-cli-archive-*/ -d | tail -n1)
tar -xzf ./"${ARCHIVE_DIR}"/*.tar.gz
mv ./"${ARCHIVE_DIR}"/*.tar.gz signal-cli-${{ steps.cli_ver.outputs.version }}.tar.gz
rm -rf signal-cli-archive-*/
# - name: Get signal-client jar version
# id: lib_ver
# run: |
# JAR_PREFIX=libsignal-client-
# jar_file=$(find ./signal-cli-*/lib/ -name "$JAR_PREFIX*.jar")
# jar_version=$(echo "$jar_file" | xargs basename | sed "s/$JAR_PREFIX//; s/.jar//")
# echo "$jar_version"
# echo "signal_client_version=${jar_version}" >> $GITHUB_OUTPUT
#
# - name: Download signal-client builds
# env:
# RELEASES_URL: https://github.com/signalapp/libsignal/releases/download/
# FILE_NAMES: signal_jni.dll libsignal_jni.dylib
# SIGNAL_CLIENT_VER: ${{ steps.lib_ver.outputs.signal_client_version }}
# run: |
# for file_name in $FILE_NAMES; do
# curl -sOL "${RELEASES_URL}/v${SIGNAL_CLIENT_VER}/${file_name}" # note: added v
# done
# tree .
- name: Compress native app
env:
SIGNAL_CLI_VER: ${{ steps.cli_ver.outputs.version }}
run: |
chmod +x signal-cli-native/signal-cli
tar -czf signal-cli-${SIGNAL_CLI_VER}-Linux-native.tar.gz -C signal-cli-native signal-cli
rm -rf signal-cli-native/
- name: Compress client app
env:
SIGNAL_CLI_VER: ${{ steps.cli_ver.outputs.version }}
run: |
chmod +x signal-cli-client-ubuntu/signal-cli-client
tar -czf signal-cli-${SIGNAL_CLI_VER}-Linux-client.tar.gz -C signal-cli-client-ubuntu signal-cli-client
rm -rf signal-cli-client-ubuntu/
# - name: Replace Windows lib
# env:
# SIGNAL_CLI_VER: ${{ steps.cli_ver.outputs.version }}
# SIGNAL_CLIENT_VER: ${{ steps.lib_ver.outputs.signal_client_version }}
# run: |
# mv signal_jni.dll libsignal_jni.so
# zip -u ./signal-cli-*/lib/libsignal-client-${SIGNAL_CLIENT_VER}.jar ./libsignal_jni.so
# tar -czf signal-cli-${SIGNAL_CLI_VER}-Windows.tar.gz signal-cli-*/
#
# - name: Replace macOS lib
# env:
# SIGNAL_CLI_VER: ${{ steps.cli_ver.outputs.version }}
# SIGNAL_CLIENT_VER: ${{ steps.lib_ver.outputs.signal_client_version }}
# run: |
# jar_file=./signal-cli-*/lib/libsignal-client-${SIGNAL_CLIENT_VER}.jar
# zip -d $jar_file libsignal_jni.so
# zip $jar_file libsignal_jni.dylib
# tar -czf signal-cli-${SIGNAL_CLI_VER}-macOS.tar.gz signal-cli-*/
mv ./signal-cli-build/* .
echo "version=$(cat VERSION)" >> $GITHUB_OUTPUT
- name: Create release
id: create_release
@ -112,8 +40,8 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: v${{ steps.cli_ver.outputs.version }} # note: added `v`
release_name: v${{ steps.cli_ver.outputs.version }} # note: added `v`
tag_name: v${{ steps.version.outputs.version }} # note: added `v`
release_name: v${{ steps.version.outputs.version }} # note: added `v`
draft: true
- name: Upload archive
@ -122,19 +50,9 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: signal-cli-${{ steps.cli_ver.outputs.version }}.tar.gz
asset_name: signal-cli-${{ steps.cli_ver.outputs.version }}.tar.gz
asset_content_type: application/x-compressed-tar # .tar.gz
# - name: Upload Linux 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.cli_ver.outputs.version }}-Linux.tar.gz
# asset_name: signal-cli-${{ steps.cli_ver.outputs.version }}-Linux.tar.gz
# asset_content_type: application/x-compressed-tar # .tar.gz
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
@ -142,9 +60,9 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: signal-cli-${{ steps.cli_ver.outputs.version }}-Linux-native.tar.gz
asset_name: signal-cli-${{ steps.cli_ver.outputs.version }}-Linux-native.tar.gz
asset_content_type: application/x-compressed-tar # .tar.gz
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
@ -152,35 +70,14 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: signal-cli-${{ steps.cli_ver.outputs.version }}-Linux-client.tar.gz
asset_name: signal-cli-${{ steps.cli_ver.outputs.version }}-Linux-client.tar.gz
asset_content_type: application/x-compressed-tar # .tar.gz
# - name: Upload windows 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.cli_ver.outputs.version }}-Windows.tar.gz
# asset_name: signal-cli-${{ steps.cli_ver.outputs.version }}-Windows.tar.gz
# asset_content_type: application/x-compressed-tar # .tar.gz
#
# - name: Upload macos 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.cli_ver.outputs.version }}-macOS.tar.gz
# asset_name: signal-cli-${{ steps.cli_ver.outputs.version }}-macOS.tar.gz
# asset_content_type: application/x-compressed-tar # .tar.gz
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
build-container:
needs: ci_wf
needs: release
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
@ -188,28 +85,19 @@ jobs:
- name: Download signal-cli build from CI workflow
uses: actions/download-artifact@v8
- name: Get signal-cli version
id: cli_ver
run: |
ver="${GITHUB_REF_NAME#v}"
echo "version=${ver}" >> $GITHUB_OUTPUT
- name: Move archive file
run: |
ARCHIVE_DIR=$(ls signal-cli-archive-*/ -d | tail -n1)
tar xf ./"${ARCHIVE_DIR}"/*.tar.gz
rm -r signal-cli-archive-* signal-cli-native
tar xf ./signal-cli-build/signal-cli-${{ needs.release.outputs.version }}.tar.gz
mkdir -p build/install/
mv ./signal-cli-"${GITHUB_REF_NAME#v}"/ build/install/signal-cli
mv ./signal-cli-"${{ needs.release.outputs.version }}"/ build/install/signal-cli
- name: Build Image
id: build_image
uses: redhat-actions/buildah-build@v2
with:
image: ${{ env.IMAGE_NAME }}
tags: latest ${{ github.sha }} ${{ steps.cli_ver.outputs.version }}
containerfiles:
./Containerfile
tags: latest ${{ github.sha }} ${{ needs.release.outputs.version }}
containerfiles: ./Containerfile
oci: true
- name: Push To GHCR
@ -227,10 +115,9 @@ jobs:
echo "${{ toJSON(steps.push.outputs) }}"
build-container-native:
needs: ci_wf
needs: release
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
@ -238,26 +125,20 @@ jobs:
- name: Download signal-cli build from CI workflow
uses: actions/download-artifact@v8
- name: Get signal-cli version
id: cli_ver
run: |
ver="${GITHUB_REF_NAME#v}"
echo "version=${ver}" >> $GITHUB_OUTPUT
- name: Move archive file
run: |
tar xf ./signal-cli-build/signal-cli-${{ needs.release.outputs.version }}-Linux-native.tar.gz
mkdir -p build/native/nativeCompile/
chmod +x ./signal-cli-native/signal-cli
mv ./signal-cli-native/signal-cli build/native/nativeCompile/
mv signal-cli build/native/nativeCompile/
chmod +x build/native/nativeCompile/signal-cli
- name: Build Image
id: build_image
uses: redhat-actions/buildah-build@v2
with:
image: ${{ env.IMAGE_NAME }}
tags: latest-native ${{ github.sha }}-native ${{ steps.cli_ver.outputs.version }}-native
containerfiles:
./native.Containerfile
tags: latest-native ${{ github.sha }}-native ${{ needs.release.outputs.version }}-native
containerfiles: ./native.Containerfile
oci: true
- name: Push To GHCR
@ -275,10 +156,9 @@ jobs:
echo "${{ toJSON(steps.push.outputs) }}"
build-container-client:
needs: ci_wf
needs: release
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
@ -286,26 +166,20 @@ jobs:
- name: Download signal-cli build from CI workflow
uses: actions/download-artifact@v8
- name: Get signal-cli version
id: cli_ver
run: |
ver="${GITHUB_REF_NAME#v}"
echo "version=${ver}" >> $GITHUB_OUTPUT
- name: Move archive file
run: |
tar xf ./signal-cli-build/signal-cli-${{ needs.release.outputs.version }}-Linux-client.tar.gz
mkdir -p client/target/release/
chmod +x ./signal-cli-client-ubuntu/signal-cli-client
mv ./signal-cli-client-ubuntu/signal-cli-client client/target/release/
mv signal-cli-client client/target/release/
chmod +x client/target/release/signal-cli-client
- name: Build Image
id: build_image
uses: redhat-actions/buildah-build@v2
with:
image: ${{ env.IMAGE_NAME }}
tags: latest-client ${{ github.sha }}-client ${{ steps.cli_ver.outputs.version }}-client
containerfiles:
./client.Containerfile
tags: latest-client ${{ github.sha }}-client ${{ needs.release.outputs.version }}-client
containerfiles: ./client.Containerfile
oci: true
- name: Push To GHCR

7
.gitignore vendored
View File

@ -1,4 +1,5 @@
.gradle/
.kotlin/
.idea/*
!.idea/codeStyles/
build/
@ -13,3 +14,9 @@ out/
.DS_Store
/bin/
/test-config/
/dist/
/github/
man/*.1
man/*.5
man/man1
man/man5

View File

@ -1,6 +1,15 @@
# Changelog
## [Unreleased]
## [0.14.1] - 2026-03-08
### Added
- Added isArchived to contact json output (Thanks @moppman)
- Added support for group member labels
### Fixed
- Adapt registration to signal server changes
## [0.14.0] - 2026-03-01

View File

@ -3,12 +3,12 @@ plugins {
application
eclipse
`check-lib-versions`
id("org.graalvm.buildtools.native") version "0.11.4"
id("org.graalvm.buildtools.native") version "0.11.5"
}
allprojects {
group = "org.asamk"
version = "0.14.1-SNAPSHOT"
version = "0.14.1"
}
java {

View File

@ -598,6 +598,12 @@ pub enum CliCommands {
#[arg(short = 'e', long)]
expiration: Option<u32>,
#[arg(long = "member-label-emoji")]
member_label_emoji: Option<String>,
#[arg(long = "member-label")]
member_label: Option<String>,
},
UpdateProfile {
#[arg(long = "given-name")]

View File

@ -460,6 +460,8 @@ pub trait Rpc {
#[allow(non_snake_case)] setPermissionEditDetails: Option<String>,
#[allow(non_snake_case)] setPermissionSendMessages: Option<String>,
expiration: Option<u32>,
#[allow(non_snake_case)] memberLabelEmoji: Option<String>,
#[allow(non_snake_case)] memberLabel: Option<String>,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "updateProfile", param_kind = map)]

View File

@ -535,6 +535,8 @@ async fn handle_command(
set_permission_edit_details,
set_permission_send_messages,
expiration,
member_label_emoji,
member_label,
} => {
client
.update_group(
@ -568,6 +570,8 @@ async fn handle_command(
GroupPermission::OnlyAdmins => "onlyAdmins".to_owned(),
}),
expiration,
member_label_emoji,
member_label,
)
.await
}

View File

@ -45,6 +45,9 @@
<content_attribute id="social-chat">intense</content_attribute>
</content_rating>
<releases>
<release version="0.14.1" date="2026-03-08">
<url type="details">https://github.com/AsamK/signal-cli/releases/tag/v0.14.1</url>
</release>
<release version="0.14.0" date="2026-03-01">
<url type="details">https://github.com/AsamK/signal-cli/releases/tag/v0.14.0</url>
</release>

View File

@ -12,10 +12,9 @@ public record Group(
String title,
String description,
GroupInviteLinkUrl groupInviteLinkUrl,
Set<RecipientAddress> members,
Set<GroupMember> members,
Set<RecipientAddress> pendingMembers,
Set<RecipientAddress> requestingMembers,
Set<RecipientAddress> adminMembers,
Set<RecipientAddress> bannedMembers,
boolean isBlocked,
int messageExpirationTimer,
@ -37,8 +36,7 @@ public record Group(
groupInfo.getGroupInviteLink(),
groupInfo.getMembers()
.stream()
.map(recipientStore::resolveRecipientAddress)
.map(org.asamk.signal.manager.storage.recipients.RecipientAddress::toApiRecipientAddress)
.map(m -> org.asamk.signal.manager.api.GroupMember.from(m, recipientStore))
.collect(Collectors.toSet()),
groupInfo.getPendingMembers()
.stream()
@ -50,11 +48,6 @@ public record Group(
.map(recipientStore::resolveRecipientAddress)
.map(org.asamk.signal.manager.storage.recipients.RecipientAddress::toApiRecipientAddress)
.collect(Collectors.toSet()),
groupInfo.getAdminMembers()
.stream()
.map(recipientStore::resolveRecipientAddress)
.map(org.asamk.signal.manager.storage.recipients.RecipientAddress::toApiRecipientAddress)
.collect(Collectors.toSet()),
groupInfo.getBannedMembers()
.stream()
.map(recipientStore::resolveRecipientAddress)

View File

@ -0,0 +1,14 @@
package org.asamk.signal.manager.api;
import org.asamk.signal.manager.helper.RecipientAddressResolver;
import org.asamk.signal.manager.storage.groups.GroupMemberInfo;
public record GroupMember(
RecipientAddress recipientAddress, boolean isAdmin, String labelEmoji, String label
) {
public static GroupMember from(final GroupMemberInfo memberInfo, final RecipientAddressResolver recipientStore) {
return new GroupMember(recipientStore.resolveRecipientAddress(memberInfo.getRecipientId())
.toApiRecipientAddress(), memberInfo.isAdmin(), memberInfo.labelEmoji(), memberInfo.labelString());
}
}

View File

@ -19,6 +19,8 @@ public class UpdateGroup {
private final String avatarFile;
private final Integer expirationTimer;
private final Boolean isAnnouncementGroup;
private final String labelEmoji;
private final String labelString;
private UpdateGroup(final Builder builder) {
name = builder.name;
@ -36,6 +38,8 @@ public class UpdateGroup {
avatarFile = builder.avatarFile;
expirationTimer = builder.expirationTimer;
isAnnouncementGroup = builder.isAnnouncementGroup;
labelEmoji = builder.labelEmoji;
labelString = builder.labelString;
}
public static Builder newBuilder() {
@ -57,7 +61,9 @@ public class UpdateGroup {
copy.editDetailsPermission,
copy.avatarFile,
copy.expirationTimer,
copy.isAnnouncementGroup);
copy.isAnnouncementGroup,
copy.labelEmoji,
copy.labelString);
}
public static Builder newBuilder(
@ -75,7 +81,9 @@ public class UpdateGroup {
final GroupPermission editDetailsPermission,
final String avatarFile,
final Integer expirationTimer,
final Boolean isAnnouncementGroup
final Boolean isAnnouncementGroup,
final String labelEmoji,
final String labelString
) {
return new Builder(name,
description,
@ -91,7 +99,9 @@ public class UpdateGroup {
editDetailsPermission,
avatarFile,
expirationTimer,
isAnnouncementGroup);
isAnnouncementGroup,
labelEmoji,
labelString);
}
public String getName() {
@ -154,6 +164,14 @@ public class UpdateGroup {
return isAnnouncementGroup;
}
public String getLabelEmoji() {
return labelEmoji;
}
public String getLabelString() {
return labelString;
}
public static final class Builder {
private String name;
@ -171,6 +189,8 @@ public class UpdateGroup {
private String avatarFile;
private Integer expirationTimer;
private Boolean isAnnouncementGroup;
private String labelEmoji;
private String labelString;
private Builder() {
}
@ -190,7 +210,9 @@ public class UpdateGroup {
final GroupPermission editDetailsPermission,
final String avatarFile,
final Integer expirationTimer,
final Boolean isAnnouncementGroup
final Boolean isAnnouncementGroup,
final String labelEmoji,
final String labelString
) {
this.name = name;
this.description = description;
@ -207,6 +229,8 @@ public class UpdateGroup {
this.avatarFile = avatarFile;
this.expirationTimer = expirationTimer;
this.isAnnouncementGroup = isAnnouncementGroup;
this.labelEmoji = labelEmoji;
this.labelString = labelString;
}
public Builder withName(final String val) {
@ -284,6 +308,16 @@ public class UpdateGroup {
return this;
}
public Builder withLabelEmoji(final String val) {
labelEmoji = val;
return this;
}
public Builder withLabelString(final String val) {
labelString = val;
return this;
}
public UpdateGroup build() {
return new UpdateGroup(this);
}

View File

@ -29,7 +29,7 @@ public class ServiceConfig {
public static AccountAttributes.Capabilities getCapabilities(boolean isPrimaryDevice) {
final var attachmentBackfill = !isPrimaryDevice;
final var spqr = !isPrimaryDevice;
final var spqr = true;
return new AccountAttributes.Capabilities(true, true, attachmentBackfill, spqr);
}

View File

@ -299,7 +299,9 @@ public class GroupHelper {
final GroupPermission editDetailsPermission,
final String avatarFile,
final Integer expirationTimer,
final Boolean isAnnouncementGroup
final Boolean isAnnouncementGroup,
final String labelEmoji,
final String labelString
) throws IOException, GroupNotFoundException, AttachmentInvalidException, NotAGroupMemberException, GroupSendingNotAllowedException {
var group = getGroupForUpdating(groupId);
final var avatarBytes = readAvatarBytes(avatarFile);
@ -323,7 +325,9 @@ public class GroupHelper {
editDetailsPermission,
avatarBytes,
expirationTimer,
isAnnouncementGroup);
isAnnouncementGroup,
labelEmoji,
labelString);
} catch (ConflictException e) {
// Detected conflicting update, refreshing group and trying again
group = getGroup(groupId, true);
@ -342,7 +346,9 @@ public class GroupHelper {
editDetailsPermission,
avatarBytes,
expirationTimer,
isAnnouncementGroup);
isAnnouncementGroup,
labelEmoji,
labelString);
}
}
@ -701,7 +707,9 @@ public class GroupHelper {
final GroupPermission editDetailsPermission,
final byte[] avatarFile,
final Integer expirationTimer,
final Boolean isAnnouncementGroup
final Boolean isAnnouncementGroup,
final String labelEmoji,
final String labelString
) throws IOException {
SendGroupMessageResults result = null;
final var groupV2Helper = context.getGroupV2Helper();
@ -758,7 +766,7 @@ public class GroupHelper {
if (admins != null) {
final var newAdmins = new HashSet<>(admins);
newAdmins.retainAll(group.getMembers());
newAdmins.removeAll(group.getAdminMembers());
newAdmins.removeAll(group.getAdminMemberRecipientIds());
if (!newAdmins.isEmpty()) {
for (var admin : newAdmins) {
var groupGroupChangePair = groupV2Helper.setMemberAdmin(group, admin, true);
@ -771,7 +779,7 @@ public class GroupHelper {
if (removeAdmins != null) {
final var existingRemoveAdmins = new HashSet<>(removeAdmins);
existingRemoveAdmins.retainAll(group.getAdminMembers());
existingRemoveAdmins.retainAll(group.getAdminMemberRecipientIds());
if (!existingRemoveAdmins.isEmpty()) {
for (var admin : existingRemoveAdmins) {
var groupGroupChangePair = groupV2Helper.setMemberAdmin(group, admin, false);
@ -830,6 +838,15 @@ public class GroupHelper {
result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second());
}
if (labelString != null || labelEmoji != null) {
final var selfRecipientId = account.getSelfRecipientId();
final var selfMember = group.getMember(selfRecipientId);
var groupGroupChangePair = groupV2Helper.setMemberLabels(group,
labelEmoji != null ? labelEmoji : selfMember.labelEmoji(),
labelString != null ? labelString : selfMember.labelString());
result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second());
}
if (name != null || description != null || avatarFile != null) {
var groupGroupChangePair = groupV2Helper.updateGroup(group, name, description, avatarFile);
if (avatarFile != null) {
@ -859,7 +876,7 @@ public class GroupHelper {
final GroupInfoV2 groupInfoV2,
final Set<RecipientId> newAdmins
) throws LastGroupAdminException, IOException {
final var currentAdmins = groupInfoV2.getAdminMembers();
final var currentAdmins = groupInfoV2.getAdminMemberRecipientIds();
newAdmins.removeAll(currentAdmins);
newAdmins.retainAll(groupInfoV2.getMembers());
if (currentAdmins.contains(account.getSelfRecipientId())
@ -888,7 +905,7 @@ public class GroupHelper {
var group = SignalServiceGroup.newBuilder(SignalServiceGroup.Type.UPDATE)
.withId(g.getGroupId().serialize())
.withName(g.name)
.withMembers(g.getMembers()
.withMembers(g.getMemberRecipientIds()
.stream()
.map(context.getRecipientHelper()::resolveSignalServiceAddress)
.toList());

View File

@ -533,6 +533,18 @@ class GroupV2Helper {
return commitChange(groupInfoV2, change);
}
Pair<DecryptedGroup, GroupChangeResponse> setMemberLabels(
GroupInfoV2 groupInfoV2,
String labelEmoji,
String labelString
) throws IOException {
final GroupsV2Operations.GroupOperations groupOperations = getGroupOperations(groupInfoV2);
final var change = groupOperations.createChangeMemberLabel(getSelfAci(),
labelString == null ? "" : labelString,
labelEmoji);
return commitChange(groupInfoV2, change);
}
private AccessControl.AccessRequired toAccessControl(final GroupLinkState state) {
return switch (state) {
case DISABLED -> AccessControl.AccessRequired.UNSATISFIABLE;

View File

@ -320,7 +320,9 @@ public class SendHelper {
messageBuilder.withExpiration(g.getMessageExpirationTimer());
final var message = messageBuilder.build();
final var recipients = includeSelf ? g.getMembers() : g.getMembersWithout(account.getSelfRecipientId());
final var recipients = includeSelf
? g.getMemberRecipientIds()
: g.getMembersWithout(account.getSelfRecipientId());
if (g.isAnnouncementGroup() && !g.isAdmin(account.getSelfRecipientId())) {
if (message.getBody().isPresent()

View File

@ -114,7 +114,7 @@ public class SyncHelper {
if (record instanceof GroupInfoV1 groupInfo) {
out.write(new DeviceGroup(groupInfo.getGroupId().serialize(),
Optional.ofNullable(groupInfo.name),
groupInfo.getMembers()
groupInfo.getMemberRecipientIds()
.stream()
.map(context.getRecipientHelper()::resolveSignalServiceAddress)
.toList(),

View File

@ -633,7 +633,9 @@ public class ManagerImpl implements Manager {
updateGroup.getEditDetailsPermission(),
updateGroup.getAvatarFile(),
updateGroup.getExpirationTimer(),
updateGroup.getIsAnnouncementGroup());
updateGroup.getIsAnnouncementGroup(),
updateGroup.getLabelEmoji(),
updateGroup.getLabelString());
}
@Override

View File

@ -6,6 +6,7 @@ import org.asamk.signal.manager.api.GroupPermission;
import org.asamk.signal.manager.storage.recipients.RecipientId;
import org.whispersystems.signalservice.api.push.DistributionId;
import java.util.Collection;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.Stream;
@ -24,7 +25,18 @@ public sealed abstract class GroupInfo permits GroupInfoV1, GroupInfoV2 {
public abstract GroupInviteLinkUrl getGroupInviteLink();
public abstract Set<RecipientId> getMembers();
public abstract Collection<GroupMemberInfo> getMembers();
public Set<RecipientId> getMemberRecipientIds() {
return getMembers().stream().map(GroupMemberInfo::getRecipientId).collect(Collectors.toSet());
}
public GroupMemberInfo getMember(RecipientId recipientId) {
return getMembers().stream()
.filter(member -> member.getRecipientId().equals(recipientId))
.findFirst()
.orElseThrow();
}
public Set<RecipientId> getBannedMembers() {
return Set.of();
@ -38,7 +50,7 @@ public sealed abstract class GroupInfo permits GroupInfoV1, GroupInfoV2 {
return Set.of();
}
public Set<RecipientId> getAdminMembers() {
public Set<RecipientId> getAdminMemberRecipientIds() {
return Set.of();
}
@ -61,21 +73,23 @@ public sealed abstract class GroupInfo permits GroupInfoV1, GroupInfoV2 {
public abstract GroupPermission getPermissionSendMessage();
public Set<RecipientId> getMembersWithout(RecipientId recipientId) {
return getMembers().stream().filter(member -> !member.equals(recipientId)).collect(Collectors.toSet());
return getMemberRecipientIds().stream()
.filter(member -> !member.equals(recipientId))
.collect(Collectors.toSet());
}
public Set<RecipientId> getMembersIncludingPendingWithout(RecipientId recipientId) {
return Stream.concat(getMembers().stream(), getPendingMembers().stream())
return Stream.concat(getMemberRecipientIds().stream(), getPendingMembers().stream())
.filter(member -> !member.equals(recipientId))
.collect(Collectors.toSet());
}
public boolean isMember(RecipientId recipientId) {
return getMembers().contains(recipientId);
return getMembers().stream().anyMatch(m -> m.getRecipientId().equals(recipientId));
}
public boolean isAdmin(RecipientId recipientId) {
return getAdminMembers().contains(recipientId);
return getMembers().stream().anyMatch(m -> m.isAdmin() && m.getRecipientId().equals(recipientId));
}
public boolean isPendingMember(RecipientId recipientId) {

View File

@ -80,8 +80,8 @@ public final class GroupInfoV1 extends GroupInfo {
return null;
}
public Set<RecipientId> getMembers() {
return new HashSet<>(members);
public Collection<GroupMemberInfo> getMembers() {
return members.stream().map(m -> (GroupMemberInfo) new GroupMemberInfoV1(m)).toList();
}
@Override

View File

@ -3,18 +3,17 @@ package org.asamk.signal.manager.storage.groups;
import org.asamk.signal.manager.api.GroupIdV2;
import org.asamk.signal.manager.api.GroupInviteLinkUrl;
import org.asamk.signal.manager.api.GroupPermission;
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.signal.core.models.ServiceId;
import org.signal.libsignal.zkgroup.groups.GroupMasterKey;
import org.signal.storageservice.storage.protos.groups.AccessControl;
import org.signal.storageservice.storage.protos.groups.Member;
import org.signal.storageservice.storage.protos.groups.local.DecryptedGroup;
import org.signal.storageservice.storage.protos.groups.local.EnabledState;
import org.whispersystems.signalservice.api.push.DistributionId;
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
import java.util.Collection;
import java.util.Set;
import java.util.stream.Collectors;
@ -122,14 +121,11 @@ public final class GroupInfoV2 extends GroupInfo {
}
@Override
public Set<RecipientId> getMembers() {
public Collection<GroupMemberInfo> getMembers() {
if (this.group == null) {
return Set.of();
}
return group.members.stream()
.map(m -> ServiceId.parseOrThrow(m.aciBytes))
.map(recipientResolver::resolveRecipient)
.collect(Collectors.toSet());
return group.members.stream().map(m -> (GroupMemberInfo) new GroupMemberInfoV2(m, recipientResolver)).toList();
}
@Override
@ -175,16 +171,11 @@ public final class GroupInfoV2 extends GroupInfo {
}
@Override
public Set<RecipientId> getAdminMembers() {
if (this.group == null) {
return Set.of();
}
return group.members.stream()
.filter(m -> m.role == Member.Role.ADMINISTRATOR)
.map(m -> new RecipientAddress(ServiceId.ACI.parseOrNull(m.aciBytes),
ServiceId.PNI.parseOrNull(m.pniBytes),
null))
.map(recipientResolver::resolveRecipient)
public Set<RecipientId> getAdminMemberRecipientIds() {
return this.getMembers()
.stream()
.filter(GroupMemberInfo::isAdmin)
.map(GroupMemberInfo::getRecipientId)
.collect(Collectors.toSet());
}

View File

@ -0,0 +1,20 @@
package org.asamk.signal.manager.storage.groups;
import org.asamk.signal.manager.storage.recipients.RecipientId;
public interface GroupMemberInfo {
RecipientId getRecipientId();
default boolean isAdmin() {
return false;
}
default String labelEmoji() {
return null;
}
default String labelString() {
return null;
}
}

View File

@ -0,0 +1,17 @@
package org.asamk.signal.manager.storage.groups;
import org.asamk.signal.manager.storage.recipients.RecipientId;
public class GroupMemberInfoV1 implements GroupMemberInfo {
private final RecipientId recipientId;
public GroupMemberInfoV1(final RecipientId recipientId) {
this.recipientId = recipientId;
}
@Override
public RecipientId getRecipientId() {
return this.recipientId;
}
}

View File

@ -0,0 +1,38 @@
package org.asamk.signal.manager.storage.groups;
import org.asamk.signal.manager.storage.recipients.RecipientId;
import org.asamk.signal.manager.storage.recipients.RecipientResolver;
import org.signal.core.models.ServiceId;
import org.signal.storageservice.storage.protos.groups.Member;
import org.signal.storageservice.storage.protos.groups.local.DecryptedMember;
public class GroupMemberInfoV2 implements GroupMemberInfo {
private final RecipientResolver recipientResolver;
private final DecryptedMember member;
public GroupMemberInfoV2(final DecryptedMember member, final RecipientResolver recipientResolver) {
this.recipientResolver = recipientResolver;
this.member = member;
}
@Override
public RecipientId getRecipientId() {
return recipientResolver.resolveRecipient(ServiceId.ACI.parseOrThrow(member.aciBytes));
}
@Override
public boolean isAdmin() {
return member.role == Member.Role.ADMINISTRATOR;
}
@Override
public String labelEmoji() {
return member.labelEmoji.isEmpty() ? null : member.labelEmoji;
}
@Override
public String labelString() {
return member.labelString.isEmpty() ? null : member.labelString;
}
}

View File

@ -648,7 +648,7 @@ public class GroupStore {
ON CONFLICT (group_id, recipient_id) DO NOTHING
""".formatted(TABLE_GROUP_V1_MEMBER);
try (final var statement = connection.prepareStatement(sqlInsertMember)) {
for (final var recipient : groupV1.getMembers()) {
for (final var recipient : groupV1.getMemberRecipientIds()) {
statement.setLong(1, internalId);
statement.setLong(2, recipient.id());
statement.executeUpdate();

View File

@ -133,6 +133,11 @@ public class KyberPreKeyStore implements SignalServiceKyberPreKeyStore {
return getPreKey(keyId) != null;
}
/**
* When we mark Kyber pre-keys used, we want to keep a record of last resort tuples, which are deleted when the key
* itself is deleted from this table via a cascading delete.
* For non-last-resort keys, this method just deletes them like normal.
*/
@Override
public void markKyberPreKeyUsed(
final int kyberPreKeyId,

View File

@ -14,8 +14,8 @@ all: $(MANPAGESRC)
.PHONY: install
install: all
$(MKDIR) -p man1 man5
for f in *.1; do $(GZIP) < "$$f" > man1/"$$f".gz ; done
for f in *.5; do $(GZIP) < "$$f" > man5/"$$f".gz ; done
for f in *.1; do $(GZIP) -n < "$$f" > man1/"$$f".gz ; done
for f in *.5; do $(GZIP) -n < "$$f" > man5/"$$f".gz ; done
.PHONY: clean
clean:

View File

@ -154,7 +154,7 @@ RESPONSE: `{"jsonrpc":"2.0","result":{"timestamp":999},"id":4}`
---
REQUEST: `{"jsonrpc":"2.0","method":"updateGroup","params":{"groupId":"GROUP_ID=","name":"new group name","members":["+ZZZ"],"link":"enabledWithApproval","setPermissionEditDetails":"only-admins"},"id":"someId"}`
REQUEST: `{"jsonrpc":"2.0","method":"updateGroup","params":{"groupId":"GROUP_ID=","name":"new group name","members":["+ZZZ"],"link":"enabledWithApproval","setPermissionEditDetails":"only-admins","memberLabelEmoji":"😀","memberLabel":"My Label"},"id":"someId"}`
RESPONSE: `{"jsonrpc":"2.0","result":{"timestamp":9999},"id":"someId"}`

View File

@ -754,6 +754,12 @@ Groups where only admins can send messages are also called announcement groups
Set expiration time of messages (seconds).
To disable expiration set expiration time to 0.
*--member-label-emoji* EMOJI::
Specify the emoji for the member label.
*--member-label* STRING::
Specify the string for the member label.
=== quitGroup
Send a quit group message to all group members and remove self from member list.

View File

@ -0,0 +1,34 @@
# Reproducible builds
This process lets you verify that the version of the app that was downloaded from the Githud Releases matches the source code in our public repository.
This is achieved by replicating the build environment as Docker images.
Currently, only the following binaries are reproducible:
- [x] JAR package (`signal-cli-XXX.tar.gz`)
- [ ] Native binary (`signal-cli-XXX-Linux-native.tar.gz`)
- [x] Rust client binary (`signal-cli-XXX-Linux-client.tar.gz`)
In the following section, we will use Signal version 0.14.0 as the reference example. Simply replace all occurrences of 0.14.0 with the version number you are about to verify.
## Step-by-step instructions
### 0. Prerequisites
Before you begin, ensure you have the following installed:
- git
- docker (or podman)
### 1. Verifying reproducibility
```bash
git clone --depth 1 --branch v0.14.0 https://github.com/AsamK/signal-cli
cd ./signal-cli
./reproducible-builds/verify.sh
```
If each one ends with `... matches!` for every binary (except the native one for now), you're good to go! You've successfully verified that the Github Release binaries were built from exactly the same code as is in the signal-cli git repository.
If you get `... doesn't match!`, it means something went wrong (except for the native one for now). Please [open an issue](https://github.com/AsamK/signal-cli/issues/new/choose).

View File

@ -0,0 +1,11 @@
FROM docker.io/azul/zulu-openjdk:25.0.2-jdk@sha256:0349494e05c22fe439e65be99771581b2bc428d89f07363b539389a11103fa5f
ENV SOURCE_DATE_EPOCH=1767225600
ENV LANG=C.UTF-8
ENV LC_CTYPE=en_US.UTF-8
ARG SNAPSHOT=20260101T000000Z
RUN echo "deb http://snapshot.ubuntu.com/ubuntu/${SNAPSHOT}/ jammy main" > /etc/apt/sources.list \
&& echo "deb http://snapshot.ubuntu.com/ubuntu/${SNAPSHOT}/ jammy universe" >> /etc/apt/sources.list
RUN apt update && apt install -y make asciidoc-base
COPY --chmod=0700 reproducible-builds/entrypoint.sh /usr/local/bin/entrypoint.sh
WORKDIR /signal-cli
ENTRYPOINT [ "/usr/local/bin/entrypoint.sh", "build" ]

45
reproducible-builds/build.sh Executable file
View File

@ -0,0 +1,45 @@
#!/bin/bash
set -eu
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../"
cd "$ROOT_DIR"
rm -rf "$ROOT_DIR/dist"
mkdir -p "$ROOT_DIR/dist"
if command -v podman >/dev/null; then
ENGINE=podman
USER=
else
ENGINE=docker
USER="--user $(id -u):$(id -g)"
fi
VERSION=$(sed -n 's/\s*version\s*=\s*"\(.*\)".*/\1/p' build.gradle.kts | tail -n1)
echo "$VERSION" >dist/VERSION
$ENGINE build -t signal-cli:build -f reproducible-builds/build.Containerfile .
$ENGINE build -t signal-cli:native -f reproducible-builds/native.Containerfile .
$ENGINE build -t signal-cli:client -f reproducible-builds/client.Containerfile .
# Build jar
git clean -Xfd -e '!/dist/' -e '!/dist/**' -e '!/github/' -e '!/github/**'
# shellcheck disable=SC2086
$ENGINE run --pull=never --rm -v "$(pwd)":/signal-cli:Z -e VERSION="$VERSION" $USER signal-cli:build
mv build/distributions/signal-cli-*.tar.gz dist/
# Build native-image
git clean -Xfd -e '!/dist/' -e '!/dist/**' -e '!/github/' -e '!/github/**'
# shellcheck disable=SC2086
$ENGINE run --pull=never --rm -v "$(pwd)":/signal-cli:Z -e VERSION="$VERSION" $USER signal-cli:native
mv build/signal-cli-*-Linux-native.tar.gz dist/
# Build rust client
git clean -Xfd -e '!/dist/' -e '!/dist/**' -e '!/github/' -e '!/github/**'
# shellcheck disable=SC2086
$ENGINE run --pull=never --rm -v "$(pwd)":/signal-cli:Z -e VERSION="$VERSION" $USER signal-cli:client
mv build/signal-cli-*-Linux-client.tar.gz dist/
ls -lsh dist/
echo -e "\e[32mBuild successful!\e[0m"

View File

@ -0,0 +1,7 @@
FROM docker.io/rust:1.93.1-slim-trixie@sha256:7f2c9f2f0dad8f4afa6faf5efa971e7e566398a36e54fb7684061407ea067058
ENV SOURCE_DATE_EPOCH=1767225600
ENV LANG=C.UTF-8
ENV LC_CTYPE=en_US.UTF-8
COPY --chmod=0700 reproducible-builds/entrypoint.sh /usr/local/bin/entrypoint.sh
WORKDIR /signal-cli
ENTRYPOINT [ "/usr/local/bin/entrypoint.sh", "client" ]

View File

@ -0,0 +1,68 @@
#!/bin/bash
set -eu
echo "Build '$1' variant $VERSION ..."
function reset_file_dates() {
find . -exec touch -m -d "@$SOURCE_DATE_EPOCH" {} \;
}
reset_file_dates
if [ "$1" == "build" ]; then
./gradlew build \
--no-daemon \
--max-workers=1 \
-Dkotlin.compiler.execution.strategy=in-process \
--no-build-cache \
-Dorg.gradle.caching=false \
-Porg.gradle.java.installations.auto-download=false \
-Porg.gradle.java.installations.auto-detect=false
cd man
make install
cd ..
reset_file_dates
tar tf build/distributions/signal-cli-*.tar | head -n1 | sed 's|signal-cli-\([^/]*\)/.*|\1|'
tar --mtime="@$SOURCE_DATE_EPOCH" --transform="flags=r;s|man|signal-cli-${VERSION}/man|" -rf "build/distributions/signal-cli-${VERSION}.tar" man/man{1,5}
gzip -n -9 build/distributions/signal-cli-*.tar
elif [ "$1" == "native" ]; then
./gradlew nativeCompile \
--no-daemon \
--max-workers=1 \
-Dkotlin.compiler.execution.strategy=in-process \
--no-build-cache \
-Dorg.gradle.caching=false \
-Dgraalvm.native-image.build-time=2026-01-01T00:00:00Z \
-Porg.gradle.java.installations.auto-download=false \
-Porg.gradle.java.installations.auto-detect=false
strip --strip-all \
--remove-section=.note.gnu.build-id \
--remove-section=.comment \
--remove-section=.gnu_debuglink \
--remove-section=.annobin.notes \
--remove-section=.gnu.build.attributes \
--remove-section=.note.ABI-tag \
build/native/nativeCompile/signal-cli
chmod +x build/native/nativeCompile/signal-cli
reset_file_dates
tar --mtime="@$SOURCE_DATE_EPOCH" -czf "build/signal-cli-${VERSION}-Linux-native.tar.gz" -C build/native/nativeCompile signal-cli
elif [ "$1" == "client" ]; then
cd client
cargo build --release --locked
cd ..
chmod +x client/target/release/signal-cli-client
mkdir -p build
tar --mtime="@$SOURCE_DATE_EPOCH" -czf "build/signal-cli-${VERSION}-Linux-client.tar.gz" -C client/target/release signal-cli-client
else
echo "Unknown build variant '$1'"
exit 1
fi

View File

@ -0,0 +1,7 @@
FROM container-registry.oracle.com/graalvm/native-image:25.0.2@sha256:e8c5ec4f256bf958f327aea060e1424aa87f63114aeb4a4318a0ac169bbdb9a1
ENV SOURCE_DATE_EPOCH=1767225600
ENV LANG=C.UTF-8
ENV LC_CTYPE=en_US.UTF-8
COPY --chmod=0700 reproducible-builds/entrypoint.sh /usr/local/bin/entrypoint.sh
WORKDIR /signal-cli
ENTRYPOINT [ "/usr/local/bin/entrypoint.sh", "native" ]

41
reproducible-builds/verify.sh Executable file
View File

@ -0,0 +1,41 @@
#!/bin/bash
set -eu
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../"
cd "$ROOT_DIR"
rm -rf "$ROOT_DIR/github"
mkdir -p "$ROOT_DIR/github"
VERSION=$(sed -n 's/\s*version\s*=\s*"\(.*\)".*/\1/p' build.gradle.kts | tail -n1)
echo "Download latest release from GitHub..."
curl -L --fail "https://github.com/AsamK/signal-cli/releases/download/v${VERSION}/signal-cli-${VERSION}.tar.gz" -o "github/signal-cli-${VERSION}.tar.gz"
curl -L --fail "https://github.com/AsamK/signal-cli/releases/download/v${VERSION}/signal-cli-${VERSION}-Linux-native.tar.gz" -o "github/signal-cli-${VERSION}-Linux-native.tar.gz"
curl -L --fail "https://github.com/AsamK/signal-cli/releases/download/v${VERSION}/signal-cli-${VERSION}-Linux-client.tar.gz" -o "github/signal-cli-${VERSION}-Linux-client.tar.gz"
./reproducible-builds/build.sh
rm -f {github,dist}/VERSION
echo "commit: $(git rev-parse HEAD)"
echo "sha256 hashes of GitHub release:"
sha256sum github/*
echo "sha256 hashes of locally built files:"
sha256sum dist/*
reproducible=true
for file in $(cd github && find . -type f); do
if diff "github/$file" "dist/$file" >/dev/null 2>&1; then
echo -e "\e[32m[+] '$(basename "$file")' matches!\e[0m"
else
echo -e "\e[31m[-] '$(basename "$file")' doesn't match!\e[0m"
reproducible=false
fi
done
if [ "$reproducible" = false ]; then
exit 1
fi

View File

@ -1,5 +1,7 @@
package org.asamk.signal.commands;
import com.fasterxml.jackson.annotation.JsonInclude;
import net.sourceforge.argparse4j.impl.Arguments;
import net.sourceforge.argparse4j.inf.Namespace;
import net.sourceforge.argparse4j.inf.Subparser;
@ -7,6 +9,7 @@ import net.sourceforge.argparse4j.inf.Subparser;
import org.asamk.signal.commands.exceptions.CommandException;
import org.asamk.signal.manager.Manager;
import org.asamk.signal.manager.api.Group;
import org.asamk.signal.manager.api.GroupMember;
import org.asamk.signal.manager.api.RecipientAddress;
import org.asamk.signal.output.JsonWriter;
import org.asamk.signal.output.OutputWriter;
@ -37,33 +40,55 @@ public class ListGroupsCommand implements JsonRpcLocalCommand {
subparser.addArgument("-g", "--group-id").help("Specify one or more group IDs to show.").nargs("*");
}
private static Set<String> resolveMembers(Set<RecipientAddress> addresses) {
private static Set<String> resolveMembers(Set<GroupMember> addresses) {
return addresses.stream()
.map(m -> m.recipientAddress().getLegacyIdentifier() + (m.isAdmin() ? "{ADMIN}" : "") + (
m.labelEmoji() != null || m.label() != null ? "(" + (
m.labelEmoji() != null ? m.labelEmoji() : ""
) + (
m.label() != null ? m.label() : ""
) + ")" : ""
))
.collect(Collectors.toSet());
}
private static Set<String> resolveMemberAddress(Set<RecipientAddress> addresses) {
return addresses.stream().map(RecipientAddress::getLegacyIdentifier).collect(Collectors.toSet());
}
private static Set<JsonGroupMember> resolveJsonMembers(Set<RecipientAddress> addresses) {
private static Set<JsonGroupMemberAddress> resolveJsonMembers(Set<RecipientAddress> addresses) {
return addresses.stream()
.map(address -> new JsonGroupMember(address.number().orElse(null),
.map(address -> new JsonGroupMemberAddress(address.number().orElse(null),
address.uuid().map(UUID::toString).orElse(null)))
.collect(Collectors.toSet());
}
private static Set<JsonGroupMember> resolveFullJsonMembers(Set<GroupMember> addresses) {
return addresses.stream().map(member -> {
final var address = member.recipientAddress();
return new JsonGroupMember(address.number().orElse(null),
address.uuid().map(UUID::toString).orElse(null),
member.isAdmin(),
member.labelEmoji(),
member.label());
}).collect(Collectors.toSet());
}
private static void printGroupPlainText(PlainTextWriter writer, Group group, boolean detailed) {
if (detailed) {
final var groupInviteLink = group.groupInviteLinkUrl();
writer.println(
"Id: {} Name: {} Description: {} Active: {} Blocked: {} Members: {} Pending members: {} Requesting members: {} Admins: {} Banned: {} Message expiration: {} Link: {}",
"Id: {} Name: {} Description: {} Active: {} Blocked: {} Members: {} Pending members: {} Requesting members: {} Banned: {} Message expiration: {} Link: {}",
group.groupId().toBase64(),
group.title(),
group.description(),
group.isMember(),
group.isBlocked(),
resolveMembers(group.members()),
resolveMembers(group.pendingMembers()),
resolveMembers(group.requestingMembers()),
resolveMembers(group.adminMembers()),
resolveMembers(group.bannedMembers()),
resolveMemberAddress(group.pendingMembers()),
resolveMemberAddress(group.requestingMembers()),
resolveMemberAddress(group.bannedMembers()),
group.messageExpirationTimer() == 0 ? "disabled" : group.messageExpirationTimer() + "s",
groupInviteLink == null ? '-' : groupInviteLink.getUrl());
} else {
@ -96,10 +121,14 @@ public class ListGroupsCommand implements JsonRpcLocalCommand {
group.isMember(),
group.isBlocked(),
group.messageExpirationTimer(),
resolveJsonMembers(group.members()),
resolveFullJsonMembers(group.members()),
resolveJsonMembers(group.pendingMembers()),
resolveJsonMembers(group.requestingMembers()),
resolveJsonMembers(group.adminMembers()),
resolveJsonMembers(group.members()
.stream()
.filter(GroupMember::isAdmin)
.map(GroupMember::recipientAddress)
.collect(Collectors.toSet())),
resolveJsonMembers(group.bannedMembers()),
group.permissionAddMember().name(),
group.permissionEditDetails().name(),
@ -125,15 +154,23 @@ public class ListGroupsCommand implements JsonRpcLocalCommand {
boolean isBlocked,
int messageExpirationTime,
Set<JsonGroupMember> members,
Set<JsonGroupMember> pendingMembers,
Set<JsonGroupMember> requestingMembers,
Set<JsonGroupMember> admins,
Set<JsonGroupMember> banned,
Set<JsonGroupMemberAddress> pendingMembers,
Set<JsonGroupMemberAddress> requestingMembers,
@Deprecated Set<JsonGroupMemberAddress> admins,
Set<JsonGroupMemberAddress> banned,
String permissionAddMember,
String permissionEditDetails,
String permissionSendMessage,
String groupInviteLink
) {}
private record JsonGroupMember(String number, String uuid) {}
private record JsonGroupMemberAddress(String number, String uuid) {}
private record JsonGroupMember(
String number,
String uuid,
boolean isAdmin,
@JsonInclude(JsonInclude.Include.NON_NULL) String labelEmoji,
@JsonInclude(JsonInclude.Include.NON_NULL) String label
) {}
}

View File

@ -75,6 +75,8 @@ public class UpdateGroupCommand implements JsonRpcLocalCommand {
.choices("every-member", "only-admins");
subparser.addArgument("-e", "--expiration").type(int.class).help("Set expiration time of messages (seconds)");
subparser.addArgument("--member-label-emoji").help("Specify the emoji for the member label.");
subparser.addArgument("--member-label").help("Specify the string for the member label.");
}
GroupLinkState getGroupLinkState(String value) throws UserErrorException {
@ -126,6 +128,8 @@ public class UpdateGroupCommand implements JsonRpcLocalCommand {
var groupAddMemberPermission = getGroupPermission(ns.getString("set-permission-add-member"));
var groupEditDetailsPermission = getGroupPermission(ns.getString("set-permission-edit-details"));
var groupSendMessagesPermission = getGroupPermission(ns.getString("set-permission-send-messages"));
var memberLabelEmoji = ns.getString("member-label-emoji");
var memberLabelString = ns.getString("member-label");
try {
boolean isNewGroup = false;
@ -159,6 +163,8 @@ public class UpdateGroupCommand implements JsonRpcLocalCommand {
.withIsAnnouncementGroup(groupSendMessagesPermission == null
? null
: groupSendMessagesPermission == GroupPermission.ONLY_ADMINS)
.withLabelEmoji(memberLabelEmoji)
.withLabelString(memberLabelString)
.build());
if (results != null) {
if (groupMessageResults == null) {

View File

@ -12,6 +12,7 @@ import org.asamk.signal.manager.api.DeviceLinkUrl;
import org.asamk.signal.manager.api.Group;
import org.asamk.signal.manager.api.GroupId;
import org.asamk.signal.manager.api.GroupInviteLinkUrl;
import org.asamk.signal.manager.api.GroupMember;
import org.asamk.signal.manager.api.GroupNotFoundException;
import org.asamk.signal.manager.api.GroupPermission;
import org.asamk.signal.manager.api.GroupSendingNotAllowedException;
@ -834,24 +835,22 @@ public class DbusManagerImpl implements Manager {
final var group = getRemoteObject(groupPath, Signal.Group.class).GetAll("org.asamk.Signal.Group");
final var id = (byte[]) group.get("Id").getValue();
try {
final var admins = new HashSet<>(((List<String>) group.get("Admins").getValue()));
return new Group(GroupId.unknownVersion(id),
(String) group.get("Name").getValue(),
(String) group.get("Description").getValue(),
GroupInviteLinkUrl.fromUri((String) group.get("GroupInviteLink").getValue()),
((List<String>) group.get("Members").getValue()).stream()
.map(m -> new RecipientAddress(m))
.map(m -> new GroupMember(new RecipientAddress(m), admins.contains(m), null, null))
.collect(Collectors.toSet()),
((List<String>) group.get("PendingMembers").getValue()).stream()
.map(m -> new RecipientAddress(m))
.map(RecipientAddress::new)
.collect(Collectors.toSet()),
((List<String>) group.get("RequestingMembers").getValue()).stream()
.map(m -> new RecipientAddress(m))
.collect(Collectors.toSet()),
((List<String>) group.get("Admins").getValue()).stream()
.map(m -> new RecipientAddress(m))
.map(RecipientAddress::new)
.collect(Collectors.toSet()),
((List<String>) group.get("Banned").getValue()).stream()
.map(m -> new RecipientAddress(m))
.map(RecipientAddress::new)
.collect(Collectors.toSet()),
(boolean) group.get("IsBlocked").getValue(),
(int) group.get("MessageExpirationTimer").getValue(),

View File

@ -10,6 +10,7 @@ import org.asamk.signal.manager.api.DeviceLinkUrl;
import org.asamk.signal.manager.api.GroupId;
import org.asamk.signal.manager.api.GroupInviteLinkUrl;
import org.asamk.signal.manager.api.GroupLinkState;
import org.asamk.signal.manager.api.GroupMember;
import org.asamk.signal.manager.api.GroupNotFoundException;
import org.asamk.signal.manager.api.GroupPermission;
import org.asamk.signal.manager.api.GroupSendingNotAllowedException;
@ -624,7 +625,7 @@ public class DbusSignalImpl implements Signal, AutoCloseable {
if (group == null) {
return List.of();
} else {
final var members = group.members();
final var members = group.members().stream().map(GroupMember::recipientAddress).collect(Collectors.toSet());
return getRecipientStrings(members);
}
}
@ -1300,13 +1301,20 @@ public class DbusSignalImpl implements Signal, AutoCloseable {
() -> getGroup().messageExpirationTimer(),
this::setMessageExpirationTime),
new DbusProperty<>("Members",
() -> new Variant<>(getRecipientStrings(getGroup().members()), "as")),
() -> new Variant<>(getRecipientStrings(getGroup().members()
.stream()
.map(GroupMember::recipientAddress)
.collect(Collectors.toSet())), "as")),
new DbusProperty<>("PendingMembers",
() -> new Variant<>(getRecipientStrings(getGroup().pendingMembers()), "as")),
new DbusProperty<>("RequestingMembers",
() -> new Variant<>(getRecipientStrings(getGroup().requestingMembers()), "as")),
new DbusProperty<>("Admins",
() -> new Variant<>(getRecipientStrings(getGroup().adminMembers()), "as")),
() -> new Variant<>(getRecipientStrings(getGroup().members()
.stream()
.filter(GroupMember::isAdmin)
.map(GroupMember::recipientAddress)
.collect(Collectors.toSet())), "as")),
new DbusProperty<>("Banned",
() -> new Variant<>(getRecipientStrings(getGroup().bannedMembers()), "as")),
new DbusProperty<>("PermissionAddMember",

View File

@ -2048,6 +2048,19 @@
"allDeclaredMethods": true,
"allDeclaredConstructors": true
},
{
"type": "org.asamk.signal.commands.ListGroupsCommand$JsonGroupMemberAddress",
"methods": [
{
"name": "number",
"parameterTypes": []
},
{
"name": "uuid",
"parameterTypes": []
}
]
},
{
"type": "org.asamk.signal.commands.ListIdentitiesCommand$JsonIdentity",
"allDeclaredFields": true,
@ -7793,6 +7806,18 @@
{
"type": "org.whispersystems.signalservice.internal.push.GroupMismatchedDevices[]"
},
{
"type": "org.whispersystems.signalservice.internal.push.GroupPatchResponse",
"methods": [
{
"name": "<init>",
"parameterTypes": [
"java.lang.Integer",
"java.lang.String"
]
}
]
},
{
"type": "org.whispersystems.signalservice.internal.push.GroupStaleDevices",
"allDeclaredFields": true,