initial (untested) support for easy onboarding invites

This commit is contained in:
Daniel Gultsch 2020-12-01 20:31:30 +01:00
parent 92083fec83
commit 1f392a688d
14 changed files with 483 additions and 1 deletions

View file

@ -20,6 +20,10 @@
android:name=".ui.MagicCreateActivity"
android:label="@string/create_new_account"
android:launchMode="singleTask" />
<activity
android:name=".ui.EasyOnboardingInviteActivity"
android:label="@string/invite_to_app"
android:launchMode="singleTask" />
<activity
android:name=".ui.ImportBackupActivity"
android:label="@string/restore_backup"

View file

@ -0,0 +1,157 @@
package eu.siacs.conversations.ui;
import android.app.Activity;
import android.content.Intent;
import android.databinding.DataBindingUtil;
import android.graphics.Bitmap;
import android.graphics.Point;
import android.os.Bundle;
import android.support.v7.widget.Toolbar;
import android.util.Log;
import android.view.Menu;
import android.view.MenuItem;
import android.view.View;
import android.widget.Toast;
import com.google.common.base.Strings;
import eu.siacs.conversations.Config;
import eu.siacs.conversations.R;
import eu.siacs.conversations.databinding.ActivityEasyInviteBinding;
import eu.siacs.conversations.entities.Account;
import eu.siacs.conversations.services.BarcodeProvider;
import eu.siacs.conversations.utils.EasyOnboardingInvite;
import eu.siacs.conversations.xmpp.Jid;
public class EasyOnboardingInviteActivity extends XmppActivity implements EasyOnboardingInvite.OnInviteRequested {
private ActivityEasyInviteBinding binding;
private EasyOnboardingInvite easyOnboardingInvite;
@Override
public void onCreate(final Bundle bundle) {
super.onCreate(bundle);
this.binding = DataBindingUtil.setContentView(this, R.layout.activity_easy_invite);
setSupportActionBar((Toolbar) binding.toolbar);
configureActionBar(getSupportActionBar(), true);
this.binding.shareButton.setOnClickListener(v -> share());
if (bundle != null && bundle.containsKey("invite")) {
this.easyOnboardingInvite = bundle.getParcelable("invite");
if (this.easyOnboardingInvite != null) {
showInvite(this.easyOnboardingInvite);
return;
}
}
this.showLoading();
}
@Override
public boolean onCreateOptionsMenu(Menu menu) {
getMenuInflater().inflate(R.menu.easy_onboarding_invite, menu);
final MenuItem share = menu.findItem(R.id.action_share);
share.setVisible(easyOnboardingInvite != null);
return super.onCreateOptionsMenu(menu);
}
public boolean onOptionsItemSelected(MenuItem menuItem) {
if (menuItem.getItemId() == R.id.action_share) {
share();
return true;
} else {
return super.onOptionsItemSelected(menuItem);
}
}
private void share() {
final String shareText = getString(
R.string.easy_invite_share_text,
easyOnboardingInvite.getDomain(),
easyOnboardingInvite.getLandingUrl()
);
final Intent sendIntent = new Intent();
sendIntent.setAction(Intent.ACTION_SEND);
sendIntent.putExtra(Intent.EXTRA_TEXT, shareText);
sendIntent.setType("text/plain");
startActivity(Intent.createChooser(sendIntent, getString(R.string.share_invite_with)));
}
@Override
protected void refreshUiReal() {
invalidateOptionsMenu();
if (easyOnboardingInvite != null) {
showInvite(easyOnboardingInvite);
} else {
showLoading();
}
}
private void showLoading() {
this.binding.inProgress.setVisibility(View.VISIBLE);
this.binding.invite.setVisibility(View.GONE);
}
private void showInvite(final EasyOnboardingInvite invite) {
this.binding.inProgress.setVisibility(View.GONE);
this.binding.invite.setVisibility(View.VISIBLE);
this.binding.tapToShare.setText(getString(R.string.tap_share_button_send_invite, invite.getDomain()));
final Point size = new Point();
getWindowManager().getDefaultDisplay().getSize(size);
final int width = Math.min(size.x, size.y);
final String content;
if (Strings.isNullOrEmpty(invite.getLandingUrl())) {
content = invite.getUri();
} else {
content = invite.getLandingUrl();
}
final Bitmap bitmap = BarcodeProvider.create2dBarcodeBitmap(content, width);
binding.qrCode.setImageBitmap(bitmap);
}
@Override
public void onSaveInstanceState(Bundle bundle) {
super.onSaveInstanceState(bundle);
if (easyOnboardingInvite != null) {
bundle.putParcelable("invite", easyOnboardingInvite);
}
}
@Override
void onBackendConnected() {
if (easyOnboardingInvite != null) {
return;
}
final Intent launchIntent = getIntent();
final String accountExtra = launchIntent.getStringExtra(EXTRA_ACCOUNT);
final Jid jid = accountExtra == null ? null : Jid.ofEscaped(accountExtra);
if (jid == null) {
return;
}
final Account account = xmppConnectionService.findAccountByJid(jid);
xmppConnectionService.requestEasyOnboardingInvite(account, this);
}
public static void launch(final Account account, final Activity context) {
final Intent intent = new Intent(context, EasyOnboardingInviteActivity.class);
intent.putExtra(EXTRA_ACCOUNT, account.getJid().asBareJid().toEscapedString());
context.startActivity(intent);
}
@Override
public void inviteRequested(EasyOnboardingInvite invite) {
this.easyOnboardingInvite = invite;
Log.d(Config.LOGTAG, "invite requested");
refreshUi();
}
@Override
public void inviteRequestFailed(final String message) {
runOnUiThread(() -> {
if (!Strings.isNullOrEmpty(message)) {
Toast.makeText(this, message, Toast.LENGTH_LONG).show();
}
finish();
});
}
}

