show current pep access model in avatar publish screen

Daniel Gultsch created

Change summary

src/main/java/eu/siacs/conversations/ui/PublishProfilePictureActivity.java | 70 
src/main/java/eu/siacs/conversations/xmpp/manager/AvatarManager.java       | 53 
src/main/java/eu/siacs/conversations/xmpp/manager/PepManager.java          |  5 
src/main/java/eu/siacs/conversations/xmpp/manager/PubSubManager.java       | 12 
src/main/java/im/conversations/android/xmpp/NodeConfiguration.java         | 16 
src/main/java/im/conversations/android/xmpp/model/avatar/Info.java         |  6 
src/main/java/im/conversations/android/xmpp/model/data/Data.java           |  4 
7 files changed, 134 insertions(+), 32 deletions(-)

Detailed changes

src/main/java/eu/siacs/conversations/ui/PublishProfilePictureActivity.java 🔗

@@ -13,11 +13,15 @@ import android.widget.Toast;
 import androidx.activity.result.ActivityResultLauncher;
 import androidx.annotation.NonNull;
 import androidx.annotation.StringRes;
+import androidx.core.content.ContextCompat;
 import androidx.databinding.DataBindingUtil;
 import com.canhub.cropper.CropImageContract;
 import com.canhub.cropper.CropImageContractOptions;
 import com.canhub.cropper.CropImageOptions;
 import com.google.android.material.dialog.MaterialAlertDialogBuilder;
+import com.google.common.util.concurrent.FutureCallback;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListenableFuture;
 import eu.siacs.conversations.Config;
 import eu.siacs.conversations.R;
 import eu.siacs.conversations.databinding.ActivityPublishProfilePictureBinding;
@@ -25,14 +29,15 @@ import eu.siacs.conversations.entities.Account;
 import eu.siacs.conversations.services.XmppConnectionService;
 import eu.siacs.conversations.ui.interfaces.OnAvatarPublication;
 import eu.siacs.conversations.utils.PhoneHelper;
+import eu.siacs.conversations.xmpp.manager.AvatarManager;
+import im.conversations.android.xmpp.NodeConfiguration;
 
 public class PublishProfilePictureActivity extends XmppActivity
         implements XmppConnectionService.OnAccountUpdate, OnAvatarPublication {
 
-    public static final int REQUEST_CHOOSE_PICTURE = 0x1337;
-
     private ActivityPublishProfilePictureBinding binding;
     private Uri avatarUri;
+    private NodeConfiguration.AccessModel accessModel;
     private Uri defaultUri;
     private Account account;
     private boolean support = false;
@@ -136,6 +141,10 @@ public class PublishProfilePictureActivity extends XmppActivity
         this.defaultUri = PhoneHelper.getProfilePictureUri(getApplicationContext());
         if (savedInstanceState != null) {
             this.avatarUri = savedInstanceState.getParcelable("uri");
+            final var accessModel = savedInstanceState.getString("access-model");
+            if (accessModel != null) {
+                this.accessModel = NodeConfiguration.AccessModel.valueOf(accessModel);
+            }
         }
     }
 
@@ -178,6 +187,9 @@ public class PublishProfilePictureActivity extends XmppActivity
         if (this.avatarUri != null) {
             outState.putParcelable("uri", this.avatarUri);
         }
+        if (this.accessModel != null) {
+            outState.putString("access-model", this.accessModel.toString());
+        }
         super.onSaveInstanceState(outState);
     }
 
