/*
* 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.text.Collator;
import java.util.Collections;
import java.util.Comparator;
import java.util.LinkedList;
import java.util.List;
import com.afollestad.materialdialogs.DialogAction;
import com.afollestad.materialdialogs.MaterialDialog;
import com.akalipetis.fragment.ActionModeListFragment;
import com.akalipetis.fragment.MultiChoiceModeListener;
import org.jxmpp.util.XmppStringUtils;
import org.spongycastle.openpgp.PGPPublicKey;
import org.spongycastle.openpgp.PGPPublicKeyRing;
import android.app.Activity;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.graphics.Color;
import android.graphics.Typeface;
import android.os.Bundle;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.support.v4.content.LocalBroadcastManager;
import android.support.v7.view.ActionMode;
import android.text.SpannableStringBuilder;
import android.text.Spanned;
import android.text.TextUtils;
import android.text.style.CharacterStyle;
import android.text.style.ForegroundColorSpan;
import android.util.SparseBooleanArray;
import android.view.LayoutInflater;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup;
import android.widget.BaseAdapter;
import android.widget.Button;
import android.widget.ListView;
import android.widget.TextView;
import org.kontalk.Kontalk;
import org.kontalk.R;
import org.kontalk.authenticator.Authenticator;
import org.kontalk.client.KontalkGroupManager.KontalkGroup;
import org.kontalk.crypto.PGP;
import org.kontalk.data.Contact;
import org.kontalk.data.Conversation;
import org.kontalk.provider.Keyring;
import org.kontalk.provider.MessagesProviderUtils;
import org.kontalk.provider.MyMessages;
import org.kontalk.provider.MyMessages.Groups;
import org.kontalk.provider.MyUsers;
import org.kontalk.service.msgcenter.MessageCenterService;
import org.kontalk.ui.view.ContactsListItem;
import org.kontalk.util.SystemUtils;
/**
* Group information fragment
* FIXME this class is too tied to the concept of "Kontalk group"
* @author Daniele Ricci
*/
public class GroupInfoFragment extends ActionModeListFragment
implements Contact.ContactChangeListener, MultiChoiceModeListener {
private TextView mTitle;
private Button mSetSubject;
private Button mLeave;
private Button mIgnoreAll;
private MenuItem mAddMenu;
private MenuItem mRemoveMenu;
private MenuItem mChatMenu;
private MenuItem mReaddMenu;
GroupMembersAdapter mMembersAdapter;
Conversation mConversation;
private int mCheckedItemCount;
private BroadcastReceiver mRosterReceiver = new BroadcastReceiver() {
@Override
public void onReceive(Context context, Intent intent) {
String jid = intent.getStringExtra(MessageCenterService.EXTRA_FROM);
boolean isSubscribed = intent
.getBooleanExtra(MessageCenterService.EXTRA_SUBSCRIBED_FROM, false) &&
intent.getBooleanExtra(MessageCenterService.EXTRA_SUBSCRIBED_TO, false);
mMembersAdapter.setSubscribed(jid, isSubscribed);
}
};
private LocalBroadcastManager mLocalBroadcastManager;
public static GroupInfoFragment newInstance(long threadId) {
GroupInfoFragment f = new GroupInfoFragment();
Bundle data = new Bundle();
data.putLong("conversation", threadId);
f.setArguments(data);
return f;
}
private void loadConversation(long threadId) {
mConversation = Conversation.loadFromId(getContext(), threadId);
mMembersAdapter.setGroupJid(mConversation.getGroupJid());
String subject = mConversation.getGroupSubject();
mTitle.setText(TextUtils.isEmpty(subject) ?
getString(R.string.group_untitled) : subject);
String selfJid = Authenticator.getSelfJID(getContext());
boolean isOwner = KontalkGroup.checkOwnership(mConversation.getGroupJid(), selfJid);
boolean isMember = mConversation.getGroupMembership() == Groups.MEMBERSHIP_MEMBER;
mSetSubject.setEnabled(isOwner && isMember);
mLeave.setEnabled(isMember);
// listen to roster entry status requests
IntentFilter filter = new IntentFilter(MessageCenterService.ACTION_ROSTER_STATUS);
mLocalBroadcastManager.registerReceiver(mRosterReceiver, filter);
// load members
boolean showIgnoreAll = false;
String[] members = getGroupMembers();
mMembersAdapter.clear();
for (String jid : members) {
Contact c = Contact.findByUserId(getContext(), jid);
if (c.isKeyChanged() || c.getTrustedLevel() == MyUsers.Keys.TRUST_UNKNOWN)
showIgnoreAll = true;
boolean owner = KontalkGroup.checkOwnership(mConversation.getGroupJid(), jid);
boolean isSelfJid = jid.equalsIgnoreCase(selfJid);
mMembersAdapter.add(c, owner, isSelfJid);
if (!isSelfJid) {
// request roster entry status
MessageCenterService.requestRosterEntryStatus(getContext(), jid);
}
}
mIgnoreAll.setVisibility(showIgnoreAll ? View.VISIBLE : View.GONE);
mMembersAdapter.notifyDataSetChanged();
updateUI();
}
private void updateUI() {
String selfJid = Authenticator.getSelfJID(getContext());
boolean isOwner = KontalkGroup.checkOwnership(mConversation.getGroupJid(), selfJid);
if (mRemoveMenu != null) {
mRemoveMenu.setVisible(isOwner);
}
if (mAddMenu != null) {
mAddMenu.setVisible(isOwner);
}
if (mReaddMenu != null) {
mReaddMenu.setVisible(isOwner);
}
}
private String[] getGroupMembers() {
String[] members = mConversation.getGroupPeers();
String[] added = MessagesProviderUtils.getGroupMembers(getContext(),
mConversation.getGroupJid(), Groups.MEMBER_PENDING_ADDED);
if (added.length > 0)
members = SystemUtils.concatenate(members, added);
// if we are in the group, add ourself to the list
if (mConversation.getGroupMembership() == Groups.MEMBERSHIP_MEMBER) {
String selfJid = Authenticator.getSelfJID(getContext());
members = SystemUtils.concatenate(members, selfJid);
}
return members;
}
@Override
public void onActivityCreated(@Nullable Bundle savedInstanceState) {
super.onActivityCreated(savedInstanceState);
mMembersAdapter = new GroupMembersAdapter(getContext(), null);
setListAdapter(mMembersAdapter);
setMultiChoiceModeListener(this);
}
@Override
public void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setHasOptionsMenu(true);
}
@Override
public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
View view = inflater.inflate(R.layout.group_info, container, false);
mTitle = (TextView) view.findViewById(R.id.title);
mSetSubject = (Button) view.findViewById(R.id.btn_change_title);
mSetSubject.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
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();
}
});
mLeave = (Button) view.findViewById(R.id.btn_leave);
mLeave.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
confirmLeave();
}
});
mIgnoreAll = (Button) view.findViewById(R.id.btn_ignore_all);
mIgnoreAll.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
new MaterialDialog.Builder(getContext())
.title(R.string.title_ignore_all_identities)
.content(R.string.msg_ignore_all_identities)
.positiveText(android.R.string.ok)
.positiveColorRes(R.color.button_danger)
.onPositive(new MaterialDialog.SingleButtonCallback() {
@Override
public void onClick(@NonNull MaterialDialog dialog, @NonNull DialogAction which) {
mMembersAdapter.ignoreAll();
reload();
}
})
.negativeText(android.R.string.cancel)
.show();
}
});
return view;
}
@Override
public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) {
inflater.inflate(R.menu.group_info_menu, menu);
mAddMenu = menu.findItem(R.id.menu_invite);
}
void setGroupSubject(String subject) {
mConversation.setGroupSubject(subject);
reload();
}
@Override
public boolean onOptionsItemSelected(MenuItem item) {
// action mode is active - no processing
if (isActionModeActive())
return true;
switch (item.getItemId()) {
case R.id.menu_invite:
Activity parent = getActivity();
if (parent != null) {
parent.setResult(GroupInfoActivity.RESULT_ADD_USERS, null);
parent.finish();
}
return true;
}
return super.onOptionsItemSelected(item);
}
public boolean isActionModeActive() {
return mCheckedItemCount > 0;
}
@Override
public void onItemCheckedStateChanged(ActionMode mode, int position, long id, boolean checked) {
if (checked)
mCheckedItemCount++;
else
mCheckedItemCount--;
mode.setTitle(getResources()
.getQuantityString(R.plurals.context_selected,
mCheckedItemCount, mCheckedItemCount));
mode.invalidate();
}
@Override
public boolean onActionItemClicked(ActionMode mode, MenuItem item) {
switch (item.getItemId()) {
case R.id.menu_remove:
// using clone because listview returns its original copy
removeSelectedUsers(SystemUtils
.cloneSparseBooleanArray(getListView().getCheckedItemPositions()));
mode.finish();
return true;
case R.id.menu_add_again:
// using clone because listview returns its original copy
readdUser(SystemUtils
.cloneSparseBooleanArray(getListView().getCheckedItemPositions()));
return true;
case R.id.menu_chat:
openChat(getCheckedItem().contact.getJID());
return true;
}
return false;
}
@Override
public boolean onCreateActionMode(ActionMode mode, Menu menu) {
MenuInflater inflater = mode.getMenuInflater();
inflater.inflate(R.menu.group_info_ctx, menu);
mRemoveMenu = menu.findItem(R.id.menu_remove);
mChatMenu = menu.findItem(R.id.menu_chat);
mReaddMenu = menu.findItem(R.id.menu_add_again);
updateUI();
return true;
}
@Override
public void onDestroyActionMode(ActionMode mode) {
mCheckedItemCount = 0;
getListView().clearChoices();
mMembersAdapter.notifyDataSetChanged();
}
@Override
public boolean onPrepareActionMode(ActionMode mode, Menu menu) {
mChatMenu.setVisible(mCheckedItemCount == 1);
return true;
}
private GroupMembersAdapter.GroupMember getCheckedItem() {
if (mCheckedItemCount != 1)
throw new IllegalStateException("checked items count must be exactly 1");
return (GroupMembersAdapter.GroupMember) getListView()
.getItemAtPosition(getCheckedItemPosition());
}
private int getCheckedItemPosition() {
SparseBooleanArray checked = getListView().getCheckedItemPositions();
return checked.keyAt(checked.indexOfValue(true));
}
private void removeSelectedUsers(final SparseBooleanArray checked) {
boolean removingSelf = false;
List<String> users = new LinkedList<>();
for (int i = 0, c = mMembersAdapter.getCount(); i < c; ++i) {
if (checked.get(i)) {
GroupMembersAdapter.GroupMember member =
(GroupMembersAdapter.GroupMember) mMembersAdapter.getItem(i);
if (Authenticator.isSelfJID(getContext(), member.contact.getJID())) {
removingSelf = true;
}
else {
users.add(member.contact.getJID());
}
}
}
if (users.size() > 0) {
mConversation.removeUsers(users.toArray(new String[users.size()]));
reload();
}
if (removingSelf)
confirmLeave();
}
@Override
public void onContactInvalidated(String userId) {
Activity context = getActivity();
if (context != null) {
context.runOnUiThread(new Runnable() {
@Override
public void run() {
// just reload
reload();
}
});
}
}
@Override
public void onListItemClick(ListView l, View v, int position, long id) {
int choiceMode = l.getChoiceMode();
if (choiceMode == ListView.CHOICE_MODE_NONE || choiceMode == ListView.CHOICE_MODE_SINGLE) {
// open identity dialog
// one day this will be the contact info activity
GroupMembersAdapter.GroupMember member =
(GroupMembersAdapter.GroupMember) mMembersAdapter.getItem(position);
showIdentityDialog(member.contact, member.subscribed);
}
else {
super.onListItemClick(l, v, position, id);
}
}
private void showIdentityDialog(Contact c, boolean subscribed) {
final String jid = c.getJID();
final String dialogFingerprint;
final String fingerprint;
final boolean selfJid = Authenticator.isSelfJID(getContext(), jid);
int titleResId = R.string.title_identity;
String uid;
PGPPublicKeyRing publicKey = Keyring.getPublicKey(getContext(), jid, MyUsers.Keys.TRUST_UNKNOWN);
if (publicKey != null) {
PGPPublicKey pk = PGP.getMasterKey(publicKey);
String rawFingerprint = PGP.getFingerprint(pk);
fingerprint = PGP.formatFingerprint(rawFingerprint);
uid = PGP.getUserId(pk, XmppStringUtils.parseDomain(jid));
dialogFingerprint = selfJid ? null : rawFingerprint;
}
else {
// FIXME using another string
fingerprint = getString(R.string.peer_unknown);
uid = null;
dialogFingerprint = null;
}
if (Authenticator.isSelfJID(getContext(), jid)) {
titleResId = R.string.title_identity_self;
}
SpannableStringBuilder text = new SpannableStringBuilder();
if (c.getName() != null && c.getNumber() != null) {
text.append(c.getName())
.append('\n')
.append(c.getNumber());
}
else {
int start = text.length();
text.append(uid != null ? uid : c.getJID());
text.setSpan(SystemUtils.getTypefaceSpan(Typeface.BOLD), start, text.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
}
text.append('\n')
.append(getString(R.string.text_invitation2))
.append('\n');
int start = text.length();
text.append(fingerprint);
text.setSpan(SystemUtils.getTypefaceSpan(Typeface.BOLD), start, text.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
int trustStringId;
CharacterStyle[] trustSpans;
if (subscribed) {
int trustedLevel;
if (c.isKeyChanged()) {
// the key has changed and was not trusted yet
trustedLevel = MyUsers.Keys.TRUST_UNKNOWN;
}
else {
trustedLevel = c.getTrustedLevel();
}
switch (trustedLevel) {
case MyUsers.Keys.TRUST_IGNORED:
trustStringId = R.string.trust_ignored;
trustSpans = new CharacterStyle[] {
SystemUtils.getTypefaceSpan(Typeface.BOLD),
SystemUtils.getColoredSpan(getContext(), R.color.button_danger)
};
break;
case MyUsers.Keys.TRUST_VERIFIED:
trustStringId = R.string.trust_verified;
trustSpans = new CharacterStyle[] {
SystemUtils.getTypefaceSpan(Typeface.BOLD),
SystemUtils.getColoredSpan(getContext(), R.color.button_success)
};
break;
case MyUsers.Keys.TRUST_UNKNOWN:
default:
trustStringId = R.string.trust_unknown;
trustSpans = new CharacterStyle[] {
SystemUtils.getTypefaceSpan(Typeface.BOLD),
SystemUtils.getColoredSpan(getContext(), R.color.button_danger)
};
break;
}
}
else {
trustStringId = R.string.status_notsubscribed;
trustSpans = new CharacterStyle[] {
SystemUtils.getTypefaceSpan(Typeface.BOLD),
};
}
text.append('\n').append(getString(R.string.status_label));
start = text.length();
text.append(getString(trustStringId));
for (CharacterStyle span : trustSpans)
text.setSpan(span, start, text.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
MaterialDialog.Builder builder = new MaterialDialog.Builder(getContext())
.content(text)
.title(titleResId);
if (dialogFingerprint != null && subscribed) {
builder.onAny(new MaterialDialog.SingleButtonCallback() {
@Override
public void onClick(@NonNull MaterialDialog dialog, @NonNull DialogAction which) {
switch (which) {
case POSITIVE:
// trust the key
trustKey(jid, dialogFingerprint, MyUsers.Keys.TRUST_VERIFIED);
break;
case NEUTRAL:
// ignore the key
trustKey(jid, dialogFingerprint, MyUsers.Keys.TRUST_IGNORED);
break;
case NEGATIVE:
// untrust the key
trustKey(jid, dialogFingerprint, MyUsers.Keys.TRUST_UNKNOWN);
break;
}
}
})
.positiveText(R.string.button_accept)
.positiveColorRes(R.color.button_success)
.neutralText(R.string.button_ignore)
.negativeText(R.string.button_refuse)
.negativeColorRes(R.color.button_danger);
}
else if (!selfJid) {
builder.onPositive(new MaterialDialog.SingleButtonCallback() {
@Override
public void onClick(@NonNull MaterialDialog dialog, @NonNull DialogAction which) {
openChat(jid);
}
})
.positiveText(R.string.button_private_chat);
}
builder.show();
}
private void readdUser(final SparseBooleanArray checked) {
List<String> users = new LinkedList<>();
for (int i = 0, c = mMembersAdapter.getCount(); i < c; ++i) {
if (checked.get(i)) {
GroupMembersAdapter.GroupMember member =
(GroupMembersAdapter.GroupMember) mMembersAdapter.getItem(i);
if (!Authenticator.isSelfJID(getContext(), member.contact.getJID())) {
users.add(member.contact.getJID());
}
}
}
if (users.size() > 0) {
mConversation.addUsers(users.toArray(new String[users.size()]));
}
getActivity().finish();
}
void openChat(String jid) {
Intent i = new Intent();
i.setData(MyMessages.Threads.getUri(jid));
Activity parent = getActivity();
parent.setResult(GroupInfoActivity.RESULT_PRIVATE_CHAT, i);
parent.finish();
}
void trustKey(String jid, String fingerprint, int trustLevel) {
Kontalk.getMessagesController(getContext())
.setTrustLevelAndRetryMessages(getContext(), jid, fingerprint, trustLevel);
Contact.invalidate(jid);
reload();
}
void confirmLeave() {
new MaterialDialog.Builder(getContext())
.content(R.string.confirm_will_leave_group)
.positiveText(android.R.string.ok)
.negativeText(android.R.string.cancel)
.onPositive(new MaterialDialog.SingleButtonCallback() {
@Override
public void onClick(@NonNull MaterialDialog dialog, @NonNull DialogAction which) {
// leave group
mConversation.leaveGroup();
getActivity().finish();
}
})
.show();
}
void reload() {
// reload conversation data
Bundle data = getArguments();
long threadId = data.getLong("conversation");
loadConversation(threadId);
}
@Override
public void onResume() {
super.onResume();
reload();
}
@Override
public void onAttach(Context context) {
super.onAttach(context);
if (!(context instanceof GroupInfoParent))
throw new IllegalArgumentException("parent activity must implement " +
GroupInfoParent.class.getSimpleName());
mLocalBroadcastManager = LocalBroadcastManager.getInstance(context);
}
@Override
public void onDetach() {
super.onDetach();
if (mLocalBroadcastManager != null)
mLocalBroadcastManager.unregisterReceiver(mRosterReceiver);
}
private static final class GroupMembersAdapter extends BaseAdapter {
private static final class GroupMember {
final Contact contact;
boolean subscribed;
GroupMember(Contact contact, boolean subscribed) {
this.contact = contact;
this.subscribed = subscribed;
}
}
private final Context mContext;
private final List<GroupMember> mMembers;
private String mOwner;
private String mGroupJid;
GroupMembersAdapter(Context context, String groupJid) {
mContext = context;
mMembers = new LinkedList<>();
mGroupJid = groupJid;
}
public void setGroupJid(String groupJid) {
mGroupJid = groupJid;
}
public void clear() {
mMembers.clear();
}
@Override
public void notifyDataSetChanged() {
Collections.sort(mMembers, new DisplayNameComparator());
super.notifyDataSetChanged();
}
public void add(Contact contact, boolean isOwner, boolean subscribed) {
mMembers.add(new GroupMember(contact, subscribed));
if (isOwner)
mOwner = contact.getJID();
}
@Override
public int getCount() {
return mMembers.size();
}
@Override
public Object getItem(int position) {
return mMembers.get(position);
}
@Override
public long getItemId(int position) {
return position;
}
@Override
public View getView(int position, View convertView, ViewGroup parent) {
View v;
if (convertView == null) {
v = newView(parent);
} else {
v = convertView;
}
bindView(v, position);
return v;
}
private View newView(ViewGroup parent) {
return LayoutInflater.from(mContext)
.inflate(R.layout.contact_item, parent, false);
}
private void bindView(View v, int position) {
ContactsListItem view = (ContactsListItem) v;
GroupMember member = (GroupMember) getItem(position);
Contact contact = member.contact;
String prependStatus = null;
CharacterStyle prependStyle = null;
if (contact.getJID().equalsIgnoreCase(mOwner)) {
prependStatus = mContext.getString(R.string.group_info_owner_member);
prependStyle = new ForegroundColorSpan(Color.RED);
}
view.bind(mContext, contact, prependStatus, prependStyle,
member.subscribed);
}
public void ignoreAll() {
synchronized (mMembers) {
for (GroupMember m : mMembers) {
Contact c = m.contact;
if (c.isKeyChanged() || c.getTrustedLevel() == MyUsers.Keys.TRUST_UNKNOWN) {
String fingerprint = c.getFingerprint();
Keyring.setTrustLevel(mContext, c.getJID(), fingerprint, MyUsers.Keys.TRUST_IGNORED);
Contact.invalidate(c.getJID());
}
}
MessageCenterService.retryMessagesTo(mContext, mGroupJid);
}
}
public void setSubscribed(String jid, boolean isSubscribed) {
synchronized (mMembers) {
for (GroupMember m : mMembers) {
Contact c = m.contact;
if (c.getJID().equalsIgnoreCase(jid)) {
m.subscribed = isSubscribed;
break;
}
}
}
// we don't need to sort, so call super directly
super.notifyDataSetChanged();
}
static class DisplayNameComparator implements
Comparator<GroupMember> {
DisplayNameComparator() {
mCollator.setStrength(Collator.PRIMARY);
}
public final int compare(GroupMember a, GroupMember b) {
return mCollator.compare(a.contact.getDisplayName(),
b.contact.getDisplayName());
}
private final Collator mCollator = Collator.getInstance();
}
}
public interface GroupInfoParent {
void dismiss();
}
}