diff --git a/build.gradle b/build.gradle
index 4de6bf686a763d81bb5992abc78577e23e1a5838..7772b7f795cae82db5ed12648e51ad431ce41e25 100644
--- a/build.gradle
+++ b/build.gradle
@@ -6,7 +6,7 @@ buildscript {
mavenCentral()
}
dependencies {
- classpath 'com.android.tools.build:gradle:8.9.1'
+ classpath 'com.android.tools.build:gradle:8.9.3'
classpath "com.diffplug.spotless:spotless-plugin-gradle:7.0.2"
}
}
@@ -51,14 +51,15 @@ dependencies {
implementation 'androidx.emoji2:emoji2:1.5.0'
freeImplementation 'androidx.emoji2:emoji2-bundled:1.5.0'
implementation 'androidx.emoji2:emoji2-emojipicker:1.5.0'
- implementation 'androidx.exifinterface:exifinterface:1.4.0'
+ implementation 'androidx.exifinterface:exifinterface:1.4.1'
+ implementation 'androidx.heifwriter:heifwriter:1.1.0-beta01'
implementation 'androidx.preference:preference:1.2.1'
implementation 'androidx.sharetarget:sharetarget:1.2.0'
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
implementation 'androidx.viewpager:viewpager:1.1.0'
- implementation 'androidx.work:work-runtime:2.10.0'
+ implementation 'androidx.work:work-runtime:2.10.1'
implementation 'com.github.open-keychain.open-keychain:openpgp-api:v5.7.1'
- implementation 'com.google.android.material:material:1.13.0-alpha12'
+ implementation 'com.google.android.material:material:1.13.0-alpha13'
implementation 'com.google.guava:guava:33.4.6-android'
implementation 'com.google.zxing:core:3.5.3'
implementation 'com.leinardi.android:speed-dial:3.3.0'
diff --git a/src/main/AndroidManifest.xml b/src/main/AndroidManifest.xml
index 9ad0b4b364d3197a1399dc7334a9a468d5e6014a..cee52eaf8d1b693dc43468f3233eaee5b46e581c 100644
--- a/src/main/AndroidManifest.xml
+++ b/src/main/AndroidManifest.xml
@@ -60,6 +60,8 @@
android:name="android.hardware.microphone"
android:required="false" />
+
+
diff --git a/src/main/java/eu/siacs/conversations/Config.java b/src/main/java/eu/siacs/conversations/Config.java
index 571c02c348de4da5bcac6dea2c1e0a52323f47a0..388abd1e36ed7f184254ce92f8b17e4ebe7f1a92 100644
--- a/src/main/java/eu/siacs/conversations/Config.java
+++ b/src/main/java/eu/siacs/conversations/Config.java
@@ -79,6 +79,7 @@ public final class Config {
// and webp
public static final int AVATAR_SIZE = 192;
public static final Bitmap.CompressFormat AVATAR_FORMAT = Bitmap.CompressFormat.JPEG;
+ public static final int AVATAR_FULL_SIZE = 1024;
public static final int AVATAR_CHAR_LIMIT = 9400;
public static final int IMAGE_SIZE = 1920;
@@ -131,6 +132,7 @@ public final class Config {
false; // require a/v calls to be verified with OMEMO
public static final boolean JINGLE_MESSAGE_INIT_STRICT_OFFLINE_CHECK = false;
public static final boolean JINGLE_MESSAGE_INIT_STRICT_DEVICE_TIMEOUT = false;
+ // TODO extend this to 12s
public static final long DEVICE_DISCOVERY_TIMEOUT = 6000; // in milliseconds
public static final boolean ONLY_INTERNAL_STORAGE =
diff --git a/src/main/java/eu/siacs/conversations/http/HttpConnectionManager.java b/src/main/java/eu/siacs/conversations/http/HttpConnectionManager.java
index 1af4ba48bd2fe4e17a889e563c9868c86294464d..533cf0c11915e290463fd87fd7352dd292857fcf 100644
--- a/src/main/java/eu/siacs/conversations/http/HttpConnectionManager.java
+++ b/src/main/java/eu/siacs/conversations/http/HttpConnectionManager.java
@@ -136,7 +136,7 @@ public class HttpConnectionManager extends AbstractConnectionManager {
return buildHttpClient(url, account, 30, interactive);
}
- OkHttpClient buildHttpClient(
+ public OkHttpClient buildHttpClient(
final HttpUrl url, final Account account, int readTimeout, boolean interactive) {
final String slotHostname = url.host();
final boolean onionSlot = slotHostname.endsWith(".onion");
diff --git a/src/main/java/eu/siacs/conversations/persistance/FileBackend.java b/src/main/java/eu/siacs/conversations/persistance/FileBackend.java
index a261afb7fd6f63c99f7242132bf3ede67fbc73d8..e8a2cc2caf55072e098e8e01675f3c7df310e1d4 100644
--- a/src/main/java/eu/siacs/conversations/persistance/FileBackend.java
+++ b/src/main/java/eu/siacs/conversations/persistance/FileBackend.java
@@ -773,7 +773,7 @@ public class FileBackend {
throw new ImageCompressionException("Source file had alpha channel");
}
Bitmap scaledBitmap = resize(originalBitmap, Config.IMAGE_SIZE);
- final int rotation = getRotation(image);
+ final int rotation = getRotation(mXmppConnectionService, image);
scaledBitmap = rotate(scaledBitmap, rotation);
boolean targetSizeReached = false;
int quality = Config.IMAGE_QUALITY;
@@ -930,9 +930,8 @@ public class FileBackend {
}
}
- private int getRotation(final Uri image) {
- try (final InputStream is =
- mXmppConnectionService.getContentResolver().openInputStream(image)) {
+ private static int getRotation(final Context context, final Uri image) {
+ try (final InputStream is = context.getContentResolver().openInputStream(image)) {
return is == null ? 0 : getRotation(is);
} catch (final Exception e) {
return 0;
@@ -1123,7 +1122,6 @@ public class FileBackend {
}
}
- @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
private Bitmap cropCenterSquarePdf(final Uri uri, final int size) {
try {
ParcelFileDescriptor fileDescriptor =
@@ -1137,7 +1135,6 @@ public class FileBackend {
}
}
- @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
private Bitmap renderPdfDocument(
ParcelFileDescriptor fileDescriptor, int targetSize, boolean fit) throws IOException {
final PdfRenderer pdfRenderer = new PdfRenderer(fileDescriptor);
@@ -1173,7 +1170,8 @@ public class FileBackend {
return getUriForFile(mXmppConnectionService, file);
}
- public Avatar getPepAvatar(Uri image, int size, Bitmap.CompressFormat format) {
+ public Avatar getPepAvatar(
+ final Uri image, final int size, final Bitmap.CompressFormat format) {
final Avatar uncompressAvatar = getUncompressedAvatar(image);
if (uncompressAvatar != null
@@ -1261,6 +1259,7 @@ public class FileBackend {
}
}
+ // this was used by republishAvatarIfNeeded()
public Avatar getStoredPepAvatar(String hash) {
if (hash == null) {
return null;
@@ -1385,8 +1384,12 @@ public class FileBackend {
return new File(mXmppConnectionService.getFilesDir(), "/avatars/");
}
- private File getAvatarFile(String avatar) {
- return new File(mXmppConnectionService.getCacheDir(), "/avatars/" + avatar);
+ public File getAvatarFile(final String avatar) {
+ return getAvatarFile(mXmppConnectionService, avatar);
+ }
+
+ public static File getAvatarFile(Context context, final String avatar) {
+ return new File(context.getCacheDir(), "/avatars/" + avatar);
}
public Uri getAvatarUri(String avatar) {
@@ -1394,18 +1397,21 @@ public class FileBackend {
}
public Bitmap cropCenterSquare(final Uri image, final int size) {
+ return cropCenterSquare(mXmppConnectionService, image, size);
+ }
+
+ public static Bitmap cropCenterSquare(final Context context, final Uri image, final int size) {
if (image == null) {
return null;
}
final BitmapFactory.Options options = new BitmapFactory.Options();
try {
- options.inSampleSize = calcSampleSize(image, size);
+ options.inSampleSize = calcSampleSize(context, image, size);
} catch (final IOException | SecurityException e) {
Log.d(Config.LOGTAG, "unable to calculate sample size for " + image, e);
return null;
}
- try (final InputStream is =
- mXmppConnectionService.getContentResolver().openInputStream(image)) {
+ try (final InputStream is = context.getContentResolver().openInputStream(image)) {
if (is == null) {
return null;
}
@@ -1413,7 +1419,7 @@ public class FileBackend {
if (originalBitmap == null) {
return null;
} else {
- final var bitmap = rotate(originalBitmap, getRotation(image));
+ final var bitmap = rotate(originalBitmap, getRotation(context, image));
return cropCenterSquare(bitmap, size);
}
} catch (final SecurityException | IOException e) {
@@ -1465,7 +1471,7 @@ public class FileBackend {
}
}
- public Bitmap cropCenterSquare(Bitmap input, int size) {
+ public static Bitmap cropCenterSquare(Bitmap input, int size) {
int w = input.getWidth();
int h = input.getHeight();
@@ -1487,10 +1493,14 @@ public class FileBackend {
}
private int calcSampleSize(final Uri image, int size) throws IOException, SecurityException {
+ return calcSampleSize(mXmppConnectionService, image, size);
+ }
+
+ private static int calcSampleSize(final Context context, final Uri image, int size)
+ throws IOException, SecurityException {
final BitmapFactory.Options options = new BitmapFactory.Options();
options.inJustDecodeBounds = true;
- try (final InputStream inputStream =
- mXmppConnectionService.getContentResolver().openInputStream(image)) {
+ try (final InputStream inputStream = context.getContentResolver().openInputStream(image)) {
BitmapFactory.decodeStream(inputStream, null, options);
return calcSampleSize(options, size);
}
diff --git a/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java b/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java
index 94a19b172791f1c1e259f3c2b9823b1f520ce5bd..288afe6809fdb953f567aaa56e48a177b1aa22ff 100644
--- a/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java
+++ b/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java
@@ -4329,40 +4329,29 @@ public class XmppConnectionService extends Service {
final Uri image,
final boolean open,
final OnAvatarPublication callback) {
- final Bitmap.CompressFormat format = Config.AVATAR_FORMAT;
- final int size = Config.AVATAR_SIZE;
- final Avatar avatar = getFileBackend().getPepAvatar(image, size, format);
- if (avatar == null) {
- callback.onAvatarPublicationFailed(R.string.error_publish_avatar_converting);
- return;
- }
- if (fileBackend.save(avatar)) {
- final var connection = account.getXmppConnection();
- final var future = connection.getManager(AvatarManager.class).publish(avatar, open);
- Futures.addCallback(
- future,
- new FutureCallback() {
- @Override
- public void onSuccess(Void result) {
- callback.onAvatarPublicationSucceeded();
- }
- @Override
- public void onFailure(@NonNull Throwable t) {
- Log.d(
- Config.LOGTAG,
- account.getJid().asBareJid() + ": could not publish avatar",
- t);
- callback.onAvatarPublicationFailed(
- R.string.error_publish_avatar_server_reject);
- }
- },
- MoreExecutors.directExecutor());
+ final var connection = account.getXmppConnection();
+ final var publicationFuture =
+ connection.getManager(AvatarManager.class).uploadAndPublish(image, open);
- } else {
- Log.d(Config.LOGTAG, "could not save avatar");
- callback.onAvatarPublicationFailed(R.string.error_saving_avatar);
- }
+ Futures.addCallback(
+ publicationFuture,
+ new FutureCallback<>() {
+ @Override
+ public void onSuccess(final Void result) {
+ Log.d(Config.LOGTAG, "published avatar");
+ callback.onAvatarPublicationSucceeded();
+ }
+
+ @Override
+ public void onFailure(@NonNull final Throwable t) {
+ Log.d(Config.LOGTAG, "avatar upload failed", t);
+ // TODO actually figure out what went wrong
+ callback.onAvatarPublicationFailed(
+ R.string.error_publish_avatar_server_reject);
+ }
+ },
+ MoreExecutors.directExecutor());
}
private void publishMucAvatar(
diff --git a/src/main/java/eu/siacs/conversations/utils/Compatibility.java b/src/main/java/eu/siacs/conversations/utils/Compatibility.java
index 36e7048e5cd2444a1e66df523b4fa65aea3fba1a..5b2b3380da327d115beede69ba0e2ee4f9197009 100644
--- a/src/main/java/eu/siacs/conversations/utils/Compatibility.java
+++ b/src/main/java/eu/siacs/conversations/utils/Compatibility.java
@@ -37,6 +37,10 @@ public class Compatibility {
return Build.VERSION.SDK_INT >= Build.VERSION_CODES.P;
}
+ public static boolean thirtyFour() {
+ return Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE;
+ }
+
public static void startService(final Context context, final Intent intent) {
try {
if (Compatibility.twentySix()) {
diff --git a/src/main/java/eu/siacs/conversations/xml/Namespace.java b/src/main/java/eu/siacs/conversations/xml/Namespace.java
index 6ee41f5f709049ffb11e66fc1d6b536ccdbfae4b..37115cbae4c12793e80ea1f00a076d9de1ef19f2 100644
--- a/src/main/java/eu/siacs/conversations/xml/Namespace.java
+++ b/src/main/java/eu/siacs/conversations/xml/Namespace.java
@@ -33,6 +33,7 @@ public final class Namespace {
public static final String REGISTER_STREAM_FEATURE = "http://jabber.org/features/iq-register";
public static final String BYTE_STREAMS = "http://jabber.org/protocol/bytestreams";
public static final String HTTP_UPLOAD = "urn:xmpp:http:upload:0";
+ public static final String HTTP_UPLOAD_PURPOSE = "urn:xmpp:http:upload:purpose:0";
public static final String STANZA_IDS = "urn:xmpp:sid:0";
public static final String IDLE = "urn:xmpp:idle:1";
public static final String DATA = "jabber:x:data";
diff --git a/src/main/java/eu/siacs/conversations/xmpp/manager/AvatarManager.java b/src/main/java/eu/siacs/conversations/xmpp/manager/AvatarManager.java
index 1f3660a10e3669749880360346b089a7cdd20444..b98da6a5204f5be805627dddb0e0afa562fdf9d6 100644
--- a/src/main/java/eu/siacs/conversations/xmpp/manager/AvatarManager.java
+++ b/src/main/java/eu/siacs/conversations/xmpp/manager/AvatarManager.java
@@ -1,15 +1,27 @@
package eu.siacs.conversations.xmpp.manager;
+import android.graphics.Bitmap;
+import android.net.Uri;
import android.util.Log;
import androidx.annotation.NonNull;
+import androidx.heifwriter.AvifWriter;
+import androidx.heifwriter.HeifWriter;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Iterables;
+import com.google.common.hash.Hashing;
+import com.google.common.hash.HashingOutputStream;
import com.google.common.io.BaseEncoding;
+import com.google.common.io.Files;
import com.google.common.util.concurrent.FutureCallback;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
import com.google.common.util.concurrent.MoreExecutors;
import eu.siacs.conversations.Config;
import eu.siacs.conversations.entities.Contact;
+import eu.siacs.conversations.persistance.FileBackend;
import eu.siacs.conversations.services.XmppConnectionService;
+import eu.siacs.conversations.utils.Compatibility;
+import eu.siacs.conversations.utils.PhoneHelper;
import eu.siacs.conversations.xml.Namespace;
import eu.siacs.conversations.xmpp.Jid;
import eu.siacs.conversations.xmpp.XmppConnection;
@@ -20,9 +32,23 @@ import im.conversations.android.xmpp.model.avatar.Data;
import im.conversations.android.xmpp.model.avatar.Info;
import im.conversations.android.xmpp.model.avatar.Metadata;
import im.conversations.android.xmpp.model.pubsub.Items;
+import im.conversations.android.xmpp.model.upload.purpose.Profile;
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.util.Collection;
+import java.util.List;
+import java.util.NoSuchElementException;
+import java.util.Objects;
+import java.util.UUID;
+import java.util.concurrent.Executor;
+import java.util.concurrent.Executors;
public class AvatarManager extends AbstractManager {
+ private static final Executor AVATAR_COMPRESSION_EXECUTOR =
+ MoreExecutors.newSequentialExecutor(Executors.newSingleThreadScheduledExecutor());
+
private final XmppConnectionService service;
public AvatarManager(final XmppConnectionService service, XmppConnection connection) {
@@ -133,29 +159,207 @@ public class AvatarManager extends AbstractManager {
}
}
- public ListenableFuture publish(final Avatar avatar, final boolean open) {
+ private Info resizeAndStoreAvatar(
+ final Uri image, final int size, final ImageFormat format, final Integer charLimit)
+ throws Exception {
+ final var centerSquare = FileBackend.cropCenterSquare(context, image, size);
+ if (charLimit == null || format == ImageFormat.PNG) {
+ return resizeAndStoreAvatar(centerSquare, format, 90);
+ } else {
+ Info avatar = null;
+ for (int quality = 90; quality >= 50; quality = quality - 2) {
+ if (avatar != null) {
+ FileBackend.getAvatarFile(context, avatar.getId()).delete();
+ }
+ Log.d(Config.LOGTAG, "trying to save thumbnail with quality " + quality);
+ avatar = resizeAndStoreAvatar(centerSquare, format, quality);
+ if (avatar.getBytes() <= charLimit) {
+ return avatar;
+ }
+ }
+ return avatar;
+ }
+ }
+
+ private Info resizeAndStoreAvatar(final Bitmap image, ImageFormat format, final int quality)
+ throws Exception {
+ return switch (format) {
+ case PNG -> resizeAndStoreAvatar(image, Bitmap.CompressFormat.PNG, quality);
+ case JPEG -> resizeAndStoreAvatar(image, Bitmap.CompressFormat.JPEG, quality);
+ case WEBP -> resizeAndStoreAvatar(image, Bitmap.CompressFormat.WEBP, quality);
+ case HEIF -> resizeAndStoreAvatarAsHeif(image, quality);
+ case AVIF -> resizeAndStoreAvatarAsAvif(image, quality);
+ };
+ }
+
+ private Info resizeAndStoreAvatar(
+ final Bitmap image, final Bitmap.CompressFormat format, final int quality)
+ throws IOException {
+ final var randomFile = new File(context.getCacheDir(), UUID.randomUUID().toString());
+ final var fileOutputStream = new FileOutputStream(randomFile);
+ final var hashingOutputStream = new HashingOutputStream(Hashing.sha1(), fileOutputStream);
+ image.compress(format, quality, hashingOutputStream);
+ hashingOutputStream.close();
+ final var sha1 = hashingOutputStream.hash().toString();
+ final var avatarFile = FileBackend.getAvatarFile(context, sha1);
+ if (randomFile.renameTo(avatarFile)) {
+ return new Info(
+ sha1,
+ avatarFile.length(),
+ ImageFormat.of(format).toContentType(),
+ image.getHeight(),
+ image.getWidth());
+ }
+ throw new IllegalStateException(
+ String.format("Could not move file to %s", avatarFile.getAbsolutePath()));
+ }
+
+ private Info resizeAndStoreAvatarAsHeif(final Bitmap image, final int quality)
+ throws Exception {
+ final var randomFile = new File(context.getCacheDir(), UUID.randomUUID().toString());
+ try (final var fileOutputStream = new FileOutputStream(randomFile);
+ final var heifWriter =
+ new HeifWriter.Builder(
+ fileOutputStream.getFD(),
+ image.getWidth(),
+ image.getHeight(),
+ HeifWriter.INPUT_MODE_BITMAP)
+ .setMaxImages(1)
+ .setQuality(quality)
+ .build()) {
+
+ heifWriter.start();
+ heifWriter.addBitmap(image);
+ heifWriter.stop(3_000);
+ }
+ return storeAsAvatar(randomFile, ImageFormat.HEIF, image.getHeight(), image.getWidth());
+ }
+
+ private Info resizeAndStoreAvatarAsAvif(final Bitmap image, final int quality)
+ throws Exception {
+ final var randomFile = new File(context.getCacheDir(), UUID.randomUUID().toString());
+ try (final var fileOutputStream = new FileOutputStream(randomFile);
+ final var avifWriter =
+ new AvifWriter.Builder(
+ fileOutputStream.getFD(),
+ image.getWidth(),
+ image.getHeight(),
+ AvifWriter.INPUT_MODE_BITMAP)
+ .setMaxImages(1)
+ .setQuality(quality)
+ .build()) {
+ avifWriter.start();
+ avifWriter.addBitmap(image);
+ avifWriter.stop(3_000);
+ }
+ return storeAsAvatar(randomFile, ImageFormat.AVIF, image.getHeight(), image.getWidth());
+ }
+
+ private Info storeAsAvatar(
+ final File randomFile, final ImageFormat type, final int height, final int width)
+ throws IOException {
+ final var sha1 = Files.asByteSource(randomFile).hash(Hashing.sha1()).toString();
+ final var avatarFile = FileBackend.getAvatarFile(context, sha1);
+ if (randomFile.renameTo(avatarFile)) {
+ return new Info(sha1, avatarFile.length(), type.toContentType(), height, width);
+ }
+ throw new IllegalStateException(
+ String.format("Could not move file to %s", avatarFile.getAbsolutePath()));
+ }
+
+ public ListenableFuture> uploadAvatar(final Uri image, final int size) {
+ final var avatarFutures = new ImmutableList.Builder>();
+ final var avatarFuture = resizeAndStoreAvatarAsync(image, size, ImageFormat.JPEG);
+ final var avatarWithUrlFuture =
+ Futures.transformAsync(avatarFuture, this::upload, MoreExecutors.directExecutor());
+ avatarFutures.add(avatarWithUrlFuture);
+
+ if (Compatibility.twentyEight() && !PhoneHelper.isEmulator()) {
+ final var avatarHeifFuture = resizeAndStoreAvatarAsync(image, size, ImageFormat.HEIF);
+ final var avatarHeifWithUrlFuture =
+ Futures.transformAsync(
+ avatarHeifFuture, this::upload, MoreExecutors.directExecutor());
+ avatarFutures.add(avatarHeifWithUrlFuture);
+ }
+ if (Compatibility.thirtyFour() && !PhoneHelper.isEmulator()) {
+ final var avatarAvifFuture = resizeAndStoreAvatarAsync(image, size, ImageFormat.AVIF);
+ final var avatarAvifWithUrlFuture =
+ Futures.transformAsync(
+ avatarAvifFuture, this::upload, MoreExecutors.directExecutor());
+ avatarFutures.add(avatarAvifWithUrlFuture);
+ }
+
+ final var avatarThumbnailFuture =
+ resizeAndStoreAvatarAsync(
+ image, Config.AVATAR_SIZE, ImageFormat.JPEG, Config.AVATAR_CHAR_LIMIT);
+ avatarFutures.add(avatarThumbnailFuture);
+
+ return Futures.allAsList(avatarFutures.build());
+ }
+
+ private ListenableFuture upload(final Info avatar) {
+ final var file = FileBackend.getAvatarFile(context, avatar.getId());
+ final var urlFuture =
+ getManager(HttpUploadManager.class).upload(file, avatar.getType(), new Profile());
+ return Futures.transform(
+ urlFuture,
+ url -> {
+ avatar.setUrl(url);
+ return avatar;
+ },
+ MoreExecutors.directExecutor());
+ }
+
+ private ListenableFuture resizeAndStoreAvatarAsync(
+ final Uri image, final int size, final ImageFormat format) {
+ return resizeAndStoreAvatarAsync(image, size, format, null);
+ }
+
+ private ListenableFuture resizeAndStoreAvatarAsync(
+ final Uri image, final int size, final ImageFormat format, final Integer charLimit) {
+ return Futures.submit(
+ () -> resizeAndStoreAvatar(image, size, format, charLimit),
+ AVATAR_COMPRESSION_EXECUTOR);
+ }
+
+ public ListenableFuture publish(final Collection avatars, final boolean open) {
+ final Info mainAvatarInfo;
+ final byte[] mainAvatar;
+ try {
+ mainAvatarInfo = Iterables.find(avatars, a -> Objects.isNull(a.getUrl()));
+ mainAvatar =
+ Files.asByteSource(FileBackend.getAvatarFile(context, mainAvatarInfo.getId()))
+ .read();
+ } catch (final IOException | NoSuchElementException e) {
+ return Futures.immediateFailedFuture(e);
+ }
final NodeConfiguration configuration =
open ? NodeConfiguration.OPEN : NodeConfiguration.PRESENCE;
final var avatarData = new Data();
- avatarData.setContent(avatar.getImageAsBytes());
+ avatarData.setContent(mainAvatar);
final var future =
- getManager(PepManager.class).publish(avatarData, avatar.sha1sum, configuration);
+ getManager(PepManager.class)
+ .publish(avatarData, mainAvatarInfo.getId(), configuration);
return Futures.transformAsync(
future,
v -> {
- final var id = avatar.sha1sum;
+ final var id = mainAvatarInfo.getId();
final var metadata = new Metadata();
- final var info = metadata.addExtension(new Info());
- info.setBytes(avatar.size);
- info.setId(avatar.sha1sum);
- info.setHeight(avatar.height);
- info.setWidth(avatar.width);
- info.setType(avatar.type);
+ metadata.addExtensions(avatars);
return getManager(PepManager.class).publish(metadata, id, configuration);
},
MoreExecutors.directExecutor());
}
+ public ListenableFuture uploadAndPublish(final Uri image, final boolean open) {
+ final var infoFuture =
+ connection
+ .getManager(AvatarManager.class)
+ .uploadAvatar(image, Config.AVATAR_FULL_SIZE);
+ return Futures.transformAsync(
+ infoFuture, avatars -> publish(avatars, open), MoreExecutors.directExecutor());
+ }
+
public boolean hasPepToVCardConversion() {
return getManager(DiscoManager.class).hasAccountFeature(Namespace.AVATAR_CONVERSION);
}
@@ -169,4 +373,41 @@ public class AvatarManager extends AbstractManager {
vs -> null,
MoreExecutors.directExecutor());
}
+
+ private String asContentType(final ImageFormat format) {
+ return switch (format) {
+ case WEBP -> "image/webp";
+ case PNG -> "image/png";
+ case JPEG -> "image/jpeg";
+ case AVIF -> "image/avif";
+ case HEIF -> "image/heif";
+ };
+ }
+
+ public enum ImageFormat {
+ PNG,
+ JPEG,
+ WEBP,
+ HEIF,
+ AVIF;
+
+ public String toContentType() {
+ return switch (this) {
+ case WEBP -> "image/webp";
+ case PNG -> "image/png";
+ case JPEG -> "image/jpeg";
+ case AVIF -> "image/avif";
+ case HEIF -> "image/heif";
+ };
+ }
+
+ public static ImageFormat of(final Bitmap.CompressFormat compressFormat) {
+ return switch (compressFormat) {
+ case PNG -> PNG;
+ case WEBP -> WEBP;
+ case JPEG -> JPEG;
+ default -> throw new AssertionError("Not implemented");
+ };
+ }
+ }
}
diff --git a/src/main/java/eu/siacs/conversations/xmpp/manager/HttpUploadManager.java b/src/main/java/eu/siacs/conversations/xmpp/manager/HttpUploadManager.java
index 13c26b77a02b88a0020c168d1bb9f0ccd121761d..ea33b9e4b9ca03ff1a53da1a7e66b879e201b396 100644
--- a/src/main/java/eu/siacs/conversations/xmpp/manager/HttpUploadManager.java
+++ b/src/main/java/eu/siacs/conversations/xmpp/manager/HttpUploadManager.java
@@ -1,47 +1,122 @@
package eu.siacs.conversations.xmpp.manager;
-import android.content.Context;
import android.util.Base64;
+import android.util.Log;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import com.google.common.base.MoreObjects;
import com.google.common.collect.ImmutableMap;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
import com.google.common.util.concurrent.MoreExecutors;
+import com.google.common.util.concurrent.SettableFuture;
+import eu.siacs.conversations.Config;
import eu.siacs.conversations.entities.DownloadableFile;
+import eu.siacs.conversations.services.XmppConnectionService;
import eu.siacs.conversations.xml.Namespace;
import eu.siacs.conversations.xmpp.Jid;
import eu.siacs.conversations.xmpp.XmppConnection;
import im.conversations.android.xmpp.model.stanza.Iq;
import im.conversations.android.xmpp.model.upload.Request;
+import im.conversations.android.xmpp.model.upload.purpose.Purpose;
+import java.io.File;
+import java.io.IOException;
import java.nio.ByteBuffer;
import java.util.Map;
import java.util.UUID;
+import okhttp3.Call;
+import okhttp3.Callback;
import okhttp3.Headers;
import okhttp3.HttpUrl;
+import okhttp3.MediaType;
+import okhttp3.OkHttpClient;
+import okhttp3.RequestBody;
+import okhttp3.Response;
public class HttpUploadManager extends AbstractManager {
- public HttpUploadManager(final Context context, final XmppConnection connection) {
- super(context, connection);
+ private final XmppConnectionService service;
+
+ public HttpUploadManager(final XmppConnectionService service, final XmppConnection connection) {
+ super(service.getApplicationContext(), connection);
+ this.service = service;
}
public ListenableFuture request(final DownloadableFile file, final String mime) {
+ return request(file.getName(), mime, file.getExpectedSize(), null);
+ }
+
+ public ListenableFuture request(
+ final String filename,
+ final String mime,
+ final long size,
+ @Nullable final Purpose purpose) {
final var result =
getManager(DiscoManager.class).findDiscoItemByFeature(Namespace.HTTP_UPLOAD);
if (result == null) {
return Futures.immediateFailedFuture(
new IllegalStateException("No HTTP upload host found"));
}
- return requestHttpUpload(result.getKey(), file, mime);
+ return requestHttpUpload(result.getKey(), filename, mime, size, purpose);
+ }
+
+ public ListenableFuture upload(
+ final File file, final String mime, final Purpose purpose) {
+ final var filename = file.getName();
+ final var size = file.length();
+ final var slotFuture = request(filename, mime, size, purpose);
+ return Futures.transformAsync(
+ slotFuture, slot -> upload(file, mime, slot), MoreExecutors.directExecutor());
+ }
+
+ private ListenableFuture upload(final File file, final String mime, final Slot slot) {
+ final SettableFuture future = SettableFuture.create();
+ final OkHttpClient client =
+ service.getHttpConnectionManager()
+ .buildHttpClient(slot.put, getAccount(), 0, false);
+ final var body = RequestBody.create(MediaType.parse(mime), file);
+ final okhttp3.Request request =
+ new okhttp3.Request.Builder().url(slot.put).put(body).headers(slot.headers).build();
+ client.newCall(request)
+ .enqueue(
+ new Callback() {
+ @Override
+ public void onFailure(@NonNull Call call, @NonNull IOException e) {
+ future.setException(e);
+ }
+
+ @Override
+ public void onResponse(@NonNull Call call, @NonNull Response response) {
+ if (response.isSuccessful()) {
+ future.set(slot.get);
+ } else {
+ future.setException(
+ new IllegalStateException(
+ String.format(
+ "Response code was %s",
+ response.code())));
+ }
+ }
+ });
+ return future;
}
private ListenableFuture requestHttpUpload(
- final Jid host, final DownloadableFile file, final String mime) {
+ final Jid host,
+ final String filename,
+ final String mime,
+ final long size,
+ @Nullable final Purpose purpose) {
final Iq iq = new Iq(Iq.Type.GET);
iq.setTo(host);
final var request = iq.addExtension(new Request());
- request.setFilename(convertFilename(file.getName()));
- request.setSize(file.getExpectedSize());
+ request.setFilename(convertFilename(filename));
+ request.setSize(size);
request.setContentType(mime);
+ if (purpose != null) {
+ request.addExtension(purpose);
+ }
+ Log.d(Config.LOGTAG, "-->" + iq);
final var iqFuture = this.connection.sendIqPacket(iq);
return Futures.transform(
iqFuture,
@@ -104,5 +179,15 @@ public class HttpUploadManager extends AbstractManager {
private Slot(final HttpUrl put, final HttpUrl get, final Map headers) {
this(put, get, Headers.of(headers));
}
+
+ @Override
+ @NonNull
+ public String toString() {
+ return MoreObjects.toStringHelper(this)
+ .add("put", put)
+ .add("get", get)
+ .add("headers", headers)
+ .toString();
+ }
}
}
diff --git a/src/main/java/eu/siacs/conversations/xmpp/pep/Avatar.java b/src/main/java/eu/siacs/conversations/xmpp/pep/Avatar.java
index 88d35e7ba5f30e6e1ca580008bc054f65e3c9442..068e59b89d9d6c808d87740dd228e4519d6915db 100644
--- a/src/main/java/eu/siacs/conversations/xmpp/pep/Avatar.java
+++ b/src/main/java/eu/siacs/conversations/xmpp/pep/Avatar.java
@@ -1,12 +1,31 @@
package eu.siacs.conversations.xmpp.pep;
import android.util.Base64;
+import androidx.annotation.NonNull;
+import com.google.common.base.MoreObjects;
import eu.siacs.conversations.xml.Element;
import eu.siacs.conversations.xmpp.Jid;
import im.conversations.android.xmpp.model.avatar.Metadata;
+import okhttp3.HttpUrl;
public class Avatar {
+ @Override
+ @NonNull
+ public String toString() {
+ return MoreObjects.toStringHelper(this)
+ .add("type", type)
+ .add("sha1sum", sha1sum)
+ .add("url", url)
+ .add("image", image)
+ .add("height", height)
+ .add("width", width)
+ .add("size", size)
+ .add("owner", owner)
+ .add("origin", origin)
+ .toString();
+ }
+
public enum Origin {
PEP,
VCARD
@@ -14,6 +33,7 @@ public class Avatar {
public String type;
public String sha1sum;
+ public HttpUrl url;
public String image;
public int height;
public int width;
diff --git a/src/main/java/im/conversations/android/xmpp/model/avatar/Info.java b/src/main/java/im/conversations/android/xmpp/model/avatar/Info.java
index 31099ff79e246d884da1b4a2c738571cb359f57a..446c04486530ac746e01228c427b5ed45a50a4bf 100644
--- a/src/main/java/im/conversations/android/xmpp/model/avatar/Info.java
+++ b/src/main/java/im/conversations/android/xmpp/model/avatar/Info.java
@@ -1,8 +1,10 @@
package im.conversations.android.xmpp.model.avatar;
+import com.google.common.base.Strings;
import eu.siacs.conversations.xml.Namespace;
import im.conversations.android.annotation.XmlElement;
import im.conversations.android.xmpp.model.Extension;
+import okhttp3.HttpUrl;
@XmlElement(namespace = Namespace.AVATAR_METADATA)
public class Info extends Extension {
@@ -11,6 +13,20 @@ public class Info extends Extension {
super(Info.class);
}
+ public Info(
+ final String id,
+ final long bytes,
+ final String type,
+ final int height,
+ final int width) {
+ this();
+ this.setId(id);
+ this.setBytes(bytes);
+ this.setType(type);
+ this.setHeight(height);
+ this.setWidth(width);
+ }
+
public long getHeight() {
return this.getLongAttribute("height");
}
@@ -27,8 +43,12 @@ public class Info extends Extension {
return this.getAttribute("type");
}
- public String getUrl() {
- return this.getAttribute("url");
+ public HttpUrl getUrl() {
+ final var url = this.getAttribute("url");
+ if (Strings.isNullOrEmpty(url)) {
+ return null;
+ }
+ return HttpUrl.parse(url);
}
public String getId() {
@@ -54,4 +74,8 @@ public class Info extends Extension {
public void setType(final String type) {
this.setAttribute("type", type);
}
+
+ public void setUrl(final HttpUrl url) {
+ this.setAttribute("url", url.toString());
+ }
}
diff --git a/src/main/java/im/conversations/android/xmpp/model/upload/purpose/Ephemeral.java b/src/main/java/im/conversations/android/xmpp/model/upload/purpose/Ephemeral.java
new file mode 100644
index 0000000000000000000000000000000000000000..213e402696d445f18a106083be41a17d8f5b897f
--- /dev/null
+++ b/src/main/java/im/conversations/android/xmpp/model/upload/purpose/Ephemeral.java
@@ -0,0 +1,11 @@
+package im.conversations.android.xmpp.model.upload.purpose;
+
+import im.conversations.android.annotation.XmlElement;
+
+@XmlElement
+public class Ephemeral extends Purpose {
+
+ public Ephemeral() {
+ super(Ephemeral.class);
+ }
+}
diff --git a/src/main/java/im/conversations/android/xmpp/model/upload/purpose/Message.java b/src/main/java/im/conversations/android/xmpp/model/upload/purpose/Message.java
new file mode 100644
index 0000000000000000000000000000000000000000..94eba9db457c5a05f711d53097184af1446252e1
--- /dev/null
+++ b/src/main/java/im/conversations/android/xmpp/model/upload/purpose/Message.java
@@ -0,0 +1,11 @@
+package im.conversations.android.xmpp.model.upload.purpose;
+
+import im.conversations.android.annotation.XmlElement;
+
+@XmlElement
+public class Message extends Purpose {
+
+ public Message() {
+ super(Purpose.class);
+ }
+}
diff --git a/src/main/java/im/conversations/android/xmpp/model/upload/purpose/Permanent.java b/src/main/java/im/conversations/android/xmpp/model/upload/purpose/Permanent.java
new file mode 100644
index 0000000000000000000000000000000000000000..4f2173c2ba803cc42274f5f05307c712e29045d8
--- /dev/null
+++ b/src/main/java/im/conversations/android/xmpp/model/upload/purpose/Permanent.java
@@ -0,0 +1,11 @@
+package im.conversations.android.xmpp.model.upload.purpose;
+
+import im.conversations.android.annotation.XmlElement;
+
+@XmlElement
+public class Permanent extends Purpose {
+
+ public Permanent() {
+ super(Permanent.class);
+ }
+}
diff --git a/src/main/java/im/conversations/android/xmpp/model/upload/purpose/Profile.java b/src/main/java/im/conversations/android/xmpp/model/upload/purpose/Profile.java
new file mode 100644
index 0000000000000000000000000000000000000000..b968423cc2ecf0a718f2bc00f807432a9fdde899
--- /dev/null
+++ b/src/main/java/im/conversations/android/xmpp/model/upload/purpose/Profile.java
@@ -0,0 +1,11 @@
+package im.conversations.android.xmpp.model.upload.purpose;
+
+import im.conversations.android.annotation.XmlElement;
+
+@XmlElement
+public class Profile extends Purpose {
+
+ public Profile() {
+ super(Profile.class);
+ }
+}
diff --git a/src/main/java/im/conversations/android/xmpp/model/upload/purpose/Purpose.java b/src/main/java/im/conversations/android/xmpp/model/upload/purpose/Purpose.java
new file mode 100644
index 0000000000000000000000000000000000000000..566b5523ef7e2f6cd3b75d102b2dbde88cddff4c
--- /dev/null
+++ b/src/main/java/im/conversations/android/xmpp/model/upload/purpose/Purpose.java
@@ -0,0 +1,10 @@
+package im.conversations.android.xmpp.model.upload.purpose;
+
+import im.conversations.android.xmpp.model.Extension;
+
+public abstract class Purpose extends Extension {
+
+ protected Purpose(final Class extends Purpose> clazz) {
+ super(clazz);
+ }
+}
diff --git a/src/main/java/im/conversations/android/xmpp/model/upload/purpose/package-info.java b/src/main/java/im/conversations/android/xmpp/model/upload/purpose/package-info.java
new file mode 100644
index 0000000000000000000000000000000000000000..e85818b2f50c664433b0db248bff373af504d569
--- /dev/null
+++ b/src/main/java/im/conversations/android/xmpp/model/upload/purpose/package-info.java
@@ -0,0 +1,5 @@
+@XmlPackage(namespace = Namespace.HTTP_UPLOAD_PURPOSE)
+package im.conversations.android.xmpp.model.upload.purpose;
+
+import eu.siacs.conversations.xml.Namespace;
+import im.conversations.android.annotation.XmlPackage;