Store profile phone number sharing mode and discoverable state

This commit is contained in:
AsamK 2024-04-16 21:55:50 +02:00
parent 71de8e63cc
commit 7e0d4c9b89
12 changed files with 202 additions and 47 deletions

View file

@ -3,5 +3,16 @@ package org.asamk.signal.manager.api;
public enum PhoneNumberSharingMode {
EVERYBODY,
CONTACTS,
NOBODY,
NOBODY;
public static PhoneNumberSharingMode valueOfOrNull(String value) {
if (value == null) {
return null;
}
try {
return valueOf(value);
} catch (IllegalArgumentException ignored) {
return null;
}
}
}

View file

@ -26,6 +26,8 @@ public class Profile {
private final Set<Capability> capabilities;
private final PhoneNumberSharingMode phoneNumberSharingMode;
public Profile(
final long lastUpdateTimestamp,
final String givenName,
@ -35,7 +37,8 @@ public class Profile {
final String avatarUrlPath,
final byte[] mobileCoinAddress,
final UnidentifiedAccessMode unidentifiedAccessMode,
final Set<Capability> capabilities
final Set<Capability> capabilities,
final PhoneNumberSharingMode phoneNumberSharingMode
) {
this.lastUpdateTimestamp = lastUpdateTimestamp;
this.givenName = givenName;
@ -46,6 +49,7 @@ public class Profile {
this.mobileCoinAddress = mobileCoinAddress;
this.unidentifiedAccessMode = unidentifiedAccessMode;
this.capabilities = capabilities;
this.phoneNumberSharingMode = phoneNumberSharingMode;
}
private Profile(final Builder builder) {
@ -58,6 +62,7 @@ public class Profile {
mobileCoinAddress = builder.mobileCoinAddress;
unidentifiedAccessMode = builder.unidentifiedAccessMode;
capabilities = builder.capabilities;
phoneNumberSharingMode = builder.phoneNumberSharingMode;
}
public static Builder newBuilder() {
@ -136,6 +141,10 @@ public class Profile {
return capabilities;
}
public PhoneNumberSharingMode getPhoneNumberSharingMode() {
return phoneNumberSharingMode;
}
public enum UnidentifiedAccessMode {
UNKNOWN,
DISABLED,
@ -200,6 +209,7 @@ public class Profile {
private byte[] mobileCoinAddress;
private UnidentifiedAccessMode unidentifiedAccessMode = UnidentifiedAccessMode.UNKNOWN;
private Set<Capability> capabilities = Collections.emptySet();
private PhoneNumberSharingMode phoneNumberSharingMode;
private long lastUpdateTimestamp = 0;
private Builder() {
@ -240,6 +250,11 @@ public class Profile {
return this;
}
public Builder withPhoneNumberSharingMode(final PhoneNumberSharingMode val) {
phoneNumberSharingMode = val;
return this;
}
public Profile build() {
return new Profile(this);
}

View file

@ -20,13 +20,16 @@ public class Recipient {
private final Profile profile;
private final Boolean discoverable;
public Recipient(
final RecipientId recipientId,
final RecipientAddress address,
final Contact contact,
final ProfileKey profileKey,
final ExpiringProfileKeyCredential expiringProfileKeyCredential,
final Profile profile
final Profile profile,
final Boolean discoverable
) {
this.recipientId = recipientId;
this.address = address;
@ -34,6 +37,7 @@ public class Recipient {
this.profileKey = profileKey;
this.expiringProfileKeyCredential = expiringProfileKeyCredential;
this.profile = profile;
this.discoverable = discoverable;
}
private Recipient(final Builder builder) {
@ -41,8 +45,9 @@ public class Recipient {
address = builder.address;
contact = builder.contact;
profileKey = builder.profileKey;
expiringProfileKeyCredential = builder.expiringProfileKeyCredential1;
expiringProfileKeyCredential = builder.expiringProfileKeyCredential;
profile = builder.profile;
discoverable = builder.discoverable;
}
public static Builder newBuilder() {
@ -55,7 +60,7 @@ public class Recipient {
builder.address = copy.getAddress();
builder.contact = copy.getContact();
builder.profileKey = copy.getProfileKey();
builder.expiringProfileKeyCredential1 = copy.getExpiringProfileKeyCredential();
builder.expiringProfileKeyCredential = copy.getExpiringProfileKeyCredential();
builder.profile = copy.getProfile();
return builder;
}
@ -84,6 +89,10 @@ public class Recipient {
return profile;
}
public Boolean getDiscoverable() {
return discoverable;
}
@Override
public boolean equals(final Object o) {
if (this == o) return true;
@ -108,8 +117,9 @@ public class Recipient {
private RecipientAddress address;
private Contact contact;
private ProfileKey profileKey;
private ExpiringProfileKeyCredential expiringProfileKeyCredential1;
private ExpiringProfileKeyCredential expiringProfileKeyCredential;
private Profile profile;
private Boolean discoverable;
private Builder() {
}
@ -135,7 +145,7 @@ public class Recipient {
}
public Builder withExpiringProfileKeyCredential(final ExpiringProfileKeyCredential val) {
expiringProfileKeyCredential1 = val;
expiringProfileKeyCredential = val;
return this;
}
@ -144,6 +154,11 @@ public class Recipient {
return this;
}
public Builder withDiscoverable(final Boolean val) {
discoverable = val;
return this;
}
public Recipient build() {
return new Recipient(this);
}

View file

@ -363,6 +363,7 @@ public final class ProfileHelper {
logger.trace("Storing profile");
account.getProfileStore().storeProfile(recipientId, newProfile);
account.getRecipientStore().markRegistered(recipientId, true);
logger.trace("Done handling retrieved profile");
}).doOnError(e -> {
@ -374,6 +375,10 @@ public final class ProfileHelper {
.withUnidentifiedAccessMode(Profile.UnidentifiedAccessMode.UNKNOWN)
.withCapabilities(Set.of())
.build();
if (e instanceof NotFoundException) {
logger.debug("Marking recipient {} as unregistered after 404 profile fetch.", recipientId);
account.getRecipientStore().markRegistered(recipientId, false);
}
account.getProfileStore().storeProfile(recipientId, newProfile);
});

View file

@ -187,7 +187,8 @@ public class RecipientHelper {
final var unregisteredUsers = new HashSet<>(numbers);
unregisteredUsers.removeAll(registeredUsers.keySet());
account.getRecipientStore().markUnregistered(unregisteredUsers);
account.getRecipientStore().markUndiscoverablePossiblyUnregistered(unregisteredUsers);
account.getRecipientStore().markDiscoverable(registeredUsers.keySet());
return registeredUsers;
}

View file

@ -1310,7 +1310,8 @@ public class ManagerImpl implements Manager {
s.getContact(),
s.getProfileKey(),
s.getExpiringProfileKeyCredential(),
s.getProfile()))
s.getProfile(),
s.getDiscoverable()))
.toList();
}

View file

@ -33,7 +33,7 @@ import java.util.UUID;
public class AccountDatabase extends Database {
private static final Logger logger = LoggerFactory.getLogger(AccountDatabase.class);
private static final long DATABASE_VERSION = 25;
private static final long DATABASE_VERSION = 26;
private AccountDatabase(final HikariDataSource dataSource) {
super(logger, DATABASE_VERSION, dataSource);
@ -591,6 +591,15 @@ public class AccountDatabase extends Database {
""");
}
}
if (oldVersion < 26) {
logger.debug("Updating database: Create discoverabel and profile_phone_number_sharing columns");
try (final var statement = connection.createStatement()) {
statement.executeUpdate("""
ALTER TABLE recipient ADD discoverable INTEGER;
ALTER TABLE recipient ADD profile_phone_number_sharing TEXT;
""");
}
}
}
private static void createUuidMappingTable(

View file

@ -914,7 +914,8 @@ public class SignalAccount implements Closeable {
: profile.getUnidentifiedAccess() != null
? Profile.UnidentifiedAccessMode.ENABLED
: Profile.UnidentifiedAccessMode.DISABLED,
capabilities);
capabilities,
null);
getProfileStore().storeProfile(recipientId, newProfile);
}
}

View file

@ -87,7 +87,8 @@ public class LegacyRecipientStore2 {
r.profile.capabilities.stream()
.map(Profile.Capability::valueOfOrNull)
.filter(Objects::nonNull)
.collect(Collectors.toSet()));
.collect(Collectors.toSet()),
null);
}
return new Recipient(recipientId,
@ -96,6 +97,7 @@ public class LegacyRecipientStore2 {
profileKey,
expiringProfileKeyCredential,
profile,
null,
null);
}).collect(Collectors.toMap(Recipient::getRecipientId, r -> r));

View file

@ -21,6 +21,8 @@ public class Recipient {
private final Profile profile;
private final Boolean discoverable;
private final byte[] storageRecord;
public Recipient(
@ -30,6 +32,7 @@ public class Recipient {
final ProfileKey profileKey,
final ExpiringProfileKeyCredential expiringProfileKeyCredential,
final Profile profile,
final Boolean discoverable,
final byte[] storageRecord
) {
this.recipientId = recipientId;
@ -38,6 +41,7 @@ public class Recipient {
this.profileKey = profileKey;
this.expiringProfileKeyCredential = expiringProfileKeyCredential;
this.profile = profile;
this.discoverable = discoverable;
this.storageRecord = storageRecord;
}
@ -48,6 +52,7 @@ public class Recipient {
profileKey = builder.profileKey;
expiringProfileKeyCredential = builder.expiringProfileKeyCredential;
profile = builder.profile;
discoverable = builder.discoverable;
storageRecord = builder.storageRecord;
}
@ -91,6 +96,10 @@ public class Recipient {
return profile;
}
public Boolean getDiscoverable() {
return discoverable;
}
public byte[] getStorageRecord() {
return storageRecord;
}
@ -121,6 +130,7 @@ public class Recipient {
private ProfileKey profileKey;
private ExpiringProfileKeyCredential expiringProfileKeyCredential;
private Profile profile;
private Boolean discoverable;
private byte[] storageRecord;
private Builder() {
@ -156,6 +166,11 @@ public class Recipient {
return this;
}
public Builder withDiscoverable(final Boolean val) {
discoverable = val;
return this;
}
public Builder withStorageRecord(final byte[] val) {
storageRecord = val;
return this;

View file

@ -2,6 +2,7 @@ package org.asamk.signal.manager.storage.recipients;
import org.asamk.signal.manager.api.Contact;
import org.asamk.signal.manager.api.Pair;
import org.asamk.signal.manager.api.PhoneNumberSharingMode;
import org.asamk.signal.manager.api.Profile;
import org.asamk.signal.manager.api.UnregisteredRecipientException;
import org.asamk.signal.manager.storage.Database;
@ -64,6 +65,7 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
aci TEXT UNIQUE,
pni TEXT UNIQUE,
unregistered_timestamp INTEGER,
discoverable INTEGER,
profile_key BLOB,
profile_key_credential BLOB,
needs_pni_signature INTEGER NOT NULL DEFAULT FALSE,
@ -92,7 +94,8 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
profile_avatar_url_path TEXT,
profile_mobile_coin_address BLOB,
profile_unidentified_access_mode TEXT,
profile_capabilities TEXT
profile_capabilities TEXT,
profile_phone_number_sharing TEXT
) STRICT;
""");
}
@ -354,7 +357,8 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
r.number, r.aci, r.pni, r.username,
r.profile_key, r.profile_key_credential,
r.given_name, r.family_name, r.nick_name, r.expiration_time, r.mute_until, r.hide_story, r.profile_sharing, r.color, r.blocked, 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_last_update_timestamp, r.profile_given_name, r.profile_family_name, r.profile_about, r.profile_about_emoji, r.profile_avatar_url_path, r.profile_mobile_coin_address, r.profile_unidentified_access_mode, r.profile_capabilities, r.profile_phone_number_sharing,
r.discoverable,
r.storage_record
FROM %s r
WHERE r._id = ?
@ -373,7 +377,8 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
r.number, r.aci, r.pni, r.username,
r.profile_key, r.profile_key_credential,
r.given_name, r.family_name, r.nick_name, r.expiration_time, r.mute_until, r.hide_story, r.profile_sharing, r.color, r.blocked, 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_last_update_timestamp, r.profile_given_name, r.profile_family_name, r.profile_about, r.profile_about_emoji, r.profile_avatar_url_path, r.profile_mobile_coin_address, r.profile_unidentified_access_mode, r.profile_capabilities, r.profile_phone_number_sharing,
r.discoverable,
r.storage_record
FROM %s r
WHERE r.storage_id = ?
@ -409,7 +414,8 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
r.number, r.aci, r.pni, r.username,
r.profile_key, r.profile_key_credential,
r.given_name, r.family_name, r.nick_name, r.expiration_time, r.mute_until, r.hide_story, r.profile_sharing, r.color, r.blocked, 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_last_update_timestamp, r.profile_given_name, r.profile_family_name, r.profile_about, r.profile_about_emoji, r.profile_avatar_url_path, r.profile_mobile_coin_address, r.profile_unidentified_access_mode, r.profile_capabilities, r.profile_phone_number_sharing,
r.discoverable,
r.storage_record
FROM %s r
WHERE (r.number IS NOT NULL OR r.aci IS NOT NULL) AND %s
@ -898,15 +904,19 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
}
}
public void markUnregistered(final Set<String> unregisteredUsers) {
logger.debug("Marking {} numbers as unregistered", unregisteredUsers.size());
public void markUndiscoverablePossiblyUnregistered(final Set<String> numbers) {
logger.debug("Marking {} numbers as unregistered", numbers.size());
try (final var connection = database.getConnection()) {
connection.setAutoCommit(false);
for (final var number : unregisteredUsers) {
final var recipient = findByNumber(connection, number);
if (recipient.isPresent()) {
final var recipientId = recipient.get().id();
markUnregisteredAndSplitIfNecessary(connection, recipientId);
for (final var number : numbers) {
final var recipientAddress = findByNumber(connection, number);
if (recipientAddress.isPresent()) {
final var recipientId = recipientAddress.get().id();
markDiscoverable(connection, recipientId, false);
final var contact = getContact(connection, recipientId);
if (recipientAddress.get().address().aci().isEmpty() || contact.unregisteredTimestamp() != null) {
markUnregisteredAndSplitIfNecessary(connection, recipientId);
}
}
}
connection.commit();
@ -915,6 +925,38 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
}
}
public void markDiscoverable(final Set<String> numbers) {
logger.debug("Marking {} numbers as discoverable", numbers.size());
try (final var connection = database.getConnection()) {
connection.setAutoCommit(false);
for (final var number : numbers) {
final var recipientAddress = findByNumber(connection, number);
if (recipientAddress.isPresent()) {
final var recipientId = recipientAddress.get().id();
markDiscoverable(connection, recipientId, true);
}
}
connection.commit();
} catch (SQLException e) {
throw new RuntimeException("Failed update recipient store", e);
}
}
public void markRegistered(final RecipientId recipientId, final boolean registered) {
logger.debug("Marking {} as registered={}", recipientId, registered);
try (final var connection = database.getConnection()) {
connection.setAutoCommit(false);
if (registered) {
markRegistered(connection, recipientId);
} else {
markUnregistered(connection, recipientId);
}
connection.commit();
} catch (SQLException e) {
throw new RuntimeException("Failed update recipient store", e);
}
}
private void markUnregisteredAndSplitIfNecessary(
final Connection connection, final RecipientId recipientId
) throws SQLException {
@ -927,6 +969,23 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
}
}
private void markDiscoverable(
final Connection connection, final RecipientId recipientId, final boolean discoverable
) throws SQLException {
final var sql = (
"""
UPDATE %s
SET discoverable = ?
WHERE _id = ?
"""
).formatted(TABLE_RECIPIENT);
try (final var statement = connection.prepareStatement(sql)) {
statement.setBoolean(1, discoverable);
statement.setLong(2, recipientId.id());
statement.executeUpdate();
}
}
private void markRegistered(
final Connection connection, final RecipientId recipientId
) throws SQLException {
@ -949,8 +1008,8 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
final var sql = (
"""
UPDATE %s
SET unregistered_timestamp = ?
WHERE _id = ? AND unregistered_timestamp IS NULL
SET unregistered_timestamp = ?, discoverable = FALSE
WHERE _id = ?
"""
).formatted(TABLE_RECIPIENT);
try (final var statement = connection.prepareStatement(sql)) {
@ -985,7 +1044,7 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
final var sql = (
"""
UPDATE %s
SET profile_last_update_timestamp = ?, profile_given_name = ?, profile_family_name = ?, profile_about = ?, profile_about_emoji = ?, profile_avatar_url_path = ?, profile_mobile_coin_address = ?, profile_unidentified_access_mode = ?, profile_capabilities = ?
SET profile_last_update_timestamp = ?, profile_given_name = ?, profile_family_name = ?, profile_about = ?, profile_about_emoji = ?, profile_avatar_url_path = ?, profile_mobile_coin_address = ?, profile_unidentified_access_mode = ?, profile_capabilities = ?, profile_phone_number_sharing = ?
WHERE _id = ?
"""
).formatted(TABLE_RECIPIENT);
@ -1002,7 +1061,11 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
profile == null
? null
: profile.getCapabilities().stream().map(Enum::name).collect(Collectors.joining(",")));
statement.setLong(10, recipientId.id());
statement.setString(10,
profile == null || profile.getPhoneNumberSharingMode() == null
? null
: profile.getPhoneNumberSharingMode().name());
statement.setLong(11, recipientId.id());
statement.executeUpdate();
}
rotateStorageId(connection, recipientId);
@ -1396,7 +1459,7 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
public Profile getProfile(final Connection connection, final RecipientId recipientId) throws SQLException {
final var sql = (
"""
SELECT 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
SELECT 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
FROM %s r
WHERE r._id = ? AND r.profile_capabilities IS NOT NULL
"""
@ -1431,6 +1494,7 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
getProfileKeyFromResultSet(resultSet),
getExpiringProfileKeyCredentialFromResultSet(resultSet),
getProfileFromResultSet(resultSet),
getDiscoverableFromResultSet(resultSet),
getStorageRecordFromResultSet(resultSet));
}
@ -1453,6 +1517,14 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
unregisteredTimestamp == 0 ? null : unregisteredTimestamp);
}
private static Boolean getDiscoverableFromResultSet(final ResultSet resultSet) throws SQLException {
final var discoverable = resultSet.getBoolean("discoverable");
if (resultSet.wasNull()) {
return null;
}
return discoverable;
}
private Profile getProfileFromResultSet(ResultSet resultSet) throws SQLException {
final var profileCapabilities = resultSet.getString("profile_capabilities");
final var profileUnidentifiedAccessMode = resultSet.getString("profile_unidentified_access_mode");
@ -1471,7 +1543,8 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
: Arrays.stream(profileCapabilities.split(","))
.map(Profile.Capability::valueOfOrNull)
.filter(Objects::nonNull)
.collect(Collectors.toSet()));
.collect(Collectors.toSet()),
PhoneNumberSharingMode.valueOfOrNull(resultSet.getString("profile_phone_number_sharing")));
}
private ProfileKey getProfileKeyFromResultSet(ResultSet resultSet) throws SQLException {

View file

@ -1,6 +1,7 @@
package org.asamk.signal.manager.util;
import org.asamk.signal.manager.api.Pair;
import org.asamk.signal.manager.api.PhoneNumberSharingMode;
import org.asamk.signal.manager.api.Profile;
import org.signal.libsignal.protocol.IdentityKey;
import org.signal.libsignal.protocol.InvalidKeyException;
@ -16,6 +17,7 @@ import org.whispersystems.signalservice.internal.push.PaymentAddress;
import java.io.IOException;
import java.util.Base64;
import java.util.HashSet;
import java.util.Optional;
public class ProfileUtils {
@ -33,11 +35,14 @@ public class ProfileUtils {
}
try {
var name = decrypt(encryptedProfile.getName(), profileCipher);
var about = trimZeros(decrypt(encryptedProfile.getAbout(), profileCipher));
var aboutEmoji = trimZeros(decrypt(encryptedProfile.getAboutEmoji(), profileCipher));
var name = decryptString(encryptedProfile.getName(), profileCipher);
var about = decryptString(encryptedProfile.getAbout(), profileCipher);
var aboutEmoji = decryptString(encryptedProfile.getAboutEmoji(), profileCipher);
final var nameParts = splitName(name);
final var remotePhoneNumberSharing = decryptBoolean(encryptedProfile.getPhoneNumberSharing(),
profileCipher).map(v -> v ? PhoneNumberSharingMode.EVERYBODY : PhoneNumberSharingMode.NOBODY)
.orElse(null);
return new Profile(System.currentTimeMillis(),
nameParts.first(),
nameParts.second(),
@ -50,7 +55,8 @@ public class ProfileUtils {
profileCipher,
identityKey.getPublicKey()),
getUnidentifiedAccessMode(encryptedProfile, profileCipher),
getCapabilities(encryptedProfile));
getCapabilities(encryptedProfile),
remotePhoneNumberSharing);
} catch (InvalidCiphertextException e) {
logger.debug("Failed to decrypt profile for {}", encryptedProfile.getServiceId(), e);
return null;
@ -83,18 +89,28 @@ public class ProfileUtils {
return capabilities;
}
private static String decrypt(
final String encryptedName, final ProfileCipher profileCipher
private static String decryptString(
final String encrypted, final ProfileCipher profileCipher
) throws InvalidCiphertextException {
try {
return encryptedName == null
? null
: new String(profileCipher.decrypt(Base64.getDecoder().decode(encryptedName)));
return encrypted == null ? null : profileCipher.decryptString(Base64.getDecoder().decode(encrypted));
} catch (IllegalArgumentException e) {
return null;
}
}
private static Optional<Boolean> decryptBoolean(
final String encrypted, final ProfileCipher profileCipher
) throws InvalidCiphertextException {
try {
return encrypted == null
? Optional.empty()
: profileCipher.decryptBoolean(Base64.getDecoder().decode(encrypted));
} catch (IllegalArgumentException e) {
return Optional.empty();
}
}
private static byte[] decryptAndVerifyMobileCoinAddress(
final byte[] encryptedPaymentAddress, final ProfileCipher profileCipher, final ECPublicKey publicKey
) throws InvalidCiphertextException {
@ -129,13 +145,4 @@ public class ProfileUtils {
default -> new Pair<>(parts[0], parts[1]);
};
}
static String trimZeros(String str) {
if (str == null) {
return null;
}
int pos = str.indexOf(0);
return pos == -1 ? str : str.substring(0, pos);
}
}