/* * Kontalk Java client * Copyright (C) 2016 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.system; import java.awt.image.BufferedImage; import java.nio.file.Path; import java.nio.file.Paths; import java.time.LocalDateTime; import java.time.ZoneId; import java.util.Arrays; import java.util.Date; import java.util.EnumSet; import java.util.LinkedList; import java.util.List; import java.util.Observable; import java.util.Optional; import java.util.Timer; import java.util.TimerTask; import java.util.concurrent.TimeUnit; import java.util.logging.Level; import java.util.logging.Logger; import java.util.stream.Collectors; import org.jivesoftware.smack.packet.XMPPError.Condition; import org.jivesoftware.smackx.chatstates.ChatState; import org.kontalk.client.Client; import org.kontalk.client.FeatureDiscovery; import org.kontalk.client.KonMessageSender; import org.kontalk.crypto.Coder; import org.kontalk.crypto.PGPUtils; import org.kontalk.crypto.PGPUtils.PGPCoderKey; import org.kontalk.crypto.PersonalKey; import org.kontalk.misc.JID; import org.kontalk.misc.KonException; import org.kontalk.misc.ViewEvent; import org.kontalk.model.Account; import org.kontalk.model.Avatar; import org.kontalk.model.Contact; import org.kontalk.model.Model; import org.kontalk.model.chat.Chat; import org.kontalk.model.chat.GroupChat; import org.kontalk.model.chat.GroupMetaData; import org.kontalk.model.chat.Member; import org.kontalk.model.chat.ProtoMember; import org.kontalk.model.chat.SingleChat; import org.kontalk.model.message.InMessage; import org.kontalk.model.message.KonMessage; import org.kontalk.model.message.MessageContent; import org.kontalk.model.message.MessageContent.Attachment; import org.kontalk.model.message.MessageContent.GroupCommand; import org.kontalk.model.message.MessageContent.OutAttachment; import org.kontalk.model.message.OutMessage; import org.kontalk.model.message.ProtoMessage; import org.kontalk.persistence.Config; import org.kontalk.persistence.Database; import org.kontalk.util.ClientUtils.MessageIDs; import org.kontalk.util.MessageUtils.SendTask; import org.kontalk.util.MessageUtils.SendTask.Encryption; import org.kontalk.util.XMPPUtils; import org.kontalk.view.View; /** * Application control logic. * @author Alexander Bikadorov {@literal <bikaejkb@mail.tu-berlin.de>} */ public final class Control { private static final Logger LOGGER = Logger.getLogger(Control.class.getName()); /** The current application state. */ public enum Status { DISCONNECTING, DISCONNECTED, CONNECTING, CONNECTED, SHUTTING_DOWN, /** Connection attempt failed. */ FAILED, /** Connection was lost due to error. */ ERROR } /** Interval between retry connection attempts after failure. */ private static final int RETRY_TIMER_INTERVAL = 20; // seconds private final ViewControl mViewControl; private final Database mDB; private final Client mClient; private final Model mModel; private final ChatStateManager mChatStateManager; private final AttachmentManager mAttachmentManager; private final RosterHandler mRosterHandler; private final AvatarHandler mAvatarHandler; private final GroupControl mGroupControl; private boolean mShuttingDown = false; private Timer mRetryTimer = null; public Control(Path appDir) throws KonException { mViewControl = new ViewControl(); Config.initialize(appDir); try { mDB = new Database(appDir); } catch (KonException ex) { LOGGER.log(Level.SEVERE, "can't initialize database", ex); throw ex; } mModel = Model.setup(mDB, appDir); mClient = Client.create(this, appDir); mChatStateManager = new ChatStateManager(mClient); mAttachmentManager = AttachmentManager.create(this, mClient, appDir); mRosterHandler = new RosterHandler(this, mClient, mModel); mAvatarHandler = new AvatarHandler(mClient, mModel); mGroupControl = new GroupControl(this, mModel); } public void launch(boolean ui) { mModel.load(); if (ui) { View view = View.create(mViewControl, mModel).orElse(null); if (view == null) { this.shutDown(true); return; // never reached } view.init(); } boolean connect = Config.getInstance().getBoolean(Config.MAIN_CONNECT_STARTUP); if (!mModel.account().isPresent()) { LOGGER.info("no account found, asking for import..."); mViewControl.changed(new ViewEvent.MissingAccount(connect)); return; } if (connect) mViewControl.connect(); } public void shutDown(boolean exit) { if (mShuttingDown) // we were already here return; mShuttingDown = true; LOGGER.info("Shutting down..."); mViewControl.disconnect(); mViewControl.changed(new ViewEvent.StatusChange(Status.SHUTTING_DOWN, EnumSet.noneOf(FeatureDiscovery.Feature.class))); mModel.onShutDown(); try { mDB.close(); } catch (RuntimeException ex) { LOGGER.log(Level.WARNING, "can't close database", ex); } Config.getInstance().saveToFile(); if (exit) { LOGGER.info("exit"); System.exit(0); } } public RosterHandler getRosterHandler() { return mRosterHandler; } public AvatarHandler getAvatarHandler() { return mAvatarHandler; } ViewControl getViewControl() { return mViewControl; } /* events from network client */ public void onStatusChange(Status status, EnumSet<FeatureDiscovery.Feature> features) { mViewControl.changed(new ViewEvent.StatusChange(status, features)); Config config = Config.getInstance(); if (status == Status.CONNECTED) { String[] strings = config.getStringArray(Config.NET_STATUS_LIST); mClient.sendUserPresence(strings.length > 0 ? strings[0] : ""); // send all pending messages for (Chat chat: mModel.chats()) chat.getMessages().getPending().forEach(this::sendMessage); // send public key requests for Kontalk contacts with missing key for (Contact contact : mModel.contacts().getAll(false, false)) this.maySendKeyRequest(contact); // TODO check current user avatar on server and upload if necessary } else if (status == Status.DISCONNECTED || status == Status.FAILED) { for (Contact contact : mModel.contacts().getAll(false, false)) contact.setOnlineStatus(Contact.Online.UNKNOWN); } if ((status == Status.FAILED || status == Status.ERROR) && config.getBoolean(Config.NET_RETRY_CONNECT)) { mRetryTimer = new Timer("Retry Timer", true); TimerTask task = new TimerTask() { private int mCountDown = RETRY_TIMER_INTERVAL; @Override public void run() { if (mCountDown > 0) { mViewControl.changed(new ViewEvent.RetryTimerMessage(mCountDown--)); } else { mViewControl.connect(); } } }; mRetryTimer.schedule(task, 0, 1000); } } public void onAuthenticated(JID jid) { mModel.setUserJID(jid); } public void onException(KonException ex) { mViewControl.changed(new ViewEvent.Exception(ex)); } // TODO unused public void onEncryptionErrors(KonMessage message, Contact contact) { EnumSet<Coder.Error> errors = message.getCoderStatus().getErrors(); if (errors.contains(Coder.Error.KEY_UNAVAILABLE) || errors.contains(Coder.Error.INVALID_SIGNATURE) || errors.contains(Coder.Error.INVALID_SENDER)) { // maybe there is something wrong with the senders key this.sendKeyRequest(contact); } this.onSecurityErrors(message); } private void onSecurityErrors(KonMessage message) { mViewControl.changed(new ViewEvent.SecurityError(message)); } /** * All-in-one method for a new incoming message (except handling server * receipts): Create, save and process the message. */ public void onNewInMessage(MessageIDs ids, Optional<Date> serverDate, MessageContent content) { LOGGER.info("new incoming message, "+ids); Contact sender = this.getOrCreateContact(ids.jid).orElse(null); if (sender == null) { LOGGER.warning("can't get contact for message"); return; } // decrypt message now to get possible group data ProtoMessage protoMessage = new ProtoMessage(sender, content); if (protoMessage.isEncrypted()) { this.myKey().ifPresent(mk -> Coder.decryptMessage(mk, protoMessage)); } // NOTE: decryption must be successful to select group chat GroupMetaData groupData = content.getGroupData().orElse(null); Chat chat = groupData != null ? mGroupControl.getGroupChat(groupData, sender, content.getGroupCommand()).orElse(null) : mModel.chats().getOrCreate(sender, ids.xmppThreadID); if (chat == null) { LOGGER.warning("no chat found, message lost: "+protoMessage); return; } InMessage newMessage = mModel.createInMessage( protoMessage, chat, ids, serverDate).orElse(null); if (newMessage == null) return; GroupCommand com = newMessage.getContent().getGroupCommand().orElse(null); if (com != null) { if (chat instanceof GroupChat) { mGroupControl.getInstanceFor((GroupChat) chat) .onInMessage(com, sender); } else { LOGGER.warning("group command for non-group chat"); } } this.processContent(newMessage); mViewControl.changed(new ViewEvent.NewMessage(newMessage)); } public void onMessageSent(MessageIDs ids) { OutMessage message = this.findMessage(ids).orElse(null); if (message == null) return; message.setStatus(KonMessage.Status.SENT); } public void onMessageReceived(MessageIDs ids, Date receivedDate) { OutMessage message = this.findMessage(ids).orElse(null); if (message == null) return; message.setReceived(ids.jid, receivedDate); } public void onMessageError(MessageIDs ids, Condition condition, String errorText) { OutMessage message = this.findMessage(ids).orElse(null); if (message == null) return ; message.setServerError(condition.toString(), errorText); } /** * Inform model (and view) about a received chat state notification. */ public void onChatStateNotification(MessageIDs ids, Optional<Date> serverDate, ChatState chatState) { if (serverDate.isPresent()) { long diff = new Date().getTime() - serverDate.get().getTime(); if (diff > TimeUnit.SECONDS.toMillis(10)) { // too old return; } } Contact contact = mModel.contacts().get(ids.jid).orElse(null); if (contact == null) { LOGGER.info("can't find contact with jid: "+ids.jid); return; } // NOTE: assume chat states are only send for single chats SingleChat chat = mModel.chats().get(contact, ids.xmppThreadID).orElse(null); if (chat == null) // not that important return; chat.setChatState(contact, chatState); } public void onPGPKey(JID jid, byte[] rawKey) { Contact contact = mModel.contacts().get(jid).orElse(null); if (contact == null) { LOGGER.warning("can't find contact with jid: "+jid); return; } this.onPGPKey(contact, rawKey); } void onPGPKey(Contact contact, byte[] rawKey) { PGPCoderKey key = PGPUtils.readPublicKey(rawKey).orElse(null); if (key == null) { LOGGER.warning("invalid public PGP key, contact: "+contact); return; } if (!key.userID.contains("<"+contact.getJID().string()+">")) { LOGGER.warning("UID does not contain contact JID"); return; } if (key.fingerprint.equals(contact.getFingerprint())) // same key return; if (contact.hasKey()) { // ask before overwriting mViewControl.changed(new ViewEvent.NewKey(contact, key)); } else { setKey(contact, key); } } public void onBlockList(JID[] jids) { for (JID jid : jids) { if (jid.isFull()) { LOGGER.info("ignoring blocking of JID with resource"); return; } this.onContactBlocked(jid, true); } } public void onContactBlocked(JID jid, boolean blocking) { Contact contact = mModel.contacts().get(jid).orElse(null); if (contact == null) { LOGGER.info("ignoring blocking of JID not in contact list"); return; } LOGGER.info("set contact blocking: "+contact+" "+blocking); contact.setBlocked(blocking); } public void onLastActivity(JID jid, long lastSecondsAgo, String status) { Contact contact = mModel.contacts().get(jid).orElse(null); if (contact == null) { LOGGER.info("can't find contact with jid: "+jid); return; } if (contact.getOnline() == Contact.Online.YES) { // mobile clients connect only for a short time, last seen is some minutes ago but they // are actually online return; } if (lastSecondsAgo == 0) { // contact is online contact.setOnlineStatus(Contact.Online.YES); return; } // 'last seen' seconds to date LocalDateTime ldt = LocalDateTime.now().minusSeconds(lastSecondsAgo); Date lastSeen = Date.from(ldt.atZone(ZoneId.systemDefault()).toInstant()); contact.setLastSeen(lastSeen, status); } /* package */ /** * All-in-one method for a new outgoing message: Create, * save, process and send message. */ boolean createAndSendMessage(Chat chat, MessageContent content) { LOGGER.config("chat: "+chat+" content: "+content); if (!chat.isValid()) { LOGGER.warning("invalid chat"); return false; } List<Contact> contacts = chat.getValidContacts(); if (contacts.isEmpty()) { LOGGER.warning("can't send message, no (valid) contact(s)"); return false; } OutMessage newMessage = mModel.createOutMessage( chat, contacts, content).orElse(null); if (newMessage == null) return false; if (newMessage.getContent().getOutAttachment().isPresent()) mAttachmentManager.mayCreateImagePreview(newMessage); return this.sendMessage(newMessage); } boolean sendMessage(OutMessage message) { final MessageContent content = message.getContent(); final OutAttachment attachment = content.getOutAttachment().orElse(null); if (attachment != null && !attachment.hasURL()) { // continue later... mAttachmentManager.queueUpload(message); return false; } final SendTask task = new SendTask(message, // TODO which encryption method to use? message.isSendEncrypted() ? Encryption.RFC3923 : Encryption.NONE, Config.getInstance().getBoolean(Config.NET_SEND_CHAT_STATE)); if (task.encryption != Encryption.NONE) { // prepare encrypted content PersonalKey myKey = this.myKey().orElse(null); if (myKey == null) return false; String encryptedData = ""; if (task.encryption == Encryption.XEP0373) { String stanza = KonMessageSender.getSignCryptElement(message); encryptedData = Coder.encryptString(myKey, message, stanza); } else if (task.encryption == Encryption.RFC3923) { // legacy Chat chat = message.getChat(); if (content.getAttachment().isPresent() || content.getGroupCommand().isPresent() || chat.isGroupChat()) { String stanza = KonMessageSender.getEncryptionPayloadRFC3923(content, chat); encryptedData = Coder.encryptStanzaRFC3923(myKey, message, stanza); } else { encryptedData = Coder.encryptMessageRFC3923(myKey, message); } } // check also for security errors just to be sure if (encryptedData.isEmpty() || !message.getCoderStatus().getErrors().isEmpty()) { LOGGER.warning("encryption failed ("+task.encryption+")"); message.setStatus(KonMessage.Status.ERROR); this.onSecurityErrors(message); return false; } else { LOGGER.config("encryption successful ("+task.encryption+")"); } task.setEncryptedData(encryptedData); } final boolean sent = mClient.sendMessage(task); mChatStateManager.handleOwnChatStateEvent(message.getChat(), ChatState.active); return sent; } private static boolean canSendKeyRequest(Contact contact) { return contact.isMe() || (contact.isKontalkUser() && contact.getSubScription() == Contact.Subscription.SUBSCRIBED); } void maySendKeyRequest(Contact contact) { if (canSendKeyRequest(contact) && !contact.hasKey()) this.sendKeyRequest(contact); } void sendKeyRequest(Contact contact) { if (!canSendKeyRequest(contact)) { LOGGER.warning("better do not, contact: "+contact); return; } mClient.sendPublicKeyRequest(contact.getJID()); } Optional<Contact> getOrCreateContact(JID jid) { Contact contact = mModel.contacts().get(jid).orElse(null); if (contact != null) return Optional.of(contact); return this.createContact(jid, ""); } Optional<Contact> createContact(JID jid, String name) { return this.createContact(jid, name, XMPPUtils.isKontalkJID(jid)); } void sendPresenceSubscription(JID jid, Client.PresenceCommand command) { mClient.sendPresenceSubscription(jid, command); } Optional<PersonalKey> myKey() { Optional<PersonalKey> myKey = mModel.account().getPersonalKey(); if (!myKey.isPresent()) { LOGGER.log(Level.WARNING, "can't get personal key"); } return myKey; } /* private */ private Optional<Contact> createContact(JID jid, String name, boolean encrypted) { if (!mClient.isConnected()) { // workaround: create only if contact can be added to roster // this is a general problem with XMPPs roster: no real sync possible LOGGER.warning("can't create contact, not connected: "+jid); return Optional.empty(); } if (name.isEmpty() && !jid.isHash()){ name = jid.local(); } Contact newContact = mModel.contacts().create(jid, name).orElse(null); if (newContact == null) { LOGGER.warning("can't create new contact"); // TODO tell view return Optional.empty(); } newContact.setEncrypted(encrypted); this.addToRoster(newContact); this.maySendKeyRequest(newContact); return Optional.of(newContact); } private void decryptAndProcess(InMessage message) { if (!message.isEncrypted()) { LOGGER.info("message not encrypted"); } else { this.myKey().ifPresent(mk -> Coder.decryptMessage(mk, message)); } this.processContent(message); } private void setKey(Contact contact, PGPCoderKey key) { for (Contact c: mModel.contacts().getAll(true, true)) { if (key.fingerprint.equals(c.getFingerprint())) { LOGGER.warning("key already set, setting for: "+contact+" set for: "+c); } } contact.setKey(key.rawKey, key.fingerprint); // enable encryption without asking contact.setEncrypted(true); // if not set, use uid in key for contact name if (contact.getName().isEmpty() && key.userID != null) { LOGGER.info("full UID in key: '" + key.userID + "'"); String contactName = PGPUtils.parseUID(key.userID)[0]; if (!contactName.isEmpty()) contact.setName(contactName); } } /** * Download attachment for incoming message if present. */ private void processContent(InMessage message) { if (!message.getCoderStatus().getErrors().isEmpty()) { this.onSecurityErrors(message); } message.getContent().getPreview() .ifPresent(p -> mAttachmentManager.savePreview(p, message.getID())); if (message.getContent().getInAttachment().isPresent()) { this.download(message); } } private void download(InMessage message){ mAttachmentManager.queueDownload(message); } private void addToRoster(Contact contact) { if (contact.isMe()) return; if (contact.isDeleted()) { LOGGER.warning("you don't want to add a deleted contact: " + contact); return; } String contactName = contact.getName(); String rosterName = Config.getInstance().getBoolean(Config.NET_SEND_ROSTER_NAME) && !contactName.isEmpty() ? contactName : contact.getJID().local(); boolean succ = mClient.addToRoster(contact.getJID(), rosterName); if (!succ) LOGGER.warning("can't add contact to roster: "+contact); } private void removeFromRoster(JID jid) { boolean succ = mClient.removeFromRoster(jid); if (!succ) { LOGGER.warning("could not remove contact from roster"); } } private Optional<OutMessage> findMessage(MessageIDs ids) { // get chat by jid -> thread ID -> message id Contact contact = mModel.contacts().get(ids.jid).orElse(null); if (contact != null) { Chat chat = mModel.chats().get(contact, ids.xmppThreadID).orElse(null); if (chat != null) { Optional<OutMessage> optM = chat.getMessages().getLast(ids.xmppID); if (optM.isPresent()) return optM; } } // TODO group chats // fallback: search in every chat LOGGER.info("fallback search, IDs: "+ids); for (Chat chat: mModel.chats()) { Optional<OutMessage> optM = chat.getMessages().getLast(ids.xmppID); if (optM.isPresent()) return optM; } LOGGER.warning("can't find message by IDs: "+ids); return Optional.empty(); } /* commands from view */ public class ViewControl extends Observable { public void shutDown() { Control.this.shutDown(true); } public void connect() { this.connect(new char[0]); } public void connect(char[] password) { if (mRetryTimer != null) mRetryTimer.cancel(); PersonalKey key = this.keyOrNull(password); if (key == null) return; mClient.connect(key); } public void disconnect() { // this should not be necessary if (mRetryTimer != null) mRetryTimer.cancel(); mChatStateManager.imGone(); mClient.disconnect(); } public void setStatusText(String status) { Config conf = Config.getInstance(); // must be editable List<String> stats = new LinkedList<>(Arrays.asList( conf.getStringArray(Config.NET_STATUS_LIST))); if (!stats.isEmpty() && stats.get(0).equals(status)) // did not change return; stats.remove(status); stats.add(0, status); if (stats.size() > 20) stats = stats.subList(0, 20); conf.setProperty(Config.NET_STATUS_LIST, stats.toArray()); mClient.sendUserPresence(status); } public void setAccountPassword(char[] oldPass, char[] newPass) throws KonException { mModel.account().setPassword(oldPass, newPass); } public Path getAttachmentDir() { return mAttachmentManager.getAttachmentDir(); } /* contact */ public void createContact(JID jid, String name, boolean encrypted) { Control.this.createContact(jid, name, encrypted); } public void deleteContact(Contact contact) { JID jid = contact.getJID(); mModel.contacts().delete(contact); Control.this.removeFromRoster(jid); } public void sendContactBlocking(Contact contact, boolean blocking) { mClient.sendBlockingCommand(contact.getJID(), blocking); } public void changeJID(Contact contact, JID newJID) { JID oldJID = contact.getJID(); if (oldJID.equals(newJID)) return; boolean succ = mModel.contacts().changeJID(contact, newJID); if (!succ) return; Control.this.removeFromRoster(oldJID); Control.this.addToRoster(contact); } public void changeName(Contact contact, String name) { if (Config.getInstance().getBoolean(Config.NET_SEND_ROSTER_NAME)) // TODO care about success? mClient.updateRosterEntry(contact.getJID(), name); contact.setName(name); } public void requestKey(Contact contact) { Control.this.sendKeyRequest(contact); } public void acceptKey(Contact contact, PGPCoderKey key) { setKey(contact, key); } public void declineKey(Contact contact) { this.sendContactBlocking(contact, true); // TODO remember that a key was not accepted } public void sendSubscriptionResponse(Contact contact, boolean accept) { Control.this.sendPresenceSubscription(contact.getJID(), accept ? Client.PresenceCommand.GRANT : Client.PresenceCommand.DENY); } public void sendSubscriptionRequest(Contact contact) { Control.this.sendPresenceSubscription(contact.getJID(), Client.PresenceCommand.REQUEST); } public void createRosterEntry(Contact contact) { Control.this.addToRoster(contact); } /* chats */ public Chat getOrCreateSingleChat(Contact contact) { return mModel.chats().getOrCreate(contact); } public Optional<GroupChat> createGroupChat(List<Contact> contacts, String subject) { // user is part of the group List<ProtoMember> members = contacts.stream() .map(ProtoMember::new) .collect(Collectors.toList()); Contact me = mModel.contacts().getMe().orElse(null); if (me == null) { LOGGER.warning("can't find myself"); return Optional.empty(); } members.add(new ProtoMember(me, Member.Role.OWNER)); GroupChat chat = mModel.chats().createNew(members, GroupControl.newKonGroupData(me.getJID()), subject); mGroupControl.getInstanceFor(chat).onCreate(); return Optional.of(chat); } public void deleteChat(Chat chat) { if (chat instanceof GroupChat) { boolean succ = mGroupControl.getInstanceFor((GroupChat) chat).beforeDelete(); if (!succ) return; } mModel.chats().delete(chat); } public void leaveGroupChat(GroupChat chat) { mGroupControl.getInstanceFor(chat).onLeave(); } public void setChatSubject(GroupChat chat, String subject) { mGroupControl.getInstanceFor(chat).onSetSubject(subject); } public void handleOwnChatStateEvent(Chat chat, ChatState state) { mChatStateManager.handleOwnChatStateEvent(chat, state); } /* messages */ public void decryptAgain(InMessage message) { Control.this.decryptAndProcess(message); } public void downloadAgain(InMessage message) { Control.this.download(message); } public void sendText(Chat chat, String text) { this.sendNewMessage(chat, text, Paths.get("")); } public void sendAttachment(Chat chat, Path file){ this.sendNewMessage(chat, "", file); } public void sendAgain(OutMessage outMessage) { Control.this.sendMessage(outMessage); } /* avatar */ public void setUserAvatar(BufferedImage image) { Avatar.UserAvatar newAvatar = Avatar.UserAvatar.set(image); byte[] avatarData = newAvatar.imageData().orElse(null); if (avatarData == null) return; mClient.publishAvatar(newAvatar.getID(), avatarData); } public void unsetUserAvatar(){ if (!Avatar.UserAvatar.get().isPresent()) { LOGGER.warning("no user avatar set"); return; } boolean succ = mClient.deleteAvatar(); if (!succ) // TODO return; Avatar.UserAvatar.remove(); } public void setCustomContactAvatar(Contact contact, BufferedImage image) { // overwriting file here! contact.setCustomAvatar(new Avatar.CustomAvatar(contact.getID(), image)); } public void unsetCustomContactAvatar(Contact contact) { if (!contact.hasCustomAvatarSet()) { LOGGER.warning("no custom avatar set, "+contact); return; } contact.deleteCustomAvatar(); } /* private */ private void sendNewMessage(Chat chat, String text, Path file) { Attachment attachment = null; if (!file.toString().isEmpty()) { attachment = AttachmentManager.createAttachmentOrNull(file); if (attachment == null) return; } MessageContent content = attachment == null ? MessageContent.plainText(text) : MessageContent.outgoing(text, attachment); Control.this.createAndSendMessage(chat, content); } private PersonalKey keyOrNull(char[] password) { Account account = mModel.account(); PersonalKey key = account.getPersonalKey().orElse(null); if (key != null) return key; if (password.length == 0) { if (account.isPasswordProtected()) { this.changed(new ViewEvent.PasswordSet()); return null; } password = account.getPassword(); } try { return account.load(password); } catch (KonException ex) { // something wrong with the account, tell view Control.this.onException(ex); return null; } } void changed(ViewEvent event) { this.setChanged(); this.notifyObservers(event); } // TODO public AccountImporter createAccountImporter() { return new AccountImporter(mModel.account()); } } }