View file

@ -0,0 +1,83 @@
<?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="fill_parent"
android:background="?attr/color_background_primary"
android:orientation="vertical">
<include
android:id="@+id/toolbar"
layout="@layout/toolbar" />
<LinearLayout
android:id="@+id/in_progress"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:visibility="gone">
<ProgressBar
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center" />
</LinearLayout>
<RelativeLayout
android:id="@+id/invite"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginLeft="@dimen/activity_horizontal_margin"
android:layout_marginTop="@dimen/activity_vertical_margin"
android:layout_marginRight="@dimen/activity_horizontal_margin"
android:layout_marginBottom="@dimen/activity_vertical_margin"
android:visibility="visible">
<TextView
android:id="@+id/tap_to_share"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/tap_share_button_send_invite"
android:textAppearance="@style/TextAppearance.Conversations.Body1" />
<TextView
android:id="@+id/scan_the_code"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_below="@+id/tap_to_share"
android:layout_marginTop="24sp"
android:text="@string/if_contact_is_nearby_use_qr"
android:textAppearance="@style/TextAppearance.Conversations.Body1" />
<ImageView
android:id="@+id/qr_code"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_above="@+id/share_button"
android:layout_below="@id/scan_the_code"
android:layout_alignParentStart="true"
android:layout_alignParentRight="true"
android:layout_centerHorizontal="true"
android:layout_margin="24sp"
android:scaleType="fitCenter" />
<Button
android:id="@+id/share_button"
style="@style/Widget.Conversations.Button.Borderless"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentBottom="true"
android:minWidth="0dp"
android:paddingLeft="16dp"
android:paddingRight="16dp"
android:text="@string/share"
android:layout_centerHorizontal="true"
android:textColor="?attr/colorAccent" />
</RelativeLayout>
</LinearLayout>
</layout>

View file

@ -0,0 +1,10 @@
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/action_share"
android:icon="?attr/icon_share"
android:title="@string/invite"
app:showAsAction="always" />
</menu>

View file

@ -9,4 +9,8 @@
<string name="magic_create_text_fixed">You have been invited to %1$s. A username has already been picked for you. We will guide you through the process of creating an account.\nYou will be able to communicate with users of other providers by giving them your full XMPP address.</string>
<string name="your_server_invitation">Your server invitation</string>
<string name="improperly_formatted_provisioning">Improperly formatted provisioning code</string>
<string name="tap_share_button_send_invite">Tap the share button to send your contact an invitation to %1$s.</string>
<string name="if_contact_is_nearby_use_qr">If your contact is nearby, they can also scan the code below to accept your invitation.</string>
<string name="easy_invite_share_text">Join %1$s and chat with me: %2$s</string>
<string name="share_invite_with">Share invite with…</string>
</resources>

View file

