Experimental WebXDC extensions functionality

Stephen Paul Weber created

Change summary

src/cheogram/java/com/cheogram/android/ExtensionSettingsFragment.java               | 145 
src/cheogram/java/com/cheogram/android/WebxdcPage.java                              |  47 
src/cheogram/res/layout/extension_item.xml                                          |  32 
src/cheogram/res/layout/fragment_extension_settings.xml                             |  28 
src/main/java/eu/siacs/conversations/ui/ConversationFragment.java                   |  24 
src/main/java/eu/siacs/conversations/ui/ConversationsOverviewFragment.java          |   2 
src/main/java/eu/siacs/conversations/ui/XmppFragment.java                           |   2 
src/main/java/eu/siacs/conversations/ui/adapter/MessageAdapter.java                 |   2 
src/main/java/eu/siacs/conversations/ui/fragment/settings/MainSettingsFragment.java |   7 
src/main/res/menu/fragment_conversation.xml                                         |   6 
src/main/res/xml/preferences_main.xml                                               |   5 
11 files changed, 286 insertions(+), 14 deletions(-)

Detailed changes

src/cheogram/java/com/cheogram/android/ExtensionSettingsFragment.java 🔗

@@ -0,0 +1,145 @@
+package com.cheogram.android;
+
+import android.Manifest;
+import android.content.Intent;
+import android.content.pm.PackageManager;
+import android.net.Uri;
+import android.os.Build;
+import android.os.Bundle;
+import android.util.Log;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.Toast;
+
+import androidx.activity.result.ActivityResultLauncher;
+import androidx.activity.result.contract.ActivityResultContracts;
+import androidx.appcompat.app.AlertDialog;
+import androidx.preference.Preference;
+import androidx.databinding.DataBindingUtil;
+import androidx.recyclerview.widget.RecyclerView;
+
+import com.google.android.material.dialog.MaterialAlertDialogBuilder;
+import com.google.common.base.Strings;
+import com.google.common.primitives.Longs;
+import com.google.common.io.Files;
+
+import java.io.File;
+import java.util.ArrayList;
+
+import eu.siacs.conversations.Config;
+import eu.siacs.conversations.R;
+import eu.siacs.conversations.databinding.FragmentExtensionSettingsBinding;
+import eu.siacs.conversations.databinding.ExtensionItemBinding;
+import eu.siacs.conversations.entities.Account;
+import eu.siacs.conversations.entities.Message;
+import eu.siacs.conversations.entities.StubConversation;
+import eu.siacs.conversations.persistance.FileBackend;
+import eu.siacs.conversations.ui.XmppActivity;
+import eu.siacs.conversations.ui.util.Attachment;
+import eu.siacs.conversations.worker.ExportBackupWorker;
+
+public class ExtensionSettingsFragment extends androidx.fragment.app.Fragment {
+	FragmentExtensionSettingsBinding binding;
+
+	@Override
+	public void onCreate(Bundle savedInstanceState) {
+		super.onCreate(savedInstanceState);
+	}
+
+	@Override
+	public View onCreateView(final LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
+		binding = DataBindingUtil.inflate(inflater, R.layout.fragment_extension_settings, container, false);
+		binding.addExtension.setOnClickListener((v) -> {
+			final var intent = new Intent();
+			intent.setAction(Intent.ACTION_GET_CONTENT);
+			intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true);
+			intent.setType("*/*");
+			intent.addCategory(Intent.CATEGORY_OPENABLE);
+			startActivityForResult(Intent.createChooser(intent, getString(R.string.perform_action_with)), 0x1);
+		});
+
+		binding.extensionList.setAdapter(new RecyclerView.Adapter<WebxdcViewHolder>() {
+			final ArrayList<WebxdcPage> xdcs = new ArrayList<>();
+
+			@Override
+			public int getItemCount() {
+				xdcs.clear();
+				final var activity = (XmppActivity) requireActivity();
+				final var xmppConnectionService = activity.xmppConnectionService;
+				if (xmppConnectionService == null) return xdcs.size();
+				final var dir = new File(xmppConnectionService.getExternalFilesDir(null), "extensions");
+				for (File file : Files.fileTraverser().breadthFirst(dir)) {
+					if (file.isFile() && file.canRead()) {
+						final var dummy = new Message(new StubConversation(null, "", null, 0), null, Message.ENCRYPTION_NONE);
+						dummy.setStatus(Message.STATUS_DUMMY);
+						dummy.setUuid(file.getName());
+						xdcs.add(new WebxdcPage(activity, file, dummy));
+					}
+				}
+				return xdcs.size();
+			}
+
+			@Override
+			public WebxdcViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
+				final ExtensionItemBinding binding = DataBindingUtil.inflate(inflater, R.layout.extension_item, container, false);
+				return new WebxdcViewHolder(binding);
+			}
+
+			@Override
+			public void onBindViewHolder(WebxdcViewHolder holder, int position) {
+				holder.bind(xdcs.get(position));
+			}
+		});
+
+		return binding.getRoot();
+	}
+
+	@Override
+	public void onSaveInstanceState(Bundle bundle) {
+		super.onSaveInstanceState(bundle);
+	}
+
+	@Override
+	public void onStart() {
+		super.onStart();
+		getActivity().setTitle("Extensions");
+	}
+
+	public void addExtension(Uri uri) {
+		final var xmppConnectionService = ((XmppActivity) requireActivity()).xmppConnectionService;
+		if (xmppConnectionService == null) return;
+		try {
+			final var fileBackend = xmppConnectionService.getFileBackend();
+			final var base = fileBackend.calculateCids(fileBackend.openInputStream(uri))[0].toString();
+			final var target = new File(new File(xmppConnectionService.getExternalFilesDir(null), "extensions"), base + ".xdc");
+			fileBackend.copyFileToPrivateStorage(target, uri);
+		} catch (final Exception e) {
+			Toast.makeText(requireActivity(), "Could not copy extension: " + e, Toast.LENGTH_SHORT).show();
+		}
+	}
+
+
+	@Override
+	public void onActivityResult(int requestCode, int resultCode, final Intent data) {
+		super.onActivityResult(requestCode, resultCode, data);
+		for (final var attachment : Attachment.extractAttachments(requireActivity(), data, Attachment.Type.FILE)) {
+			if ("application/webxdc+zip".equals(attachment.getMime())) addExtension(attachment.getUri());
+		}
+		binding.extensionList.getAdapter().notifyDataSetChanged();
+	}
+
+	protected static class WebxdcViewHolder extends RecyclerView.ViewHolder {
+		final ExtensionItemBinding binding;
+
+		public WebxdcViewHolder(final ExtensionItemBinding binding) {
+			super(binding.getRoot());
+			this.binding = binding;
+		}
+
+		public void bind(WebxdcPage xdc) {
+			binding.icon.setImageDrawable(xdc.getIcon());
+			binding.name.setText(xdc.getName());
+		}
+	}
+}

