initial (untested) support for easy onboarding invites

(cherry picked from commit 1f392a688d1439fdf163cc767e3630d566726d83)
This commit is contained in:
Daniel Gultsch 2020-12-01 20:31:30 +01:00 committed by Christian Schneppe
parent 26c4c1bbc2
commit 74051c85a9
12 changed files with 479 additions and 1 deletions

View file

@ -129,6 +129,7 @@ import eu.siacs.conversations.ui.interfaces.OnSearchResultsAvailable;
import eu.siacs.conversations.utils.Compatibility; import eu.siacs.conversations.utils.Compatibility;
import eu.siacs.conversations.utils.ConversationsFileObserver; import eu.siacs.conversations.utils.ConversationsFileObserver;
import eu.siacs.conversations.utils.CryptoHelper; import eu.siacs.conversations.utils.CryptoHelper;
import eu.siacs.conversations.utils.EasyOnboardingInvite;
import eu.siacs.conversations.utils.ExceptionHelper; import eu.siacs.conversations.utils.ExceptionHelper;
import eu.siacs.conversations.utils.MimeUtils; import eu.siacs.conversations.utils.MimeUtils;
import eu.siacs.conversations.utils.Namespace; import eu.siacs.conversations.utils.Namespace;
@ -1865,6 +1866,43 @@ public class XmppConnectionService extends Service {
sendMessage(message, true, delay); 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) { public void fetchRosterFromServer(final Account account) {
final IqPacket iqPacket = new IqPacket(IqPacket.TYPE.GET); final IqPacket iqPacket = new IqPacket(IqPacket.TYPE.GET);
if (!"".equals(account.getRosterVersion())) { if (!"".equals(account.getRosterVersion())) {

View file

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

View file

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

View file

@ -32,6 +32,7 @@ package eu.siacs.conversations.ui;
import android.animation.Animator; import android.animation.Animator;
import android.animation.AnimatorInflater; import android.animation.AnimatorInflater;
import android.app.Activity; import android.app.Activity;
import android.app.AlertDialog;
import android.app.Fragment; import android.app.Fragment;
import android.content.Intent; import android.content.Intent;
import android.os.Bundle; import android.os.Bundle;
@ -45,13 +46,17 @@ import android.view.ViewGroup;
import androidx.databinding.DataBindingUtil; import androidx.databinding.DataBindingUtil;
import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.LinearLayoutManager;
import com.google.common.collect.Collections2;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.concurrent.atomic.AtomicReference;
import eu.siacs.conversations.Config; import eu.siacs.conversations.Config;
import eu.siacs.conversations.R; import eu.siacs.conversations.R;
import eu.siacs.conversations.databinding.FragmentConversationsOverviewBinding; import eu.siacs.conversations.databinding.FragmentConversationsOverviewBinding;
import eu.siacs.conversations.entities.Account;
import eu.siacs.conversations.entities.Conversation; import eu.siacs.conversations.entities.Conversation;
import eu.siacs.conversations.ui.adapter.ConversationAdapter; import eu.siacs.conversations.ui.adapter.ConversationAdapter;
import eu.siacs.conversations.ui.interfaces.OnConversationSelected; import eu.siacs.conversations.ui.interfaces.OnConversationSelected;
@ -59,6 +64,7 @@ import eu.siacs.conversations.ui.util.PendingActionHelper;
import eu.siacs.conversations.ui.util.PendingItem; import eu.siacs.conversations.ui.util.PendingItem;
import eu.siacs.conversations.ui.util.ScrollState; import eu.siacs.conversations.ui.util.ScrollState;
import eu.siacs.conversations.utils.MenuDoubleTabUtil; import eu.siacs.conversations.utils.MenuDoubleTabUtil;
import eu.siacs.conversations.utils.EasyOnboardingInvite;
public class ConversationsOverviewFragment extends XmppFragment { public class ConversationsOverviewFragment extends XmppFragment {
@ -179,6 +185,8 @@ public class ConversationsOverviewFragment extends XmppFragment {
@Override @Override
public void onCreateOptionsMenu(Menu menu, MenuInflater menuInflater) { public void onCreateOptionsMenu(Menu menu, MenuInflater menuInflater) {
menuInflater.inflate(R.menu.fragment_conversations_overview, menu); menuInflater.inflate(R.menu.fragment_conversations_overview, menu);
final MenuItem easyOnboardInvite = menu.findItem(R.id.action_easy_invite);
easyOnboardInvite.setVisible(EasyOnboardingInvite.anyHasSupport(activity == null ? null : activity.xmppConnectionService));
} }
@Override @Override
@ -234,8 +242,31 @@ public class ConversationsOverviewFragment extends XmppFragment {
startActivity(new Intent(getActivity(), SearchActivity.class)); startActivity(new Intent(getActivity(), SearchActivity.class));
activity.overridePendingTransition(R.animator.fade_in, R.animator.fade_out); activity.overridePendingTransition(R.animator.fade_in, R.animator.fade_out);
return true; return true;
case R.id.action_easy_invite:
selectAccountToStartEasyInvite();
return true;
} }
return super.onOptionsItemSelected(item); 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 @Override

View file

@ -0,0 +1,158 @@
package eu.siacs.conversations.ui;
import android.app.Activity;
import android.content.Intent;
import androidx.appcompat.widget.Toolbar;
import androidx.databinding.DataBindingUtil;
import android.graphics.Bitmap;
import android.graphics.Point;
import android.os.Bundle;
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,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 BOOKMARKS2_COMPAT = BOOKMARKS2 + "#compat";
public static final String INVITE = "urn:xmpp:invite"; public static final String INVITE = "urn:xmpp:invite";
public static final String PARS = "urn:xmpp:pars:0"; public static final String PARS = "urn:xmpp:pars:0";
public static final String EASY_ONBOARDING_INVITE = "urn:xmpp:invite#invite";
} }

View file

@ -145,6 +145,7 @@ public class XmppConnection implements Runnable {
protected final Account account; protected final Account account;
private final Features features = new Features(this); private final Features features = new Features(this);
private final HashMap<Jid, ServiceDiscoveryResult> disco = new HashMap<>(); 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 SparseArray<AbstractAcknowledgeableStanza> mStanzaQueue = new SparseArray<>();
private final Hashtable<String, Pair<IqPacket, OnIqPacketReceived>> packetCallbacks = new Hashtable<>(); private final Hashtable<String, Pair<IqPacket, OnIqPacketReceived>> packetCallbacks = new Hashtable<>();
private final Set<OnAdvancedStreamFeaturesLoaded> advancedStreamFeaturesLoadedListeners = new HashSet<>(); private final Set<OnAdvancedStreamFeaturesLoaded> advancedStreamFeaturesLoadedListeners = new HashSet<>();
@ -240,6 +241,12 @@ public class XmppConnection implements Runnable {
} }
} }
public Jid getJidForCommand(final String node) {
synchronized (this.commands) {
return this.commands.get(node);
}
}
public void prepareNewConnection() { public void prepareNewConnection() {
this.lastConnect = SystemClock.elapsedRealtime(); this.lastConnect = SystemClock.elapsedRealtime();
this.lastPingSent = SystemClock.elapsedRealtime(); this.lastPingSent = SystemClock.elapsedRealtime();
@ -1070,6 +1077,9 @@ public class XmppConnection implements Runnable {
synchronized (this.disco) { synchronized (this.disco) {
disco.clear(); disco.clear();
} }
synchronized (this.commands) {
this.commands.clear();
}
} }
private void sendBindRequest() { private void sendBindRequest() {
@ -1294,6 +1304,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() { public boolean isMamPreferenceAlways() {
return isMamPreferenceAlways; return isMamPreferenceAlways;
} }
@ -1317,6 +1356,9 @@ public class XmppConnection implements Runnable {
if (getFeatures().carbons() && !features.carbonsEnabled) { if (getFeatures().carbons() && !features.carbonsEnabled) {
sendEnableCarbons(); sendEnableCarbons();
} }
if (getFeatures().commands()) {
discoverCommands();
}
} }
private void sendServiceDiscoveryItems(final Jid server) { private void sendServiceDiscoveryItems(final Jid server) {
@ -1901,6 +1943,16 @@ public class XmppConnection implements Runnable {
return hasDiscoFeature(account.getDomain(), "urn:xmpp:carbons:2"); 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() { public boolean bookmarksConversion() {
return hasDiscoFeature(account.getJid().asBareJid(), Namespace.BOOKMARKS_CONVERSION) && pepPublishOptions(); return hasDiscoFeature(account.getJid().asBareJid(), Namespace.BOOKMARKS_CONVERSION) && pepPublishOptions();
} }

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

@ -36,4 +36,8 @@
android:title="@string/search_messages" android:title="@string/search_messages"
android:icon="?attr/icon_search" android:icon="?attr/icon_search"
app:showAsAction="always" /> app:showAsAction="always" />
<item
android:id="@+id/action_easy_invite"
android:orderInCategory="89"
android:title="@string/invite_to_app" />
</menu> </menu>

View file

@ -1077,5 +1077,13 @@
<string name="move_data">Move data</string> <string name="move_data">Move data</string>
<string name="error_moving_data">An error occurred while moving your old data. Please move them manually.</string> <string name="error_moving_data">An error occurred while moving your old data. Please move them manually.</string>
<string name="data_successfully_moved">Your data has been moved successfully.</string> <string name="data_successfully_moved">Your data has been moved successfully.</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>
<string name="your_server">Your provider</string> <string name="your_server">Your provider</string>
<string name="tap_share_button_send_invite">Tap the share button to send your contact an invitation to %1$s.</string>
<string name="invite">Invite</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> </resources>