@ -120,6 +120,7 @@ import eu.siacs.conversations.ui.interfaces.OnSearchResultsAvailable;
import eu.siacs.conversations.utils.Compatibility;
import eu.siacs.conversations.utils.ConversationsFileObserver;
import eu.siacs.conversations.utils.CryptoHelper;
import eu.siacs.conversations.utils.EasyOnboardingInvite;
import eu.siacs.conversations.utils.ExceptionHelper;
import eu.siacs.conversations.utils.MimeUtils;
import eu.siacs.conversations.utils.PhoneHelper;
@ -1619,6 +1620,43 @@ public class XmppConnectionService extends Service {
sendMessage(message, true, delay);
}
public void requestEasyOnboardingInvite(final Account account, final EasyOnboardingInvite.OnInviteRequested callback) {
final XmppConnection connection = account.getXmppConnection();
final Jid jid = connection == null ? null : connection.getJidForCommand(Namespace.EASY_ONBOARDING_INVITE);
if (jid == null) {
callback.inviteRequestFailed(getString(R.string.server_does_not_support_easy_onboarding_invites));
return;
}
final IqPacket request = new IqPacket(IqPacket.TYPE.SET);
request.setTo(jid);
final Element command = request.addChild("command", Namespace.COMMANDS);
command.setAttribute("node", Namespace.COMMANDS);
command.setAttribute("action", "execute");
sendIqPacket(account, request, (a, response) -> {
if (response.getType() == IqPacket.TYPE.RESULT) {
final Element resultCommand = response.findChild("command", Namespace.COMMANDS);
final Element x = resultCommand == null ? null : resultCommand.findChild("x", Namespace.DATA);
if (x != null) {
final Data data = Data.parse(x);
final String uri = data.getValue("uri");
final String landingUrl = data.getValue("landing-url");
if (uri != null) {
final EasyOnboardingInvite invite = new EasyOnboardingInvite(jid.getDomain().toEscapedString(), uri, landingUrl);
callback.inviteRequested(invite);
return;
}
}
callback.inviteRequestFailed(getString(R.string.unable_to_parse_invite));
Log.d(Config.LOGTAG, response.toString());
} else if (response.getType() == IqPacket.TYPE.ERROR) {
callback.inviteRequestFailed(IqParser.extractErrorMessage(response));
} else {
callback.inviteRequestFailed(getString(R.string.remote_server_timeout));
}
});
}
public void fetchRosterFromServer(final Account account) {
final IqPacket iqPacket = new IqPacket(IqPacket.TYPE.GET);
if (!"".equals(account.getRosterVersion())) {

View file

@ -2117,6 +2117,7 @@ public class ConversationFragment extends XmppFragment implements EditMessage.Ke
this.binding.textinput.setKeyboardListener(this);
messageListAdapter.updatePreferences();
refresh(false);
activity.invalidateOptionsMenu();
this.conversation.messagesLoaded.set(true);
Log.d(Config.LOGTAG, "scrolledToBottomAndNoPending=" + Boolean.toString(scrolledToBottomAndNoPending));
@ -2397,7 +2398,6 @@ public class ConversationFragment extends XmppFragment implements EditMessage.Ke
}
updateSendButton();
updateEditablity();
activity.invalidateOptionsMenu();
}
}
}

View file

