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