/* * Kontalk Android client * Copyright (C) 2017 Kontalk Devteam <devteam@kontalk.org> * 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 <http://www.gnu.org/licenses/>. */ package org.kontalk.ui; import java.util.Collections; import java.util.HashSet; import java.util.Set; import com.afollestad.materialdialogs.DialogAction; import com.afollestad.materialdialogs.MaterialDialog; import org.jivesoftware.smack.packet.Presence; import org.jxmpp.util.XmppStringUtils; import org.spongycastle.openpgp.PGPPublicKeyRing; import android.app.Activity; import android.content.Context; import android.content.Intent; import android.database.sqlite.SQLiteDiskIOException; import android.net.Uri; import android.os.Bundle; import android.support.annotation.NonNull; import android.text.TextUtils; import android.view.Menu; import android.view.MenuInflater; import android.view.MenuItem; import android.view.View; import android.widget.Toast; import org.kontalk.BuildConfig; import org.kontalk.Kontalk; import org.kontalk.Log; import org.kontalk.R; import org.kontalk.authenticator.Authenticator; import org.kontalk.client.KontalkGroupManager; import org.kontalk.crypto.PGP; import org.kontalk.data.Contact; import org.kontalk.data.Conversation; import org.kontalk.message.CompositeMessage; import org.kontalk.provider.MyMessages; import org.kontalk.provider.MyMessages.Groups; import org.kontalk.service.msgcenter.MessageCenterService; import org.kontalk.util.Preferences; import org.kontalk.util.XMPPUtils; /** * Composer fragment for group chats. * @author Daniele Ricci */ public class GroupMessageFragment extends AbstractComposeFragment { private static final String TAG = ComposeMessage.TAG; private static final int REQUEST_GROUP_INFO = REQUEST_FIRST_CHILD + 1; /** The virtual or real group JID. */ private String mGroupJID; private MenuItem mInviteGroupMenu; private MenuItem mSetGroupSubjectMenu; private MenuItem mGroupInfoMenu; private MenuItem mLeaveGroupMenu; private MenuItem mAttachMenu; @Override public boolean sendInactive() { // TODO return false; } @Override protected void updateUI() { super.updateUI(); if (mInviteGroupMenu != null) { boolean visible; String myUser = Authenticator.getSelfJID(getContext()); // menu items requiring ownership and membership visible = KontalkGroupManager.KontalkGroup .checkOwnership(mConversation.getGroupJid(), myUser) && mConversation.getGroupMembership() == Groups.MEMBERSHIP_MEMBER; mInviteGroupMenu.setVisible(visible); mInviteGroupMenu.setEnabled(visible); mSetGroupSubjectMenu.setVisible(visible); mSetGroupSubjectMenu.setEnabled(visible); // menu items requiring membership visible = mConversation.getGroupMembership() == Groups.MEMBERSHIP_MEMBER; mGroupInfoMenu.setVisible(visible); mGroupInfoMenu.setEnabled(visible); mLeaveGroupMenu.setVisible(visible); mLeaveGroupMenu.setEnabled(visible); if (visible) { if (mConversation != null) { int count = mConversation.getGroupPeers().length + 1; visible = count > 1; } else { visible = false; } } mAttachMenu.setVisible(visible); mAttachMenu.setEnabled(visible); if (!visible) tryHideAttachmentView(); } } @Override protected void onInflateOptionsMenu(Menu menu, MenuInflater inflater) { inflater.inflate(R.menu.group_message_menu, menu); mInviteGroupMenu = menu.findItem(R.id.invite_group); mSetGroupSubjectMenu = menu.findItem(R.id.group_subject); mGroupInfoMenu = menu.findItem(R.id.group_info); mLeaveGroupMenu = menu.findItem(R.id.leave_group); mAttachMenu = menu.findItem(R.id.menu_attachment); } @Override public boolean onOptionsItemSelected(MenuItem item) { if (super.onOptionsItemSelected(item)) return true; switch (item.getItemId()) { case R.id.group_info: viewGroupInfo(); return true; case R.id.group_subject: changeGroupSubject(); return true; case R.id.leave_group: leaveGroup(); return true; } return false; } @Override protected void addUsers(String[] members) { Set<String> existingMembers = new HashSet<>(); Collections.addAll(existingMembers, mConversation.getGroupPeers()); // ensure no duplicates String selfJid = Authenticator.getSelfJID(getContext()); Set<String> usersList = new HashSet<>(); for (String member : members) { // exclude ourselves and do not add if already an existing member if (!member.equalsIgnoreCase(selfJid) && !existingMembers.contains(member)) usersList.add(member); } if (usersList.size() > 0) { String[] users = usersList.toArray(new String[usersList.size()]); mConversation.addUsers(users); // reload conversation ((ComposeMessageParent) getActivity()).loadConversation(getThreadId(), false); } } @Override protected void deleteConversation() { try { // this will also leave the group if true is passed mConversation.delete(false); } catch (SQLiteDiskIOException e) { Log.w(TAG, "error deleting thread"); Toast.makeText(getActivity(), R.string.error_delete_thread, Toast.LENGTH_LONG).show(); } } private void leaveGroup() { new MaterialDialog.Builder(getActivity()) .content(R.string.confirm_will_leave_group) .positiveText(android.R.string.ok) .positiveColorRes(R.color.button_danger) .onPositive(new MaterialDialog.SingleButtonCallback() { @Override public void onClick(@NonNull MaterialDialog dialog, @NonNull DialogAction which) { // leave group if (dialog.isPromptCheckBoxChecked()) { mConversation.delete(true); // manually close the conversation closeConversation(); } else { mConversation.leaveGroup(); // reload conversation if (isVisible()) startQuery(); } } }) .checkBoxPromptRes(R.string.leave_group_delete_messages, false, null) .negativeText(android.R.string.cancel) .show(); } private void changeGroupSubject() { new MaterialDialog.Builder(getContext()) .title(R.string.title_group_subject) .positiveText(android.R.string.ok) .negativeText(android.R.string.cancel) .input(null, mConversation.getGroupSubject(), true, new MaterialDialog.InputCallback() { @Override public void onInput(@NonNull MaterialDialog dialog, CharSequence input) { setGroupSubject(!TextUtils.isEmpty(input) ? input.toString() : null); } }) .inputRange(0, Groups.GROUP_SUBJECT_MAX_LENGTH) .show(); } void setGroupSubject(String subject) { mConversation.setGroupSubject(subject); // reload conversation ((ComposeMessageParent) getActivity()).loadConversation(getThreadId(), false); } private void requestPresence() { Context context; if (mConversation != null && (context = getContext()) != null) { String[] users = mConversation.getGroupPeers(); if (users != null) { for (String user : users) { MessageCenterService.requestPresence(context, user); } } } } @Override protected String getDecodedPeer(CompositeMessage msg) { if (msg.getDirection() == MyMessages.Messages.DIRECTION_IN) { String userId = msg.getSender(); Contact c = Contact.findByUserId(getContext(), userId); return c != null ? c.getNumber() : userId; } return null; } @Override protected String getDecodedName(CompositeMessage msg) { if (msg.getDirection() == MyMessages.Messages.DIRECTION_IN) { String userId = msg.getSender(); Contact c = Contact.findByUserId(getContext(), userId); return c.getName(); } return null; } @Override protected void loadConversationMetadata(Uri uri) { super.loadConversationMetadata(uri); if (mConversation != null) { mGroupJID = mConversation.getRecipient(); mUserName = mGroupJID; } } /** * Since this is a group chat, it can be only opened from within the app. * No ACTION_VIEW intent ever will be delivered for this. */ @Override protected void handleActionView(Uri uri) { throw new AssertionError("This shouldn't be called ever!"); } /** * Used only during activity restore. */ @Override protected boolean handleActionViewConversation(Uri uri, Bundle args) { mGroupJID = uri.getPathSegments().get(1); mConversation = Conversation.loadFromUserId(getActivity(), mGroupJID); // unlikely, but better safe than sorry if (mConversation == null) { Log.i(TAG, "conversation for " + mGroupJID + " not found - exiting"); return false; } setThreadId(mConversation.getThreadId()); mUserName = mGroupJID; return true; } @Override protected void onArgumentsProcessed() { if (getArguments().getBoolean(ComposeMessage.EXTRA_CREATING_GROUP) && Preferences.getGroupChatCreateDisclaimer()) { new MaterialDialog.Builder(getContext()) .content(R.string.create_group_disclaimer) .checkBoxPromptRes(R.string.check_dont_show_again, false, null) .positiveText(android.R.string.ok) .onPositive(new MaterialDialog.SingleButtonCallback() { @Override public void onClick(@NonNull MaterialDialog dialog, @NonNull DialogAction which) { if (dialog.isPromptCheckBoxChecked()) { Preferences.setGroupChatCreateDisclaimer(); } } }) .show(); } } @Override protected void onConversationCreated() { // warning will be reloaded if necessary hideWarning(); super.onConversationCreated(); // set group title String subject = mConversation.getGroupSubject(); if (TextUtils.isEmpty(subject)) subject = getString(R.string.group_untitled); String status; boolean sendEnabled; int membership = mConversation.getGroupMembership(); switch (membership) { case Groups.MEMBERSHIP_PARTED: status = getString(R.string.group_command_text_part_self); sendEnabled = false; break; case Groups.MEMBERSHIP_KICKED: status = getString(R.string.group_command_text_part_kicked); sendEnabled = false; break; case Groups.MEMBERSHIP_OBSERVER: { int count = mConversation.getGroupPeers().length; status = getResources() .getQuantityString(R.plurals.group_people, count, count); sendEnabled = count > 1; break; } case Groups.MEMBERSHIP_MEMBER: { // +1 because we are not included in the members list int count = mConversation.getGroupPeers().length + 1; status = getResources() .getQuantityString(R.plurals.group_people, count, count); sendEnabled = count > 1; break; } default: // shouldn't happen throw new RuntimeException("Unknown membership status: " + membership); } // disable sending if necessary mComposer.setSendEnabled(sendEnabled); setActivityTitle(subject, status); } private void showKeyWarning() { Activity context = getActivity(); if (context != null) { showWarning(context.getText(R.string.warning_public_key_group_unverified), new View.OnClickListener() { @Override public void onClick(View v) { viewGroupInfo(); } }, WarningType.FATAL); } } @Override protected void onPresence(String jid, Presence.Type type, boolean removed, Presence.Mode mode, String fingerprint) { Context context = getContext(); if (context == null) return; if (BuildConfig.DEBUG) { Log.d(TAG, "group member presence from " + jid + " (type=" + type + ", fingerprint=" + fingerprint + ")"); } // handle null type - meaning no subscription (warn user) if (type == null) { // some users are missing subscription - disable sending // FIXME a toast isn't the right way to warn about this (discussion going on in #179) Toast.makeText(context, "You can't chat with some of the group members because you haven't been authorized yet. Open a private chat with unknown users first.", Toast.LENGTH_LONG).show(); mComposer.setSendEnabled(false); } else if (type == Presence.Type.available || type == Presence.Type.unavailable) { // no encryption - pointless to verify keys if (!Preferences.getEncryptionEnabled(context)) return; String bareJid = XmppStringUtils.parseBareJid(jid); Contact contact = Contact.findByUserId(context, bareJid); if (contact != null) { // if this is null, we are accepting the key for the first time PGPPublicKeyRing trustedPublicKey = contact.getTrustedPublicKeyRing(); // request the key if we don't have a trusted one and of course if the user has a key boolean unknownKey = (trustedPublicKey == null && contact.getFingerprint() != null); boolean changedKey = false; // check if fingerprint changed if (trustedPublicKey != null && fingerprint != null) { String oldFingerprint = PGP.getFingerprint(PGP.getMasterKey(trustedPublicKey)); if (!fingerprint.equalsIgnoreCase(oldFingerprint)) { // fingerprint has changed since last time changedKey = true; } } if (changedKey || unknownKey) { showKeyWarning(); } } } } @Override protected void onConnected() { // TODO } @Override protected void onRosterLoaded() { requestPresence(); } @Override protected void onStartTyping(String jid) { // TODO } @Override protected void onStopTyping(String jid) { // TODO } @Override protected boolean isUserId(String jid) { if (mConversation != null) { String[] users = mConversation.getGroupPeers(); if (users != null) { for (String user : users) { if (XMPPUtils.equalsBareJID(jid, user)) return true; } } } return false; } @Override public String getUserId() { return mGroupJID; } @Override public boolean sendTyping() { // TODO return false; } public void viewGroupInfo() { final Context ctx = getContext(); if (ctx == null) return; int membership = mConversation != null ? mConversation.getGroupMembership() : Groups.MEMBERSHIP_PARTED; if (membership == Groups.MEMBERSHIP_MEMBER || membership == Groups.MEMBERSHIP_OBSERVER) { if (Kontalk.hasTwoPanesUI(ctx)) { GroupInfoDialog.start(ctx, this, getThreadId(), REQUEST_GROUP_INFO); } else { GroupInfoActivity.start(ctx, this, getThreadId(), REQUEST_GROUP_INFO); } } } @Override public void onActivityResult(int requestCode, int resultCode, Intent data) { if (requestCode == REQUEST_GROUP_INFO) { switch (resultCode) { case GroupInfoActivity.RESULT_PRIVATE_CHAT: ((ComposeMessageParent) getActivity()) .loadConversation(data.getData()); break; case GroupInfoActivity.RESULT_ADD_USERS: addUsers(); break; } } else { super.onActivityResult(requestCode, resultCode, data); } } }