Experimental WebXDC extensions functionality

(cherry picked from commit 3de2b965c8725320cae1fd6b3c3a116b8e03097d)
This commit is contained in:
Stephen Paul Weber 2024-12-09 07:13:21 +01:00 committed by Arne
parent 0e4d3ea9ac
commit aeac25dd54
11 changed files with 286 additions and 13 deletions

View file

@ -122,6 +122,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;
@ -311,6 +312,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;
@ -2127,6 +2129,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;
}
@ -2446,6 +2464,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:
@ -4075,7 +4097,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);

View file

@ -499,7 +499,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;

View file

@ -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) {

View file

@ -855,7 +855,7 @@ public class MessageAdapter extends ArrayAdapter<Message> {
private void displayWebxdcMessage(BubbleMessageItemViewHolder 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);

View file

@ -38,6 +38,13 @@ public class MainSettingsFragment extends PreferenceFragmentCompat {
connection.setSummary(R.string.pref_connection_summary);
}
up.setVisible(!Strings.isNullOrEmpty(getString(R.string.default_push_server)));
findPreference("extensions").setOnPreferenceClickListener((p) -> {
getFragmentManager().beginTransaction()
.replace(R.id.fragment_container, new com.cheogram.android.ExtensionSettingsFragment())
.addToBackStack(null)
.commit();
return true;
});
}
@Override

View file

@ -84,10 +84,13 @@
android:id="@+id/attach_subject"
android:icon="@drawable/subject"
android:title="@string/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

View file

@ -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="@string/pref_extensions_summary"
app:title="@string/pref_extensions_title" />
<Preference
android:icon="@drawable/ic_archive_24dp"
android:key="backup"

View file

@ -0,0 +1,145 @@
package de.monocles.chat;
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());
}
}
}

View file

@ -84,14 +84,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));
@ -107,7 +107,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");
@ -121,7 +129,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) {
@ -213,6 +221,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();
@ -309,7 +318,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[]{activity.get().getString(R.string.add_to_home_screen), activity.get().getString(R.string.action_close)}) {
final var actions =
source.getStatus() == Message.STATUS_DUMMY ?
new String[]{activity.get().getString(R.string.action_close)} :
new String[]{activity.get().getString(R.string.add_to_home_screen), activity.get().getString(R.string.action_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);
@ -321,7 +337,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());
@ -352,6 +368,7 @@ public class WebxdcPage implements ConversationPage {
}
public void refresh() {
if (source.getStatus() == Message.STATUS_DUMMY) return;
if (binding == null) return;
if (activity != null) {
activity.get().getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE);
@ -360,6 +377,7 @@ public class WebxdcPage implements ConversationPage {
}
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") + "');"));
@ -398,6 +416,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);
@ -449,6 +469,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)) {
@ -488,7 +510,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();
@ -498,6 +527,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");

View file

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

View file

@ -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="@string/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>