@@ -209,18 +221,57 @@ public class PublishProfilePictureActivity extends XmppActivity
 
     @Override
     protected void onBackendConnected() {
-        this.account = extractAccount(getIntent());
-        if (this.account != null) {
-            reloadAvatar();
+        final var account = extractAccount(getIntent());
+        this.account = account;
+        if (account != null) {
+            loadCurrentAccessModel(account);
+            reloadAvatar(account);
         }
     }
 
+    private void loadCurrentAccessModel(final Account account) {
+        binding.contactOnly.setVisibility(View.INVISIBLE);
+        final var currentPepAccessModel = getPepAccessModelOrCached(account);
+        Futures.addCallback(
+                currentPepAccessModel,
+                new FutureCallback<>() {
+                    @Override
+                    public void onSuccess(final NodeConfiguration.AccessModel result) {
+                        accessModel = result; // cache for after rotation
+                        Log.d(Config.LOGTAG, "current access model: " + result);
+                        binding.contactOnly.setChecked(
+                                result == NodeConfiguration.AccessModel.PRESENCE);
+                        binding.contactOnly.jumpDrawablesToCurrentState();
+                        binding.contactOnly.setVisibility(View.VISIBLE);
+                    }
+
+                    @Override
+                    public void onFailure(@NonNull Throwable t) {
+                        Log.d(Config.LOGTAG, "could not fetch access model", t);
+                        binding.contactOnly.setChecked(false);
+                        binding.contactOnly.setVisibility(View.VISIBLE);
+                    }
+                },
+                ContextCompat.getMainExecutor(getApplication()));
+    }
+
+    private ListenableFuture<NodeConfiguration.AccessModel> getPepAccessModelOrCached(
+            final Account account) {
+        final var cached = this.accessModel;
+        if (cached != null) {
+            return Futures.immediateFuture(cached);
+        }
+        return account.getXmppConnection().getManager(AvatarManager.class).getPepAccessModel();
+    }
+
     private void reloadAvatar() {
-        this.support =
-                this.account.getXmppConnection() != null
-                        && this.account.getXmppConnection().getFeatures().pep();
+        reloadAvatar(this.account);
+    }
+
+    private void reloadAvatar(final Account account) {
+        this.support = account.getXmppConnection().getFeatures().pep();
         if (this.avatarUri == null) {
-            if (this.account.getAvatar() != null || this.defaultUri == null) {
+            if (account.getAvatar() != null || this.defaultUri == null) {
                 loadImageIntoPreview(null);
             } else {
                 this.avatarUri = this.defaultUri;
@@ -308,6 +359,7 @@ public class PublishProfilePictureActivity extends XmppActivity
         final boolean status = enabled && !publishing;
         this.binding.publishButton.setText(publishing ? R.string.publishing : res);
         this.binding.publishButton.setEnabled(status);
+        this.binding.contactOnly.setEnabled(status);
     }
 
     public void refreshUiReal() {

src/main/java/eu/siacs/conversations/xmpp/manager/AvatarManager.java 🔗

@@ -8,7 +8,9 @@ import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
 import androidx.heifwriter.AvifWriter;
 import androidx.heifwriter.HeifWriter;
+import com.google.common.base.CaseFormat;
 import com.google.common.base.Preconditions;
+import com.google.common.base.Strings;
 import com.google.common.collect.Collections2;
 import com.google.common.collect.ComparisonChain;
 import com.google.common.collect.ImmutableList;
@@ -108,6 +110,23 @@ public class AvatarManager extends AbstractManager {
         this.service = service;
     }
 
+    public ListenableFuture<NodeConfiguration.AccessModel> getPepAccessModel() {
+        final var nodeConfiguration =
+                getManager(PepManager.class).getNodeConfiguration(Namespace.AVATAR_DATA);
+        return Futures.transform(
+                nodeConfiguration,
+                data -> {
+                    final var accessModel = data.getValue(NodeConfiguration.ACCESS_MODEL);
+                    if (Strings.isNullOrEmpty(accessModel)) {
+                        throw new IllegalStateException(
+                                "Access model missing from node configuration");
+                    }
+                    return NodeConfiguration.AccessModel.valueOf(
+                            CaseFormat.LOWER_HYPHEN.to(CaseFormat.UPPER_UNDERSCORE, accessModel));
+                },
+                MoreExecutors.directExecutor());
+    }
+
     private ListenableFuture<byte[]> fetch(final Jid address, final String itemId) {
         final var future = getManager(PubSubManager.class).fetchItem(address, itemId, Data.class);
         return Futures.transform(future, ByteContent::asBytes, MoreExecutors.directExecutor());
@@ -476,8 +495,8 @@ public class AvatarManager extends AbstractManager {
                     sha1,
                     avatarFile.length(),
                     ImageFormat.of(format).toContentType(),
-                    image.getHeight(),
-                    image.getWidth());
+                    image.getWidth(),
+                    image.getHeight());
         }
         throw new IllegalStateException(
                 String.format("Could not move file to %s", avatarFile.getAbsolutePath()));
@@ -501,7 +520,10 @@ public class AvatarManager extends AbstractManager {
             heifWriter.addBitmap(image);
             heifWriter.stop(3_000);
         }
-        return storeAsAvatar(randomFile, ImageFormat.HEIF, image.getHeight(), image.getWidth());
+        final var width = image.getWidth();
+        final var height = image.getHeight();
+        checkDecoding(randomFile, ImageFormat.HEIF, width, height);
+        return storeAsAvatar(randomFile, ImageFormat.HEIF, width, height);
     }
 
     private Info resizeAndStoreAvatarAsAvif(final Bitmap image, final int quality)
@@ -521,26 +543,33 @@ public class AvatarManager extends AbstractManager {
             avifWriter.addBitmap(image);
             avifWriter.stop(3_000);
         }
+        final var width = image.getWidth();
+        final var height = image.getHeight();
+        checkDecoding(randomFile, ImageFormat.AVIF, width, height);
+        return storeAsAvatar(randomFile, ImageFormat.AVIF, width, height);
+    }
+
+    private void checkDecoding(
+            final File randomFile, final ImageFormat format, final int width, final int height) {
         var readCheck = BitmapFactory.decodeFile(randomFile.getAbsolutePath());
         if (readCheck == null) {
-            throw new AvifCompressionException("AVIF image was null after trying to decode");
+            throw new ImageCompressionException(
+                    String.format("%s image was null after trying to decode", format));
         }
-        if (readCheck.getWidth() != image.getWidth()
-                || readCheck.getHeight() != image.getHeight()) {
+        if (readCheck.getWidth() != width || readCheck.getHeight() != height) {
             readCheck.recycle();
-            throw new AvifCompressionException("AVIF had wrong image bounds");
+            throw new ImageCompressionException(String.format("%s had wrong image bounds", format));
         }
         readCheck.recycle();
-        return storeAsAvatar(randomFile, ImageFormat.AVIF, image.getHeight(), image.getWidth());
     }
 
     private Info storeAsAvatar(
-            final File randomFile, final ImageFormat type, final int height, final int width)
+            final File randomFile, final ImageFormat type, final int width, final int height)
             throws IOException {
         final var sha1 = Files.asByteSource(randomFile).hash(Hashing.sha1()).toString();
         final var avatarFile = FileBackend.getAvatarFile(context, sha1);
         if (moveAvatarIntoCache(randomFile, avatarFile)) {
-            return new Info(sha1, avatarFile.length(), type.toContentType(), height, width);
+            return new Info(sha1, avatarFile.length(), type.toContentType(), width, height);
         }
         throw new IllegalStateException(
                 String.format("Could not move file to %s", avatarFile.getAbsolutePath()));
@@ -840,8 +869,8 @@ public class AvatarManager extends AbstractManager {
                 AVATAR_COMPRESSION_EXECUTOR);
     }
 
-    private static final class AvifCompressionException extends IllegalStateException {
-        AvifCompressionException(final String message) {
+    private static final class ImageCompressionException extends IllegalStateException {
+        ImageCompressionException(final String message) {
             super(message);
         }
     }

src/main/java/eu/siacs/conversations/xmpp/manager/PepManager.java 🔗

@@ -9,6 +9,7 @@ import eu.siacs.conversations.xmpp.Jid;
 import eu.siacs.conversations.xmpp.XmppConnection;
 import im.conversations.android.xmpp.NodeConfiguration;
 import im.conversations.android.xmpp.model.Extension;
+import im.conversations.android.xmpp.model.data.Data;
 import im.conversations.android.xmpp.model.stanza.Iq;
 import java.util.Map;
 
@@ -55,6 +56,10 @@ public class PepManager extends AbstractManager {
         return Futures.transform(future, iq -> null, MoreExecutors.directExecutor());
     }
 
+    public ListenableFuture<Data> getNodeConfiguration(final String node) {
+        return pubSubManager().getNodeConfiguration(pepService(), node);
+    }
+
     public boolean hasPublishOptions() {
         return getManager(DiscoManager.class).hasAccountFeature(Namespace.PUBSUB_PUBLISH_OPTIONS);
     }

src/main/java/eu/siacs/conversations/xmpp/manager/PubSubManager.java 🔗

@@ -308,12 +308,19 @@ public class PubSubManager extends AbstractManager {
 
     private ListenableFuture<Void> reconfigureNode(
             final Jid address, final String node, final NodeConfiguration nodeConfiguration) {
+        return Futures.transformAsync(
+                getNodeConfiguration(address, node),
+                data -> setNodeConfiguration(address, node, data.submit(nodeConfiguration)),
+                MoreExecutors.directExecutor());
+    }
+
+    public ListenableFuture<Data> getNodeConfiguration(final Jid address, final String node) {
         final Iq iq = new Iq(Iq.Type.GET);
         iq.setTo(address);
         final var pubSub = iq.addExtension(new PubSubOwner());
         final var configure = pubSub.addExtension(new Configure());
         configure.setNode(node);
-        return Futures.transformAsync(
+        return Futures.transform(
                 connection.sendIqPacket(iq),
                 result -> {
                     final var pubSubOwnerResult = result.getExtension(PubSubOwner.class);
@@ -325,8 +332,7 @@ public class PubSubManager extends AbstractManager {
                         throw new IllegalStateException(
                                 "No configuration found in configuration request result");
                     }
-                    final var data = configureResult.getData();
-                    return setNodeConfiguration(address, node, data.submit(nodeConfiguration));
+                    return configureResult.getData();
                 },
                 MoreExecutors.directExecutor());
     }

src/main/java/im/conversations/android/xmpp/NodeConfiguration.java 🔗

@@ -10,7 +10,7 @@ import java.util.Set;
 public class NodeConfiguration implements Map<String, Object> {
 
     private static final String PERSIST_ITEMS = "pubsub#persist_items";
-    private static final String ACCESS_MODEL = "pubsub#access_model";
+    public static final String ACCESS_MODEL = "pubsub#access_model";
     private static final String SEND_LAST_PUBLISHED_ITEM = "pubsub#send_last_published_item";
     private static final String MAX_ITEMS = "pubsub#max_items";
     private static final String NOTIFY_DELETE = "pubsub#notify_delete";
@@ -20,25 +20,25 @@ public class NodeConfiguration implements Map<String, Object> {
             new NodeConfiguration(
                     new ImmutableMap.Builder<String, Object>()
                             .put(PERSIST_ITEMS, Boolean.TRUE)
-                            .put(ACCESS_MODEL, "open")
+                            .put(ACCESS_MODEL, AccessModel.OPEN)
                             .build());
     public static final NodeConfiguration PRESENCE =
             new NodeConfiguration(
                     new ImmutableMap.Builder<String, Object>()
                             .put(PERSIST_ITEMS, Boolean.TRUE)
-                            .put(ACCESS_MODEL, "presence")
+                            .put(ACCESS_MODEL, AccessModel.PRESENCE)
                             .build());
     public static final NodeConfiguration WHITELIST =
             new NodeConfiguration(
                     new ImmutableMap.Builder<String, Object>()
                             .put(PERSIST_ITEMS, Boolean.TRUE)
-                            .put(ACCESS_MODEL, "whitelist")
+                            .put(ACCESS_MODEL, AccessModel.WHITELIST)
                             .build());
     public static final NodeConfiguration WHITELIST_MAX_ITEMS =
             new NodeConfiguration(
                     new ImmutableMap.Builder<String, Object>()
                             .put(PERSIST_ITEMS, Boolean.TRUE)
-                            .put(ACCESS_MODEL, "whitelist")
+                            .put(ACCESS_MODEL, AccessModel.WHITELIST)
                             .put(SEND_LAST_PUBLISHED_ITEM, "never")
                             .put(MAX_ITEMS, "max")
                             .put(NOTIFY_DELETE, Boolean.TRUE)
@@ -115,4 +115,10 @@ public class NodeConfiguration implements Map<String, Object> {
     public Set<Entry<String, Object>> entrySet() {
         return this.delegate.entrySet();
     }
+
+    public enum AccessModel {
+        OPEN,
+        WHITELIST,
+        PRESENCE
+    }
 }

src/main/java/im/conversations/android/xmpp/model/avatar/Info.java 🔗

@@ -17,14 +17,14 @@ public class Info extends Extension {
             final String id,
             final long bytes,
             final String type,
-            final int height,
-            final int width) {
+            final int width,
+            final int height) {
         this();
         this.setId(id);
         this.setBytes(bytes);
         this.setType(type);
-        this.setHeight(height);
         this.setWidth(width);
+        this.setHeight(height);
     }
 
     public long getHeight() {

src/main/java/im/conversations/android/xmpp/model/data/Data.java 🔗

@@ -1,5 +1,6 @@
 package im.conversations.android.xmpp.model.data;
 
+import com.google.common.base.CaseFormat;
 import com.google.common.collect.Collections2;
 import com.google.common.collect.Iterables;
 import im.conversations.android.annotation.XmlElement;
@@ -67,6 +68,9 @@ public class Data extends Extension {
             final var valueExtension = field.addExtension(new Value());
             if (value instanceof String) {
                 valueExtension.setContent((String) value);
+            } else if (value instanceof Enum<?> e) {
+                valueExtension.setContent(
+                        CaseFormat.UPPER_UNDERSCORE.to(CaseFormat.LOWER_HYPHEN, e.toString()));
             } else if (value instanceof Integer) {
                 valueExtension.setContent(String.valueOf(value));
             } else if (value instanceof Boolean) {