Merge branch 'jabber-iq-gateway2'

* jabber-iq-gateway2:
  Improve example hinting
  Move GONE/VISIBLE to the controller instead of the model
  Show identity category gateway even without jabber:iq:gateway prompt
  Sort gateways
  Switch to AndroidX LinearLayoutManager
  Parse phone numbers using local settings before asking gateway
  Try all gateway translations options
  Change input type based on gateway type
  Load gateways into UI
  Use a RecyclerView for list of gateway options
  Fetch jabber:iq:gateway prompt
This commit is contained in:
Stephen Paul Weber 2022-03-09 20:31:15 -05:00
commit 6269ccc054
No known key found for this signature in database
GPG Key ID: D11C2911CE519CDE
7 changed files with 385 additions and 51 deletions

View File

@ -94,6 +94,7 @@ dependencies {
implementation 'com.google.guava:guava:30.1.1-android'
implementation 'io.michaelrocks:libphonenumber-android:8.12.36'
implementation 'io.github.nishkarsh:android-permissions:2.1.6'
implementation 'androidx.recyclerview:recyclerview:1.1.0'
implementation urlFile('https://cloudflare-ipfs.com/ipfs/QmeqMiLxHi8AAjXobxr3QTfa1bSSLyAu86YviAqQnjxCjM/libwebrtc.aar', 'libwebrtc.aar')
// INSERT
}

View File

