diff --git a/build.gradle b/build.gradle index 04441ebab..2778e7f76 100644 --- a/build.gradle +++ b/build.gradle @@ -79,7 +79,7 @@ dependencies { implementation "com.squareup.okhttp3:okhttp:4.10.0" implementation 'com.google.guava:guava:31.1-android' - quicksyImplementation 'io.michaelrocks:libphonenumber-android:8.12.49' + implementation 'io.michaelrocks:libphonenumber-android:8.12.49' implementation 'im.conversations.webrtc:webrtc-android:104.0.0' implementation 'androidx.constraintlayout:constraintlayout:2.1.4' diff --git a/src/conversations/java/eu/siacs/conversations/utils/PhoneNumberUtilWrapper.java b/src/conversations/java/eu/siacs/conversations/utils/PhoneNumberUtilWrapper.java index 2f7963cf6..fc7aacf0c 100644 --- a/src/conversations/java/eu/siacs/conversations/utils/PhoneNumberUtilWrapper.java +++ b/src/conversations/java/eu/siacs/conversations/utils/PhoneNumberUtilWrapper.java @@ -2,10 +2,103 @@ package eu.siacs.conversations.utils; import android.content.Context; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + import eu.siacs.conversations.xmpp.Jid; +import io.michaelrocks.libphonenumber.android.NumberParseException; +import io.michaelrocks.libphonenumber.android.PhoneNumberUtil; +import io.michaelrocks.libphonenumber.android.Phonenumber; public class PhoneNumberUtilWrapper { - public static String toFormattedPhoneNumber(Context context, Jid jid) { - throw new AssertionError("This method is not implemented in Conversations"); + + private static volatile PhoneNumberUtil instance; + + + public static String getCountryForCode(String code) { + Locale locale = new Locale("", code); + return locale.getDisplayCountry(); } + + public static String toFormattedPhoneNumber(Context context, Jid jid) { + try { + return getInstance(context).format(toPhoneNumber(context, jid), PhoneNumberUtil.PhoneNumberFormat.INTERNATIONAL).replace(' ','\u202F'); + } catch (Exception e) { + return jid.getEscapedLocal(); + } + } + + public static Phonenumber.PhoneNumber toPhoneNumber(Context context, Jid jid) throws NumberParseException { + return getInstance(context).parse(jid.getEscapedLocal(), "de"); + } + + public static String normalize(Context context, String input) throws IllegalArgumentException, NumberParseException { + return normalize(context, input, false); + } + + public static String normalize(Context context, String input, boolean preferNetwork) throws IllegalArgumentException, NumberParseException { + final Phonenumber.PhoneNumber number = getInstance(context).parse(input, LocationProvider.getUserCountry(context, preferNetwork)); + if (!getInstance(context).isValidNumber(number)) { + throw new IllegalArgumentException(String.format("%s is not a valid phone number", input)); + } + return normalize(context, number); + } + + public static String normalize(Context context, Phonenumber.PhoneNumber phoneNumber) { + return getInstance(context).format(phoneNumber, PhoneNumberUtil.PhoneNumberFormat.E164); + } + + public static PhoneNumberUtil getInstance(final Context context) { + PhoneNumberUtil localInstance = instance; + if (localInstance == null) { + synchronized (PhoneNumberUtilWrapper.class) { + localInstance = instance; + if (localInstance == null) { + instance = localInstance = PhoneNumberUtil.createInstance(context); + } + + } + } + return localInstance; + } + + public static List getCountries(final Context context) { + List countries = new ArrayList<>(); + for (String region : getInstance(context).getSupportedRegions()) { + countries.add(new Country(region, getInstance(context).getCountryCodeForRegion(region))); + } + return countries; + + } + + public static class Country implements Comparable { + private final String name; + private final String region; + private final int code; + + Country(String region, int code) { + this.name = getCountryForCode(region); + this.region = region; + this.code = code; + } + + public String getName() { + return name; + } + + public String getRegion() { + return region; + } + + public String getCode() { + return '+' + String.valueOf(code); + } + + @Override + public int compareTo(Country o) { + return name.compareTo(o.name); + } + } + } diff --git a/src/main/java/eu/siacs/conversations/entities/Presences.java b/src/main/java/eu/siacs/conversations/entities/Presences.java index 04d378cc2..4753e2138 100644 --- a/src/main/java/eu/siacs/conversations/entities/Presences.java +++ b/src/main/java/eu/siacs/conversations/entities/Presences.java @@ -149,6 +149,22 @@ public class Presences { return false; } + public boolean anyIdentity(final String category, final String type) { + synchronized (this.presences) { + if (this.presences.size() == 0) { + // https://github.com/iNPUTmice/Conversations/issues/4230 + return false; + } + for (Presence presence : this.presences.values()) { + ServiceDiscoveryResult disco = presence.getServiceDiscoveryResult(); + if (disco != null && disco.hasIdentity(category, type)) { + return true; + } + } + } + return false; + } + public Pair, Map> toTypeAndNameMap() { Map typeMap = new HashMap<>(); Map nameMap = new HashMap<>(); diff --git a/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java b/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java index e54c95eaf..32973ed9d 100644 --- a/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java +++ b/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java @@ -153,6 +153,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; @@ -3376,6 +3377,24 @@ public class XmppConnectionService extends Service { fetchConferenceConfiguration(conversation, null); } + public void checkIfMuc(final Account account, final Jid jid, eu.siacs.conversations.utils.Consumer cb) { + if (jid.isDomainJid()) { + // Spec basically says MUC needs to have a node + // And also specifies that MUC and MUC service should have the same identity... + cb.accept(false); + return; + } + + IqPacket request = mIqGenerator.queryDiscoInfo(jid.asBareJid()); + sendIqPacket(account, request, (acct, reply) -> { + ServiceDiscoveryResult result = new ServiceDiscoveryResult(reply); + cb.accept( + result.getFeatures().contains("http://jabber.org/protocol/muc") && + result.hasIdentity("conference", null) + ); + }); + } + public void fetchConferenceConfiguration(final Conversation conversation, final OnConferenceConfigurationFetched callback) { IqPacket request = mIqGenerator.queryDiscoInfo(conversation.getJid().asBareJid()); sendIqPacket(conversation.getAccount(), request, new OnIqPacketReceived() { @@ -4741,6 +4760,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 key = new Pair<>(presence.getHash(), presence.getVer()); final ServiceDiscoveryResult disco = getCachedServiceDiscoveryResult(key); diff --git a/src/main/java/eu/siacs/conversations/ui/BlocklistActivity.java b/src/main/java/eu/siacs/conversations/ui/BlocklistActivity.java index 55cf9ba03..52e12d960 100644 --- a/src/main/java/eu/siacs/conversations/ui/BlocklistActivity.java +++ b/src/main/java/eu/siacs/conversations/ui/BlocklistActivity.java @@ -80,12 +80,14 @@ public class BlocklistActivity extends AbstractSearchableListItemActivity implem getString(R.string.block_jabber_id), getString(R.string.block), null, + null, account.getJid().asBareJid().toEscapedString(), true, - false + false, + EnterJidDialog.SanityCheck.NO ); - dialog.setOnEnterJidDialogPositiveListener((accountJid, contactJid) -> { + dialog.setOnEnterJidDialogPositiveListener((accountJid, contactJid, x, y) -> { Blockable blockable = new RawBlockable(account, contactJid); if (xmppConnectionService.sendBlockRequest(blockable, false)) { Toast.makeText(BlocklistActivity.this, R.string.corresponding_conversations_closed, Toast.LENGTH_SHORT).show(); diff --git a/src/main/java/eu/siacs/conversations/ui/ChooseContactActivity.java b/src/main/java/eu/siacs/conversations/ui/ChooseContactActivity.java index 9da5c7546..3377e6fe7 100644 --- a/src/main/java/eu/siacs/conversations/ui/ChooseContactActivity.java +++ b/src/main/java/eu/siacs/conversations/ui/ChooseContactActivity.java @@ -314,13 +314,15 @@ public class ChooseContactActivity extends AbstractSearchableListItemActivity im mActivatedAccounts, getString(R.string.enter_contact), getString(R.string.select), + null, jid == null ? null : jid.asBareJid().toString(), getIntent().getStringExtra(EXTRA_ACCOUNT), true, - false + false, + EnterJidDialog.SanityCheck.NO ); - dialog.setOnEnterJidDialogPositiveListener((accountJid, contactJid) -> { + dialog.setOnEnterJidDialogPositiveListener((accountJid, contactJid, x, y) -> { final Intent request = getIntent(); final Intent data = new Intent(); data.putExtra("contact", contactJid.toString()); diff --git a/src/main/java/eu/siacs/conversations/ui/EnterJidDialog.java b/src/main/java/eu/siacs/conversations/ui/EnterJidDialog.java index 9ffd9c673..dcf67f97b 100644 --- a/src/main/java/eu/siacs/conversations/ui/EnterJidDialog.java +++ b/src/main/java/eu/siacs/conversations/ui/EnterJidDialog.java @@ -2,73 +2,106 @@ 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 { private static final List SUSPICIOUS_DOMAINS = - Arrays.asList("conference", "muc", "room", "rooms", "chat"); + Arrays.asList("conference", "muc", "room", "rooms"); private OnEnterJidDialogPositiveListener mListener = null; private static final String TITLE_KEY = "title"; private static final String POSITIVE_BUTTON_KEY = "positive_button"; + private static final String SECONDARY_BUTTON_KEY = "secondary_button"; private static final String PREFILLED_JID_KEY = "prefilled_jid"; private static final String ACCOUNT_KEY = "account"; private static final String ALLOW_EDIT_JID_KEY = "allow_edit_jid"; private static final String ACCOUNTS_LIST_KEY = "activated_accounts_list"; private static final String SANITY_CHECK_JID = "sanity_check_jid"; + private static final String SHOW_BOOKMARK_CHECKBOX = "show_bookmark_checkbox"; private KnownHostsAdapter knownHostsAdapter; private Collection whitelistedDomains = Collections.emptyList(); private EnterJidDialogBinding binding; private AlertDialog dialog; - private boolean sanityCheckJid = false; + private SanityCheck sanityCheckJid = SanityCheck.NO; private boolean issuedWarning = false; + private GatewayListAdapter gatewayListAdapter = new GatewayListAdapter(); + + public static enum SanityCheck { + NO, + YES, + ALLOW_MUC + } public static EnterJidDialog newInstance( final List activatedAccounts, final String title, final String positiveButton, + final String secondaryButton, final String prefilledJid, final String account, boolean allowEditJid, - final boolean sanity_check_jid) { + boolean showBookmarkCheckbox, + final SanityCheck sanity_check_jid) { EnterJidDialog dialog = new EnterJidDialog(); Bundle bundle = new Bundle(); bundle.putString(TITLE_KEY, title); bundle.putString(POSITIVE_BUTTON_KEY, positiveButton); + bundle.putString(SECONDARY_BUTTON_KEY, secondaryButton); bundle.putString(PREFILLED_JID_KEY, prefilledJid); bundle.putString(ACCOUNT_KEY, account); bundle.putBoolean(ALLOW_EDIT_JID_KEY, allowEditJid); bundle.putStringArrayList(ACCOUNTS_LIST_KEY, (ArrayList) activatedAccounts); - bundle.putBoolean(SANITY_CHECK_JID, sanity_check_jid); + bundle.putInt(SANITY_CHECK_JID, sanity_check_jid.ordinal()); + bundle.putBoolean(SHOW_BOOKMARK_CHECKBOX, showBookmarkCheckbox); dialog.setArguments(bundle); return dialog; } @@ -110,7 +143,11 @@ public class EnterJidDialog extends DialogFragment implements OnBackendConnected binding.jid.setCursorVisible(false); } } - sanityCheckJid = getArguments().getBoolean(SANITY_CHECK_JID, false); + sanityCheckJid = SanityCheck.values()[getArguments().getInt(SANITY_CHECK_JID, SanityCheck.NO.ordinal())]; + + if (!getArguments().getBoolean(SHOW_BOOKMARK_CHECKBOX, false)) { + binding.bookmark.setVisibility(View.GONE); + } DelayedHintHelper.setHint(R.string.account_settings_example_jabber_id, binding.jid); @@ -129,80 +166,153 @@ public class EnterJidDialog extends DialogFragment implements OnBackendConnected binding.account.setAdapter(adapter); } - builder.setView(binding.getRoot()); - builder.setNegativeButton(R.string.cancel, null); - builder.setPositiveButton(getArguments().getString(POSITIVE_BUTTON_KEY), null); - this.dialog = builder.create(); + 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)); - View.OnClickListener dialogOnClick = - v -> { - handleEnter(binding, account); - }; + binding.account.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() { + @Override + public void onItemSelected(AdapterView accountSpinner, View view, int position, long id) { + XmppActivity context = (XmppActivity) getActivity(); + if (context == null || context.xmppConnectionService == null || accountJid() == null) return; + + gatewayListAdapter.clear(); + final Account account = context.xmppConnectionService.findAccountByJid(accountJid()); + if (account == null) return; + + 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.setPositiveButton(getArguments().getString(POSITIVE_BUTTON_KEY), null); + if (getArguments().getString(SECONDARY_BUTTON_KEY) == null) { + builder.setNegativeButton(R.string.cancel, null); + } else { + builder.setNegativeButton(getArguments().getString(SECONDARY_BUTTON_KEY), null); + builder.setNeutralButton(R.string.cancel, null); + } + this.dialog = builder.create(); binding.jid.setOnEditorActionListener( (v, actionId, event) -> { - handleEnter(binding, account); + handleEnter(binding, account, false); return true; }); dialog.show(); - dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener(dialogOnClick); + dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener((v) -> handleEnter(binding, account, false)); + if (getArguments().getString(SECONDARY_BUTTON_KEY) != null) { + dialog.getButton(AlertDialog.BUTTON_NEGATIVE).setOnClickListener((v) -> handleEnter(binding, account, true)); + } return dialog; } - private void handleEnter(EnterJidDialogBinding binding, String account) { - final Jid accountJid; + 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, boolean secondary) { 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().trim()); - } catch (final IllegalArgumentException e) { - binding.jidLayout.setError(getActivity().getString(R.string.invalid_jid)); - return; - } + final Jid accountJid = accountJid(); + final OnGatewayResult finish = (final String jidString, final String errorMessage) -> { + Activity context = getActivity(); + if (context == null) return; // Race condition, we got the reply after the UI was closed - 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(); + context.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; + } + + Jid contactJid = null; + try { + contactJid = Jid.ofEscaped(jidString); + } catch (final IllegalArgumentException e) { + binding.jidLayout.setError(getActivity().getString(R.string.invalid_jid)); + return; + } + + if (!issuedWarning && sanityCheckJid != SanityCheck.NO) { + 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 (sanityCheckJid != SanityCheck.ALLOW_MUC && 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, secondary, binding.bookmark.isChecked())) { + dialog.dismiss(); + } + } catch (JidError error) { + binding.jidLayout.setError(error.toString()); + dialog.getButton(AlertDialog.BUTTON_POSITIVE).setText(R.string.add); + issuedWarning = false; + } + } + }); + }; + + Pair> p = gatewayListAdapter.getSelected(); + final String type = gatewayListAdapter.getSelectedType(); + + // Resolve based on local settings before submission + if (type != null && (type.equals("pstn") || type.equals("sms"))) { + try { + binding.jid.setText(PhoneNumberUtilWrapper.normalize(getActivity(), binding.jid.getText().toString(), true)); + } catch (NumberParseException | IllegalArgumentException | NullPointerException e) { } + } + + if (p == null) { + finish.onGatewayResult(binding.jid.getText().toString().trim(), 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().trim(), finish); + } else if (p.second.first.isDomainJid() && p.second.second.getServiceDiscoveryResult().getFeatures().contains("jid\\20escaping")) { + finish.onGatewayResult(Jid.ofLocalAndDomain(binding.jid.getText().toString().trim(), p.second.first.getDomain().toString()).toString(), null); + } else if (p.second.first.isDomainJid()) { + finish.onGatewayResult(Jid.ofLocalAndDomain(binding.jid.getText().toString().trim().replace("@", "%"), p.second.first.getDomain().toString()).toString(), null); + } else { + finish.onGatewayResult(null, null); } } @@ -244,7 +354,7 @@ public class EnterJidDialog extends DialogFragment implements OnBackendConnected } public interface OnEnterJidDialogPositiveListener { - boolean onEnterJidDialogPositive(Jid account, Jid contact) throws EnterJidDialog.JidError; + boolean onEnterJidDialogPositive(Jid account, Jid contact, boolean secondary, boolean save) throws EnterJidDialog.JidError; } public static class JidError extends Exception { @@ -276,4 +386,210 @@ 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 { + 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> 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 == null) type = ""; + 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) { + List types = getTypes(gateway); + return types.isEmpty() ? null : types.get(0); + } + + public List getTypes(Contact gateway) { + List types = new ArrayList<>(); + + for(Presence p : gateway.getPresences().getPresences()) { + if(p.getServiceDiscoveryResult() != null) { + for (ServiceDiscoveryResult.Identity id : p.getServiceDiscoveryResult().getIdentities()) { + if ("gateway".equals(id.getCategory())) types.add(id.getType()); + } + } + } + + return types; + } + + public String getSelectedType() { + return getType(selected); + } + + public Pair> getSelected() { + if(this.selected == 0) { + return null; // No gateway, just use direct JID entry + } + + Pair gateway = this.gateways.get(this.selected - 1); + + Pair presence = null; + for (Map.Entry 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(); + } + } } diff --git a/src/main/java/eu/siacs/conversations/ui/RtpSessionActivity.java b/src/main/java/eu/siacs/conversations/ui/RtpSessionActivity.java index b7352aa27..1a853834e 100644 --- a/src/main/java/eu/siacs/conversations/ui/RtpSessionActivity.java +++ b/src/main/java/eu/siacs/conversations/ui/RtpSessionActivity.java @@ -10,7 +10,6 @@ import android.app.PictureInPictureParams; import android.content.ActivityNotFoundException; import android.content.Context; import android.content.Intent; -import android.content.pm.ActivityInfo; import android.content.pm.PackageManager; import android.os.Build; import android.os.Bundle; @@ -40,6 +39,7 @@ import com.google.common.collect.ImmutableSet; import com.google.common.util.concurrent.FutureCallback; import com.google.common.util.concurrent.Futures; +import org.jetbrains.annotations.NotNull; import org.webrtc.RendererCommon; import org.webrtc.SurfaceViewRenderer; import org.webrtc.VideoTrack; @@ -58,6 +58,7 @@ import eu.siacs.conversations.entities.Contact; import eu.siacs.conversations.entities.Conversation; import eu.siacs.conversations.services.AppRTCAudioManager; import eu.siacs.conversations.services.XmppConnectionService; +import eu.siacs.conversations.ui.widget.DialpadView; import eu.siacs.conversations.ui.util.AvatarWorkerTask; import eu.siacs.conversations.ui.util.MainThreadExecutor; import eu.siacs.conversations.ui.util.Rationals; @@ -74,7 +75,7 @@ import eu.siacs.conversations.xmpp.jingle.RtpEndUserState; public class RtpSessionActivity extends XmppActivity implements XmppConnectionService.OnJingleRtpConnectionUpdate, - eu.siacs.conversations.ui.widget.SurfaceViewRenderer.OnAspectRatioChanged { + eu.siacs.conversations.ui.widget.SurfaceViewRenderer.OnAspectRatioChanged { public static final String EXTRA_WITH = "with"; public static final String EXTRA_SESSION_ID = "session_id"; @@ -86,7 +87,7 @@ public class RtpSessionActivity extends XmppActivity private static final int CALL_DURATION_UPDATE_INTERVAL = 333; - private static final List END_CARD = + public static final List END_CARD = Arrays.asList( RtpEndUserState.APPLICATION_ERROR, RtpEndUserState.SECURITY_ERROR, @@ -164,6 +165,17 @@ public class RtpSessionActivity extends XmppActivity | WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON); this.binding = DataBindingUtil.setContentView(this, R.layout.activity_rtp_session); setSupportActionBar(binding.toolbar); + + binding.dialpad.setClickConsumer(tag -> { + final JingleRtpConnection connection = + this.rtpConnectionReference != null ? this.rtpConnectionReference.get() : null; + if (connection != null) connection.applyDtmfTone(tag); + }); + + if (savedInstanceState != null) { + boolean dialpadVisible = savedInstanceState.getBoolean("dialpad_visible"); + binding.dialpad.setVisibility(dialpadVisible ? View.VISIBLE : View.GONE); + } } @Override @@ -171,10 +183,12 @@ public class RtpSessionActivity extends XmppActivity getMenuInflater().inflate(R.menu.activity_rtp_session, menu); final MenuItem help = menu.findItem(R.id.action_help); final MenuItem gotoChat = menu.findItem(R.id.action_goto_chat); + final MenuItem dialpad = menu.findItem(R.id.action_dialpad); final MenuItem switchToVideo = menu.findItem(R.id.action_switch_to_video); help.setVisible(Config.HELP != null && isHelpButtonVisible()); gotoChat.setVisible(isSwitchToConversationVisible()); switchToVideo.setVisible(isSwitchToVideoVisible()); + dialpad.setVisible(isAudioOnlyConversation()); return super.onCreateOptionsMenu(menu); } @@ -212,6 +226,13 @@ public class RtpSessionActivity extends XmppActivity && STATES_SHOWING_SWITCH_TO_CHAT.contains(connection.getEndUserState()); } + private boolean isAudioOnlyConversation() { + final JingleRtpConnection connection = + this.rtpConnectionReference != null ? this.rtpConnectionReference.get() : null; + + return connection != null && !connection.getMedia().contains(Media.VIDEO); + } + private boolean isSwitchToVideoVisible() { final JingleRtpConnection connection = this.rtpConnectionReference != null ? this.rtpConnectionReference.get() : null; @@ -229,6 +250,15 @@ public class RtpSessionActivity extends XmppActivity switchToConversation(conversation); } + private void toggleDialpadVisibility() { + if (binding.dialpad.getVisibility() == View.VISIBLE) { + binding.dialpad.setVisibility(View.GONE); + } + else { + binding.dialpad.setVisibility(View.VISIBLE); + } + } + public boolean onOptionsItemSelected(final MenuItem item) { switch (item.getItemId()) { case R.id.action_help: @@ -237,6 +267,9 @@ public class RtpSessionActivity extends XmppActivity case R.id.action_goto_chat: switchToConversation(); return true; + case R.id.action_dialpad: + toggleDialpadVisibility(); + return true; case R.id.action_switch_to_video: requestPermissionAndSwitchToVideo(); return true; @@ -407,26 +440,17 @@ public class RtpSessionActivity extends XmppActivity private void putScreenInCallMode(final Set media) { getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); - if (Media.audioOnly(media)) { + if (!media.contains(Media.VIDEO)) { final JingleRtpConnection rtpConnection = rtpConnectionReference != null ? rtpConnectionReference.get() : null; final AppRTCAudioManager audioManager = rtpConnection == null ? null : rtpConnection.getAudioManager(); if (audioManager == null || audioManager.getSelectedAudioDevice() - == AppRTCAudioManager.AudioDevice.EARPIECE) { + == AppRTCAudioManager.AudioDevice.EARPIECE) { acquireProximityWakeLock(); } } - lockOrientation(media); - } - - private void lockOrientation(final Set media) { - if (Media.audioOnly(media)) { - setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT); - } else { - setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED); - } } @SuppressLint("WakelockTimeout") @@ -543,8 +567,8 @@ public class RtpSessionActivity extends XmppActivity } if (END_CARD.contains(state) || xmppConnectionService - .getJingleConnectionManager() - .hasMatchingProposal(account, with)) { + .getJingleConnectionManager() + .hasMatchingProposal(account, with)) { return; } Log.d(Config.LOGTAG, "restored state (" + state + ") was not an end card. finishing"); @@ -552,7 +576,7 @@ public class RtpSessionActivity extends XmppActivity } } - private void setWidth(final RtpEndUserState state) { + private void setWith(final RtpEndUserState state) { setWith(getWith(), state); } @@ -738,10 +762,10 @@ public class RtpSessionActivity extends XmppActivity final JingleRtpConnection rtpConnection = requireRtpConnection(); return rtpConnection.getMedia().contains(Media.VIDEO) && Arrays.asList( - RtpEndUserState.ACCEPTING_CALL, - RtpEndUserState.CONNECTING, - RtpEndUserState.CONNECTED) - .contains(rtpConnection.getEndUserState()); + RtpEndUserState.ACCEPTING_CALL, + RtpEndUserState.CONNECTING, + RtpEndUserState.CONNECTED) + .contains(rtpConnection.getEndUserState()); } catch (final IllegalStateException e) { return false; } @@ -759,8 +783,9 @@ public class RtpSessionActivity extends XmppActivity .getJingleConnectionManager() .getTerminalSessionState(with, sessionId); if (terminatedRtpSession == null) { - throw new IllegalStateException( - "failed to initialize activity with running rtp session. session not found"); + Log.e(Config.LOGTAG, "failed to initialize activity with running rtp session. session not found"); + finish(); + return true; } initializeWithTerminatedSessionState(account, with, terminatedRtpSession); return true; @@ -781,7 +806,7 @@ public class RtpSessionActivity extends XmppActivity requireRtpConnection().getState())) { putScreenInCallMode(); } - setWidth(currentState); + setWith(currentState); updateVideoViews(currentState); updateStateDisplay(currentState, media, contentAddition); updateVerifiedShield(verified && STATES_SHOWING_SWITCH_TO_CHAT.contains(currentState)); @@ -992,11 +1017,11 @@ public class RtpSessionActivity extends XmppActivity this.binding.acceptCall.setImageResource(R.drawable.ic_voicemail_white_24dp); this.binding.acceptCall.setVisibility(View.VISIBLE); } else if (asList( - RtpEndUserState.CONNECTIVITY_ERROR, - RtpEndUserState.CONNECTIVITY_LOST_ERROR, - RtpEndUserState.APPLICATION_ERROR, - RtpEndUserState.RETRACTED, - RtpEndUserState.SECURITY_ERROR) + RtpEndUserState.CONNECTIVITY_ERROR, + RtpEndUserState.CONNECTIVITY_LOST_ERROR, + RtpEndUserState.APPLICATION_ERROR, + RtpEndUserState.RETRACTED, + RtpEndUserState.SECURITY_ERROR) .contains(state)) { this.binding.rejectCall.setContentDescription(getString(R.string.exit)); this.binding.rejectCall.setOnClickListener(this::exit); @@ -1389,7 +1414,6 @@ public class RtpSessionActivity extends XmppActivity final AbstractJingleConnection.Id id = requireRtpConnection().getId(); final boolean verified = requireRtpConnection().isVerified(); final Set media = getMedia(); - lockOrientation(media); final ContentAddition contentAddition = getPendingContentAddition(); final Contact contact = getWith(); if (account == id.account && id.with.equals(with) && id.sessionId.equals(sessionId)) { @@ -1453,6 +1477,12 @@ public class RtpSessionActivity extends XmppActivity } } + @Override + protected void onSaveInstanceState(@NonNull @NotNull Bundle outState) { + super.onSaveInstanceState(outState); + outState.putBoolean("dialpad_visible", binding.dialpad.getVisibility() == View.VISIBLE); + } + private void updateRtpSessionProposalState( final Account account, final Jid with, final RtpEndUserState state) { final Intent currentIntent = getIntent(); diff --git a/src/main/java/eu/siacs/conversations/ui/StartConversationActivity.java b/src/main/java/eu/siacs/conversations/ui/StartConversationActivity.java index a2d0b484c..4a1c56843 100644 --- a/src/main/java/eu/siacs/conversations/ui/StartConversationActivity.java +++ b/src/main/java/eu/siacs/conversations/ui/StartConversationActivity.java @@ -517,15 +517,17 @@ public class StartConversationActivity extends XmppActivity implements XmppConne ft.addToBackStack(null); EnterJidDialog dialog = EnterJidDialog.newInstance( mActivatedAccounts, - getString(R.string.add_contact), - getString(R.string.add), + getString(R.string.start_conversation), + getString(R.string.message), + "Call", prefilledJid, invite == null ? null : invite.account, invite == null || !invite.hasFingerprints(), - true + true, + EnterJidDialog.SanityCheck.ALLOW_MUC ); - dialog.setOnEnterJidDialogPositiveListener((accountJid, contactJid) -> { + dialog.setOnEnterJidDialogPositiveListener((accountJid, contactJid, call, save) -> { if (!xmppConnectionServiceBound) { return false; } @@ -534,25 +536,57 @@ public class StartConversationActivity extends XmppActivity implements XmppConne if (account == null) { return true; } - final Contact contact = account.getRoster().getContact(contactJid); + if (invite != null && invite.getName() != null) { contact.setServerName(invite.getName()); } - if (contact.isSelf()) { - switchToConversation(contact); - return true; - } else if (contact.showInRoster()) { - throw new EnterJidDialog.JidError(getString(R.string.contact_already_exists)); - } else { - final String preAuth = invite == null ? null : invite.getParameter(XmppUri.PARAMETER_PRE_AUTH); - xmppConnectionService.createContact(contact, true, preAuth); - if (invite != null && invite.hasFingerprints()) { - xmppConnectionService.verifyFingerprints(contact, invite.getFingerprints()); - } - switchToConversationDoNotAppend(contact, invite == null ? null : invite.getBody()); + + if (contact.isSelf() || contact.showInRoster()) { + switchToConversationDoNotAppend(contact, invite == null ? null : invite.getBody(), call ? "call" : null); return true; } + + xmppConnectionService.checkIfMuc(account, contactJid, (isMuc) -> { + if (isMuc) { + if (save) { + Bookmark bookmark = account.getBookmark(contactJid); + if (bookmark != null) { + openConversationsForBookmark(bookmark); + } else { + bookmark = new Bookmark(account, contactJid.asBareJid()); + bookmark.setAutojoin(getBooleanPreference("autojoin", R.bool.autojoin)); + final String nick = contactJid.getResource(); + if (nick != null && !nick.isEmpty() && !nick.equals(MucOptions.defaultNick(account))) { + bookmark.setNick(nick); + } + xmppConnectionService.createBookmark(account, bookmark); + final Conversation conversation = xmppConnectionService + .findOrCreateConversation(account, contactJid, true, true, true); + bookmark.setConversation(conversation); + switchToConversationDoNotAppend(conversation, invite == null ? null : invite.getBody()); + } + } else { + final Conversation conversation = xmppConnectionService.findOrCreateConversation(account, contactJid, true, true, true); + switchToConversationDoNotAppend(conversation, invite == null ? null : invite.getBody()); + } + } else { + if (save) { + final String preAuth = invite == null ? null : invite.getParameter(XmppUri.PARAMETER_PRE_AUTH); + xmppConnectionService.createContact(contact, true, preAuth); + if (invite != null && invite.hasFingerprints()) { + xmppConnectionService.verifyFingerprints(contact, invite.getFingerprints()); + } + } + switchToConversationDoNotAppend(contact, invite == null ? null : invite.getBody(), call ? "call" : null); + } + + try { + dialog.dismiss(); + } catch (final IllegalStateException e) { } + }); + + return false; }); dialog.show(ft, FRAGMENT_TAG_DIALOG); } diff --git a/src/main/java/eu/siacs/conversations/ui/XmppActivity.java b/src/main/java/eu/siacs/conversations/ui/XmppActivity.java index c6a6bc630..adbf9ea01 100644 --- a/src/main/java/eu/siacs/conversations/ui/XmppActivity.java +++ b/src/main/java/eu/siacs/conversations/ui/XmppActivity.java @@ -551,6 +551,11 @@ public abstract class XmppActivity extends ActionBarActivity { switchToConversation(conversation, text, false, null, false, true); } + protected void switchToConversationDoNotAppend(Contact contact, String body, String postInit) { + Conversation conversation = xmppConnectionService.findOrCreateConversation(contact.getAccount(), contact.getJid(), false, true); + switchToConversation(conversation, body, false, null, false, true, postInit); + } + public void highlightInMuc(Conversation conversation, String nick) { switchToConversation(conversation, null, false, nick, false, false); } @@ -559,7 +564,13 @@ public abstract class XmppActivity extends ActionBarActivity { switchToConversation(conversation, null, false, nick, true, false); } - private void switchToConversation(Conversation conversation, String text, boolean asQuote, String nick, boolean pm, boolean doNotAppend) { + public void switchToConversation(Conversation conversation, String text, boolean asQuote, String nick, boolean pm, boolean doNotAppend) { + switchToConversation(conversation, text, asQuote, nick, pm, doNotAppend, null); + } + + public void switchToConversation(Conversation conversation, String text, boolean asQuote, String nick, boolean pm, boolean doNotAppend, String postInit) { + if (conversation == null) return; + Intent intent = new Intent(this, ConversationsActivity.class); intent.setAction(ConversationsActivity.ACTION_VIEW_CONVERSATION); intent.putExtra(ConversationsActivity.EXTRA_CONVERSATION, conversation.getUuid()); @@ -576,6 +587,7 @@ public abstract class XmppActivity extends ActionBarActivity { if (doNotAppend) { intent.putExtra(ConversationsActivity.EXTRA_DO_NOT_APPEND, true); } + intent.putExtra(ConversationsActivity.EXTRA_POST_INIT_ACTION, postInit); intent.setFlags(intent.getFlags() | Intent.FLAG_ACTIVITY_CLEAR_TOP); startActivity(intent); finish(); diff --git a/src/main/java/eu/siacs/conversations/ui/widget/DialpadView.java b/src/main/java/eu/siacs/conversations/ui/widget/DialpadView.java new file mode 100644 index 000000000..fb955cdbf --- /dev/null +++ b/src/main/java/eu/siacs/conversations/ui/widget/DialpadView.java @@ -0,0 +1,69 @@ +/* + * Copyright 2012-2015 the original author or authors. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package eu.siacs.conversations.ui.widget; + +import android.content.Context; +import android.util.AttributeSet; +import android.view.LayoutInflater; +import android.view.View; + +import androidx.constraintlayout.widget.ConstraintLayout; +import androidx.databinding.DataBindingUtil; + +import eu.siacs.conversations.R; +import eu.siacs.conversations.databinding.DialpadBinding; +import eu.siacs.conversations.utils.Consumer; + +public class DialpadView extends ConstraintLayout implements View.OnClickListener { + + protected Consumer clickConsumer = null; + + public DialpadView(Context context) { + super(context); + init(); + } + + public DialpadView(Context context, AttributeSet attrs) { + super(context, attrs); + init(); + } + + public DialpadView(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + init(); + } + + public void setClickConsumer(Consumer clickConsumer) { + this.clickConsumer = clickConsumer; + } + + private void init() { + DialpadBinding binding = DataBindingUtil.inflate( + LayoutInflater.from(getContext()), + R.layout.dialpad, + this, + true + ); + binding.setDialpadView(this); + } + + @Override + public void onClick(View v) { + clickConsumer.accept(v.getTag().toString()); + } +} diff --git a/src/main/java/eu/siacs/conversations/utils/LocationProvider.java b/src/main/java/eu/siacs/conversations/utils/LocationProvider.java index 3eb786e39..049772f8f 100644 --- a/src/main/java/eu/siacs/conversations/utils/LocationProvider.java +++ b/src/main/java/eu/siacs/conversations/utils/LocationProvider.java @@ -21,19 +21,25 @@ public class LocationProvider { public static final GeoPoint FALLBACK = new GeoPoint(0.0, 0.0); public static String getUserCountry(final Context context) { + return getUserCountry(context, false); + } + + public static String getUserCountry(final Context context, boolean preferNetwork) { try { final TelephonyManager tm = ContextCompat.getSystemService(context, TelephonyManager.class); if (tm == null) { return getUserCountryFallback(); } - final String simCountry = tm.getSimCountryIso(); + final String simCountry = tm.getSimOperator().equals("20801") ? "us" : tm.getSimCountryIso(); + final String networkCountry = tm.getPhoneType() == TelephonyManager.PHONE_TYPE_CDMA ? null : tm.getNetworkCountryIso(); // if device is not 3G would be unreliable + if (preferNetwork && networkCountry != null && networkCountry.length() == 2) { + return networkCountry.toUpperCase(Locale.US); + } + if (simCountry != null && simCountry.length() == 2) { // SIM country code is available return simCountry.toUpperCase(Locale.US); - } else if (tm.getPhoneType() != TelephonyManager.PHONE_TYPE_CDMA) { // device is not 3G (would be unreliable) - String networkCountry = tm.getNetworkCountryIso(); - if (networkCountry != null && networkCountry.length() == 2) { // network country code is available - return networkCountry.toUpperCase(Locale.US); - } + } else if (networkCountry != null && networkCountry.length() == 2) { // network country code is available + return networkCountry.toUpperCase(Locale.US); } return getUserCountryFallback(); } catch (final Exception e) { @@ -72,4 +78,4 @@ public class LocationProvider { return FALLBACK; } -} \ No newline at end of file +} diff --git a/src/main/java/eu/siacs/conversations/xmpp/OnGatewayResult.java b/src/main/java/eu/siacs/conversations/xmpp/OnGatewayResult.java new file mode 100644 index 000000000..fd95d0761 --- /dev/null +++ b/src/main/java/eu/siacs/conversations/xmpp/OnGatewayResult.java @@ -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); +} diff --git a/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleRtpConnection.java b/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleRtpConnection.java index 139153b4a..faa5725e2 100644 --- a/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleRtpConnection.java +++ b/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleRtpConnection.java @@ -270,6 +270,10 @@ public class JingleRtpConnection extends AbstractJingleConnection } } + public boolean applyDtmfTone(String tone) { + return webRTCWrapper.applyDtmfTone(tone); + } + private void receiveSessionTerminate(final JinglePacket jinglePacket) { respondOk(jinglePacket); final JinglePacket.ReasonWrapper wrapper = jinglePacket.getReason(); diff --git a/src/main/java/eu/siacs/conversations/xmpp/jingle/ToneManager.java b/src/main/java/eu/siacs/conversations/xmpp/jingle/ToneManager.java index b64f963d0..954ab6eb1 100644 --- a/src/main/java/eu/siacs/conversations/xmpp/jingle/ToneManager.java +++ b/src/main/java/eu/siacs/conversations/xmpp/jingle/ToneManager.java @@ -148,7 +148,7 @@ class ToneManager { private void scheduleWaitingTone() { this.currentTone = JingleConnectionManager.SCHEDULED_EXECUTOR_SERVICE.scheduleAtFixedRate(() -> { - startTone(ToneGenerator.TONE_CDMA_DIAL_TONE_LITE, 750); + startTone(ToneGenerator.TONE_CDMA_NETWORK_USA_RINGBACK, 750); }, 0, 3, TimeUnit.SECONDS); } @@ -161,14 +161,16 @@ class ToneManager { currentTone.cancel(true); } if (toneGenerator != null) { - toneGenerator.stopTone(); + // catch race condition with already-released generator + try { + toneGenerator.stopTone(); + } catch (final RuntimeException e) { } } } - private void startTone(final int toneType, final int durationMs) { + public void startTone(final int toneType, final int durationMs) { if (this.toneGenerator != null) { - this.toneGenerator.release();; - + this.toneGenerator.release(); } final AudioManager audioManager = ContextCompat.getSystemService(context, AudioManager.class); final boolean ringerModeNormal = audioManager == null || audioManager.getRingerMode() == AudioManager.RINGER_MODE_NORMAL; diff --git a/src/main/java/eu/siacs/conversations/xmpp/jingle/WebRTCWrapper.java b/src/main/java/eu/siacs/conversations/xmpp/jingle/WebRTCWrapper.java index 08154260a..fd649a425 100644 --- a/src/main/java/eu/siacs/conversations/xmpp/jingle/WebRTCWrapper.java +++ b/src/main/java/eu/siacs/conversations/xmpp/jingle/WebRTCWrapper.java @@ -1,6 +1,7 @@ package eu.siacs.conversations.xmpp.jingle; import android.content.Context; +import android.media.ToneGenerator; import android.os.Build; import android.os.Handler; import android.os.Looper; @@ -8,6 +9,7 @@ import android.util.Log; import com.google.common.base.Optional; import com.google.common.base.Preconditions; +import com.google.common.collect.ImmutableMap; import com.google.common.collect.ImmutableSet; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; @@ -20,6 +22,7 @@ import org.webrtc.CandidatePairChangeEvent; import org.webrtc.DataChannel; import org.webrtc.DefaultVideoDecoderFactory; import org.webrtc.DefaultVideoEncoderFactory; +import org.webrtc.DtmfSender; import org.webrtc.EglBase; import org.webrtc.IceCandidate; import org.webrtc.MediaConstraints; @@ -38,6 +41,7 @@ import org.webrtc.voiceengine.WebRtcAudioEffects; import java.util.LinkedList; import java.util.List; import java.util.Queue; +import java.util.Map; import java.util.Set; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; @@ -57,6 +61,25 @@ public class WebRTCWrapper { private final ExecutorService executorService = Executors.newSingleThreadExecutor(); + private static final int TONE_DURATION = 500; + private static final Map TONE_CODES; + static { + ImmutableMap.Builder builder = new ImmutableMap.Builder<>(); + builder.put("0", ToneGenerator.TONE_DTMF_0); + builder.put("1", ToneGenerator.TONE_DTMF_1); + builder.put("2", ToneGenerator.TONE_DTMF_2); + builder.put("3", ToneGenerator.TONE_DTMF_3); + builder.put("4", ToneGenerator.TONE_DTMF_4); + builder.put("5", ToneGenerator.TONE_DTMF_5); + builder.put("6", ToneGenerator.TONE_DTMF_6); + builder.put("7", ToneGenerator.TONE_DTMF_7); + builder.put("8", ToneGenerator.TONE_DTMF_8); + builder.put("9", ToneGenerator.TONE_DTMF_9); + builder.put("*", ToneGenerator.TONE_DTMF_S); + builder.put("#", ToneGenerator.TONE_DTMF_P); + TONE_CODES = builder.build(); + } + private static final Set HARDWARE_AEC_BLACKLIST = new ImmutableSet.Builder() .add("Pixel") @@ -510,8 +533,14 @@ public class WebRTCWrapper { } boolean isMicrophoneEnabled() { - final Optional audioTrack = - TrackWrapper.get(peerConnection, this.localAudioTrack); + Optional audioTrack = null; + try { + audioTrack = TrackWrapper.get(peerConnection, this.localAudioTrack); + } catch (final IllegalStateException e) { + Log.d(Config.LOGTAG, "unable to check microphone", e); + // ignoring race condition in case sender has been disposed + return false; + } if (audioTrack.isPresent()) { try { return audioTrack.get().enabled(); @@ -526,8 +555,14 @@ public class WebRTCWrapper { } boolean setMicrophoneEnabled(final boolean enabled) { - final Optional audioTrack = - TrackWrapper.get(peerConnection, this.localAudioTrack); + Optional audioTrack = null; + try { + audioTrack = TrackWrapper.get(peerConnection, this.localAudioTrack); + } catch (final IllegalStateException e) { + Log.d(Config.LOGTAG, "unable to toggle microphone", e); + // ignoring race condition in case sender has been disposed + return false; + } if (audioTrack.isPresent()) { try { audioTrack.get().setEnabled(enabled); @@ -650,6 +685,15 @@ public class WebRTCWrapper { return peerConnection; } + public boolean applyDtmfTone(String tone) { + if (toneManager == null || peerConnection == null || localAudioTrack == null) { + return false; + } + localAudioTrack.rtpSender.dtmf().insertDtmf(tone, TONE_DURATION, 100); + toneManager.startTone(TONE_CODES.get(tone), TONE_DURATION); + return true; + } + @Nonnull private PeerConnectionFactory requirePeerConnectionFactory() { final PeerConnectionFactory peerConnectionFactory = this.peerConnectionFactory; diff --git a/src/main/res/drawable/ic_dialpad_white_24dp.xml b/src/main/res/drawable/ic_dialpad_white_24dp.xml new file mode 100644 index 000000000..18e745c10 --- /dev/null +++ b/src/main/res/drawable/ic_dialpad_white_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/src/main/res/layout/activity_rtp_session.xml b/src/main/res/layout/activity_rtp_session.xml index 7c52cf8a2..023df7833 100644 --- a/src/main/res/layout/activity_rtp_session.xml +++ b/src/main/res/layout/activity_rtp_session.xml @@ -74,7 +74,6 @@ android:layout_width="match_parent" android:layout_height="32dp"/> - - + + + app:strokeColor="?colorAccent" + app:shapeAppearance="@style/ShapeAppearanceOverlay.IncomingCall" /> @@ -178,7 +186,7 @@ app:elevation="4dp" app:fabCustomSize="72dp" app:maxImageSize="36dp" - tools:visibility="visible" /> + tools:visibility="gone" /> + tools:visibility="gone" /> @@ -204,7 +212,7 @@ android:layout_centerVertical="true" android:layout_margin="@dimen/in_call_fab_margin" android:layout_toStartOf="@+id/end_call" - android:visibility="gone" + android:visibility="visible" app:backgroundTint="?color_background_primary" app:elevation="4dp" app:fabSize="mini" @@ -230,7 +238,7 @@ android:layout_centerVertical="true" android:layout_margin="@dimen/in_call_fab_margin" android:layout_toEndOf="@+id/end_call" - android:visibility="gone" + android:visibility="visible" app:backgroundTint="?color_background_primary" app:elevation="4dp" app:fabSize="mini" @@ -243,7 +251,7 @@ android:layout_centerVertical="true" android:layout_margin="@dimen/in_call_fab_margin" android:layout_toEndOf="@+id/in_call_action_right" - android:visibility="gone" + android:visibility="visible" app:backgroundTint="?color_background_primary" app:elevation="4dp" app:fabSize="mini" diff --git a/src/main/res/layout/dialpad.xml b/src/main/res/layout/dialpad.xml new file mode 100644 index 000000000..5a8932d60 --- /dev/null +++ b/src/main/res/layout/dialpad.xml @@ -0,0 +1,385 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/res/layout/enter_jid_dialog.xml b/src/main/res/layout/enter_jid_dialog.xml index cacb98a6f..50c30e849 100644 --- a/src/main/res/layout/enter_jid_dialog.xml +++ b/src/main/res/layout/enter_jid_dialog.xml @@ -22,6 +22,11 @@ android:layout_width="fill_parent" android:layout_height="wrap_content"/> + + + + diff --git a/src/main/res/layout/enter_jid_dialog_gateway_list_item.xml b/src/main/res/layout/enter_jid_dialog_gateway_list_item.xml new file mode 100644 index 000000000..bb8a0a829 --- /dev/null +++ b/src/main/res/layout/enter_jid_dialog_gateway_list_item.xml @@ -0,0 +1,14 @@ + + + diff --git a/src/main/res/menu/activity_rtp_session.xml b/src/main/res/menu/activity_rtp_session.xml index 8768c2906..d4e1a0f23 100644 --- a/src/main/res/menu/activity_rtp_session.xml +++ b/src/main/res/menu/activity_rtp_session.xml @@ -8,6 +8,11 @@ android:icon="?attr/icon_help" android:title="@string/help" app:showAsAction="always" /> + 16sp 18sp + 30sp + 16dp + 30dp diff --git a/src/main/res/values/strings.xml b/src/main/res/values/strings.xml index fcd6b4d64..6b135a81a 100644 --- a/src/main/res/values/strings.xml +++ b/src/main/res/values/strings.xml @@ -1,6 +1,7 @@ Settings + Dialpad New conversation Manage accounts Manage account diff --git a/src/main/res/values/styles.xml b/src/main/res/values/styles.xml index b6b8b76b0..9d0376468 100644 --- a/src/main/res/values/styles.xml +++ b/src/main/res/values/styles.xml @@ -159,4 +159,20 @@ + + + + + + + \ No newline at end of file