@ -131,6 +131,7 @@ public class ConversationsActivity extends XmppActivity implements OnConversatio
@Override
protected void refreshUiReal() {
invalidateOptionsMenu();
for (@IdRes int id : FRAGMENT_ID_NOTIFICATION_ORDER) {
refreshFragment(id);
}

View file

@ -30,6 +30,7 @@
package eu.siacs.conversations.ui;
import android.app.Activity;
import android.app.AlertDialog;
import android.app.Fragment;
import android.content.Intent;
import android.databinding.DataBindingUtil;
@ -48,12 +49,16 @@ import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup;
import com.google.common.collect.Collections2;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicReference;
import eu.siacs.conversations.Config;
import eu.siacs.conversations.R;
import eu.siacs.conversations.databinding.FragmentConversationsOverviewBinding;
import eu.siacs.conversations.entities.Account;
import eu.siacs.conversations.entities.Conversation;
import eu.siacs.conversations.entities.Conversational;
import eu.siacs.conversations.ui.adapter.ConversationAdapter;
@ -65,6 +70,7 @@ import eu.siacs.conversations.ui.util.PendingItem;
import eu.siacs.conversations.ui.util.ScrollState;
import eu.siacs.conversations.ui.util.StyledAttributes;
import eu.siacs.conversations.utils.AccountUtils;
import eu.siacs.conversations.utils.EasyOnboardingInvite;
import eu.siacs.conversations.utils.ThemeHelper;
import static android.support.v7.widget.helper.ItemTouchHelper.LEFT;
@ -300,6 +306,8 @@ public class ConversationsOverviewFragment extends XmppFragment {
public void onCreateOptionsMenu(Menu menu, MenuInflater menuInflater) {
menuInflater.inflate(R.menu.fragment_conversations_overview, menu);
AccountUtils.showHideMenuItems(menu);
final MenuItem easyOnboardInvite = menu.findItem(R.id.action_easy_invite);
easyOnboardInvite.setVisible(EasyOnboardingInvite.anyHasSupport(activity == null ? null : activity.xmppConnectionService));
}
@Override
@ -354,10 +362,33 @@ public class ConversationsOverviewFragment extends XmppFragment {
case R.id.action_search:
startActivity(new Intent(getActivity(), SearchActivity.class));
return true;
case R.id.action_easy_invite:
selectAccountToStartEasyInvite();
return true;
}
return super.onOptionsItemSelected(item);
}
private void selectAccountToStartEasyInvite() {
final List<Account> accounts = EasyOnboardingInvite.getSupportingAccounts(activity.xmppConnectionService);
if (accounts.size() == 1) {
openEasyInviteScreen(accounts.get(0));
} else {
final AtomicReference<Account> selectedAccount = new AtomicReference<>(accounts.get(0));
final AlertDialog.Builder alertDialogBuilder = new AlertDialog.Builder(activity);
alertDialogBuilder.setTitle(R.string.choose_account);
final String[] asStrings = Collections2.transform(accounts, a -> a.getJid().asBareJid().toEscapedString()).toArray(new String[0]);
alertDialogBuilder.setSingleChoiceItems(asStrings, 0, (dialog, which) -> selectedAccount.set(accounts.get(which)));
alertDialogBuilder.setNegativeButton(R.string.cancel, null);
alertDialogBuilder.setPositiveButton(R.string.ok, (dialog, which) -> openEasyInviteScreen(selectedAccount.get()));
alertDialogBuilder.create().show();
}
}
private void openEasyInviteScreen(final Account account) {
EasyOnboardingInviteActivity.launch(account, activity);
}
@Override
void refresh() {
if (this.binding == null || this.activity == null) {

View file

@ -0,0 +1,94 @@
package eu.siacs.conversations.utils;
import android.os.Parcel;
import android.os.Parcelable;
import com.google.common.collect.ImmutableList;
import java.util.Collections;
import java.util.List;
import eu.siacs.conversations.entities.Account;
import eu.siacs.conversations.services.QuickConversationsService;
import eu.siacs.conversations.services.XmppConnectionService;
import eu.siacs.conversations.xmpp.XmppConnection;
public class EasyOnboardingInvite implements Parcelable {
private String domain;
private String uri;
private String landingUrl;
protected EasyOnboardingInvite(Parcel in) {
domain = in.readString();
uri = in.readString();
landingUrl = in.readString();
}
public EasyOnboardingInvite(String domain, String uri, String landingUrl) {
this.domain = domain;
this.uri = uri;
this.landingUrl = landingUrl;
}
@Override
public void writeToParcel(Parcel dest, int flags) {
dest.writeString(domain);
dest.writeString(uri);
dest.writeString(landingUrl);
}
@Override
public int describeContents() {
return 0;
}
public static final Creator<EasyOnboardingInvite> CREATOR = new Creator<EasyOnboardingInvite>() {
@Override
public EasyOnboardingInvite createFromParcel(Parcel in) {
return new EasyOnboardingInvite(in);
}
@Override
public EasyOnboardingInvite[] newArray(int size) {
return new EasyOnboardingInvite[size];
}
};
public static boolean anyHasSupport(final XmppConnectionService service) {
if (QuickConversationsService.isQuicksy()) {
return false;
}
return getSupportingAccounts(service).size() > 0;
}
public static List<Account> getSupportingAccounts(final XmppConnectionService service) {
final ImmutableList.Builder<Account> supportingAccountsBuilder = new ImmutableList.Builder<>();
final List<Account> accounts = service == null ? Collections.emptyList() : service.getAccounts();
for(Account account : accounts) {
final XmppConnection xmppConnection = account.getXmppConnection();
if (xmppConnection != null && xmppConnection.getFeatures().easyOnboardingInvites()) {
supportingAccountsBuilder.add(account);
}
}
return supportingAccountsBuilder.build();
}
public String getUri() {
return uri;
}
public String getLandingUrl() {
return landingUrl;
}
public String getDomain() {
return domain;
}
public interface OnInviteRequested {
void inviteRequested(EasyOnboardingInvite invite);
void inviteRequestFailed(String message);
}
}

View file

@ -52,4 +52,5 @@ public final class Namespace {
public static final String BOOKMARKS2_COMPAT = BOOKMARKS2 + "#compat";
public static final String INVITE = "urn:xmpp:invite";
public static final String PARS = "urn:xmpp:pars:0";
public static final String EASY_ONBOARDING_INVITE = "urn:xmpp:invite#invite";
}

View file

@ -137,6 +137,7 @@ public class XmppConnection implements Runnable {
protected final Account account;
private final Features features = new Features(this);
private final HashMap<Jid, ServiceDiscoveryResult> disco = new HashMap<>();
private final HashMap<String, Jid> commands = new HashMap<>();
private final SparseArray<AbstractAcknowledgeableStanza> mStanzaQueue = new SparseArray<>();
private final Hashtable<String, Pair<IqPacket, OnIqPacketReceived>> packetCallbacks = new Hashtable<>();
private final Set<OnAdvancedStreamFeaturesLoaded> advancedStreamFeaturesLoadedListeners = new HashSet<>();
@ -228,6 +229,12 @@ public class XmppConnection implements Runnable {
}
}
public Jid getJidForCommand(final String node) {
synchronized (this.commands) {
return this.commands.get(node);
}
}
public void prepareNewConnection() {
this.lastConnect = SystemClock.elapsedRealtime();
this.lastPingSent = SystemClock.elapsedRealtime();
@ -1028,6 +1035,9 @@ public class XmppConnection implements Runnable {
synchronized (this.disco) {
disco.clear();
}
synchronized (this.commands) {
this.commands.clear();
}
}
private void sendBindRequest() {
@ -1250,6 +1260,35 @@ public class XmppConnection implements Runnable {
});
}
private void discoverCommands() {
final IqPacket request = new IqPacket(IqPacket.TYPE.GET);
request.setTo(account.getDomain());
request.addChild("query", Namespace.DISCO_ITEMS).setAttribute("node", Namespace.COMMANDS);
sendIqPacket(request, (account, response) -> {
if (response.getType() == IqPacket.TYPE.RESULT) {
final Element query = response.findChild("query",Namespace.DISCO_ITEMS);
if (query == null) {
return;
}
final HashMap<String, Jid> commands = new HashMap<>();
for(final Element child : query.getChildren()) {
if ("item".equals(child.getName())) {
final String node = child.getAttribute("node");
final Jid jid = child.getAttributeAsJid("jid");
if (node != null && jid != null) {
commands.put(node, jid);
}
}
}
Log.d(Config.LOGTAG,commands.toString());
synchronized (this.commands) {
this.commands.clear();
this.commands.putAll(commands);
}
}
});
}
public boolean isMamPreferenceAlways() {
return isMamPreferenceAlways;
}
@ -1273,6 +1312,9 @@ public class XmppConnection implements Runnable {
if (getFeatures().carbons() && !features.carbonsEnabled) {
sendEnableCarbons();
}
if (getFeatures().commands()) {
discoverCommands();
}
}
private void sendServiceDiscoveryItems(final Jid server) {
@ -1788,6 +1830,16 @@ public class XmppConnection implements Runnable {
return hasDiscoFeature(account.getDomain(), "urn:xmpp:carbons:2");
}
public boolean commands() {
return hasDiscoFeature(account.getDomain(), Namespace.COMMANDS);
}
public boolean easyOnboardingInvites() {
synchronized (commands) {
return commands.containsKey(Namespace.EASY_ONBOARDING_INVITE);
}
}
public boolean bookmarksConversion() {
return hasDiscoFeature(account.getJid().asBareJid(), Namespace.BOOKMARKS_CONVERSION) && pepPublishOptions();
}

View file

@ -35,6 +35,10 @@
android:title="@string/search_messages"
android:visible="@bool/show_individual_search_options"
app:showAsAction="never" />
<item
android:id="@+id/action_easy_invite"
android:orderInCategory="89"
android:title="@string/invite_to_app" />
<item
android:id="@+id/action_accounts"
android:orderInCategory="90"

View file

@ -950,4 +950,7 @@
<string name="failed_deliveries">Failed deliveries</string>
<string name="more_options">More options</string>
<string name="no_application_found">No application found</string>
<string name="invite_to_app">Invite to Conversations</string>
<string name="unable_to_parse_invite">Unable to parse invite</string>
<string name="server_does_not_support_easy_onboarding_invites">Server does not support generating invites</string>
</resources>