@ -168,15 +168,19 @@ public class ServiceDiscoveryResult {
return this.features;
}
public boolean hasIdentity(String category, String type) {
public Identity getIdentity(String category, String type) {
for (Identity id : this.getIdentities()) {
if ((category == null || id.getCategory().equals(category)) &&
(type == null || id.getType().equals(type))) {
return true;
return id;
}
}
return false;
return null;
}
public boolean hasIdentity(String category, String type) {
return getIdentity(category, type) != null;
}
public String getExtendedDiscoInformation(String formType, String name) {

View File

@ -145,6 +145,7 @@ import eu.siacs.conversations.xml.Namespace;
import eu.siacs.conversations.xmpp.Jid;
import eu.siacs.conversations.xmpp.OnBindListener;
import eu.siacs.conversations.xmpp.OnContactStatusChanged;
import eu.siacs.conversations.xmpp.OnGatewayResult;
import eu.siacs.conversations.xmpp.OnIqPacketReceived;
import eu.siacs.conversations.xmpp.OnKeyStatusUpdated;
import eu.siacs.conversations.xmpp.OnMessageAcknowledged;
@ -4655,6 +4656,24 @@ public class XmppConnectionService extends Service {
}
}
public void fetchFromGateway(Account account, final Jid jid, final String input, final OnGatewayResult callback) {
IqPacket request = new IqPacket(input == null ? IqPacket.TYPE.GET : IqPacket.TYPE.SET);
request.setTo(jid);
Element query = request.query("jabber:iq:gateway");
if (input != null) {
Element prompt = query.addChild("prompt");
prompt.setContent(input);
}
sendIqPacket(account, request, (Account acct, IqPacket packet) -> {
if (packet.getType() == IqPacket.TYPE.RESULT) {
callback.onGatewayResult(packet.query().findChildContent(input == null ? "prompt" : "jid"), null);
} else {
Element error = packet.findChild("error");
callback.onGatewayResult(null, error == null ? null : error.findChildContent("text"));
}
});
}
public void fetchCaps(Account account, final Jid jid, final Presence presence) {
final Pair<String, String> key = new Pair<>(presence.getHash(), presence.getVer());
final ServiceDiscoveryResult disco = getCachedServiceDiscoveryResult(key);

View File

@ -2,31 +2,51 @@ package eu.siacs.conversations.ui;
import android.app.Activity;
import android.app.Dialog;
import android.content.DialogInterface.OnClickListener;
import android.content.DialogInterface;
import android.os.Bundle;
import android.text.Editable;
import android.text.InputType;
import android.text.TextWatcher;
import android.util.Pair;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.AdapterView;
import android.widget.ArrayAdapter;
import android.widget.TextView;
import android.widget.ToggleButton;
import androidx.annotation.NonNull;
import androidx.appcompat.app.AlertDialog;
import androidx.databinding.DataBindingUtil;
import androidx.fragment.app.DialogFragment;
import androidx.recyclerview.widget.RecyclerView;
import androidx.recyclerview.widget.LinearLayoutManager;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import io.michaelrocks.libphonenumber.android.NumberParseException;
import eu.siacs.conversations.Config;
import eu.siacs.conversations.R;
import eu.siacs.conversations.databinding.EnterJidDialogBinding;
import eu.siacs.conversations.services.XmppConnectionService;
import eu.siacs.conversations.entities.Account;
import eu.siacs.conversations.entities.Contact;
import eu.siacs.conversations.entities.Presence;
import eu.siacs.conversations.entities.ServiceDiscoveryResult;
import eu.siacs.conversations.ui.adapter.KnownHostsAdapter;
import eu.siacs.conversations.ui.interfaces.OnBackendConnected;
import eu.siacs.conversations.ui.util.DelayedHintHelper;
import eu.siacs.conversations.utils.PhoneNumberUtilWrapper;
import eu.siacs.conversations.xmpp.Jid;
import eu.siacs.conversations.xmpp.OnGatewayResult;
public class EnterJidDialog extends DialogFragment implements OnBackendConnected, TextWatcher {
@ -51,6 +71,7 @@ public class EnterJidDialog extends DialogFragment implements OnBackendConnected
private boolean sanityCheckJid = false;
private boolean issuedWarning = false;
private GatewayListAdapter gatewayListAdapter = new GatewayListAdapter();
public static EnterJidDialog newInstance(
final List<String> activatedAccounts,
@ -129,6 +150,39 @@ public class EnterJidDialog extends DialogFragment implements OnBackendConnected
binding.account.setAdapter(adapter);
}
binding.gatewayList.setLayoutManager(new LinearLayoutManager(getActivity(), LinearLayoutManager.HORIZONTAL, false));
binding.gatewayList.setAdapter(gatewayListAdapter);
gatewayListAdapter.setOnEmpty(() -> binding.gatewayList.setVisibility(View.GONE));
gatewayListAdapter.setOnNonEmpty(() -> binding.gatewayList.setVisibility(View.VISIBLE));
binding.account.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() {
@Override
public void onItemSelected(AdapterView accountSpinner, View view, int position, long id) {
XmppActivity context = (XmppActivity) getActivity();
if (context.xmppConnectionService == null || accountJid() == null) return;
gatewayListAdapter.clear();
final Account account = context.xmppConnectionService.findAccountByJid(accountJid());
for (final Contact contact : account.getRoster().getContacts()) {
if (contact.showInRoster() && (contact.getPresences().anyIdentity("gateway", null) || contact.getPresences().anySupport("jabber:iq:gateway"))) {
context.xmppConnectionService.fetchFromGateway(account, contact.getJid(), null, (final String prompt, String errorMessage) -> {
if (prompt == null && !contact.getPresences().anyIdentity("gateway", null)) return;
context.runOnUiThread(() -> {
gatewayListAdapter.add(contact, prompt);
});
});
}
}
}
@Override
public void onNothingSelected(AdapterView accountSpinner) {
gatewayListAdapter.clear();
}
});
builder.setView(binding.getRoot());
builder.setNegativeButton(R.string.cancel, null);
builder.setPositiveButton(getArguments().getString(POSITIVE_BUTTON_KEY), null);
@ -150,59 +204,92 @@ public class EnterJidDialog extends DialogFragment implements OnBackendConnected
return dialog;
}
protected Jid accountJid() {
try {
if (Config.DOMAIN_LOCK != null) {
return Jid.ofEscaped((String) binding.account.getSelectedItem(), Config.DOMAIN_LOCK, null);
} else {
return Jid.ofEscaped((String) binding.account.getSelectedItem());
}
} catch (final IllegalArgumentException e) {
return null;
}
}
private void handleEnter(EnterJidDialogBinding binding, String account) {
final Jid accountJid;
if (!binding.account.isEnabled() && account == null) {
return;
}
try {
if (Config.DOMAIN_LOCK != null) {
accountJid =
Jid.ofEscaped(
(String) binding.account.getSelectedItem(),
Config.DOMAIN_LOCK,
null);
} else {
accountJid = Jid.ofEscaped((String) binding.account.getSelectedItem());
}
} catch (final IllegalArgumentException e) {
return;
}
final Jid contactJid;
try {
contactJid = Jid.ofEscaped(binding.jid.getText().toString());
} catch (final IllegalArgumentException e) {
binding.jidLayout.setError(getActivity().getString(R.string.invalid_jid));
return;
}
if (!issuedWarning && sanityCheckJid) {
if (contactJid.isDomainJid()) {
binding.jidLayout.setError(
getActivity().getString(R.string.this_looks_like_a_domain));
dialog.getButton(AlertDialog.BUTTON_POSITIVE).setText(R.string.add_anway);
issuedWarning = true;
return;
}
if (suspiciousSubDomain(contactJid.getDomain().toEscapedString())) {
binding.jidLayout.setError(
getActivity().getString(R.string.this_looks_like_channel));
dialog.getButton(AlertDialog.BUTTON_POSITIVE).setText(R.string.add_anway);
issuedWarning = true;
return;
}
}
if (mListener != null) {
try {
if (mListener.onEnterJidDialogPositive(accountJid, contactJid)) {
dialog.dismiss();
final Jid accountJid = accountJid();
final OnGatewayResult finish = (final String jidString, final String errorMessage) -> {
getActivity().runOnUiThread(() -> {
if (errorMessage != null) {
binding.jidLayout.setError(errorMessage);
return;
}
} catch (JidError error) {
binding.jidLayout.setError(error.toString());
dialog.getButton(AlertDialog.BUTTON_POSITIVE).setText(R.string.add);
issuedWarning = false;
}
if (jidString == null) {
binding.jidLayout.setError(getActivity().getString(R.string.invalid_jid));
return;
}
final Jid contactJid;
try {
contactJid = Jid.ofEscaped(jidString);
} catch (final IllegalArgumentException e) {
binding.jidLayout.setError(getActivity().getString(R.string.invalid_jid));
return;
}
if (!issuedWarning && sanityCheckJid) {
if (contactJid.isDomainJid()) {
binding.jidLayout.setError(getActivity().getString(R.string.this_looks_like_a_domain));
dialog.getButton(AlertDialog.BUTTON_POSITIVE).setText(R.string.add_anway);
issuedWarning = true;
return;
}
if (suspiciousSubDomain(contactJid.getDomain().toEscapedString())) {
binding.jidLayout.setError(getActivity().getString(R.string.this_looks_like_channel));
dialog.getButton(AlertDialog.BUTTON_POSITIVE).setText(R.string.add_anway);
issuedWarning = true;
return;
}
}
if (mListener != null) {
try {
if (mListener.onEnterJidDialogPositive(accountJid, contactJid)) {
dialog.dismiss();
}
} catch (JidError error) {
binding.jidLayout.setError(error.toString());
dialog.getButton(AlertDialog.BUTTON_POSITIVE).setText(R.string.add);
issuedWarning = false;
}
}
});
};
Pair<String,Pair<Jid,Presence>> p = gatewayListAdapter.getSelected();
final String type = gatewayListAdapter.getSelectedType();
// Resolve based on local settings before submission
if (type.equals("pstn") || type.equals("sms")) {
try {
binding.jid.setText(PhoneNumberUtilWrapper.normalize(getActivity(), binding.jid.getText().toString()));
} catch (NumberParseException | NullPointerException e) { }
}
if (p == null) {
finish.onGatewayResult(binding.jid.getText().toString(), null);
} else if (p.first != null) { // Gateway already responsed to jabber:iq:gateway once
final Account acct = ((XmppActivity) getActivity()).xmppConnectionService.findAccountByJid(accountJid);
((XmppActivity) getActivity()).xmppConnectionService.fetchFromGateway(acct, p.second.first, binding.jid.getText().toString(), finish);
} else if (p.second.first.isDomainJid() && p.second.second.getServiceDiscoveryResult().getFeatures().contains("jid\\20escaping")) {
finish.onGatewayResult(Jid.ofLocalAndDomain(binding.jid.getText().toString(), p.second.first.getDomain().toString()).toString(), null);
} else if (p.second.first.isDomainJid()) {
finish.onGatewayResult(Jid.ofLocalAndDomain(binding.jid.getText().toString().replace("@", "%"), p.second.first.getDomain().toString()).toString(), null);
} else {
finish.onGatewayResult(null, null);
}
}
@ -276,4 +363,201 @@ public class EnterJidDialog extends DialogFragment implements OnBackendConnected
final String[] parts = domain.split("\\.");
return parts.length >= 3 && SUSPICIOUS_DOMAINS.contains(parts[0]);
}
protected class GatewayListAdapter extends RecyclerView.Adapter<GatewayListAdapter.ViewHolder> {
protected class ViewHolder extends RecyclerView.ViewHolder {
protected ToggleButton button;
protected int index;
public ViewHolder(View view, int i) {
super(view);
this.button = (ToggleButton) view.findViewById(R.id.button);
setIndex(i);
button.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
button.setChecked(true); // Force visual not to flap to unchecked
setSelected(index);
}
});
}
public void setIndex(int i) {
this.index = i;
button.setChecked(selected == i);
}
public void useButton(int res) {
button.setText(res);
button.setTextOff(button.getText());
button.setTextOn(button.getText());
button.setChecked(selected == this.index);
binding.gatewayList.setVisibility(View.VISIBLE);
button.setVisibility(View.VISIBLE);
}
public void useButton(String txt) {
button.setTextOff(txt);
button.setTextOn(txt);
button.setChecked(selected == this.index);
binding.gatewayList.setVisibility(View.VISIBLE);
button.setVisibility(View.VISIBLE);
}
}
protected List<Pair<Contact,String>> gateways = new ArrayList();
protected int selected = 0;
protected Runnable onEmpty = () -> {};
protected Runnable onNonEmpty = () -> {};
@Override
public ViewHolder onCreateViewHolder(ViewGroup viewGroup, int i) {
View view = LayoutInflater.from(viewGroup.getContext()).inflate(R.layout.enter_jid_dialog_gateway_list_item, null);
return new ViewHolder(view, i);
}
@Override
public void onBindViewHolder(ViewHolder viewHolder, int i) {
viewHolder.setIndex(i);
if(i == 0) {
viewHolder.useButton(R.string.account_settings_jabber_id);
} else {
viewHolder.useButton(getLabel(i));
}
}
@Override
public int getItemCount() {
return this.gateways.size() + 1;
}
public void setSelected(int i) {
int old = this.selected;
this.selected = i;
if(i == 0) {
binding.jid.setThreshold(1);
binding.jid.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS | InputType.TYPE_TEXT_FLAG_AUTO_COMPLETE);
binding.jidLayout.setHint(R.string.account_settings_jabber_id);
if(binding.jid.hasFocus()) {
binding.jid.setHint(R.string.account_settings_example_jabber_id);
} else {
DelayedHintHelper.setHint(R.string.account_settings_example_jabber_id, binding.jid);
}
} else {
binding.jid.setThreshold(999999); // do not autocomplete
binding.jid.setHint(null);
binding.jid.setOnFocusChangeListener((v, hasFocus) -> {});
binding.jidLayout.setHint(this.gateways.get(i-1).second);
String type = getType(i);
if (type.equals("pstn") || type.equals("sms")) {
binding.jid.setInputType(InputType.TYPE_CLASS_PHONE);
} else if (type.equals("email") || type.equals("sip")) {
binding.jid.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS);
if(binding.jid.hasFocus()) {
binding.jid.setHint(R.string.account_settings_example_jabber_id);
} else {
DelayedHintHelper.setHint(R.string.account_settings_example_jabber_id, binding.jid);
}
} else {
binding.jid.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS);
}
}
notifyItemChanged(old);
notifyItemChanged(i);
}
public String getLabel(Contact gateway) {
String type = getType(gateway);
if (type != null) return type;
return gateway.getDisplayName();
}
public String getLabel(int i) {
if (i == 0) return null;
return getLabel(this.gateways.get(i-1).first);
}
public String getType(int i) {
if (i == 0) return null;
return getType(this.gateways.get(i-1).first);
}
public String getType(Contact gateway) {
for(Presence p : gateway.getPresences().getPresences()) {
ServiceDiscoveryResult.Identity id;
if(p.getServiceDiscoveryResult() != null && (id = p.getServiceDiscoveryResult().getIdentity("gateway", null)) != null) {
return id.getType();
}
}
return null;
}
public String getSelectedType() {
return getType(selected);
}
public Pair<String, Pair<Jid,Presence>> getSelected() {
if(this.selected == 0) {
return null; // No gateway, just use direct JID entry
}
Pair<Contact,String> gateway = this.gateways.get(this.selected - 1);
Pair<Jid,Presence> presence = null;
for (Map.Entry<String,Presence> e : gateway.first.getPresences().getPresencesMap().entrySet()) {
Presence p = e.getValue();
if (p.getServiceDiscoveryResult() != null) {
if (p.getServiceDiscoveryResult().getFeatures().contains("jabber:iq:gateway")) {
if (e.getKey().equals("")) {
presence = new Pair<>(gateway.first.getJid(), p);
} else {
presence = new Pair<>(gateway.first.getJid().withResource(e.getKey()), p);
}
break;
}
if (p.getServiceDiscoveryResult().hasIdentity("gateway", null)) {
if (e.getKey().equals("")) {
presence = new Pair<>(gateway.first.getJid(), p);
} else {
presence = new Pair<>(gateway.first.getJid().withResource(e.getKey()), p);
}
}
}
}
return presence == null ? null : new Pair(gateway.second, presence);
}
public void setOnEmpty(Runnable r) {
onEmpty = r;
}
public void setOnNonEmpty(Runnable r) {
onNonEmpty = r;
}
public void clear() {
gateways.clear();
onEmpty.run();
notifyDataSetChanged();
setSelected(0);
}
public void add(Contact gateway, String prompt) {
if (getItemCount() < 2) onNonEmpty.run();
this.gateways.add(new Pair<>(gateway, prompt));
Collections.sort(this.gateways, (x, y) -> getLabel(x.first).compareTo(getLabel(y.first)));
notifyDataSetChanged();
}
}
}

View File

@ -0,0 +1,7 @@
package eu.siacs.conversations.xmpp;
public interface OnGatewayResult {
// if prompt is null, there was an error
// errorText may or may not be set
public void onGatewayResult(String prompt, String errorText);
}

View File

@ -22,6 +22,11 @@
android:layout_width="fill_parent"
android:layout_height="wrap_content"/>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/gateway_list"
android:layout_width="fill_parent"
android:layout_height="wrap_content" />
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/jid_layout"
android:layout_width="match_parent"

View File

@ -0,0 +1,14 @@
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingRight="5dp">
<ToggleButton
android:id="@+id/button"
android:gravity="center"
android:visibility="gone"
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:textColor="?attr/edit_text_color"
android:textSize="?attr/TextSizeBody1" />
</LinearLayout>