src/cheogram/java/com/cheogram/android/WebxdcPage.java 🔗

@@ -83,14 +83,14 @@ public class WebxdcPage implements ConversationPage {
 	protected Message source;
 	protected WebxdcUpdate lastUpdate = null;
 	protected WeakReference<XmppActivity> activity;
+	protected WeakReference<Consumer<ConversationPage>> remover;
 
-	public WebxdcPage(final XmppActivity activity, Cid cid, Message source, XmppConnectionService xmppConnectionService) {
-		this.xmppConnectionService = xmppConnectionService;
+	public WebxdcPage(final XmppActivity activity, File f, Message source) {
+		this.xmppConnectionService = activity.xmppConnectionService;
 		this.source = source;
 		this.activity = new WeakReference(activity);
-		File f = xmppConnectionService.getFileForCid(cid);
 		try {
-			if (f != null) zip = new ZipFile(xmppConnectionService.getFileForCid(cid));
+			if (f != null) zip = new ZipFile(f);
 			final ZipEntry manifestEntry = zip == null ? null : zip.getEntry("manifest.toml");
 			if (manifestEntry != null) {
 				manifest = Toml.parse(zip.getInputStream(manifestEntry));
@@ -106,7 +106,15 @@ public class WebxdcPage implements ConversationPage {
 		baseUrl = "https://" + source.getUuid() + ".localhost";
 	}
 
+	public WebxdcPage(final XmppActivity activity, Cid cid, Message source) {
+		this(activity, activity.xmppConnectionService.getFileForCid(cid), source);
+	}
+
 	public Drawable getIcon() {
+		return getIcon(288);
+	}
+
+	public Drawable getIcon(int dp) {
 		if (android.os.Build.VERSION.SDK_INT < 28) return null;
 		if (zip == null) return null;
 		ZipEntry entry = zip.getEntry("icon.webp");
@@ -120,7 +128,7 @@ public class WebxdcPage implements ConversationPage {
 			return ImageDecoder.decodeDrawable(source, (decoder, info, src) -> {
 				int w = info.getSize().getWidth();
 				int h = info.getSize().getHeight();
-				Rect r = FileBackend.rectForSize(w, h, (int)(metrics.density * 288));
+				Rect r = FileBackend.rectForSize(w, h, (int)(metrics.density * dp));
 				decoder.setTargetSize(r.width(), r.height());
 			});
 		} catch (final IOException e) {
@@ -212,6 +220,7 @@ public class WebxdcPage implements ConversationPage {
 	}
 
 	public View inflateUi(Context context, Consumer<ConversationPage> remover) {
+		this.remover = new WeakReference<>(remover);
 		if (binding != null) {
 			binding.webview.loadUrl("javascript:__webxdcUpdate();");
 			return getView();
@@ -306,7 +315,14 @@ public class WebxdcPage implements ConversationPage {
 
 		binding.webview.loadUrl(baseUrl + "/index.html");
 
-		binding.actions.setAdapter(new ArrayAdapter<String>(context, R.layout.simple_list_item, new String[]{"Add to Home Screen", "Close"}) {
+		final var actions =
+			source.getStatus() == Message.STATUS_DUMMY ?
+			new String[]{"Close"} :
+			new String[]{"Add to Home Screen", "Close"};
+
+		if (source.getStatus() == Message.STATUS_DUMMY) binding.actions.setNumColumns(1);
+
+		binding.actions.setAdapter(new ArrayAdapter<String>(context, R.layout.simple_list_item, actions) {
 			@Override
 			public View getView(int position, View convertView, ViewGroup parent) {
 				View v = super.getView(position, convertView, parent);
@@ -318,7 +334,7 @@ public class WebxdcPage implements ConversationPage {
 			}
 		});
 		binding.actions.setOnItemClickListener((parent, v, pos, id) -> {
-			if (pos == 0) {
+			if (pos == 0 && actions.length > 1) {
 				Intent intent = new Intent(xmppConnectionService, ConversationsActivity.class);
 				intent.setAction(ConversationsActivity.ACTION_VIEW_CONVERSATION);
 				intent.putExtra(ConversationsActivity.EXTRA_CONVERSATION, ((Conversation) source.getConversation()).getUuid());
@@ -349,12 +365,14 @@ public class WebxdcPage implements ConversationPage {
 	}
 
 	public void refresh() {
+		if (source.getStatus() == Message.STATUS_DUMMY) return;
 		if (binding == null) return;
 
 		binding.webview.post(() -> binding.webview.loadUrl("javascript:__webxdcUpdate();"));
 	}
 
 	public void realtimeData(String base64) {
+		if (source.getStatus() == Message.STATUS_DUMMY) return;
 		if (binding == null) return;
 
 		binding.webview.post(() -> binding.webview.loadUrl("javascript:__webxdcRealtimeData('" + base64.replace("'", "").replace("\\", "").replace("+", "%2B") + "');"));
@@ -393,6 +411,8 @@ public class WebxdcPage implements ConversationPage {
 
 		@JavascriptInterface
 		public boolean sendStatusUpdate(String paramS, String descr) {
+			if (source.getStatus() == Message.STATUS_DUMMY) return false;
+
 			JSONObject params = new JSONObject();
 			try {
 				params = new JSONObject(paramS);
@@ -444,6 +464,8 @@ public class WebxdcPage implements ConversationPage {
 
 		@JavascriptInterface
 		public String getStatusUpdates(long lastKnownSerial) {
+			if (source.getStatus() == Message.STATUS_DUMMY) return "[]";
+
 			StringBuilder builder = new StringBuilder("[");
 			String sep = "";
 			for (WebxdcUpdate update : xmppConnectionService.findWebxdcUpdates(source, lastKnownSerial)) {
@@ -483,7 +505,14 @@ public class WebxdcPage implements ConversationPage {
 					if (mimeType == null) mimeType = "application/octet-stream";
 					intent.putExtra(Intent.EXTRA_STREAM, Uri.parse("data:" + mimeType + ";base64," + data));
 				}
-				activity.get().startActivity(intent);
+				activity.get().runOnUiThread(() -> {
+					if (source.getStatus() == Message.STATUS_DUMMY) {
+						binding.webview.loadUrl("about:blank");
+						final var remover = WebxdcPage.this.remover.get();
+						if (remover != null) remover.accept(WebxdcPage.this);
+					}
+					activity.get().startActivity(intent);
+				});
 				return null;
 			} catch (Exception e) {
 				e.printStackTrace();
@@ -493,6 +522,8 @@ public class WebxdcPage implements ConversationPage {
 
 		@JavascriptInterface
 		public void sendRealtime(byte[] data) {
+			if (source.getStatus() == Message.STATUS_DUMMY) return;
+
 			Message message = new Message(source.getConversation(), null, Message.ENCRYPTION_NONE);
 			message.addPayload(new Element("no-store", "urn:xmpp:hints"));
 			Element webxdc = new Element("x", "urn:xmpp:webxdc:0");

src/cheogram/res/layout/extension_item.xml 🔗

@@ -0,0 +1,32 @@
+<?xml version="1.0" encoding="utf-8"?>
+<layout xmlns:android="http://schemas.android.com/apk/res/android"
+	xmlns:app="http://schemas.android.com/apk/res-auto">
+
+	<LinearLayout
+		android:layout_width="fill_parent"
+		android:layout_height="wrap_content"
+		android:layout_centerVertical="true"
+		android:orientation="horizontal"
+		android:paddingTop="8dp"
+		android:paddingBottom="8dp"
+		android:paddingLeft="@dimen/avatar_item_distance"
+		android:paddingRight="@dimen/avatar_item_distance"
+		android:background="@drawable/background_selectable_list_item">
+
+		<ImageView
+			android:id="@+id/icon"
+			android:layout_width="48dp"
+			android:layout_height="48dp"
+			android:gravity="center_vertical"
+			android:layout_marginEnd="10sp" />
+
+		<TextView
+			android:id="@+id/name"
+			android:layout_width="wrap_content"
+			android:layout_height="fill_parent"
+			android:gravity="center_vertical"
+			android:textAppearance="?textAppearanceBodyLarge" />
+
+	</LinearLayout>
+
+</layout>

src/cheogram/res/layout/fragment_extension_settings.xml 🔗

@@ -0,0 +1,28 @@
+<layout xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto">
+
+    <LinearLayout
+        android:layout_width="fill_parent"
+        android:layout_height="fill_parent"
+        android:orientation="vertical">
+
+        <com.google.android.material.button.MaterialButton
+            android:id="@+id/add_extension"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:layout_marginBottom="10dp"
+            android:layout_gravity="center"
+            app:icon="@drawable/ic_add_24dp"
+            app:iconTint="?colorOnPrimary"
+            android:text="Add Extension" />
+
+        <androidx.recyclerview.widget.RecyclerView
+            android:id="@+id/extension_list"
+            android:layout_width="match_parent"
+            android:layout_height="match_parent"
+            android:orientation="vertical"
+            app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" />
+
+    </LinearLayout>
+
+</layout>

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

@@ -101,6 +101,7 @@ import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Lists;
 import com.google.common.collect.Ordering;
+import com.google.common.io.Files;
 
 import com.otaliastudios.autocomplete.Autocomplete;
 import com.otaliastudios.autocomplete.AutocompleteCallback;
@@ -263,6 +264,7 @@ public class ConversationFragment extends XmppFragment
     private final PendingItem<String> pendingLastMessageUuid = new PendingItem<>();
     private final PendingItem<Message> pendingMessage = new PendingItem<>();
     public Uri mPendingEditorContent = null;
+    protected ArrayList<WebxdcPage> extensions = new ArrayList<>();
     protected MessageAdapter messageListAdapter;
     protected CommandAdapter commandAdapter;
     private MediaPreviewAdapter mediaPreviewAdapter;
@@ -1812,6 +1814,22 @@ public class ConversationFragment extends XmppFragment
                 MenuItem newItem = menu.add(item.getGroupId(), item.getItemId(), item.getOrder(), item.getTitle());
                 newItem.setIcon(item.getIcon());
             }
+
+            extensions.clear();
+            final var xmppConnectionService = activity.xmppConnectionService;
+            final var dir = new File(xmppConnectionService.getExternalFilesDir(null), "extensions");
+            for (File file : Files.fileTraverser().breadthFirst(dir)) {
+                if (file.isFile() && file.canRead()) {
+                    final var dummy = new Message(conversation, null, conversation.getNextEncryption());
+                    dummy.setStatus(Message.STATUS_DUMMY);
+                    dummy.setThread(conversation.getThread());
+                    dummy.setUuid(file.getName());
+                    final var xdc = new WebxdcPage(activity, file, dummy);
+                    extensions.add(xdc);
+                    final var item = menu.add(0x1, extensions.size() - 1, 0, xdc.getName());
+                    item.setIcon(xdc.getIcon(24));
+                }
+            }
             ConversationMenuConfigurator.configureAttachmentMenu(conversation, menu, TextUtils.isEmpty(binding.textinput.getText()));
             return;
         }
@@ -2126,6 +2144,10 @@ public class ConversationFragment extends XmppFragment
         } else if (conversation == null) {
             return super.onOptionsItemSelected(item);
         }
+        if (item.getGroupId() == 0x1) {
+            conversation.startWebxdc(extensions.get(item.getItemId()));
+            return true;
+        }
         switch (item.getItemId()) {
             case R.id.encryption_choice_axolotl:
             case R.id.encryption_choice_pgp:
@@ -3645,7 +3667,7 @@ public class ConversationFragment extends XmppFragment
             if (message == null) return;
 
             Cid webxdcCid = message.getFileParams().getCids().get(0);
-            WebxdcPage webxdc = new WebxdcPage(activity, webxdcCid, message, activity.xmppConnectionService);
+            WebxdcPage webxdc = new WebxdcPage(activity, webxdcCid, message);
             Conversation conversation = (Conversation) message.getConversation();
             if (!conversation.switchToSession("webxdc\0" + message.getUuid())) {
                 conversation.startWebxdc(webxdc);

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

@@ -478,7 +478,7 @@ public class ConversationsOverviewFragment extends XmppFragment {
 	}
 
 	@Override
-	void refresh() {
+	protected void refresh() {
 		if (this.binding == null || this.activity == null) {
 			Log.d(Config.LOGTAG,"ConversationsOverviewFragment.refresh() skipped updated because view binding or activity was null");
 			return;

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

@@ -45,7 +45,7 @@ public abstract class XmppFragment extends Fragment implements OnBackendConnecte
 
 	protected LifecycleRegistry lifecycle = new LifecycleRegistry(this);
 
-	abstract void refresh();
+	abstract protected void refresh();
 	public void refreshForNewCaps(final Set<Jid> newCapsJids) { }
 
 	protected void runOnUiThread(Runnable runnable) {

src/main/java/eu/siacs/conversations/ui/adapter/MessageAdapter.java 🔗

@@ -852,7 +852,7 @@ public class MessageAdapter extends ArrayAdapter<Message> {
 
     private void displayWebxdcMessage(ViewHolder viewHolder, final Message message, final BubbleColor bubbleColor, final int type) {
         Cid webxdcCid = message.getFileParams().getCids().get(0);
-        WebxdcPage webxdc = new WebxdcPage(activity, webxdcCid, message, activity.xmppConnectionService);
+        WebxdcPage webxdc = new WebxdcPage(activity, webxdcCid, message);
         displayTextMessage(viewHolder, message, bubbleColor, type);
         viewHolder.image.setVisibility(View.GONE);
         viewHolder.audioPlayer.setVisibility(View.GONE);

src/main/java/eu/siacs/conversations/ui/fragment/settings/MainSettingsFragment.java 🔗

@@ -36,6 +36,13 @@ public class MainSettingsFragment extends PreferenceFragmentCompat {
         if (ConnectionSettingsFragment.hideChannelDiscovery()) {
             connection.setSummary(R.string.pref_connection_summary);
         }
+        findPreference("extensions").setOnPreferenceClickListener((p) -> {
+            getFragmentManager().beginTransaction()
+                .replace(R.id.fragment_container, new com.cheogram.android.ExtensionSettingsFragment())
+                .addToBackStack(null)
+                .commit();
+            return true;
+        });
     }
 
     @Override

src/main/res/menu/fragment_conversation.xml 🔗

@@ -60,12 +60,14 @@
             <item
                 android:id="@+id/attach_subject"
                 android:icon="@drawable/subject"
-                android:title="Add Subject" />
+                android:title="Add Subject"
+                android:orderInCategory="2" />
 
             <item
                 android:id="@+id/attach_schedule"
                 android:icon="@drawable/schedule_message"
-                android:title="@string/schedule_message" />
+                android:title="@string/schedule_message"
+                android:orderInCategory="3" />
         </menu>
     </item>
     <item

src/main/res/xml/preferences_main.xml 🔗

@@ -32,6 +32,11 @@
         app:fragment="eu.siacs.conversations.ui.fragment.settings.ConnectionSettingsFragment"
         app:summary="@string/pref_connection_summary_w_cd"
         app:title="@string/pref_connection_options" />
+    <Preference
+        android:icon="@drawable/toys_and_games_24dp"
+        android:key="extensions"
+        app:summary="Add, remove, and configure extensions"
+        app:title="Extensions" />
     <Preference
         android:icon="@drawable/ic_archive_24dp"
         android:key="backup"