Migrate avatars and group avatars.

master
Greyson Parrelli 2020-03-26 15:38:27 -04:00
parent 9848599807
commit 10bfc8a753
22 changed files with 317 additions and 136 deletions

View File

@ -20,6 +20,7 @@ package org.thoughtcrime.securesms;
import android.app.Activity;
import android.content.Intent;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.drawable.Drawable;
import android.os.AsyncTask;
import android.os.Bundle;
@ -63,6 +64,7 @@ import org.thoughtcrime.securesms.mediasend.AvatarSelectionBottomSheetDialogFrag
import org.thoughtcrime.securesms.mediasend.Media;
import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader.DecryptableUri;
import org.thoughtcrime.securesms.mms.GlideApp;
import org.thoughtcrime.securesms.profiles.AvatarHelper;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.util.BitmapUtil;
@ -76,6 +78,7 @@ import org.thoughtcrime.securesms.util.task.ProgressDialogAsyncTask;
import org.whispersystems.libsignal.util.guava.Optional;
import org.whispersystems.signalservice.api.util.InvalidNumberException;
import java.io.IOException;
import java.util.Collection;
import java.util.HashSet;
import java.util.LinkedList;
@ -102,7 +105,6 @@ public class GroupCreateActivity extends PassphraseRequiredActionBarActivity
private static final short REQUEST_CODE_SELECT_AVATAR = 26165;
private static final int PICK_CONTACT = 1;
public static final int AVATAR_SIZE = 210;
private EditText groupName;
private ListView lv;
@ -321,7 +323,7 @@ public class GroupCreateActivity extends PassphraseRequiredActionBarActivity
.skipMemoryCache(true)
.diskCacheStrategy(DiskCacheStrategy.NONE)
.centerCrop()
.override(AVATAR_SIZE, AVATAR_SIZE)
.override(AvatarHelper.AVATAR_DIMENSIONS, AvatarHelper.AVATAR_DIMENSIONS)
.into(new SimpleTarget<Bitmap>() {
@Override
public void onResourceReady(@NonNull Bitmap resource, Transition<? super Bitmap> transition) {
@ -554,10 +556,16 @@ public class GroupCreateActivity extends PassphraseRequiredActionBarActivity
existingContacts.addAll(recipients);
if (group.isPresent()) {
Bitmap avatar = null;
try {
avatar = BitmapFactory.decodeStream(AvatarHelper.getAvatar(getContext(), group.get().getRecipientId()));
} catch (IOException e) {
Log.w(TAG, "Failed to read avatar.");
}
return Optional.of(new GroupData(groupIds[0],
existingContacts,
BitmapUtil.fromByteArray(group.get().getAvatar()),
group.get().getAvatar(),
avatar,
BitmapUtil.toByteArray(avatar),
group.get().getTitle()));
} else {
return Optional.absent();

View File

@ -21,7 +21,6 @@ import org.thoughtcrime.securesms.crypto.ClassicDecryptingPartInputStream;
import org.thoughtcrime.securesms.crypto.IdentityKeyUtil;
import org.thoughtcrime.securesms.crypto.ModernDecryptingPartInputStream;
import org.thoughtcrime.securesms.database.AttachmentDatabase;
import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.database.GroupReceiptDatabase;
import org.thoughtcrime.securesms.database.JobDatabase;
import org.thoughtcrime.securesms.database.KeyValueDatabase;
@ -49,7 +48,6 @@ import java.io.OutputStream;
import java.security.InvalidAlgorithmParameterException;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.util.HashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Set;
@ -118,9 +116,11 @@ public class FullBackupExporter extends FullBackupBase {
stopwatch.split("prefs");
for (File avatar : AvatarHelper.getAvatarFiles(context)) {
EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, ++count));
outputStream.write(avatar.getName(), new FileInputStream(avatar), avatar.length());
for (AvatarHelper.Avatar avatar : AvatarHelper.getAvatars(context)) {
if (avatar != null) {
EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, ++count));
outputStream.write(avatar.getFilename(), avatar.getInputStream(), avatar.getLength());
}
}
stopwatch.split("avatars");

View File

@ -176,7 +176,7 @@ public class FullBackupImporter extends FullBackupBase {
private static void processAvatar(@NonNull Context context, @NonNull SQLiteDatabase db, @NonNull BackupProtos.Avatar avatar, @NonNull BackupRecordInputStream inputStream) throws IOException {
if (avatar.hasRecipientId()) {
RecipientId recipientId = RecipientId.from(avatar.getRecipientId());
inputStream.readAttachmentTo(new FileOutputStream(AvatarHelper.getAvatarFile(context, recipientId)), avatar.getLength());
inputStream.readAttachmentTo(AvatarHelper.getOutputStream(context, recipientId), avatar.getLength());
} else {
if (avatar.hasName() && SqlUtil.tableExists(db, "recipient_preferences")) {
Log.w(TAG, "Avatar is missing a recipientId. Clearing signal_profile_avatar (legacy) so it can be fetched later.");

View File

@ -10,6 +10,7 @@ import androidx.annotation.Nullable;
import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.database.GroupDatabase;
import org.thoughtcrime.securesms.groups.GroupId;
import org.thoughtcrime.securesms.profiles.AvatarHelper;
import org.thoughtcrime.securesms.util.Conversions;
import org.whispersystems.libsignal.util.guava.Optional;
@ -33,11 +34,11 @@ public final class GroupRecordContactPhoto implements ContactPhoto {
GroupDatabase groupDatabase = DatabaseFactory.getGroupDatabase(context);
Optional<GroupDatabase.GroupRecord> groupRecord = groupDatabase.getGroup(groupId);
if (groupRecord.isPresent() && groupRecord.get().getAvatar() != null) {
return new ByteArrayInputStream(groupRecord.get().getAvatar());
if (!groupRecord.isPresent() || !AvatarHelper.hasAvatar(context, groupRecord.get().getRecipientId())) {
throw new IOException("No avatar for group: " + groupId);
}
throw new IOException("Couldn't load avatar for group: " + groupId);
return AvatarHelper.getAvatar(context, groupRecord.get().getRecipientId());
}
@Override

View File

@ -31,13 +31,12 @@ public class ProfileContactPhoto implements ContactPhoto {
@Override
public @NonNull InputStream openInputStream(Context context) throws IOException {
return AvatarHelper.getInputStreamFor(context, recipient.getId());
return AvatarHelper.getAvatar(context, recipient.getId());
}
@Override
public @Nullable Uri getUri(@NonNull Context context) {
File avatarFile = AvatarHelper.getAvatarFile(context, recipient.getId());
return avatarFile.exists() ? Uri.fromFile(avatarFile) : null;
return null;
}
@Override
@ -72,12 +71,6 @@ public class ProfileContactPhoto implements ContactPhoto {
return 0;
}
File avatarFile = AvatarHelper.getAvatarFile(ApplicationDependencies.getApplication(), recipient.getId());
if (avatarFile.exists()) {
return avatarFile.lastModified();
} else {
return 0;
}
return AvatarHelper.getLastModified(ApplicationDependencies.getApplication(), recipient.getId());
}
}

View File

@ -54,4 +54,7 @@ public class ModernEncryptingPartOutputStream {
}
}
public static long getPlaintextLength(long cipherTextLength) {
return cipherTextLength - 32;
}
}

View File

@ -42,7 +42,6 @@ public class GroupDatabase extends Database {
static final String RECIPIENT_ID = "recipient_id";
private static final String TITLE = "title";
private static final String MEMBERS = "members";
private static final String AVATAR = "avatar";
private static final String AVATAR_ID = "avatar_id";
private static final String AVATAR_KEY = "avatar_key";
private static final String AVATAR_CONTENT_TYPE = "avatar_content_type";
@ -59,7 +58,6 @@ public class GroupDatabase extends Database {
RECIPIENT_ID + " INTEGER, " +
TITLE + " TEXT, " +
MEMBERS + " TEXT, " +
AVATAR + " BLOB, " +
AVATAR_ID + " INTEGER, " +
AVATAR_KEY + " BLOB, " +
AVATAR_CONTENT_TYPE + " TEXT, " +
@ -75,7 +73,7 @@ public class GroupDatabase extends Database {
};
private static final String[] GROUP_PROJECTION = {
GROUP_ID, RECIPIENT_ID, TITLE, MEMBERS, AVATAR, AVATAR_ID, AVATAR_KEY, AVATAR_CONTENT_TYPE, AVATAR_RELAY, AVATAR_DIGEST,
GROUP_ID, RECIPIENT_ID, TITLE, MEMBERS, AVATAR_ID, AVATAR_KEY, AVATAR_CONTENT_TYPE, AVATAR_RELAY, AVATAR_DIGEST,
TIMESTAMP, ACTIVE, MMS
};
@ -120,7 +118,7 @@ public class GroupDatabase extends Database {
return true;
}
boolean noMetadata = group.get().getAvatar() == null && TextUtils.isEmpty(group.get().getTitle());
boolean noMetadata = !group.get().hasAvatar() && TextUtils.isEmpty(group.get().getTitle());
boolean noMembers = group.get().getMembers().isEmpty() || (group.get().getMembers().size() == 1 && group.get().getMembers().contains(Recipient.self().getId()));
return noMetadata && noMembers;
@ -228,6 +226,8 @@ public class GroupDatabase extends Database {
contentValues.put(AVATAR_KEY, avatar.getKey());
contentValues.put(AVATAR_CONTENT_TYPE, avatar.getContentType());
contentValues.put(AVATAR_DIGEST, avatar.getDigest().orNull());
} else {
contentValues.put(AVATAR_ID, 0);
}
contentValues.put(AVATAR_RELAY, relay);
@ -252,6 +252,8 @@ public class GroupDatabase extends Database {
contentValues.put(AVATAR_CONTENT_TYPE, avatar.getContentType());
contentValues.put(AVATAR_KEY, avatar.getKey());
contentValues.put(AVATAR_DIGEST, avatar.getDigest().orNull());
} else {
contentValues.put(AVATAR_ID, 0);
}
databaseHelper.getWritableDatabase().update(TABLE_NAME, contentValues,
@ -274,20 +276,12 @@ public class GroupDatabase extends Database {
Recipient.live(groupRecipient).refresh();
}
public void updateAvatar(@NonNull GroupId groupId, @Nullable Bitmap avatar) {
updateAvatar(groupId, BitmapUtil.toByteArray(avatar));
}
public void updateAvatar(@NonNull GroupId groupId, @Nullable byte[] avatar) {
long avatarId;
if (avatar != null) avatarId = Math.abs(new SecureRandom().nextLong());
else avatarId = 0;
ContentValues contentValues = new ContentValues(2);
contentValues.put(AVATAR, avatar);
contentValues.put(AVATAR_ID, avatarId);
/**
* Used to bust the Glide cache when an avatar changes.
*/
public void onAvatarUpdated(@NonNull GroupId groupId, boolean hasAvatar) {
ContentValues contentValues = new ContentValues(1);
contentValues.put(AVATAR_ID, hasAvatar ? Math.abs(new SecureRandom().nextLong()) : 0);
databaseHelper.getWritableDatabase().update(TABLE_NAME, contentValues, GROUP_ID + " = ?",
new String[] {groupId.toString()});
@ -388,7 +382,6 @@ public class GroupDatabase extends Database {
RecipientId.from(cursor.getLong(cursor.getColumnIndexOrThrow(RECIPIENT_ID))),
cursor.getString(cursor.getColumnIndexOrThrow(TITLE)),
cursor.getString(cursor.getColumnIndexOrThrow(MEMBERS)),
cursor.getBlob(cursor.getColumnIndexOrThrow(AVATAR)),
cursor.getLong(cursor.getColumnIndexOrThrow(AVATAR_ID)),
cursor.getBlob(cursor.getColumnIndexOrThrow(AVATAR_KEY)),
cursor.getString(cursor.getColumnIndexOrThrow(AVATAR_CONTENT_TYPE)),
@ -411,7 +404,6 @@ public class GroupDatabase extends Database {
private final RecipientId recipientId;
private final String title;
private final List<RecipientId> members;
private final byte[] avatar;
private final long avatarId;
private final byte[] avatarKey;
private final byte[] avatarDigest;
@ -420,14 +412,13 @@ public class GroupDatabase extends Database {
private final boolean active;
private final boolean mms;
public GroupRecord(@NonNull GroupId id, @NonNull RecipientId recipientId, String title, String members, byte[] avatar,
public GroupRecord(@NonNull GroupId id, @NonNull RecipientId recipientId, String title, String members,
long avatarId, byte[] avatarKey, String avatarContentType,
String relay, boolean active, byte[] avatarDigest, boolean mms)
{
this.id = id;
this.recipientId = recipientId;
this.title = title;
this.avatar = avatar;
this.avatarId = avatarId;
this.avatarKey = avatarKey;
this.avatarDigest = avatarDigest;
@ -456,8 +447,8 @@ public class GroupDatabase extends Database {
return members;
}
public byte[] getAvatar() {
return avatar;
public boolean hasAvatar() {
return avatarId != 0;
}
public long getAvatarId() {

View File

@ -21,7 +21,9 @@ import net.sqlcipher.database.SQLiteDatabase;
import net.sqlcipher.database.SQLiteDatabaseHook;
import net.sqlcipher.database.SQLiteOpenHelper;
import org.thoughtcrime.securesms.profiles.AvatarHelper;
import org.thoughtcrime.securesms.profiles.ProfileName;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.storage.StorageSyncHelper;
import org.thoughtcrime.securesms.crypto.DatabaseSecret;
import org.thoughtcrime.securesms.crypto.MasterSecret;
@ -51,14 +53,17 @@ import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.notifications.NotificationChannels;
import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter;
import org.thoughtcrime.securesms.service.KeyCachingService;
import org.thoughtcrime.securesms.storage.StorageSyncHelper;
import org.thoughtcrime.securesms.util.Base64;
import org.thoughtcrime.securesms.util.FileUtils;
import org.thoughtcrime.securesms.util.ServiceUtil;
import org.thoughtcrime.securesms.util.SqlUtil;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.Util;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.util.List;
public class SQLCipherOpenHelper extends SQLiteOpenHelper {
@ -118,8 +123,9 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
private static final int GROUPS_V2_RECIPIENT_CAPABILITY = 51;
private static final int TRANSFER_FILE_CLEANUP = 52;
private static final int PROFILE_DATA_MIGRATION = 53;
private static final int AVATAR_LOCATION_MIGRATION = 54;
private static final int DATABASE_VERSION = 53;
private static final int DATABASE_VERSION = 54;
private static final String DATABASE_NAME = "signal.db";
private final Context context;
@ -802,6 +808,49 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
}
}
if (oldVersion < AVATAR_LOCATION_MIGRATION) {
File oldAvatarDirectory = new File(context.getFilesDir(), "avatars");
File[] results = oldAvatarDirectory.listFiles();
if (results != null) {
Log.i(TAG, "Preparing to migrate " + results.length + " avatars.");
for (File file : results) {
if (Util.isLong(file.getName())) {
try {
AvatarHelper.setAvatar(context, RecipientId.from(file.getName()), new FileInputStream(file));
} catch(IOException e) {
Log.w(TAG, "Failed to copy file " + file.getName() + "! Skipping.");
}
} else {
Log.w(TAG, "Invalid avatar name '" + file.getName() + "'! Skipping.");
}
}
} else {
Log.w(TAG, "No avatar directory files found.");
}
if (!FileUtils.deleteDirectory(oldAvatarDirectory)) {
Log.w(TAG, "Failed to delete avatar directory.");
}
try (Cursor cursor = db.rawQuery("SELECT recipient_id, avatar FROM groups", null)) {
while (cursor != null && cursor.moveToNext()) {
RecipientId recipientId = RecipientId.from(cursor.getLong(cursor.getColumnIndexOrThrow("recipient_id")));
byte[] avatar = cursor.getBlob(cursor.getColumnIndexOrThrow("avatar"));
try {
AvatarHelper.setAvatar(context, recipientId, avatar != null ? new ByteArrayInputStream(avatar) : null);
} catch (IOException e) {
Log.w(TAG, "Failed to copy avatar for " + recipientId + "! Skipping.", e);
}
}
}
db.execSQL("UPDATE groups SET avatar_id = 0 WHERE avatar IS NULL");
db.execSQL("UPDATE groups SET avatar = NULL");
}
db.setTransactionSuccessful();
} finally {
db.endTransaction();

View File

@ -19,7 +19,9 @@ import org.thoughtcrime.securesms.database.ThreadDatabase;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.groups.GroupManager.GroupActionResult;
import org.thoughtcrime.securesms.jobs.LeaveGroupJob;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.mms.OutgoingGroupMediaMessage;
import org.thoughtcrime.securesms.profiles.AvatarHelper;
import org.thoughtcrime.securesms.providers.BlobProvider;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
@ -32,6 +34,8 @@ import org.whispersystems.libsignal.util.guava.Optional;
import org.whispersystems.signalservice.api.util.InvalidNumberException;
import org.whispersystems.signalservice.internal.push.SignalServiceProtos.GroupContext;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.util.Collections;
import java.util.LinkedList;
import java.util.List;
@ -39,6 +43,8 @@ import java.util.Set;
final class V1GroupManager {
private static final String TAG = Log.tag(V1GroupManager.class);
static @NonNull GroupActionResult createGroup(@NonNull Context context,
@NonNull Set<RecipientId> memberIds,
@Nullable Bitmap avatar,
@ -55,7 +61,12 @@ final class V1GroupManager {
groupDatabase.create(groupId, name, new LinkedList<>(memberIds), null, null);
if (!mms) {
groupDatabase.updateAvatar(groupId, avatarBytes);
try {
AvatarHelper.setAvatar(context, groupRecipientId, avatarBytes != null ? new ByteArrayInputStream(avatarBytes) : null);
} catch (IOException e) {
Log.w(TAG, "Failed to save avatar!", e);
}
groupDatabase.onAvatarUpdated(groupId, avatarBytes != null);
DatabaseFactory.getRecipientDatabase(context).setProfileSharing(groupRecipient.getId(), true);
return sendGroupUpdate(context, groupId, memberIds, name, avatarBytes);
} else {
@ -71,18 +82,23 @@ final class V1GroupManager {
@Nullable String name)
throws InvalidNumberException
{
final GroupDatabase groupDatabase = DatabaseFactory.getGroupDatabase(context);
final byte[] avatarBytes = BitmapUtil.toByteArray(avatar);
final GroupDatabase groupDatabase = DatabaseFactory.getGroupDatabase(context);
final RecipientId groupRecipientId = DatabaseFactory.getRecipientDatabase(context).getOrInsertFromGroupId(groupId);
final byte[] avatarBytes = BitmapUtil.toByteArray(avatar);
memberAddresses.add(Recipient.self().getId());
groupDatabase.updateMembers(groupId, new LinkedList<>(memberAddresses));
groupDatabase.updateTitle(groupId, name);
groupDatabase.updateAvatar(groupId, avatarBytes);
groupDatabase.onAvatarUpdated(groupId, avatarBytes != null);
if (!groupId.isMmsGroup()) {
try {
AvatarHelper.setAvatar(context, groupRecipientId, avatarBytes != null ? new ByteArrayInputStream(avatarBytes) : null);
} catch (IOException e) {
Log.w(TAG, "Failed to save avatar!", e);
}
return sendGroupUpdate(context, groupId, memberAddresses, name, avatarBytes);
} else {
RecipientId groupRecipientId = DatabaseFactory.getRecipientDatabase(context).getOrInsertFromGroupId(groupId);
Recipient groupRecipient = Recipient.resolved(groupRecipientId);
long threadId = DatabaseFactory.getThreadDatabase(context).getThreadIdFor(groupRecipient);
return new GroupActionResult(groupRecipient, threadId);

View File

@ -14,6 +14,7 @@ import org.thoughtcrime.securesms.jobmanager.Job;
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.mms.AttachmentStreamUriLoader.AttachmentModel;
import org.thoughtcrime.securesms.profiles.AvatarHelper;
import org.thoughtcrime.securesms.util.BitmapDecodingException;
import org.thoughtcrime.securesms.util.BitmapUtil;
import org.thoughtcrime.securesms.util.Hex;
@ -90,13 +91,14 @@ public class AvatarDownloadJob extends BaseJob {
SignalServiceMessageReceiver receiver = ApplicationDependencies.getSignalServiceMessageReceiver();
SignalServiceAttachmentPointer pointer = new SignalServiceAttachmentPointer(avatarId, contentType, key, Optional.of(0), Optional.absent(), 0, 0, digest, fileName, false, Optional.absent(), Optional.absent());
InputStream inputStream = receiver.retrieveAttachment(pointer, attachment, MAX_AVATAR_SIZE);
Bitmap avatar = BitmapUtil.createScaledBitmap(context, new AttachmentModel(attachment, key, 0, digest), 500, 500);
InputStream inputStream = receiver.retrieveAttachment(pointer, attachment, AvatarHelper.AVATAR_DOWNLOAD_FAILSAFE_MAX_SIZE);
AvatarHelper.setAvatar(context, record.get().getRecipientId(), inputStream);
DatabaseFactory.getGroupDatabase(context).onAvatarUpdated(groupId, true);
database.updateAvatar(groupId, avatar);
inputStream.close();
}
} catch (BitmapDecodingException | NonSuccessfulResponseCodeException | InvalidMessageException e) {
} catch (NonSuccessfulResponseCodeException | InvalidMessageException e) {
Log.w(TAG, e);
} finally {
if (attachment != null)

View File

@ -11,6 +11,7 @@ import org.thoughtcrime.securesms.jobmanager.Data;
import org.thoughtcrime.securesms.jobmanager.Job;
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.profiles.AvatarHelper;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.recipients.RecipientUtil;
@ -100,7 +101,7 @@ public class MultiDeviceGroupUpdateJob extends BaseJob {
out.write(new DeviceGroup(record.getId().getDecodedId(),
Optional.fromNullable(record.getTitle()),
members,
getAvatar(record.getAvatar()),
getAvatar(record.getRecipientId()),
record.isActive(),
expirationTimer,
Optional.of(recipient.getColor().serialize()),
@ -151,13 +152,13 @@ public class MultiDeviceGroupUpdateJob extends BaseJob {
}
private Optional<SignalServiceAttachmentStream> getAvatar(@Nullable byte[] avatar) {
if (avatar == null) return Optional.absent();
private Optional<SignalServiceAttachmentStream> getAvatar(@NonNull RecipientId recipientId) throws IOException {
if (!AvatarHelper.hasAvatar(context, recipientId)) return Optional.absent();
return Optional.of(SignalServiceAttachment.newStreamBuilder()
.withStream(new ByteArrayInputStream(avatar))
.withStream(AvatarHelper.getAvatar(context, recipientId))
.withContentType("image/*")
.withLength(avatar.length)
.withLength(AvatarHelper.getAvatarLength(context, recipientId))
.build());
}

View File

@ -13,6 +13,7 @@ import org.thoughtcrime.securesms.jobmanager.Data;
import org.thoughtcrime.securesms.jobmanager.Job;
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.profiles.AvatarHelper;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.recipients.RecipientUtil;
@ -80,16 +81,16 @@ public class PushGroupUpdateJob extends BaseJob {
Optional<GroupRecord> record = groupDatabase.getGroup(groupId);
SignalServiceAttachment avatar = null;
if (record == null) {
if (record == null || !record.isPresent()) {
Log.w(TAG, "No information for group record info request: " + groupId.toString());
return;
}
if (record.get().getAvatar() != null) {
if (AvatarHelper.hasAvatar(context, record.get().getRecipientId())) {
avatar = SignalServiceAttachmentStream.newStreamBuilder()
.withContentType("image/jpeg")
.withStream(new ByteArrayInputStream(record.get().getAvatar()))
.withLength(record.get().getAvatar().length)
.withStream(AvatarHelper.getAvatar(context, record.get().getRecipientId()))
.withLength(AvatarHelper.getAvatarLength(context, record.get().getRecipientId()))
.build();
}

View File

@ -97,17 +97,10 @@ public class RetrieveProfileAvatarJob extends BaseJob {
File downloadDestination = File.createTempFile("avatar", "jpg", context.getCacheDir());
try {
SignalServiceMessageReceiver receiver = ApplicationDependencies.getSignalServiceMessageReceiver();
InputStream avatarStream = receiver.retrieveProfileAvatar(profileAvatar, downloadDestination, profileKey, MAX_PROFILE_SIZE_BYTES);
File decryptDestination = File.createTempFile("avatar", "jpg", context.getCacheDir());
SignalServiceMessageReceiver receiver = ApplicationDependencies.getSignalServiceMessageReceiver();
InputStream avatarStream = receiver.retrieveProfileAvatar(profileAvatar, downloadDestination, profileKey, AvatarHelper.AVATAR_DOWNLOAD_FAILSAFE_MAX_SIZE);
try {
Util.copy(avatarStream, new FileOutputStream(decryptDestination));
} catch (AssertionError e) {
throw new IOException("Failed to copy stream. Likely a Conscrypt issue.", e);
}
decryptDestination.renameTo(AvatarHelper.getAvatarFile(context, recipient.getId()));
AvatarHelper.setAvatar(context, recipient.getId(), avatarStream);
} catch (PushNetworkException e) {
if (e.getCause() instanceof NonSuccessfulResponseCodeException) {
Log.w(TAG, "Removing profile avatar (no image available) for: " + recipient.getId().serialize());

View File

@ -17,6 +17,7 @@ import androidx.lifecycle.ViewModelProviders;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.TransportOptions;
import org.thoughtcrime.securesms.imageeditor.model.EditorModel;
import org.thoughtcrime.securesms.profiles.AvatarHelper;
import org.thoughtcrime.securesms.providers.BlobProvider;
import org.thoughtcrime.securesms.scribbles.ImageEditorFragment;
import org.thoughtcrime.securesms.util.MediaUtil;
@ -27,7 +28,7 @@ import java.util.Collections;
public class AvatarSelectionActivity extends AppCompatActivity implements CameraFragment.Controller, ImageEditorFragment.Controller, MediaPickerFolderFragment.Controller, MediaPickerItemFragment.Controller {
private static final Point AVATAR_DIMENSIONS = new Point(1024, 1024);
private static final Point AVATAR_DIMENSIONS = new Point(AvatarHelper.AVATAR_DIMENSIONS, AvatarHelper.AVATAR_DIMENSIONS);
private static final String IMAGE_CAPTURE = "IMAGE_CAPTURE";
private static final String IMAGE_EDITOR = "IMAGE_EDITOR";

View File

@ -11,6 +11,7 @@ import org.thoughtcrime.securesms.profiles.AvatarHelper;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.util.Util;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
@ -64,7 +65,7 @@ public class AvatarMigrationJob extends MigrationJob {
Recipient recipient = Recipient.external(context, file.getName());
byte[] data = Util.readFully(new FileInputStream(file));
AvatarHelper.setAvatar(context, recipient.getId(), data);
AvatarHelper.setAvatar(context, recipient.getId(), new ByteArrayInputStream(data));
} else {
Log.w(TAG, "Invalid file name! Can't migrate this file. It'll just get deleted.");
}

View File

@ -6,81 +6,191 @@ import android.content.Context;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.annimon.stream.Stream;
import org.thoughtcrime.securesms.crypto.AttachmentSecret;
import org.thoughtcrime.securesms.crypto.AttachmentSecretProvider;
import org.thoughtcrime.securesms.crypto.ModernDecryptingPartInputStream;
import org.thoughtcrime.securesms.crypto.ModernEncryptingPartOutputStream;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.util.ByteUnit;
import org.thoughtcrime.securesms.util.MediaUtil;
import org.thoughtcrime.securesms.util.Util;
import org.whispersystems.signalservice.api.util.StreamDetails;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.LinkedList;
import java.util.List;
import java.io.OutputStream;
import java.util.Collections;
import java.util.Iterator;
public class AvatarHelper {
private static final String TAG = Log.tag(AvatarHelper.class);
public static int AVATAR_DIMENSIONS = 1024;
public static long AVATAR_DOWNLOAD_FAILSAFE_MAX_SIZE = ByteUnit.MEGABYTES.toBytes(10);
private static final String AVATAR_DIRECTORY = "avatars";
public static InputStream getInputStreamFor(@NonNull Context context, @NonNull RecipientId recipientId)
throws IOException
{
return new FileInputStream(getAvatarFile(context, recipientId));
}
public static List<File> getAvatarFiles(@NonNull Context context) {
File avatarDirectory = new File(context.getFilesDir(), AVATAR_DIRECTORY);
/**
* Retrieves an iterable set of avatars. Only intended to be used during backup.
*/
public static Iterable<Avatar> getAvatars(@NonNull Context context) {
File avatarDirectory = context.getDir(AVATAR_DIRECTORY, Context.MODE_PRIVATE);
File[] results = avatarDirectory.listFiles();
if (results == null) return new LinkedList<>();
else return Stream.of(results).toList();
if (results == null) {
return Collections.emptyList();
}
return () -> {
return new Iterator<Avatar>() {
int i = 0;
@Override
public boolean hasNext() {
return i < results.length;
}
@Override
public Avatar next() {
File file = results[i];
try {
return new Avatar(getAvatar(context, RecipientId.from(file.getName())),
file.getName(),
ModernEncryptingPartOutputStream.getPlaintextLength(file.length()));
} catch (IOException e) {
return null;
} finally {
i++;
}
}
};
};
}
/**
* Deletes and avatar.
*/
public static void delete(@NonNull Context context, @NonNull RecipientId recipientId) {
getAvatarFile(context, recipientId).delete();
}
public static @NonNull File getAvatarFile(@NonNull Context context, @NonNull RecipientId recipientId) {
File avatarDirectory = new File(context.getFilesDir(), AVATAR_DIRECTORY);
avatarDirectory.mkdirs();
return new File(avatarDirectory, new File(recipientId.serialize()).getName());
/**
* Whether or not an avatar is present for the given recipient.
*/
public static boolean hasAvatar(@NonNull Context context, @NonNull RecipientId recipientId) {
return getAvatarFile(context, recipientId).exists();
}
public static void setAvatar(@NonNull Context context, @NonNull RecipientId recipientId, @Nullable byte[] data)
throws IOException
/**
* Retrieves a stream for an avatar. If there is no avatar, the stream will likely throw an
* IOException. It is recommended to call {@link #hasAvatar(Context, RecipientId)} first.
*/
public static @NonNull InputStream getAvatar(@NonNull Context context, @NonNull RecipientId recipientId) throws IOException {
AttachmentSecret attachmentSecret = AttachmentSecretProvider.getInstance(context).getOrCreateAttachmentSecret();
File avatarFile = getAvatarFile(context, recipientId);
return ModernDecryptingPartInputStream.createFor(attachmentSecret, avatarFile, 0);
}
/**
* Returns the size of the avatar on disk.
*/
public static long getAvatarLength(@NonNull Context context, @NonNull RecipientId recipientId) {
return ModernEncryptingPartOutputStream.getPlaintextLength(getAvatarFile(context, recipientId).length());
}
/**
* Saves the contents of the input stream as the avatar for the specified recipient. If you pass
* in null for the stream, the avatar will be deleted.
*/
public static void setAvatar(@NonNull Context context, @NonNull RecipientId recipientId, @Nullable InputStream inputStream)
throws IOException
{
if (data == null) {
if (inputStream == null) {
delete(context, recipientId);
} else {
FileOutputStream out = new FileOutputStream(getAvatarFile(context, recipientId));
out.write(data);
out.close();
return;
}
OutputStream outputStream = null;
try {
outputStream = getOutputStream(context, recipientId);
Util.copy(inputStream, outputStream);
} finally {
Util.close(outputStream);
}
}
public static @NonNull StreamDetails avatarStream(@NonNull byte[] data) {
return new StreamDetails(new ByteArrayInputStream(data), MediaUtil.IMAGE_JPEG, data.length);
/**
* Retrieves an output stream you can write to that will be saved as the avatar for the specified
* recipient. Only intended to be used for backup. Otherwise, use {@link #setAvatar(Context, RecipientId, InputStream)}.
*/
public static @NonNull OutputStream getOutputStream(@NonNull Context context, @NonNull RecipientId recipientId) throws IOException {
AttachmentSecret attachmentSecret = AttachmentSecretProvider.getInstance(context).getOrCreateAttachmentSecret();
File targetFile = getAvatarFile(context, recipientId);
return ModernEncryptingPartOutputStream.createFor(attachmentSecret, targetFile, true).second;
}
public static @Nullable StreamDetails getSelfProfileAvatarStream(@NonNull Context context) {
File avatarFile = getAvatarFile(context, Recipient.self().getId());
/**
* Returns the timestamp of when the avatar was last modified, or zero if the avatar doesn't exist.
*/
public static long getLastModified(@NonNull Context context, @NonNull RecipientId recipientId) {
File file = getAvatarFile(context, recipientId);
if (avatarFile.exists() && avatarFile.length() > 0) {
try {
FileInputStream stream = new FileInputStream(avatarFile);
return new StreamDetails(stream, MediaUtil.IMAGE_JPEG, avatarFile.length());
} catch (FileNotFoundException e) {
throw new AssertionError(e);
}
if (file.exists()) {
return file.lastModified();
} else {
return 0;
}
}
/**
* Returns a {@link StreamDetails} for the local user's own avatar, or null if one does not exist.
*/
public static @Nullable StreamDetails getSelfProfileAvatarStream(@NonNull Context context) {
RecipientId selfId = Recipient.self().getId();
if (!hasAvatar(context, selfId)) {
return null;
}
try {
InputStream stream = getAvatar(context, selfId);
return new StreamDetails(stream, MediaUtil.IMAGE_JPEG, getAvatarLength(context, selfId));
} catch (IOException e) {
Log.w(TAG, "Failed to read own avatar!", e);
return null;
}
}
private static @NonNull File getAvatarFile(@NonNull Context context, @NonNull RecipientId recipientId) {
File directory = context.getDir(AVATAR_DIRECTORY, Context.MODE_PRIVATE);
return new File(directory, recipientId.serialize());
}
public static class Avatar {
private final InputStream inputStream;
private final String filename;
private final long length;
public Avatar(@NonNull InputStream inputStream, @NonNull String filename, long length) {
this.inputStream = inputStream;
this.filename = filename;
this.length = length;
}
public @NonNull InputStream getInputStream() {
return inputStream;
}
public @NonNull String getFilename() {
return filename;
}
public long getLength() {
return length;
}
}
}

View File

@ -30,6 +30,7 @@ import org.thoughtcrime.securesms.util.concurrent.SimpleTask;
import org.whispersystems.libsignal.util.guava.Optional;
import org.whispersystems.signalservice.api.profiles.SignalServiceProfile;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.security.SecureRandom;
import java.util.Arrays;
@ -76,10 +77,10 @@ class EditProfileRepository {
void getCurrentAvatar(@NonNull Consumer<byte[]> avatarConsumer) {
RecipientId selfId = Recipient.self().getId();
if (AvatarHelper.getAvatarFile(context, selfId).exists() && AvatarHelper.getAvatarFile(context, selfId).length() > 0) {
if (AvatarHelper.hasAvatar(context, selfId)) {
SimpleTask.run(() -> {
try {
return Util.readFully(AvatarHelper.getInputStreamFor(context, selfId));
return Util.readFully(AvatarHelper.getAvatar(context, selfId));
} catch (IOException e) {
Log.w(TAG, e);
return null;
@ -106,7 +107,7 @@ class EditProfileRepository {
DatabaseFactory.getRecipientDatabase(context).setProfileName(Recipient.self().getId(), profileName);
try {
AvatarHelper.setAvatar(context, Recipient.self().getId(), avatar);
AvatarHelper.setAvatar(context, Recipient.self().getId(), avatar != null ? new ByteArrayInputStream(avatar) : null);
} catch (IOException e) {
return UploadResult.ERROR_FILE_IO;
}

View File

@ -201,7 +201,7 @@ public final class LiveRecipient {
title = unnamedGroupName;
}
if (groupRecord.get().getAvatar() != null && groupRecord.get().getAvatar().length > 0) {
if (groupRecord.get().hasAvatar()) {
avatarId = Optional.of(groupRecord.get().getAvatarId());
}

View File

@ -49,13 +49,13 @@ public final class FileUtils {
}
}
public static void deleteDirectory(@Nullable File directory) {
public static boolean deleteDirectory(@Nullable File directory) {
if (directory == null || !directory.exists() || !directory.isDirectory()) {
return;
return false;
}
deleteDirectoryContents(directory);
directory.delete();
return directory.delete();
}
}

View File

@ -224,7 +224,9 @@ public class Util {
}
}
public static void close(Closeable closeable) {
public static void close(@Nullable Closeable closeable) {
if (closeable == null) return;
try {
closeable.close();
} catch (IOException e) {
@ -607,4 +609,12 @@ public class Util {
return concat;
}
public static boolean isLong(String value) {
try {
Long.parseLong(value);
return true;
} catch (NumberFormatException e) {
return false;
}
}
}

View File

@ -113,7 +113,7 @@ public class SignalServiceMessageReceiver {
* @throws IOException
* @throws InvalidMessageException
*/
public InputStream retrieveAttachment(SignalServiceAttachmentPointer pointer, File destination, int maxSizeBytes)
public InputStream retrieveAttachment(SignalServiceAttachmentPointer pointer, File destination, long maxSizeBytes)
throws IOException, InvalidMessageException
{
return retrieveAttachment(pointer, destination, maxSizeBytes, null);
@ -142,7 +142,7 @@ public class SignalServiceMessageReceiver {
return socket.retrieveProfileByUsername(username, unidentifiedAccess);
}
public InputStream retrieveProfileAvatar(String path, File destination, ProfileKey profileKey, int maxSizeBytes)
public InputStream retrieveProfileAvatar(String path, File destination, ProfileKey profileKey, long maxSizeBytes)
throws IOException
{
socket.retrieveProfileAvatar(path, destination, maxSizeBytes);
@ -162,7 +162,7 @@ public class SignalServiceMessageReceiver {
* @throws IOException
* @throws InvalidMessageException
*/
public InputStream retrieveAttachment(SignalServiceAttachmentPointer pointer, File destination, int maxSizeBytes, ProgressListener listener)
public InputStream retrieveAttachment(SignalServiceAttachmentPointer pointer, File destination, long maxSizeBytes, ProgressListener listener)
throws IOException, InvalidMessageException
{
if (!pointer.getDigest().isPresent()) throw new InvalidMessageException("No attachment digest!");

View File

@ -498,7 +498,7 @@ public class PushServiceSocket {
makeServiceRequest(SIGNED_PREKEY_PATH, "PUT", JsonUtil.toJson(signedPreKeyEntity));
}
public void retrieveAttachment(long attachmentId, File destination, int maxSizeBytes, ProgressListener listener)
public void retrieveAttachment(long attachmentId, File destination, long maxSizeBytes, ProgressListener listener)
throws NonSuccessfulResponseCodeException, PushNetworkException
{
downloadFromCdn(destination, String.format(Locale.US, ATTACHMENT_DOWNLOAD_PATH, attachmentId), maxSizeBytes, listener);
@ -590,7 +590,7 @@ public class PushServiceSocket {
}
}
public void retrieveProfileAvatar(String path, File destination, int maxSizeBytes)
public void retrieveProfileAvatar(String path, File destination, long maxSizeBytes)
throws NonSuccessfulResponseCodeException, PushNetworkException
{
downloadFromCdn(destination, path, maxSizeBytes, null);
@ -874,7 +874,7 @@ public class PushServiceSocket {
return new Pair<>(id, digest);
}
private void downloadFromCdn(File destination, String path, int maxSizeBytes, ProgressListener listener)
private void downloadFromCdn(File destination, String path, long maxSizeBytes, ProgressListener listener)
throws PushNetworkException, NonSuccessfulResponseCodeException
{
try (FileOutputStream outputStream = new FileOutputStream(destination, true)) {
@ -884,7 +884,7 @@ public class PushServiceSocket {
}
}
private void downloadFromCdn(OutputStream outputStream, long offset, String path, int maxSizeBytes, ProgressListener listener)
private void downloadFromCdn(OutputStream outputStream, long offset, String path, long maxSizeBytes, ProgressListener listener)
throws PushNetworkException, NonSuccessfulResponseCodeException
{
ConnectionHolder connectionHolder = getRandom(cdnClients, random);