diff --git a/.gitignore b/.gitignore index f5980143..3879d3ef 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,8 @@ /kontalk.db /kontalk.properties *.pgp +*.asc +*.crt /win_installer/*.exe # Gradle @@ -14,12 +16,8 @@ /.nb-gradle/ # IntelliJ Idea -/.idea/workspace.xml -/.idea/tasks.xml -/.idea/gradle.xml -/.idea/libraries/ -/.idea/inspectionProfiles/ -/*.iml +.idea/ +*.iml # Package Files # #*.jar diff --git a/.idea/.name b/.idea/.name deleted file mode 100644 index b1f2454c..00000000 --- a/.idea/.name +++ /dev/null @@ -1 +0,0 @@ -kontalk_desktop_java \ No newline at end of file diff --git a/.idea/compiler.xml b/.idea/compiler.xml deleted file mode 100644 index 96cc43ef..00000000 --- a/.idea/compiler.xml +++ /dev/null @@ -1,22 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/.idea/copyright/profiles_settings.xml b/.idea/copyright/profiles_settings.xml deleted file mode 100644 index e7bedf33..00000000 --- a/.idea/copyright/profiles_settings.xml +++ /dev/null @@ -1,3 +0,0 @@ - - - \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml deleted file mode 100644 index 61df1558..00000000 --- a/.idea/misc.xml +++ /dev/null @@ -1,57 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml deleted file mode 100644 index 3b2f3ba1..00000000 --- a/.idea/modules.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/.idea/uiDesigner.xml b/.idea/uiDesigner.xml deleted file mode 100644 index e96534fb..00000000 --- a/.idea/uiDesigner.xml +++ /dev/null @@ -1,124 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml deleted file mode 100644 index 35eb1ddf..00000000 --- a/.idea/vcs.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 00000000..9421d903 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,8 @@ +language: java + +jdk: + - oraclejdk8 + +before_install: + - git submodule update --init + diff --git a/README.md b/README.md index 2368fbd1..57d9f56f 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ kontalk-java-client =================== +[![Build Status](https://travis-ci.org/kontalk/desktopclient-java.svg?branch=master)](https://travis-ci.org/kontalk/desktopclient-java) + A platform independent Java client for Kontalk (http://www.kontalk.org). Includes connectivity to the Jabber network! The desktop client uses your existing Kontalk account from the [Android client](https://github.com/kontalk/androidclient/blob/master/README.md#kontalk-official-android-client). Instructions for exporting the key [here](https://github.com/kontalk/androidclient/wiki/Export-personal-key-to-another-device). @@ -19,19 +21,28 @@ The desktop client uses your existing Kontalk account from the [Android client]( ## Key Features -- connecting to Kontalk server with an already existing Kontalk account -- automatically adding XMPP roster entries from server -- manually adding arbitrary Kontalk or Jabber user -- automatically requesting/adding public keys for other Kontalk user -- sending/receiving (encrypted) text messages from/to Kontalk user -- sending/receiving (plain) text messages from/to arbitrary Jabber/XMPP user (clients like [Pidgin](https://pidgin.im/) or [Conversations](https://github.com/siacs/Conversations)) -- sending/requesting server receipts according to XMPP extension -- ability to block all messages for specific user -- receiving files send from the Android client +Connect with Kontalk... +- Use the existing Kontalk account from your phone. +- Synchronized contact list (=XMPP roster). +- Add new Kontalk users by phone number. +- The client automatically requests public keys for safe communication. +- Your communication with Kontalk users is encrypted by default. + +... and beyond: +- Exchange text messages with any Jabber/XMPP users! +- Add new Jabber contacts by JID. +- Tested with clients like [Pidgin](https://pidgin.im/) or [Conversations](https://github.com/siacs/Conversations). **Note: private key and messages are saved unencrypted and can be read by other applications on your computer!** +## Implemented XEP features: +- XEP-0184: Message receipts +- XEP-0085: Chat state notifications +- XEP-0191: User blocking +- XEP-0066: File transfer over server +- XEP-0084: Avatar images + ## Support us * If you are missing a feature or found a bug [report it!](https://github.com/kontalk/desktopclient-java/issues) diff --git a/build.gradle b/build.gradle index 7301b3e6..833b8279 100644 --- a/build.gradle +++ b/build.gradle @@ -2,8 +2,8 @@ apply plugin: 'application' apply plugin: 'java' -sourceCompatibility = '1.7' -targetCompatibility = '1.7' +sourceCompatibility = '1.8' +targetCompatibility = '1.8' mainClassName = 'org.kontalk.Kontalk' ext.clientCommonDir = 'client-common-java' @@ -33,6 +33,7 @@ dependencies { compile group: 'org.bouncycastle', name: 'bcpg-jdk15on', version: "$bcVersion" compile group: 'org.bouncycastle', name: 'bcprov-jdk15on', version: "$bcVersion" compile group: 'org.bouncycastle', name: 'bcpkix-jdk15on', version: "$bcVersion" + compile group: 'commons-cli', name: 'commons-cli', version: "1.3.1" compile group: 'commons-codec', name: 'commons-codec', version: "1.10" compile group: 'commons-configuration', name: 'commons-configuration', version: "1.10" compile group: 'commons-io', name: 'commons-io', version: "2.4" diff --git a/client-common-java b/client-common-java index 01fb70a8..25d6e8ff 160000 --- a/client-common-java +++ b/client-common-java @@ -1 +1 @@ -Subproject commit 01fb70a8d0843cf7e67996a648f42a5ded0c401e +Subproject commit 25d6e8ff5e6eb7e40e2cc4ecc2ffb858bf137ea8 diff --git a/nbproject/licenseheader.txt b/nbproject/licenseheader.txt index cf7fb787..829f1040 100644 --- a/nbproject/licenseheader.txt +++ b/nbproject/licenseheader.txt @@ -2,7 +2,7 @@ ${licenseFirst} ${licensePrefix} Kontalk Java client -${licensePrefix} Copyright (C) 2014 Kontalk Devteam +${licensePrefix} Copyright (C) 2016 Kontalk Devteam ${licensePrefix} ${licensePrefix} This program is free software: you can redistribute it and/or modify ${licensePrefix} it under the terms of the GNU General Public License as published by diff --git a/src/main/java/org/kontalk/Kontalk.java b/src/main/java/org/kontalk/Kontalk.java index 17e1b997..acd76f73 100644 --- a/src/main/java/org/kontalk/Kontalk.java +++ b/src/main/java/org/kontalk/Kontalk.java @@ -1,6 +1,6 @@ /* * Kontalk Java client - * Copyright (C) 2014 Kontalk Devteam + * Copyright (C) 2016 Kontalk Devteam * * 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 @@ -18,29 +18,31 @@ package org.kontalk; -import org.kontalk.misc.KonException; -import org.kontalk.system.Database; import java.io.IOException; import java.net.InetAddress; import java.net.ServerSocket; +import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; -import java.util.Optional; import java.util.logging.ConsoleHandler; import java.util.logging.FileHandler; import java.util.logging.Handler; import java.util.logging.Level; import java.util.logging.Logger; import java.util.logging.SimpleFormatter; +import org.apache.commons.cli.CommandLine; +import org.apache.commons.cli.CommandLineParser; +import org.apache.commons.cli.DefaultParser; +import org.apache.commons.cli.HelpFormatter; +import org.apache.commons.cli.Option; +import org.apache.commons.cli.Options; +import org.apache.commons.cli.ParseException; import org.apache.commons.lang.SystemUtils; import org.kontalk.crypto.PGPUtils; -import org.kontalk.model.ChatList; -import org.kontalk.model.ContactList; -import org.kontalk.system.AccountLoader; -import org.kontalk.system.Config; +import org.kontalk.misc.KonException; import org.kontalk.system.Control; -import org.kontalk.system.Control.ViewControl; import org.kontalk.util.CryptoUtils; +import org.kontalk.util.EncodingUtils; import org.kontalk.util.Tr; import org.kontalk.view.View; @@ -50,10 +52,10 @@ public final class Kontalk { private static final Logger LOGGER = Logger.getLogger(Kontalk.class.getName()); - public static final String VERSION = "3.0.4"; - private final Path mAppDir; + public static final String VERSION = "3.1"; - private static ServerSocket RUN_LOCK = null; + private final Path mAppDir; + private ServerSocket mRunLock = null; Kontalk() { // platform dependent configuration directory @@ -62,17 +64,18 @@ public final class Kontalk { } Kontalk(Path appDir) { - mAppDir = appDir; + mAppDir = appDir.toAbsolutePath(); } - private void start() { + int start(boolean ui) { // check if already running + int port = (1 << 14) + (1 << 15) + mAppDir.hashCode() % (1 << 14); try { InetAddress addr = InetAddress.getByAddress(new byte[] {127, 0, 0, 1}); - RUN_LOCK = new ServerSocket(9871, 10, addr); + mRunLock = new ServerSocket(port, 10, addr); } catch(java.net.BindException ex) { LOGGER.severe("already running"); - System.exit(2); + return 2; } catch(IOException ex) { LOGGER.log(Level.WARNING, "can't create socket", ex); } @@ -83,15 +86,22 @@ private void start() { // check java version String jVersion = System.getProperty("java.version"); if (jVersion.startsWith("1.7")) { + // NOTE: we wont be here if 7 is used; still here for the bright + // future View.showWrongJavaVersionDialog(); LOGGER.severe("java too old: "+jVersion); - System.exit(-3); + return 3; } // create app directory boolean created = mAppDir.toFile().mkdirs(); if (created) - LOGGER.info("created application directory"); + LOGGER.info("created application directory: "+mAppDir); + + if (!Files.isWritable(mAppDir)) { + LOGGER.severe("invalid app directory: "+mAppDir); + return 4; + } // logging Logger logger = Logger.getLogger(""); @@ -118,53 +128,45 @@ private void start() { // fix crypto restriction CryptoUtils.removeCryptographyRestrictions(); - // register provider + // register security provider PGPUtils.registerProvider(); - - Config.initialize(mAppDir.resolve(Config.FILENAME)); - AccountLoader.initialize(mAppDir); - - ViewControl control = Control.create(mAppDir); - - Optional optView = View.create(control); - if (!optView.isPresent()) { - control.shutDown(); - return; // never reached - } - View view = optView.get(); - + Control control; try { - Database.initialize(mAppDir.resolve(Database.FILENAME)); + control = new Control(mAppDir); } catch (KonException ex) { - LOGGER.log(Level.SEVERE, "can't initialize database", ex); - control.shutDown(); - return; // never reached + LOGGER.log(Level.SEVERE, "can't create application", ex); + return 5; } - // order matters! - ContactList.getInstance().load(); - ChatList.getInstance().load(); - - view.init(); + // handle shutdown signals/System.exit() calls + Runtime.getRuntime().addShutdownHook(new Thread("Shutdown Hook") { + @Override + public void run() { + // NOTE: logging does not work here anymore + control.shutDown(false); + Kontalk.this.removeLock(); + System.out.println("Kontalk: shutdown finished"); + } + }); - control.launch(); - } + control.launch(ui); - public Path getAppDir() { - return mAppDir; + return 0; } - public static void exit() { - if (RUN_LOCK != null) { - try { - RUN_LOCK.close(); - } catch (IOException ex) { - LOGGER.log(Level.WARNING, "can't close run socket", ex); - } + private boolean removeLock() { + if (mRunLock == null) { + LOGGER.warning("no lock"); + return false; } - LOGGER.info("exit"); - System.exit(0); + try { + mRunLock.close(); + } catch (IOException ex) { + LOGGER.log(Level.WARNING, "can't close run socket", ex); + return false; + } + return true; } /** @@ -173,7 +175,65 @@ public static void exit() { public static void main(String[] args) { LOGGER.setLevel(Level.ALL); - Kontalk app = new Kontalk(); - app.start(); + // parse args, i18n? + Options options = new Options(); + options.addOption("h", "help", false, "show this help message"); + options.addOption(Option.builder("d") + .argName("app_dir") + .hasArg() + .longOpt("app-dir") + .desc("set custom configuration directory") + .build() + ); + options.addOption("c", "no-gui", false, "run without user interface"); + + CommandLineParser parser = new DefaultParser(); + CommandLine cmd; + try { + cmd = parser.parse(options, args); + } catch (ParseException e) { + showHelp(options); + return; + } + if (cmd.hasOption("h")) { + showHelp(options); + return; + } + + String appDir = cmd.getOptionValue("d", ""); + + Kontalk app = !appDir.isEmpty() ? + new Kontalk(Paths.get(appDir)) : + new Kontalk(); + + int returnCode = app.start(!cmd.hasOption("c")); + if (returnCode != 0) + // didn't work + System.exit(returnCode); + + new Thread("Kontalk Main") { + @Override + public void run() { + try { + // wait until exit call + Object lock = new Object(); + synchronized (lock) { + lock.wait(); + } + } catch (InterruptedException ex) { + LOGGER.log(Level.WARNING, "interrupted while waiting", ex); + } + } + }.start(); + } + + private static void showHelp(Options options) { + HelpFormatter formatter = new HelpFormatter(); + String eol = EncodingUtils.EOL; + formatter.printHelp("java -jar [kontalk_jar]", + eol + "Kontalk Java Desktop Client" + eol, + options, + "", + true); } } diff --git a/src/main/java/org/kontalk/client/AcknowledgedListener.java b/src/main/java/org/kontalk/client/AcknowledgedListener.java index 328fd35d..5f170f03 100644 --- a/src/main/java/org/kontalk/client/AcknowledgedListener.java +++ b/src/main/java/org/kontalk/client/AcknowledgedListener.java @@ -1,6 +1,6 @@ /* * Kontalk Java client - * Copyright (C) 2014 Kontalk Devteam + * Copyright (C) 2016 Kontalk Devteam * * 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 @@ -43,8 +43,8 @@ public AcknowledgedListener(Control control) { @Override public void processPacket(Stanza p) { - // note: the packet is not the acknowledgement itself but the packet that - // is acknowledged + // NOTE: the packet is not the acknowledgement itself but the packet + // that is acknowledged if (!(p instanceof Message)) { // we are only interested in acks for messages return; @@ -54,8 +54,8 @@ public void processPacket(Stanza p) { LOGGER.config("for message: "+m); if (DeliveryReceipt.from(m) != null) { - // this is an ack for a 'received' message send by - // KonMessageListener (XEP-0184), nothing must be done + // this is an ack for a 'received' message (XEP-0184) send by + // KonMessageListener, ignore return; } @@ -65,7 +65,6 @@ public void processPacket(Stanza p) { return; } - mControl.setSent(MessageIDs.from(m)); + mControl.onMessageSent(MessageIDs.to(m)); } - } diff --git a/src/main/java/org/kontalk/client/AvatarSendReceiver.java b/src/main/java/org/kontalk/client/AvatarSendReceiver.java new file mode 100644 index 00000000..fb441a88 --- /dev/null +++ b/src/main/java/org/kontalk/client/AvatarSendReceiver.java @@ -0,0 +1,230 @@ +/* + * Kontalk Java client + * Copyright (C) 2016 Kontalk Devteam + * + * 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 . + */ + +package org.kontalk.client; + +import java.util.Arrays; +import java.util.List; +import java.util.logging.Level; +import java.util.logging.Logger; +import org.jivesoftware.smack.SmackException; +import org.jivesoftware.smack.StanzaListener; +import org.jivesoftware.smack.XMPPException; +import org.jivesoftware.smack.packet.ExtensionElement; +import org.jivesoftware.smack.packet.IQ; +import org.jivesoftware.smack.packet.Stanza; +import org.jivesoftware.smack.provider.ProviderManager; +import org.jivesoftware.smackx.pubsub.Item; +import org.jivesoftware.smackx.pubsub.ItemsExtension; +import org.jivesoftware.smackx.pubsub.LeafNode; +import org.jivesoftware.smackx.pubsub.PayloadItem; +import org.jivesoftware.smackx.pubsub.PubSubElementType; +import org.jivesoftware.smackx.pubsub.PubSubManager; +import org.jivesoftware.smackx.pubsub.packet.PubSub; +import org.jivesoftware.smackx.pubsub.packet.PubSubNamespace; +import org.kontalk.misc.JID; +import org.kontalk.system.AvatarHandler; + +/** + * Manage publishing and requesting user avatars (XEP-0084). + * + * Metadata notification events are incoming as PubSub messages from message + * listener. + * + * @author Alexander Bikadorov {@literal } + */ +final class AvatarSendReceiver { + private static final Logger LOGGER = Logger.getLogger(AvatarSendReceiver.class.getName()); + + static final String NOTIFY_FEATURE = "urn:xmpp:avatar:metadata+notify"; + static final String METADATA_NODE = "urn:xmpp:avatar:metadata"; + + private static final String DATA_NODE = "urn:xmpp:avatar:data"; + + static { + ProviderManager.addExtensionProvider( + AvatarMetadataExtension.ELEMENT_NAME, + AvatarMetadataExtension.NAMESPACE, + new AvatarMetadataExtension.Provider()); + + ProviderManager.addExtensionProvider( + AvatarDataExtension.ELEMENT_NAME, + AvatarDataExtension.NAMESPACE, + new AvatarDataExtension.Provider()); + + } + + private final KonConnection mConn; + private final AvatarHandler mHandler; + + AvatarSendReceiver(KonConnection conn, AvatarHandler handler) { + mConn = conn; + mHandler = handler; + } + + // TODO beta.kontalk.net does not support this, untested + void publish(String id, byte[] data) { + if (!mConn.isAuthenticated()) { + LOGGER.info("not logged in"); + return; + } + + PubSubManager mPubSubManager = new PubSubManager(mConn, mConn.getServiceName()); + LeafNode node; + try { + node = mPubSubManager.createNode(DATA_NODE); + } catch (SmackException.NoResponseException | + XMPPException.XMPPErrorException | + SmackException.NotConnectedException ex) { + LOGGER.log(Level.WARNING, "can't create node", ex); + return; + } + + PayloadItem item = new PayloadItem<>(id, + new AvatarDataExtension(data)); + try { + // blocking + node.send(item); + } catch (SmackException.NoResponseException | + XMPPException.XMPPErrorException | + SmackException.NotConnectedException ex) { + LOGGER.log(Level.WARNING, "can't send item", ex); + return; + } + + // TODO + LOGGER.warning("not implemented"); + // publish meta data... + } + + boolean delete() { + if (!mConn.isAuthenticated()) { + LOGGER.info("not logged in"); + return false; + } + + // TODO + LOGGER.warning("not implemented"); + return false; + } + + void processMetadataEvent(JID jid, ItemsExtension itemsExt) { + List items = itemsExt.getItems(); + if (items.isEmpty()) { + LOGGER.warning("no items in items event"); + return; + } + + // there should be only one item + ExtensionElement e = items.get(0); + if (!(e instanceof PayloadItem)) { + LOGGER.warning("element not a payloaditem"); + return; + } + + PayloadItem item = (PayloadItem) e; + ExtensionElement metadataExt = item.getPayload(); + if (!(metadataExt instanceof AvatarMetadataExtension)) { + LOGGER.warning("payload not avatar metadata"); + return; + } + AvatarMetadataExtension metadata = (AvatarMetadataExtension) metadataExt; + List infos = metadata.getInfos(); + if (infos.isEmpty()) { + // this means the contact disabled avatar publishing + mHandler.onNotify(jid, ""); + return; + } + // assuming infos are always in the same order + for (AvatarMetadataExtension.Info info : infos) { + if (AvatarHandler.SUPPORTED_TYPES.contains(info.getType())) { + mHandler.onNotify(jid, info.getId()); + break; + } else { + LOGGER.info("image type not supported: "+info.getType()); + } + } + } + + void requestAndListen(final JID jid, final String id) { + // I dont get how to use this here + //PubSubManager manager = new PubSubManager(conn); + + PubSub request = new PubSub(jid.toBare().string(), + IQ.Type.get, + PubSubNamespace.BASIC); + + request.addExtension( + new ItemsExtension( + ItemsExtension.ItemsElementType.items, + DATA_NODE, + Arrays.asList(new Item(id)))); + + // handle response + StanzaListener callback = new StanzaListener() { + @Override + public void processPacket(Stanza packet) + throws SmackException.NotConnectedException { + + if (!(packet instanceof PubSub)) { + LOGGER.warning("response not a pubsub packet"); + return; + } + PubSub pubSub = (PubSub) packet; + + ExtensionElement itemsExt = pubSub.getExtension(PubSubElementType.ITEMS); + if (!(itemsExt instanceof ItemsExtension)) { + LOGGER.warning("no items extension in response"); + return; + } + + ItemsExtension items = (ItemsExtension) itemsExt; + List itemsList = items.getItems(); + if (itemsList.isEmpty()) { + LOGGER.warning("no items in itemlist"); + } + + // there should be only one item + ExtensionElement e = itemsList.get(0); + if (!(e instanceof PayloadItem)) { + LOGGER.warning("element not a payloaditem"); + return; + } + + PayloadItem item = (PayloadItem) e; + ExtensionElement dataExt = item.getPayload(); + if (!(dataExt instanceof AvatarDataExtension)) { + LOGGER.warning("payload not avatar data"); + return; + } + + AvatarDataExtension avatarExt = (AvatarDataExtension) dataExt; + + byte[] avatarData = avatarExt.getData(); + if (avatarData.length == 0) { + LOGGER.warning("no avatar data in packet"); + return; + } + + mHandler.onData(jid, id, avatarData); + } + }; + + mConn.sendWithCallback(request, callback); + } +} diff --git a/src/main/java/org/kontalk/client/BlockListListener.java b/src/main/java/org/kontalk/client/BlockListListener.java index 93904ae5..42dd2bdf 100644 --- a/src/main/java/org/kontalk/client/BlockListListener.java +++ b/src/main/java/org/kontalk/client/BlockListListener.java @@ -1,6 +1,6 @@ /* * Kontalk Java client - * Copyright (C) 2014 Kontalk Devteam + * Copyright (C) 2016 Kontalk Devteam * * 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 @@ -36,14 +36,16 @@ final class BlockListListener implements StanzaListener { private final Control mControl; - public BlockListListener(Control control) { - mControl = control; - + static { ProviderManager.addIQProvider(BlockingCommand.BLOCKLIST, BlockingCommand.NAMESPACE, new BlockingCommand.Provider()); } + BlockListListener(Control control) { + mControl = control; + } + @Override public void processPacket(Stanza packet) { BlockingCommand p = (BlockingCommand) packet; @@ -54,7 +56,7 @@ public void processPacket(Stanza packet) { List jids = new ArrayList<>(items.size()); for (String s : items) jids.add(JID.full(s)); - mControl.setBlockedContacts(jids.toArray(new JID[0])); + mControl.onBlockList(jids.toArray(new JID[0])); } } } diff --git a/src/main/java/org/kontalk/client/BlockResponseListener.java b/src/main/java/org/kontalk/client/BlockSendReceiver.java similarity index 64% rename from src/main/java/org/kontalk/client/BlockResponseListener.java rename to src/main/java/org/kontalk/client/BlockSendReceiver.java index 0292598a..65130aec 100644 --- a/src/main/java/org/kontalk/client/BlockResponseListener.java +++ b/src/main/java/org/kontalk/client/BlockSendReceiver.java @@ -1,6 +1,6 @@ /* * Kontalk Java client - * Copyright (C) 2014 Kontalk Devteam + * Copyright (C) 2016 Kontalk Devteam * * 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 @@ -21,26 +21,25 @@ import java.util.logging.Logger; import org.jivesoftware.smack.StanzaListener; import org.jivesoftware.smack.SmackException; -import org.jivesoftware.smack.XMPPConnection; import org.jivesoftware.smack.packet.IQ; import org.jivesoftware.smack.packet.Stanza; import org.kontalk.misc.JID; import org.kontalk.system.Control; /** - * + * Send blocking command and listen to response. * @author Alexander Bikadorov {@literal } */ -final class BlockResponseListener implements StanzaListener { - private static final Logger LOGGER = Logger.getLogger(BlockResponseListener.class.getName()); +final class BlockSendReceiver implements StanzaListener { + private static final Logger LOGGER = Logger.getLogger(BlockSendReceiver.class.getName()); private final Control mControl; - private final XMPPConnection mConn; + private final KonConnection mConn; private final boolean mBlocking; private final JID mJID; - BlockResponseListener(Control control, - XMPPConnection conn, + BlockSendReceiver(Control control, + KonConnection conn, boolean blocking, JID jid){ mControl = control; @@ -49,24 +48,31 @@ final class BlockResponseListener implements StanzaListener { mJID = jid; } + void sendAndListen() { + LOGGER.info("jid: "+mJID+" blocking="+mBlocking); + + String command = mBlocking ? BlockingCommand.BLOCK : BlockingCommand.UNBLOCK; + BlockingCommand blockingCommand = new BlockingCommand(command, mJID.string()); + + mConn.sendWithCallback(blockingCommand, this); + } + @Override public void processPacket(Stanza packet) throws SmackException.NotConnectedException { - LOGGER.info("block response: "+packet); - - mConn.removeSyncStanzaListener(this); + LOGGER.info("response: "+packet); if (!(packet instanceof IQ)) { - LOGGER.warning("block response not an IQ packet"); + LOGGER.warning("response not an IQ packet"); return; } IQ p = (IQ) packet; if (p.getType() != IQ.Type.result) { - LOGGER.warning("ignoring block response with IQ type: "+p.getType()); + LOGGER.warning("ignoring response with IQ type: "+p.getType()); return; } - mControl.setContactBlocking(mJID, mBlocking); + mControl.onContactBlocked(mJID, mBlocking); } }; diff --git a/src/main/java/org/kontalk/client/Client.java b/src/main/java/org/kontalk/client/Client.java index 3fd1212a..246778ef 100644 --- a/src/main/java/org/kontalk/client/Client.java +++ b/src/main/java/org/kontalk/client/Client.java @@ -1,6 +1,6 @@ /* * Kontalk Java client - * Copyright (C) 2014 Kontalk Devteam + * Copyright (C) 2016 Kontalk Devteam * * 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 @@ -18,8 +18,12 @@ package org.kontalk.client; +import java.io.File; import java.io.IOException; +import java.nio.file.Path; import java.util.ArrayList; +import java.util.EnumMap; +import java.util.EnumSet; import java.util.List; import java.util.Optional; import java.util.concurrent.LinkedBlockingQueue; @@ -31,103 +35,125 @@ import org.jivesoftware.smack.roster.RosterListener; import org.jivesoftware.smack.SmackException; import org.jivesoftware.smack.XMPPException; -import org.jivesoftware.smack.filter.NotFilter; -import org.jivesoftware.smack.filter.OrFilter; +import org.jivesoftware.smack.filter.IQTypeFilter; import org.jivesoftware.smack.filter.StanzaFilter; -import org.jivesoftware.smack.filter.StanzaIdFilter; import org.jivesoftware.smack.filter.StanzaTypeFilter; import org.jivesoftware.smack.packet.IQ; import org.jivesoftware.smack.packet.Message; import org.jivesoftware.smack.packet.Stanza; import org.jivesoftware.smack.packet.Presence; import org.jivesoftware.smack.roster.RosterEntry; -import org.jivesoftware.smack.roster.packet.RosterPacket; +import org.jivesoftware.smackx.caps.EntityCapsManager; +import org.jivesoftware.smackx.caps.cache.SimpleDirectoryPersistentCache; import org.jivesoftware.smackx.chatstates.ChatState; import org.jivesoftware.smackx.chatstates.packet.ChatStateExtension; -import org.jivesoftware.smackx.receipts.DeliveryReceiptRequest; -import org.kontalk.system.Config; +import org.jivesoftware.smackx.disco.ServiceDiscoveryManager; +import org.kontalk.persistence.Config; import org.kontalk.misc.KonException; -import org.kontalk.crypto.Coder; import org.kontalk.crypto.PersonalKey; -import org.kontalk.model.Chat; import org.kontalk.misc.JID; -import org.kontalk.model.GroupChat; -import org.kontalk.model.GroupChat.GID; -import org.kontalk.model.KonMessage.Status; -import org.kontalk.model.OutMessage; -import org.kontalk.model.MessageContent; -import org.kontalk.model.MessageContent.Attachment; -import org.kontalk.model.MessageContent.Preview; -import org.kontalk.model.Transmission; +import org.kontalk.model.message.OutMessage; +import org.kontalk.system.AttachmentManager; import org.kontalk.system.Control; import org.kontalk.system.RosterHandler; -import org.kontalk.util.ClientUtils; -import org.kontalk.util.EncodingUtils; /** * Network client for an XMPP Kontalk Server. * - * Note: By default incoming presence subscription requests are automatically - * granted by Smack (but Kontalk uses a custom subscription request!?) - * * @author Alexander Bikadorov {@literal } */ public final class Client implements StanzaListener, Runnable { private static final Logger LOGGER = Logger.getLogger(Client.class.getName()); + private static final String CAPS_CACHE_DIR = "caps_cache"; private static final LinkedBlockingQueue TASK_QUEUE = new LinkedBlockingQueue<>(); - private static enum Command {CONNECT, DISCONNECT}; + public enum PresenceCommand {REQUEST, GRANT, DENY}; + + private enum Command {CONNECT, DISCONNECT}; private final Control mControl; + + private final KonMessageSender mMessageSender; + private final EnumMap mFeatures; + private KonConnection mConn = null; + private AvatarSendReceiver mAvatarSendReceiver = null; + private HTTPFileSlotRequester mSlotRequester = null; - public Client(Control control) { + private Client(Control control, Path appDir) { mControl = control; //mLimited = limited; + mMessageSender = new KonMessageSender(this); + // enable Smack debugging (print raw XML packet) //SmackConfiguration.DEBUG = true; + + mFeatures = new EnumMap<>(FeatureDiscovery.Feature.class); + + // setting caps cache + File cacheDir = appDir.resolve(CAPS_CACHE_DIR).toFile(); + if (cacheDir.mkdir()) + LOGGER.info("created caps cache directory"); + + if (!cacheDir.isDirectory()) { + LOGGER.warning("invalid cache directory: "+cacheDir); + return; + } + + EntityCapsManager.setPersistentCache( + new SimpleDirectoryPersistentCache(cacheDir)); } - /** Connect to server without logging in. */ - public void connect() { - this.connect(null); + public static Client create(Control control, Path appDir) { + Client client = new Client(control, appDir); + + Thread clientThread = new Thread(client, "Client Connector"); + clientThread.setDaemon(true); + clientThread.start(); + + return client; } public void connect(PersonalKey key) { this.disconnect(); LOGGER.config("connecting..."); - mControl.setStatus(Control.Status.CONNECTING); + this.newStatus(Control.Status.CONNECTING); Config config = Config.getInstance(); - // tigase: use hostname as network //String network = config.getString(KonConf.SERV_NET); - String network = config.getString(Config.SERV_HOST); String host = config.getString(Config.SERV_HOST); int port = config.getInt(Config.SERV_PORT); - EndpointServer server = new EndpointServer(network, host, port); + EndpointServer server = new EndpointServer(host, port); + boolean validateCertificate = config.getBoolean(Config.SERV_CERT_VALIDATION); // create connection - mConn = key == null ? - new KonConnection(server, validateCertificate) : - new KonConnection(server, + mConn = new KonConnection(server, key.getServerLoginKey(), key.getBridgeCertificate(), validateCertificate); // connection listener - mConn.addConnectionListener(new KonConnectionListener(mControl)); + mConn.addConnectionListener(new KonConnectionListener(this, mControl)); + + Roster roster = Roster.getInstanceFor(mConn); + // subscriptions handled by roster handler + roster.setSubscriptionMode(Roster.SubscriptionMode.manual); + + mAvatarSendReceiver = new AvatarSendReceiver(mConn, mControl.getAvatarHandler()); // packet listeners - RosterHandler rosterSyncer = mControl.getRosterHandler(); - RosterListener rl = new KonRosterListener(Roster.getInstanceFor(mConn), rosterSyncer); - Roster.getInstanceFor(mConn).addRosterListener(rl); + RosterHandler rosterHandler = mControl.getRosterHandler(); + RosterListener rl = new KonRosterListener(roster, rosterHandler); + roster.addRosterListener(rl); StanzaFilter messageFilter = new StanzaTypeFilter(Message.class); - mConn.addAsyncStanzaListener(new KonMessageListener(this, mControl), messageFilter); + mConn.addAsyncStanzaListener( + new KonMessageListener(this, mControl, mAvatarSendReceiver), + messageFilter); StanzaFilter vCardFilter = new StanzaTypeFilter(VCard4.class); mConn.addAsyncStanzaListener(new VCardListener(mControl), vCardFilter); @@ -139,23 +165,19 @@ public void connect(PersonalKey key) { mConn.addAsyncStanzaListener(new PublicKeyListener(mControl), publicKeyFilter); StanzaFilter presenceFilter = new StanzaTypeFilter(Presence.class); - mConn.addAsyncStanzaListener(new PresenceListener(Roster.getInstanceFor(mConn), rosterSyncer), presenceFilter); - - // fallback listener - mConn.addAsyncStanzaListener(this, - new NotFilter( - new OrFilter( - messageFilter, - vCardFilter, - blockingCommandFilter, - publicKeyFilter, - vCardFilter, - presenceFilter, - // handled by roster listener - new StanzaTypeFilter(RosterPacket.class) - ) - ) - ); + mConn.addAsyncStanzaListener(new PresenceListener(roster, rosterHandler), presenceFilter); + + if (config.getBoolean(Config.NET_REQUEST_AVATARS)) { + // our service discovery: want avatar from other users + ServiceDiscoveryManager.getInstanceFor(mConn). + addFeature(AvatarSendReceiver.NOTIFY_FEATURE); + } + + // listen to all acks + mConn.addStanzaAcknowledgedListener(new AcknowledgedListener(mControl)); + + // listen to all IQ errors + mConn.addAsyncStanzaListener(this, IQTypeFilter.ERROR); // continue async List args = new ArrayList<>(0); @@ -170,42 +192,90 @@ private void connectAsync() { mConn.connect(); } catch (XMPPException | SmackException | IOException ex) { LOGGER.log(Level.WARNING, "can't connect to "+mConn.getServer(), ex); - mControl.setStatus(Control.Status.FAILED); - mControl.handleException(new KonException(KonException.Error.CLIENT_CONNECT, ex)); + this.newStatus(Control.Status.FAILED); + mControl.onException(new KonException(KonException.Error.CLIENT_CONNECT, ex)); return; } - if (mConn.hasLoginCredentials()) { - // login - try { - mConn.login(); - } catch (XMPPException | SmackException | IOException ex) { - LOGGER.log(Level.WARNING, "can't login on "+mConn.getServer(), ex); - mConn.disconnect(); - mControl.setStatus(Control.Status.FAILED); - mControl.handleException(new KonException(KonException.Error.CLIENT_LOGIN, ex)); - return; - } + // login + try { + mConn.login(); + } catch (XMPPException | SmackException | IOException ex) { + LOGGER.log(Level.WARNING, "can't login on "+mConn.getServer(), ex); + mConn.disconnect(); + this.newStatus(Control.Status.FAILED); + mControl.onException(new KonException(KonException.Error.CLIENT_LOGIN, ex)); + return; } } - mConn.addStanzaAcknowledgedListener(new AcknowledgedListener(mControl)); - - if (mConn.isAuthenticated()) { - this.sendInitialPresence(); - this.sendBlocklistRequest(); - } - - mControl.setStatus(Control.Status.CONNECTED); + mFeatures.clear(); + mFeatures.putAll(FeatureDiscovery.discover(mConn)); + + mSlotRequester = mFeatures.containsKey(FeatureDiscovery.Feature.HTTP_FILE_UPLOAD) ? + new HTTPFileSlotRequester(mConn, + JID.bare(mFeatures.get(FeatureDiscovery.Feature.HTTP_FILE_UPLOAD))) : + null; + + // Caps, XEP-0115 + // NOTE: caps manager is automatically used by Smack + //EntityCapsManager capsManager = EntityCapsManager.getInstanceFor(mConn); + + // PEP, XEP-0163 + // NOTE: Smack's implementation is not usable, use PubSub instead +// PEPManager m = new PEPManager(mConn); +// m.addPEPListener(new PEPListener() { +// @Override +// public void eventReceived(String from, PEPEvent event) { +// LOGGER.info("from: "+from+" event: "+event); +// } +// }); + + // PubSub, XEP-0060 + // NOTE: pubsub is currently unsupported by beta.kontalk.net +// PubSubManager pubSubManager = new PubSubManager(mConn, mConn.getServiceName()); +// try { +// DiscoverInfo i = pubSubManager.getSupportedFeatures(); +// // same as server service discovery features!? +// for (DiscoverInfo.Feature f: i.getFeatures()) { +// System.out.println("feature: "+f.getVar()); +// } +// } catch (SmackException.NoResponseException | +// XMPPException.XMPPErrorException | +// SmackException.NotConnectedException ex) { +// Logger.getLogger(Client.class.getName()).log(Level.SEVERE, null, ex); +// } + // here be exceptions +// try { +// for (Affiliation a: pubSubManager.getAffiliations()) { +// System.out.println("aff: "+a.toXML()); +// } +// for (Subscription s: pubSubManager.getSubscriptions()) { +// System.out.println("subs: "+s.toXML()); +// } +// } catch (SmackException.NoResponseException | +// XMPPException.XMPPErrorException | +// SmackException.NotConnectedException ex) { +// Logger.getLogger(Client.class.getName()).log(Level.SEVERE, null, ex); +// } + + this.sendBlocklistRequest(); + + this.newStatus(Control.Status.CONNECTED); } + public void disconnect() { synchronized (this) { if (mConn != null && mConn.isConnected()) { + this.newStatus(Control.Status.DISCONNECTING); mConn.disconnect(); } } - mControl.setStatus(Control.Status.DISCONNECTED); + } + + public boolean isConnected() { + return mConn != null && mConn.isAuthenticated(); } /** @@ -218,118 +288,14 @@ public Optional getOwnJID() { return Optional.of(JID.full(user)); } - public boolean sendMessage(OutMessage message, boolean sendChatState) { - // check for correct receipt status and reset it - Status status = message.getStatus(); - assert status == Status.PENDING || status == Status.ERROR; - message.setStatus(Status.PENDING); - - if (!this.isConnected()) { - LOGGER.info("not sending message(s), not connected"); - return false; - } - - MessageContent content = message.getContent(); - Optional optAtt = content.getAttachment(); - if (optAtt.isPresent() && !optAtt.get().hasURL()) { - LOGGER.warning("attachment not uploaded"); - message.setStatus(Status.ERROR); - return false; - } - - boolean encrypted = - message.getCoderStatus().getEncryption() != Coder.Encryption.NOT || - message.getCoderStatus().getSigning() != Coder.Signing.NOT; - - Chat chat = message.getChat(); - - Message protoMessage = encrypted ? new Message() : rawMessage(content, chat, false); - - protoMessage.setType(Message.Type.chat); - protoMessage.setStanzaId(message.getXMPPID()); - String threadID = chat.getXMPPID(); - if (!threadID.isEmpty()) - protoMessage.setThread(threadID); - - // extensions - - // TODO with group chat? (for muc "NOT RECOMMENDED") - if (!chat.isGroupChat()) - protoMessage.addExtension(new DeliveryReceiptRequest()); - - if (sendChatState) - protoMessage.addExtension(new ChatStateExtension(ChatState.active)); - - if (encrypted) { - Optional encryptedData = content.isComplex() || chat.isGroupChat() ? - Coder.encryptStanza(message, - rawMessage(content, chat, true).toXML().toString()) : - Coder.encryptMessage(message); - // check also for security errors just to be sure - if (!encryptedData.isPresent() || - !message.getCoderStatus().getErrors().isEmpty()) { - LOGGER.warning("encryption failed"); - message.setStatus(Status.ERROR); - mControl.handleSecurityErrors(message); - return false; - } - protoMessage.addExtension(new E2EEncryption(encryptedData.get())); - } - - // transmission specific - Transmission[] transmissions = message.getTransmissions(); - ArrayList sendMessages = new ArrayList<>(transmissions.length); - for (Transmission transmission: message.getTransmissions()) { - Message sendMessage = protoMessage.clone(); - JID to = transmission.getJID(); - if (!to.isValid()) { - LOGGER.warning("invalid JID: "+to); - return false; - } - sendMessage.setTo(to.string()); - sendMessages.add(sendMessage); - } - - return this.sendPackets(sendMessages.toArray(new Message[0])); + public EnumSet getServerFeature() { + EnumSet e = EnumSet.noneOf(FeatureDiscovery.Feature.class); + e.addAll(mFeatures.keySet()); + return e; } - private static Message rawMessage(MessageContent content, Chat chat, boolean encrypted) { - Message smackMessage = new Message(); - - // text - String text = content.getPlainText(); - if (!text.isEmpty()) - smackMessage.setBody(content.getPlainText()); - - // attachment - Optional optAtt = content.getAttachment(); - if (optAtt.isPresent()) { - Attachment att = optAtt.get(); - - OutOfBandData oobData = new OutOfBandData(att.getURL().toString(), - att.getMimeType(), att.getLength(), encrypted); - smackMessage.addExtension(oobData); - - Optional optPreview = content.getPreview(); - if (optPreview.isPresent()) { - Preview preview = optPreview.get(); - String data = EncodingUtils.bytesToBase64(preview.getData()); - BitsOfBinary bob = new BitsOfBinary(preview.getMimeType(), data); - smackMessage.addExtension(bob); - } - } - - // group command - if (chat instanceof GroupChat) { - GroupChat groupChat = (GroupChat) chat; - GID gid = groupChat.getGID(); - Optional optGroupCommand = content.getGroupCommand(); - smackMessage.addExtension(optGroupCommand.isPresent() ? - ClientUtils.groupCommandToGroupExtension(groupChat, optGroupCommand.get()) : - new GroupExtension(gid.id, gid.ownerJID.string())); - } - - return smackMessage; + public boolean sendMessage(OutMessage message, boolean sendChatState) { + return mMessageSender.sendMessage(message, sendChatState); } // TODO unused @@ -352,26 +318,19 @@ public void sendBlocklistRequest() { } public void sendBlockingCommand(JID jid, boolean blocking) { - LOGGER.info("jid: "+jid+" blocking="+blocking); - - String command = blocking ? BlockingCommand.BLOCK : BlockingCommand.UNBLOCK; - BlockingCommand blockingCommand = new BlockingCommand(command, jid.string()); - - // add response listener - StanzaListener blockResponseListener = new BlockResponseListener(mControl, mConn, blocking, jid); - mConn.addAsyncStanzaListener(blockResponseListener, new StanzaIdFilter(blockingCommand)); + if (mConn == null || !this.isConnected()) { + LOGGER.warning("not connected"); + return; + } - this.sendPacket(blockingCommand); + new BlockSendReceiver(mControl, mConn, blocking, jid).sendAndListen(); } - public void sendInitialPresence() { + public void sendUserPresence(String statusText) { Presence presence = new Presence(Presence.Type.available); - List stats = Config.getInstance().getList(Config.NET_STATUS_LIST); - if (!stats.isEmpty()) { - String stat = (String) stats.get(0); - if (!stat.isEmpty()) - presence.setStatus(stat); - } + if (!statusText.isEmpty()) + presence.setStatus(statusText); + // note: not setting priority, according to anti-dicrimination rules;) // for testing @@ -380,11 +339,17 @@ public void sendInitialPresence() { this.sendPacket(presence); } - public void sendPresenceSubscriptionRequest(JID jid) { - LOGGER.info("to "+jid); - Presence subscribeRequest = new Presence(Presence.Type.subscribe); - subscribeRequest.setTo(jid.string()); - this.sendPacket(subscribeRequest); + public void sendPresenceSubscription(JID jid, PresenceCommand command) { + LOGGER.info("to: "+jid+ ", command: "+command); + Presence.Type type = null; + switch(command) { + case REQUEST: type = Presence.Type.subscribe; break; + case GRANT: type = Presence.Type.subscribed; break; + case DENY: type = Presence.Type.unsubscribed; break; + } + Presence presence = new Presence(type); + presence.setTo(jid.string()); + this.sendPacket(presence); } public void sendChatState(JID jid, String threadID, ChatState state) { @@ -396,7 +361,7 @@ public void sendChatState(JID jid, String threadID, ChatState state) { this.sendPacket(message); } - private synchronized boolean sendPackets(Stanza[] stanzas) { + synchronized boolean sendPackets(Stanza[] stanzas) { boolean sent = true; for (Stanza s: stanzas) sent &= this.sendPacket(s); @@ -404,19 +369,17 @@ private synchronized boolean sendPackets(Stanza[] stanzas) { } synchronized boolean sendPacket(Stanza p) { - try { - mConn.sendStanza(p); - } catch (SmackException.NotConnectedException ex) { - LOGGER.info("can't send packet, not connected."); + if (mConn == null) { + LOGGER.warning("not connected"); return false; } - LOGGER.config("packet: "+p); - return true; + + return mConn.send(p); } @Override public void processPacket(Stanza packet) { - LOGGER.config("unhandled: "+packet); + LOGGER.warning("IQ error: "+packet); } public boolean addToRoster(JID jid, String name) { @@ -484,6 +447,68 @@ public boolean updateRosterEntry(JID jid, String newName) { return true; } + public void requestAvatar(JID jid, String id) { + if (mAvatarSendReceiver == null) { + LOGGER.warning("no avatar sender"); + return; + } + mAvatarSendReceiver.requestAndListen(jid, id); + } + + public void publishAvatar(String id, byte[] data) { + if (mAvatarSendReceiver == null) { + LOGGER.warning("no avatar sender"); + return; + } + if (mFeatures.containsKey(FeatureDiscovery.Feature.USER_AVATAR)) { + mAvatarSendReceiver.publish(id, data); + } else { + LOGGER.warning("not supported by server"); + } + } + + public boolean deleteAvatar() { + if (mAvatarSendReceiver == null) { + LOGGER.warning("no avatar sender"); + return false; + } + + if (mFeatures.containsKey(FeatureDiscovery.Feature.USER_AVATAR)) { + return mAvatarSendReceiver.delete(); + } else { + LOGGER.warning("not supported by server"); + return false; + } + } + + /** Request upload slot (XEP-0636). Blocking */ + public AttachmentManager.Slot getUploadSlot(String name, long length, String mime) { + if (mSlotRequester == null) { + LOGGER.warning("no slot requester"); + return new AttachmentManager.Slot(); + } + + return mSlotRequester.getSlot(name, length, mime); + } + + /* package internal*/ + + void newStatus(Control.Status status) { + if (status != Control.Status.CONNECTED) + mFeatures.clear(); + + mControl.onStatusChange(status, this.getServerFeature()); + } + + void newException(KonException konException) { + mControl.onException(konException); + } + + String multiAddressHost() { + return mFeatures.containsKey(FeatureDiscovery.Feature.MULTI_ADDRESSING) + && mConn != null ? mConn.getHost() : ""; + } + @Override public void run() { while (true) { @@ -506,10 +531,6 @@ public void run() { } } - public boolean isConnected() { - return mConn != null && mConn.isAuthenticated(); - } - private static class Task { final Command command; diff --git a/src/main/java/org/kontalk/client/EndpointServer.java b/src/main/java/org/kontalk/client/EndpointServer.java index 2c2338c1..72024834 100644 --- a/src/main/java/org/kontalk/client/EndpointServer.java +++ b/src/main/java/org/kontalk/client/EndpointServer.java @@ -1,6 +1,6 @@ /* * Kontalk Java client - * Copyright (C) 2014 Kontalk Devteam + * Copyright (C) 2016 Kontalk Devteam * 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 @@ -29,6 +29,13 @@ public final class EndpointServer { private final int mPort; private final String mNetwork; + public EndpointServer(String host, int port) { + // tigase: use hostname as network + mNetwork = host; + mHost = host; + mPort = port; + } + public EndpointServer(String network, String host, int port) { mNetwork = network; mHost = host; diff --git a/src/main/java/org/kontalk/client/FeatureDiscovery.java b/src/main/java/org/kontalk/client/FeatureDiscovery.java new file mode 100644 index 00000000..7b884c21 --- /dev/null +++ b/src/main/java/org/kontalk/client/FeatureDiscovery.java @@ -0,0 +1,116 @@ +/* + * Kontalk Java client + * Copyright (C) 2016 Kontalk Devteam + * + * 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 . + */ + +package org.kontalk.client; + +import java.util.EnumMap; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.logging.Level; +import java.util.logging.Logger; +import java.util.stream.Collectors; +import org.jivesoftware.smack.SmackException; +import org.jivesoftware.smack.XMPPConnection; +import org.jivesoftware.smack.XMPPException; +import org.jivesoftware.smackx.address.packet.MultipleAddresses; +import org.jivesoftware.smackx.disco.ServiceDiscoveryManager; +import org.jivesoftware.smackx.disco.packet.DiscoverInfo; +import org.jivesoftware.smackx.disco.packet.DiscoverItems; +import org.jivesoftware.smackx.pubsub.packet.PubSub; + +/** + * + * @author Alexander Bikadorov {@literal } + */ +public final class FeatureDiscovery { + private static final Logger LOGGER = Logger.getLogger(FeatureDiscovery.class.getName()); + + private static final Map FEATURE_MAP; + + public enum Feature { + USER_AVATAR, + MULTI_ADDRESSING, + /** New XEP-0363 upload service. */ + HTTP_FILE_UPLOAD + } + + static { + FEATURE_MAP = new HashMap<>(); + FEATURE_MAP.put(PubSub.NAMESPACE, Feature.USER_AVATAR); + FEATURE_MAP.put(MultipleAddresses.NAMESPACE, Feature.MULTI_ADDRESSING); + FEATURE_MAP.put(HTTPFileUpload.NAMESPACE, Feature.HTTP_FILE_UPLOAD); + } + + /** (server) service discovery, XEP-0030. */ + static EnumMap discover(XMPPConnection conn) { + // NOTE: smack automatically creates instances of SDM and CapsM and connects them + ServiceDiscoveryManager discoManager = ServiceDiscoveryManager.getInstanceFor(conn); + + // 1. get features from server + EnumMap features = discover(discoManager, conn.getServiceName()); + + DiscoverItems items = null; + try { + items = discoManager.discoverItems(conn.getServiceName()); + } catch (SmackException.NoResponseException | + XMPPException.XMPPErrorException | + SmackException.NotConnectedException ex) { + LOGGER.log(Level.WARNING, "can't get service discovery items", ex); + return features; + } + + // 2. get features from server items + for (DiscoverItems.Item item: items.getItems()) { + features.putAll(discover(discoManager, item.getEntityID())); + } + + LOGGER.info("supported server features: "+features); + return features; + } + + private static EnumMap discover(ServiceDiscoveryManager dm, String entity) { + EnumMap features = new EnumMap<>(FeatureDiscovery.Feature.class); + DiscoverInfo info; + try { + // blocking + // NOTE: null parameter does not work + info = dm.discoverInfo(entity); + } catch (SmackException.NoResponseException | + XMPPException.XMPPErrorException | + SmackException.NotConnectedException ex) { + LOGGER.log(Level.WARNING, "can't get service discovery info", ex); + return features; + } + + List identities = info.getIdentities(); + LOGGER.config("entity: " + entity + " identities: " + + identities.stream() + .map(i -> i.toXML()) + .collect(Collectors.toList())); + + for (DiscoverInfo.Feature feature: info.getFeatures()) { + String var = feature.getVar(); + if (FEATURE_MAP.containsKey(var)) { + features.put(FEATURE_MAP.get(var), entity); + } + } + + return features; + } +} diff --git a/src/main/java/org/kontalk/client/HKPClient.java b/src/main/java/org/kontalk/client/HKPClient.java index cd1645e5..383dccb3 100644 --- a/src/main/java/org/kontalk/client/HKPClient.java +++ b/src/main/java/org/kontalk/client/HKPClient.java @@ -1,6 +1,6 @@ /* * Kontalk Java client - * Copyright (C) 2014 Kontalk Devteam + * Copyright (C) 2016 Kontalk Devteam * * 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 @@ -40,8 +40,8 @@ public final class HKPClient { private static final Logger LOGGER = Logger.getLogger(HKPClient.class.getName()); - //private static final short DEFAULT_PORT = 11371; - private static final short DEFAULT_SSL_PORT = 443; + //private static final int DEFAULT_PORT = 11371; + //private static final int DEFAULT_SSL_PORT = 443; private static final int MAX_CONTENT_LENGTH = 9001; diff --git a/src/main/java/org/kontalk/client/HTTPFileClient.java b/src/main/java/org/kontalk/client/HTTPFileClient.java index 320b83e0..d95cf30d 100644 --- a/src/main/java/org/kontalk/client/HTTPFileClient.java +++ b/src/main/java/org/kontalk/client/HTTPFileClient.java @@ -1,6 +1,6 @@ /* * Kontalk Java client - * Copyright (C) 2014 Kontalk Devteam + * Copyright (C) 2016 Kontalk Devteam * * 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 @@ -24,7 +24,7 @@ import java.io.FileOutputStream; import java.io.IOException; import java.net.URI; -import java.net.URISyntaxException; +import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.security.KeyManagementException; @@ -39,6 +39,7 @@ import java.util.regex.Matcher; import java.util.regex.Pattern; import javax.net.ssl.SSLContext; +import org.apache.commons.io.FilenameUtils; import org.apache.commons.io.output.CountingOutputStream; import org.apache.commons.lang.StringUtils; import org.apache.http.Header; @@ -47,14 +48,16 @@ import org.apache.http.client.config.RequestConfig; import org.apache.http.client.methods.CloseableHttpResponse; import org.apache.http.client.methods.HttpGet; -import org.apache.http.client.methods.HttpPost; +import org.apache.http.client.methods.HttpPut; import org.apache.http.client.methods.HttpRequestBase; +import org.apache.http.client.utils.HttpClientUtils; import org.apache.http.entity.InputStreamEntity; import org.apache.http.impl.client.CloseableHttpClient; import org.apache.http.impl.client.HttpClientBuilder; import org.apache.http.impl.client.HttpClients; import org.apache.http.util.EntityUtils; import org.kontalk.misc.KonException; +import org.kontalk.util.EncodingUtils; import org.kontalk.util.MediaUtils; import org.kontalk.util.TrustUtils; @@ -65,6 +68,8 @@ public class HTTPFileClient { private static final Logger LOGGER = Logger.getLogger(HTTPFileClient.class.getName()); + static final String KON_UPLOAD_FEATURE = "http://kontalk.org/extensions/message#upload"; + /** Regex used to parse content-disposition headers for download. */ private static final Pattern CONTENT_DISPOSITION_PATTERN = Pattern .compile("attachment;\\s*filename\\s*=\\s*\"([^\"]*)\""); @@ -101,34 +106,33 @@ public void abort() { } /** - * Downloads to a directory represented by a {@link File} object, - * determining the file name from the Content-Disposition header. + * Download file to directory. * @param url URL of file * @param base base directory in which the download is saved - * @return the absolute path of the downloaded file, empty if the file could - * not be downloaded + * @return absolute path of downloaded file, empty if download failed */ - public Path download(URI url, Path base, ProgressListener listener) throws KonException { + public synchronized Path download(URI url, Path base, ProgressListener listener) + throws KonException { if (mHTTPClient == null) { mHTTPClient = httpClientOrNull(mPrivateKey, mCertificate, mValidateCertificate); if (mHTTPClient == null) throw new KonException(KonException.Error.DOWNLOAD_CREATE); } - LOGGER.info("from URL=" + url+ "..."); + LOGGER.config("from URL=" + url+ " ..."); mCurrentRequest = new HttpGet(url); mCurrentListener = listener; // execute request - CloseableHttpResponse response; + CloseableHttpResponse response = null; try { - response = mHTTPClient.execute(mCurrentRequest); - } catch (IOException ex) { - LOGGER.log(Level.WARNING, "can't execute request", ex); - throw new KonException(KonException.Error.DOWNLOAD_EXECUTE); - } + try { + response = mHTTPClient.execute(mCurrentRequest); + } catch (IOException ex) { + LOGGER.log(Level.WARNING, "can't execute request", ex); + throw new KonException(KonException.Error.DOWNLOAD_EXECUTE); + } - try { int code = response.getStatusLine().getStatusCode(); if (code != HttpStatus.SC_OK) { LOGGER.warning("unexpected response code: " + code); @@ -141,12 +145,10 @@ public Path download(URI url, Path base, ProgressListener listener) throws KonEx throw new KonException(KonException.Error.DOWNLOAD_RESPONSE); } - // get filename + // try getting filename from header String filename = ""; Header dispHeader = response.getFirstHeader("Content-Disposition"); - if (dispHeader == null) { - LOGGER.warning("no content header"); - } else { + if (dispHeader != null) { filename = parseContentDisposition(dispHeader.getValue()); // never trust incoming data filename = Paths.get(filename).getFileName().toString(); @@ -154,13 +156,12 @@ public Path download(URI url, Path base, ProgressListener listener) throws KonEx LOGGER.warning("can't parse filename in content: "+dispHeader.getValue()); } } + // NOTE: could try getting the extension (and filename) from URL, security? if (filename.isEmpty()) { // fallback String type = StringUtils.defaultString(entity.getContentType().getValue()); String ext = MediaUtils.extensionForMIME(type); - filename = "att_" + - org.jivesoftware.smack.util.StringUtils.randomString(4) + - ext; + filename = "att_" + EncodingUtils.randomString(4) + "." + ext; } // get file size @@ -178,12 +179,18 @@ public Path download(URI url, Path base, ProgressListener listener) throws KonEx final long fileSize = s; mCurrentListener.updateProgress(s < 0 ? -2 : 0); - File destination = new File(base.toString(), filename); - if (destination.exists()) { - LOGGER.warning("file already exists: "+destination.getAbsolutePath()); - return Paths.get(""); + Path destination = Paths.get(base.toString(), filename); + if (Files.exists(destination)) { + destination = Paths.get(base.toString(), + FilenameUtils.getBaseName(filename) + + "_" + EncodingUtils.randomString(4) + + "." + FilenameUtils.getExtension(filename)); + if (Files.exists(destination)) { + LOGGER.warning("not possible"); + return Paths.get(""); + } } - try (FileOutputStream out = new FileOutputStream(destination)){ + try (FileOutputStream out = new FileOutputStream(destination.toFile())){ CountingOutputStream cOut = new CountingOutputStream(out) { @Override protected synchronized void afterWrite(int n) { @@ -204,91 +211,58 @@ protected synchronized void afterWrite(int n) { // release http connection resource EntityUtils.consumeQuietly(entity); - // TODO + return destination.toAbsolutePath(); + } finally { + HttpClientUtils.closeQuietly(response); mCurrentRequest = null; mCurrentListener = null; - - return Paths.get(destination.getAbsolutePath()); - } finally { - try { - response.close(); - } catch (IOException ex) { - LOGGER.log(Level.WARNING, "can't close response", ex); - // TODO can't use this client anymore(?) - } } } /** - * Upload a file. - * @param file file to download - * @param url the upload URL pointing to the upload service - * @param mime mime-type of file - * @param encrypted is the file encrypted? + * Upload file using a PUT request. * @return the URL the file can be downloaded with. */ - public URI upload(File file, URI url, String mime, boolean encrypted) throws KonException { + public synchronized void upload(File file, URI uploadURL, String mime, boolean encrypted) + throws KonException { + if (mHTTPClient == null) { mHTTPClient = httpClientOrNull(mPrivateKey, mCertificate, mValidateCertificate); if (mHTTPClient == null) throw new KonException(KonException.Error.UPLOAD_CREATE); } - // request type - HttpPost req = new HttpPost(url); + // request + HttpPut req = new HttpPut(uploadURL); req.setHeader("Content-Type", mime); if (encrypted) req.addHeader(HEADER_MESSAGE_FLAGS, "encrypted"); + LOGGER.config("to URL=" + uploadURL+ " ..."); + // execute request - CloseableHttpResponse response; - try(FileInputStream in = new FileInputStream(file)) { - req.setEntity(new InputStreamEntity(in, file.length())); + CloseableHttpResponse response = null; + try { + try(FileInputStream in = new FileInputStream(file)) { + req.setEntity(new InputStreamEntity(in, file.length())); - mCurrentRequest = req; + mCurrentRequest = req; - //response = execute(currentRequest); - response = mHTTPClient.execute(mCurrentRequest); - } catch (IOException ex) { - LOGGER.log(Level.WARNING, "can't upload file", ex); - throw new KonException(KonException.Error.UPLOAD_EXECUTE); - } + //response = execute(currentRequest); + response = mHTTPClient.execute(mCurrentRequest); + } catch (IOException ex) { + LOGGER.log(Level.WARNING, "can't upload file", ex); + throw new KonException(KonException.Error.UPLOAD_EXECUTE); + } - // get URL from response entity - String downloadURL; - try { int code = response.getStatusLine().getStatusCode(); if (code != HttpStatus.SC_OK) { LOGGER.warning("unexpected response code: " + code); throw new KonException(KonException.Error.UPLOAD_RESPONSE); } - - HttpEntity entity = response.getEntity(); - if (entity == null) { - LOGGER.warning("no upload response entity"); - throw new KonException(KonException.Error.UPLOAD_RESPONSE); - } - - downloadURL = EntityUtils.toString(entity); - - // release http connection resource - EntityUtils.consume(entity); - } catch (IOException ex) { - LOGGER.log(Level.WARNING, "can't get url from response", ex); - throw new KonException(KonException.Error.UPLOAD_RESPONSE); } finally { - try { - response.close(); - } catch (IOException ex) { - LOGGER.log(Level.WARNING, "can't close response", ex); - } - } - - try { - return new URI(downloadURL); - } catch (URISyntaxException ex) { - LOGGER.log(Level.WARNING, "can't parse URI", ex); - throw new KonException(KonException.Error.UPLOAD_RESPONSE); + HttpClientUtils.closeQuietly(response); + mCurrentRequest = null; } } diff --git a/src/main/java/org/kontalk/client/HTTPFileSlotRequester.java b/src/main/java/org/kontalk/client/HTTPFileSlotRequester.java new file mode 100644 index 00000000..9b1ccf47 --- /dev/null +++ b/src/main/java/org/kontalk/client/HTTPFileSlotRequester.java @@ -0,0 +1,84 @@ +/* + * Kontalk Java client + * Copyright (C) 2016 Kontalk Devteam + * + * 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 . + */ + +package org.kontalk.client; + +import java.util.logging.Logger; +import org.jivesoftware.smack.SmackException; +import org.jivesoftware.smack.StanzaListener; +import org.jivesoftware.smack.packet.Stanza; +import org.jivesoftware.smack.provider.ProviderManager; +import org.kontalk.misc.Callback; +import org.kontalk.misc.JID; +import org.kontalk.system.AttachmentManager; +import org.kontalk.util.EncodingUtils; + +/** + * + * @author Alexander Bikadorov {@literal } + */ +final class HTTPFileSlotRequester { + private static final Logger LOGGER = Logger.getLogger(HTTPFileSlotRequester.class.getName()); + + static { + ProviderManager.addIQProvider( + HTTPFileUpload.Slot.ELEMENT_NAME, + HTTPFileUpload.NAMESPACE, + new HTTPFileUpload.Slot.Provider()); + } + + private final KonConnection mConn; + private final JID mService; + + public HTTPFileSlotRequester(KonConnection conn, JID service) { + mConn = conn; + mService = service; + } + + private HTTPFileUpload.Slot mSlotPacket; + synchronized AttachmentManager.Slot getSlot(String filename, long size, String mime) { + HTTPFileUpload.Request request = new HTTPFileUpload.Request(filename, size, mime); + request.setTo(mService.string()); + + final Callback.Synchronizer syncer = new Callback.Synchronizer(); + mSlotPacket = null; + mConn.sendWithCallback(request, new StanzaListener() { + @Override + public void processPacket(Stanza packet) + throws SmackException.NotConnectedException { + LOGGER.config("response: "+packet); + + if (!(packet instanceof HTTPFileUpload.Slot)) { + LOGGER.warning("response not a slot packet: "+packet); + syncer.sync(); + return; + } + mSlotPacket = (HTTPFileUpload.Slot) packet; + syncer.sync(); + } + }); + + syncer.waitForSync(); + + return mSlotPacket != null ? + new AttachmentManager.Slot( + EncodingUtils.toURI(mSlotPacket.getPutUrl()), + EncodingUtils.toURI(mSlotPacket.getGetUrl())) : + new AttachmentManager.Slot(); + } +} diff --git a/src/main/java/org/kontalk/client/KonConnection.java b/src/main/java/org/kontalk/client/KonConnection.java index f9c96b07..dc86e11b 100644 --- a/src/main/java/org/kontalk/client/KonConnection.java +++ b/src/main/java/org/kontalk/client/KonConnection.java @@ -1,6 +1,6 @@ /* * Kontalk Java client - * Copyright (C) 2014 Kontalk Devteam + * Copyright (C) 2016 Kontalk Devteam * 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 @@ -36,7 +36,12 @@ import javax.security.auth.callback.UnsupportedCallbackException; import org.jivesoftware.smack.ConnectionConfiguration.SecurityMode; +import org.jivesoftware.smack.ExceptionCallback; import org.jivesoftware.smack.SASLAuthentication; +import org.jivesoftware.smack.SmackException; +import org.jivesoftware.smack.StanzaListener; +import org.jivesoftware.smack.packet.IQ; +import org.jivesoftware.smack.packet.Stanza; import org.jivesoftware.smack.tcp.XMPPTCPConnection; import org.jivesoftware.smack.tcp.XMPPTCPConnectionConfiguration; import org.jivesoftware.smack.tcp.XMPPTCPConnectionConfiguration.Builder; @@ -148,4 +153,29 @@ String getServer() { boolean hasLoginCredentials() { return mHasLoginCredentials; } + + boolean send(Stanza p) { + try { + super.sendStanza(p); + } catch (SmackException.NotConnectedException ex) { + LOGGER.info("can't send packet, not connected."); + return false; + } + LOGGER.config("packet: "+p); + return true; + } + + void sendWithCallback(IQ packet, StanzaListener callback) { + LOGGER.config("packet: "+packet); + try { + super.sendIqWithResponseCallback(packet, callback, new ExceptionCallback() { + @Override + public void processException(Exception ex) { + LOGGER.log(Level.WARNING, "exception response", ex); + } + }); + } catch (SmackException.NotConnectedException ex) { + LOGGER.log(Level.WARNING, "not connected", ex); + } + } } diff --git a/src/main/java/org/kontalk/client/KonConnectionListener.java b/src/main/java/org/kontalk/client/KonConnectionListener.java index 1ae6d648..b4a6a88e 100644 --- a/src/main/java/org/kontalk/client/KonConnectionListener.java +++ b/src/main/java/org/kontalk/client/KonConnectionListener.java @@ -1,6 +1,6 @@ /* * Kontalk Java client - * Copyright (C) 2014 Kontalk Devteam + * Copyright (C) 2016 Kontalk Devteam * * 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 @@ -24,7 +24,6 @@ import org.jivesoftware.smack.XMPPConnection; import org.kontalk.misc.JID; import org.kontalk.misc.KonException; -import org.kontalk.system.Config; import org.kontalk.system.Control; /** @@ -34,10 +33,13 @@ final class KonConnectionListener implements ConnectionListener { private static final Logger LOGGER = Logger.getLogger(KonConnectionListener.class.getName()); + private final Client mClient; private final Control mControl; + private boolean mConnected = false; - KonConnectionListener(Control control) { + KonConnectionListener(Client client, Control control) { + mClient = client; mControl = control; } @@ -51,13 +53,14 @@ public void connected(XMPPConnection connection) { public void authenticated(XMPPConnection connection, boolean resumed) { JID jid = JID.bare(connection.getUser()); LOGGER.info("as "+jid); - Config.getInstance().setProperty(Config.ACC_JID, jid.string()); + mControl.onAuthenticated(jid); } @Override public void connectionClosed() { mConnected = false; LOGGER.info("connection closed"); + mClient.newStatus(Control.Status.DISCONNECTED); } @Override @@ -70,8 +73,8 @@ public void connectionClosedOnError(Exception ex) { return; LOGGER.log(Level.WARNING, "connection closed on error", ex); - mControl.setStatus(Control.Status.ERROR); - mControl.handleException(new KonException(KonException.Error.CLIENT_ERROR, ex)); + mClient.newStatus(Control.Status.ERROR); + mClient.newException(new KonException(KonException.Error.CLIENT_ERROR, ex)); } @Override diff --git a/src/main/java/org/kontalk/client/KonMessageListener.java b/src/main/java/org/kontalk/client/KonMessageListener.java index 61a2542a..64602e94 100644 --- a/src/main/java/org/kontalk/client/KonMessageListener.java +++ b/src/main/java/org/kontalk/client/KonMessageListener.java @@ -1,6 +1,6 @@ /* * Kontalk Java client - * Copyright (C) 2014 Kontalk Devteam + * Copyright (C) 2016 Kontalk Devteam * * 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 @@ -18,11 +18,8 @@ package org.kontalk.client; -import java.net.URI; -import java.net.URISyntaxException; import java.util.Date; import java.util.Optional; -import java.util.logging.Level; import java.util.logging.Logger; import org.apache.commons.lang.StringUtils; @@ -35,18 +32,18 @@ import org.jivesoftware.smackx.chatstates.ChatState; import org.jivesoftware.smackx.chatstates.packet.ChatStateExtension; import org.jivesoftware.smackx.delay.packet.DelayInformation; +import org.jivesoftware.smackx.pubsub.EventElement; +import org.jivesoftware.smackx.pubsub.EventElementType; +import org.jivesoftware.smackx.pubsub.ItemsExtension; +import org.jivesoftware.smackx.pubsub.NodeExtension; +import org.jivesoftware.smackx.pubsub.packet.PubSubNamespace; import org.jivesoftware.smackx.receipts.DeliveryReceipt; import org.jivesoftware.smackx.receipts.DeliveryReceiptRequest; import org.kontalk.misc.JID; -import org.kontalk.model.GroupChat.GID; -import org.kontalk.model.MessageContent; -import org.kontalk.model.MessageContent.Attachment; -import org.kontalk.model.MessageContent.GroupCommand; -import org.kontalk.model.MessageContent.Preview; +import org.kontalk.model.message.MessageContent; import org.kontalk.system.Control; import org.kontalk.util.ClientUtils; import org.kontalk.util.ClientUtils.MessageIDs; -import org.kontalk.util.EncodingUtils; /** * Listen and handle all incoming XMPP message packets. @@ -57,24 +54,28 @@ final public class KonMessageListener implements StanzaListener { private final Client mClient; private final Control mControl; + private final AvatarSendReceiver mAvatarHandler; - KonMessageListener(Client client, Control control) { - mClient = client; - mControl = control; - + static { ProviderManager.addExtensionProvider(E2EEncryption.ELEMENT_NAME, E2EEncryption.NAMESPACE, new E2EEncryption.Provider()); ProviderManager.addExtensionProvider(OutOfBandData.ELEMENT_NAME, OutOfBandData.NAMESPACE, new OutOfBandData.Provider()); ProviderManager.addExtensionProvider(BitsOfBinary.ELEMENT_NAME, BitsOfBinary.NAMESPACE, new BitsOfBinary.Provider()); ProviderManager.addExtensionProvider(GroupExtension.ELEMENT_NAME, GroupExtension.NAMESPACE, new GroupExtension.Provider()); } + KonMessageListener(Client client, Control control, AvatarSendReceiver avatarHandler) { + mClient = client; + mControl = control; + mAvatarHandler = avatarHandler; + } + @Override public void processPacket(Stanza packet) { Message m = (Message) packet; + Message.Type type = m.getType(); // check for delivery receipt (XEP-0184) - if (m.getType() == Message.Type.normal || - m.getType() == Message.Type.chat) { + if (type == Message.Type.normal || type == Message.Type.chat) { DeliveryReceipt receipt = DeliveryReceipt.from(m); if (receipt != null) { // HOORAY! our message was received @@ -83,13 +84,13 @@ public void processPacket(Stanza packet) { } } - if (m.getType() == Message.Type.chat) { + if (type == Message.Type.chat) { // somebody has news for us this.processChatMessage(m); return; } - if (m.getType() == Message.Type.error) { + if (type == Message.Type.error) { LOGGER.warning("got error message: "+m); XMPPError error = m.getError(); @@ -98,7 +99,12 @@ public void processPacket(Stanza packet) { return; } String text = StringUtils.defaultString(error.getDescriptiveText()); - mControl.setMessageError(MessageIDs.from(m), error.getCondition(), text); + mControl.onMessageError(MessageIDs.from(m), error.getCondition(), text); + return; + } + + if (type == Message.Type.headline) { + this.processHeadlineMessage(m); return; } @@ -111,7 +117,7 @@ private void processReceiptMessage(Message m, DeliveryReceipt receipt) { if (receiptID == null || receiptID.isEmpty()) { LOGGER.warning("message has invalid receipt ID: "+receiptID); } else { - mControl.setReceived(MessageIDs.from(m, receiptID)); + mControl.onMessageReceived(MessageIDs.from(m, receiptID)); } // we ignore anything else that might be in this message } @@ -121,8 +127,6 @@ private void processChatMessage(Message m) { // note: thread and subject are null if message comes from the Kontalk // Android client - String threadID = m.getThread() != null ? m.getThread() : ""; - // TODO a message can contain all sorts of extensions, we should loop // over all of them @@ -143,13 +147,14 @@ private void processChatMessage(Message m) { optServerDate = Optional.of(date); } + MessageIDs ids = MessageIDs.from(m); + // process possible chat state notification (XEP-0085) ExtensionElement csExt = m.getExtension(ChatStateExtension.NAMESPACE); ChatState chatState = null; if (csExt != null) { chatState = ((ChatStateExtension) csExt).getChatState(); - mControl.processChatState(JID.bare(m.getFrom()), - threadID, + mControl.onChatStateNotification(ids, optServerDate, chatState); } @@ -157,7 +162,7 @@ private void processChatMessage(Message m) { // must be an incoming message // get content/text from body and/or encryption/url extension - MessageContent content = parseMessageContent(m); + MessageContent content = ClientUtils.parseMessageContent(m); // make sure not to save a message without content if (content.isEmpty()) { @@ -169,83 +174,39 @@ private void processChatMessage(Message m) { return; } - MessageIDs ids = MessageIDs.from(m); - // add message - boolean success = mControl.newInMessage(ids, optServerDate, content); + mControl.onNewInMessage(ids, optServerDate, content); - // on success, send a 'received' for a request (XEP-0184) + // send a 'received' for a receipt request (XEP-0184) DeliveryReceiptRequest request = DeliveryReceiptRequest.from(m); - if (request != null && success && !ids.xmppID.isEmpty()) { + if (request != null && !ids.xmppID.isEmpty()) { Message received = new Message(m.getFrom(), Message.Type.chat); received.addExtension(new DeliveryReceipt(ids.xmppID)); mClient.sendPacket(received); } } - public static MessageContent parseMessageContent(Message m) { - // default body - String plainText = StringUtils.defaultString(m.getBody()); - - // encryption extension (RFC 3923), decrypted later - String encrypted = ""; - ExtensionElement encryptionExt = m.getExtension(E2EEncryption.ELEMENT_NAME, E2EEncryption.NAMESPACE); - if (encryptionExt instanceof E2EEncryption) { - if (m.getBody() != null) - LOGGER.config("message contains encryption and body (ignoring body): "+m.getBody()); - E2EEncryption encryption = (E2EEncryption) encryptionExt; - encrypted = EncodingUtils.bytesToBase64(encryption.getData()); - } - - // Bits of Binary: preview for file attachment - Preview preview = null; - ExtensionElement bobExt = m.getExtension(BitsOfBinary.ELEMENT_NAME, BitsOfBinary.NAMESPACE); - if (bobExt instanceof BitsOfBinary) { - BitsOfBinary bob = (BitsOfBinary) bobExt; - String mime = StringUtils.defaultString(bob.getType()); - byte[] bits = bob.getContents(); - if (bits == null) - bits = new byte[0]; - if (mime.isEmpty() || bits.length <= 0) - LOGGER.warning("invalid BOB data: "+bob.toXML()); - else - preview = new Preview(bits, mime); - } + private void processHeadlineMessage(Message m) { + LOGGER.config("message: "+m); - // Out of Band Data: a URI to a file - Attachment attachment = null; - ExtensionElement oobExt = m.getExtension(OutOfBandData.ELEMENT_NAME, OutOfBandData.NAMESPACE); - if (oobExt instanceof OutOfBandData) { - OutOfBandData oobData = (OutOfBandData) oobExt; - URI url; - try { - url = new URI(oobData.getUrl()); - } catch (URISyntaxException ex) { - LOGGER.log(Level.WARNING, "can't parse URL", ex); - url = URI.create(""); + // this should be a pubsub event + PubSubNamespace ns = PubSubNamespace.EVENT; + ExtensionElement eventExt = m.getExtension(ns.getFragment(), ns.getXmlns()); + if (eventExt instanceof EventElement){ + EventElement event = (EventElement) eventExt; + + if (event.getEventType() == EventElementType.items) { + NodeExtension extension = event.getEvent(); + if (extension instanceof ItemsExtension) { + ItemsExtension items = (ItemsExtension) extension; + if (items.getNode().equals(AvatarSendReceiver.METADATA_NODE)) { + mAvatarHandler.processMetadataEvent(JID.full(m.getFrom()), items); + return; + } + } } - attachment = new MessageContent.Attachment(url, - oobData.getMime() != null ? oobData.getMime() : "", - oobData.getLength(), - oobData.isEncrypted()); - } - - // group command - GID gid = null; - GroupCommand groupCommand = null; - ExtensionElement groupExt = m.getExtension(GroupExtension.ELEMENT_NAME, - GroupExtension.NAMESPACE); - if (groupExt instanceof GroupExtension) { - GroupExtension group = (GroupExtension) groupExt; - gid = new GID(JID.bare(group.getOwner()), group.getID()); - groupCommand = ClientUtils.groupExtensionToGroupCommand( - group.getCommand(), group.getMember(), group.getSubject()).orElse(null); } - return new MessageContent.Builder(plainText, encrypted) - .attachment(attachment) - .preview(preview) - .gid(gid) - .groupCommand(groupCommand).build(); + LOGGER.warning("unhandled"); } } diff --git a/src/main/java/org/kontalk/client/KonMessageSender.java b/src/main/java/org/kontalk/client/KonMessageSender.java new file mode 100644 index 00000000..d0d4d469 --- /dev/null +++ b/src/main/java/org/kontalk/client/KonMessageSender.java @@ -0,0 +1,176 @@ +/* + * Kontalk Java client + * Copyright (C) 2016 Kontalk Devteam + * + * 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 . + */ + +package org.kontalk.client; + +import java.util.ArrayList; +import java.util.List; +import java.util.logging.Logger; +import java.util.stream.Collectors; +import org.jivesoftware.smack.packet.Message; +import org.jivesoftware.smackx.address.packet.MultipleAddresses; +import org.jivesoftware.smackx.chatstates.ChatState; +import org.jivesoftware.smackx.chatstates.packet.ChatStateExtension; +import org.jivesoftware.smackx.receipts.DeliveryReceiptRequest; +import org.kontalk.misc.JID; +import org.kontalk.model.chat.Chat; +import org.kontalk.model.chat.GroupChat.KonGroupChat; +import org.kontalk.model.chat.GroupMetaData.KonGroupData; +import org.kontalk.model.message.KonMessage; +import org.kontalk.model.message.MessageContent; +import org.kontalk.model.message.OutMessage; +import org.kontalk.util.ClientUtils; +import org.kontalk.util.EncodingUtils; + +/** + * + * @author Alexander Bikadorov {@literal } + */ +public final class KonMessageSender { + private static final Logger LOGGER = Logger.getLogger(KonMessageSender.class.getName()); + + private final Client mClient; + + KonMessageSender(Client client) { + mClient = client; + } + + boolean sendMessage(OutMessage message, boolean sendChatState) { + // check for correct receipt status and reset it + KonMessage.Status status = message.getStatus(); + assert status == KonMessage.Status.PENDING || status == KonMessage.Status.ERROR; + message.setStatus(KonMessage.Status.PENDING); + + if (!mClient.isConnected()) { + LOGGER.info("not sending message(s), not connected"); + return false; + } + + MessageContent content = message.getContent(); + MessageContent.Attachment att = content.getAttachment().orElse(null); + if (att != null && !att.hasURL()) { + LOGGER.warning("attachment not uploaded"); + message.setStatus(KonMessage.Status.ERROR); + return false; + } + + boolean encrypted = message.isSendEncrypted(); + + Chat chat = message.getChat(); + + Message protoMessage = encrypted ? + new Message() : + rawMessage(content, chat, false); + + protoMessage.setType(Message.Type.chat); + protoMessage.setStanzaId(message.getXMPPID()); + String threadID = chat.getXMPPID(); + if (!threadID.isEmpty()) + protoMessage.setThread(threadID); + + // extensions + + // not with group chat (at least not for Kontalk groups or MUC) + if (!chat.isGroupChat()) + protoMessage.addExtension(new DeliveryReceiptRequest()); + + // TEMP: server bug workaround, always include body + if (protoMessage.getBody() == null) + protoMessage.setBody("dummy"); + + if (sendChatState) + protoMessage.addExtension(new ChatStateExtension(ChatState.active)); + + if (encrypted) { + byte[] encryptedData = content.getEncryptedData().orElse(null); + if (encryptedData == null) { + LOGGER.warning("no encrypted data"); + return false; + } + protoMessage.addExtension(new E2EEncryption(encryptedData)); + } + + List JIDs = message.getTransmissions().stream() + .map(t -> t.getJID()) + .collect(Collectors.toList()); + + String multiAddressHost = mClient.multiAddressHost(); + if (JIDs.size() > 1 && !multiAddressHost.isEmpty()) { + // send one message to multiple receiver using XEP-0033 + protoMessage.setTo(multiAddressHost); + MultipleAddresses addresses = new MultipleAddresses(); + for (JID to: JIDs) { + addresses.addAddress(MultipleAddresses.Type.to, to.string(), null, null, false, null); + } + protoMessage.addExtension(addresses); + + return mClient.sendPacket(protoMessage); + } + + // onle one receiver or fallback: send one message to each receiver + ArrayList sendMessages = new ArrayList<>(); + for (JID to: JIDs) { + Message sendMessage = protoMessage.clone(); + sendMessage.setTo(to.string()); + sendMessages.add(sendMessage); + } + + return mClient.sendPackets(sendMessages.toArray(new Message[0])); + } + + public static Message rawMessage(MessageContent content, Chat chat, boolean encrypted) { + Message smackMessage = new Message(); + + MessageContent.Attachment att = content.getAttachment().orElse(null); + + // text body + String text = content.getPlainText(); + if (text.isEmpty() && att != null) { + // use attachment URL as body + text = att.getURL().toString(); + } + if (!text.isEmpty()) + smackMessage.setBody(text); + + // attachment + if (att != null) { + OutOfBandData oobData = new OutOfBandData(att.getURL().toString(), + att.getMimeType(), att.getLength(), encrypted); + smackMessage.addExtension(oobData); + + MessageContent.Preview preview = content.getPreview().orElse(null); + if (preview != null) { + String data = EncodingUtils.bytesToBase64(preview.getData()); + BitsOfBinary bob = new BitsOfBinary(preview.getMimeType(), data); + smackMessage.addExtension(bob); + } + } + + // group command + if (chat instanceof KonGroupChat) { + KonGroupChat groupChat = (KonGroupChat) chat; + KonGroupData gid = groupChat.getGroupData(); + MessageContent.GroupCommand groupCommand = content.getGroupCommand().orElse(null); + smackMessage.addExtension(groupCommand != null ? + ClientUtils.groupCommandToGroupExtension(groupChat, groupCommand) : + new GroupExtension(gid.id, gid.owner.string())); + } + + return smackMessage; + } +} diff --git a/src/main/java/org/kontalk/client/KonRosterListener.java b/src/main/java/org/kontalk/client/KonRosterListener.java index d7715000..b5888b9a 100644 --- a/src/main/java/org/kontalk/client/KonRosterListener.java +++ b/src/main/java/org/kontalk/client/KonRosterListener.java @@ -1,6 +1,6 @@ /* * Kontalk Java client - * Copyright (C) 2014 Kontalk Devteam + * Copyright (C) 2016 Kontalk Devteam * * 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 diff --git a/src/main/java/org/kontalk/client/PresenceListener.java b/src/main/java/org/kontalk/client/PresenceListener.java index 2bf6e644..8ea7a2f3 100644 --- a/src/main/java/org/kontalk/client/PresenceListener.java +++ b/src/main/java/org/kontalk/client/PresenceListener.java @@ -1,6 +1,6 @@ /* * Kontalk Java client - * Copyright (C) 2014 Kontalk Devteam + * Copyright (C) 2016 Kontalk Devteam * * 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 @@ -27,6 +27,7 @@ import org.jivesoftware.smack.packet.Presence; import org.jivesoftware.smack.packet.XMPPError; import org.jivesoftware.smack.provider.ProviderManager; +import org.jivesoftware.smackx.muc.packet.MUCUser; import org.kontalk.misc.JID; import org.kontalk.system.RosterHandler; @@ -62,46 +63,69 @@ public PresenceListener(Roster roster, RosterHandler handler) { @Override public void processPacket(Stanza packet) { + if (MUCUser.from(packet) != null) { + // handled by MUC manager + LOGGER.config("ignoring MUC presence, from: "+packet.getFrom()); + return; + } + LOGGER.config("packet: "+packet); Presence presence = (Presence) packet; - JID jid = JID.bare(presence.getFrom()); + JID jid = JID.full(presence.getFrom()); - if (presence.getType() == Presence.Type.error) { - XMPPError error = presence.getError(); - if (error == null) { - LOGGER.warning("error presence does not contain error"); + ExtensionElement publicKeyExt = presence.getExtension( + PublicKeyPresence.ELEMENT_NAME, + PublicKeyPresence.NAMESPACE); + PublicKeyPresence pubKey = publicKeyExt instanceof PublicKeyPresence ? + (PublicKeyPresence) publicKeyExt : + null; + + switch(presence.getType()) { + case error: + XMPPError error = presence.getError(); + if (error == null) { + LOGGER.warning("error presence does not contain error"); + return; + } + mHandler.onPresenceError(jid, error.getType(), + error.getCondition()); + return; + // NOTE: only handled here if Roster.SubscriptionMode is set to 'manual' + case subscribe: + byte[] key = pubKey != null ? pubKey.getKey() : null; + if (key == null) + key = new byte[0]; + mHandler.onSubscriptionRequest(jid, key); + return; + case unsubscribe: + // nothing to do(?) + LOGGER.info(("ignoring unsubscribe, JID: "+jid)); return; - } - mHandler.onPresenceError(jid, error.getType(), - error.getCondition()); - return; } - Presence bestPresence = mRoster.getPresence(jid.string()); - // NOTE: a delay extension is sometimes included, don't know why; // ignoring mode, always null anyway - mHandler.onPresenceUpdate(JID.bare(bestPresence.getFrom()), + // NOTE: using only the "best" presence to ignore unimportant updates + // from multiple clients + Presence bestPresence = mRoster.getPresence(jid.string()); + + mHandler.onPresenceUpdate(jid, bestPresence.getType(), bestPresence.getStatus()); - ExtensionElement publicKeyExt = presence.getExtension( - PublicKeyPresence.ELEMENT_NAME, - PublicKeyPresence.NAMESPACE); - if (publicKeyExt instanceof PublicKeyPresence) { - PublicKeyPresence pubKey = (PublicKeyPresence) publicKeyExt; - String fingerprint = StringUtils.defaultString(pubKey.getFingerprint()); - if (!fingerprint.isEmpty()) { - mHandler.onFingerprintPresence(jid, fingerprint); - } else { + if (pubKey != null) { + String fp = StringUtils.defaultString(pubKey.getFingerprint()).toLowerCase(); + if (fp.isEmpty()) { LOGGER.warning("no fingerprint in public key presence extension"); + } else { + mHandler.onFingerprintPresence(jid, fp); } } - ExtensionElement signatureExt = presence.getExtension( + ExtensionElement signatureExt = bestPresence.getExtension( PresenceSignature.ELEMENT_NAME, PresenceSignature.NAMESPACE); if (signatureExt instanceof PresenceSignature) { diff --git a/src/main/java/org/kontalk/client/PrivateKeyReceiver.java b/src/main/java/org/kontalk/client/PrivateKeyReceiver.java new file mode 100644 index 00000000..d175c0e2 --- /dev/null +++ b/src/main/java/org/kontalk/client/PrivateKeyReceiver.java @@ -0,0 +1,158 @@ +/* + * Kontalk Java client + * Copyright (C) 2016 Kontalk Devteam + * + * 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 . + */ + +package org.kontalk.client; + +import java.io.IOException; +import java.util.List; +import java.util.logging.Level; +import java.util.logging.Logger; +import org.jivesoftware.smack.ExceptionCallback; +import org.jivesoftware.smack.SmackException; +import org.jivesoftware.smack.StanzaListener; +import org.jivesoftware.smack.XMPPException; +import org.jivesoftware.smack.packet.IQ; +import org.jivesoftware.smack.packet.Stanza; +import org.jivesoftware.smackx.iqregister.packet.Registration; +import org.jivesoftware.smackx.xdata.Form; +import org.jivesoftware.smackx.xdata.FormField; +import org.jivesoftware.smackx.xdata.packet.DataForm; +import org.kontalk.misc.Callback; + +/** + * Send request and listen to response for private data over + * 'jabber:iq:register' namespace. + * Temporary server connection is established on requesting. + * @author Alexander Bikadorov {@literal } + */ +public final class PrivateKeyReceiver implements StanzaListener { + private static final Logger LOGGER = Logger.getLogger(PrivateKeyReceiver.class.getName()); + + private static final String FORM_TYPE_VALUE = "http://kontalk.org/protocol/register#privatekey"; + private static final String FORM_TOKEN_VAR = "token"; + + private final Callback.Handler mHandler; + private KonConnection mConn = null; + + public PrivateKeyReceiver(Callback.Handler handler) { + mHandler = handler; + } + + public void sendRequest(EndpointServer server, boolean validateCertificate, + final String registrationToken) { + // create connection + mConn = new KonConnection(server, validateCertificate); + + Thread thread = new Thread("Private Key Request") { + @Override + public void run() { + PrivateKeyReceiver.this.sendRequestAsync(registrationToken); + } + }; + thread.setDaemon(true); + thread.start(); + } + + private void sendRequestAsync(String registrationToken) { + // connect + try { + mConn.connect(); + } catch (XMPPException | SmackException | IOException ex) { + LOGGER.log(Level.WARNING, "can't connect to "+mConn.getServer(), ex); + mHandler.handle(new Callback(ex)); + return; + } + + Registration iq = new Registration(); + iq.setType(IQ.Type.set); + iq.setTo(mConn.getServiceName()); + Form form = new Form(DataForm.Type.submit); + + // form type field + FormField type = new FormField(FormField.FORM_TYPE); + type.setType(FormField.Type.hidden); + type.addValue(FORM_TYPE_VALUE); + form.addField(type); + + // token field + FormField fieldKey = new FormField(FORM_TOKEN_VAR); + fieldKey.setLabel("Registration token"); + fieldKey.setType(FormField.Type.text_single); + fieldKey.addValue(registrationToken); + form.addField(fieldKey); + + iq.addExtension(form.getDataFormToSend()); + + try { + mConn.sendIqWithResponseCallback(iq, this, new ExceptionCallback() { + @Override + public void processException(Exception exception) { + mHandler.handle(new Callback(exception)); + } + }); + } catch (SmackException.NotConnectedException ex) { + LOGGER.log(Level.WARNING, "not connected", ex); + mHandler.handle(new Callback(ex)); + } + } + + @Override + public void processPacket(Stanza packet) { + LOGGER.info("response: "+packet); + + mConn.removeSyncStanzaListener(this); + mConn.disconnect(); + + if (!(packet instanceof IQ)) { + LOGGER.warning("response not an IQ packet"); + finish(null); + return; + } + IQ iq = (IQ) packet; + + if (iq.getType() != IQ.Type.result) { + LOGGER.warning("ignoring response with IQ type: "+iq.getType()); + this.finish(null); + return; + } + + DataForm response = iq.getExtension(DataForm.ELEMENT, DataForm.NAMESPACE); + if (response == null) { + this.finish(null); + return; + } + + String token = null; + List fields = response.getFields(); + for (FormField field : fields) { + if ("token".equals(field.getVariable())) { + token = field.getValues().get(0); + break; + } + } + + this.finish(token); + } + + private void finish(String token) { + mHandler.handle(token == null ? + new Callback() : + new Callback<>(token)); + } + +} diff --git a/src/main/java/org/kontalk/client/PublicKeyListener.java b/src/main/java/org/kontalk/client/PublicKeyListener.java index 1804cba1..efc5a8f3 100644 --- a/src/main/java/org/kontalk/client/PublicKeyListener.java +++ b/src/main/java/org/kontalk/client/PublicKeyListener.java @@ -1,6 +1,6 @@ /* * Kontalk Java client - * Copyright (C) 2014 Kontalk Devteam + * Copyright (C) 2016 Kontalk Devteam * * 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 @@ -36,12 +36,14 @@ public class PublicKeyListener implements StanzaListener { private final Control mControl; + static { + ProviderManager.addIQProvider(PublicKeyPublish.ELEMENT_NAME, + PublicKeyPublish.NAMESPACE, + new PublicKeyPublish.Provider()); + } + public PublicKeyListener(Control control) { mControl = control; - - ProviderManager.addIQProvider(PublicKeyPublish.ELEMENT_NAME, - PublicKeyPublish.NAMESPACE, - new PublicKeyPublish.Provider()); } @Override @@ -61,7 +63,7 @@ public void processPacket(Stanza packet) { LOGGER.warning("got public key packet without public key"); return; } - mControl.handlePGPKey(JID.bare(publicKeyPacket.getFrom()), keyData); + mControl.onPGPKey(JID.bare(publicKeyPacket.getFrom()), keyData); } } diff --git a/src/main/java/org/kontalk/client/VCardListener.java b/src/main/java/org/kontalk/client/VCardListener.java index 5ff07b9c..35b84290 100644 --- a/src/main/java/org/kontalk/client/VCardListener.java +++ b/src/main/java/org/kontalk/client/VCardListener.java @@ -24,10 +24,15 @@ final class VCardListener implements StanzaListener { private final Control mControl; + static { + ProviderManager.addIQProvider( + VCard4.ELEMENT_NAME, + VCard4.NAMESPACE, + new VCard4.Provider()); + } + VCardListener(Control control) { mControl = control; - - ProviderManager.addIQProvider(VCard4.ELEMENT_NAME, VCard4.NAMESPACE, new VCard4.Provider()); } @Override @@ -48,7 +53,7 @@ public void processPacket(Stanza packet) { LOGGER.warning("got vcard without pgp key included"); return; } - mControl.handlePGPKey(JID.bare(p.getFrom()), publicKey); + mControl.onPGPKey(JID.bare(p.getFrom()), publicKey); } } } diff --git a/src/main/java/org/kontalk/crypto/Coder.java b/src/main/java/org/kontalk/crypto/Coder.java index de994f70..6baf3d97 100644 --- a/src/main/java/org/kontalk/crypto/Coder.java +++ b/src/main/java/org/kontalk/crypto/Coder.java @@ -1,6 +1,6 @@ /* * Kontalk Java client - * Copyright (C) 2014 Kontalk Devteam + * Copyright (C) 2016 Kontalk Devteam * * 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 @@ -22,14 +22,12 @@ import java.nio.file.Path; import java.util.HashMap; import java.util.Optional; -import java.util.logging.Level; import java.util.logging.Logger; import org.kontalk.crypto.PGPUtils.PGPCoderKey; import org.kontalk.model.Contact; -import org.kontalk.model.OutMessage; -import org.kontalk.model.DecryptMessage; -import org.kontalk.model.InMessage; -import org.kontalk.system.AccountLoader; +import org.kontalk.model.message.OutMessage; +import org.kontalk.model.message.DecryptMessage; +import org.kontalk.model.message.InMessage; /** * Static methods for decryption and encryption of a message. @@ -61,7 +59,7 @@ public static enum Signing {NOT, SIGNED, VERIFIED, UNKNOWN} public static enum Error { /** Some unknown error. */ UNKNOWN_ERROR, - /** Own personal key not found. */ + /** Own personal key not found. Unused. */ MY_KEY_UNAVAILABLE, /** Public key of sender not found. */ KEY_UNAVAILABLE, @@ -88,15 +86,6 @@ public static enum Error { private static final HashMap KEY_MAP = new HashMap<>(); - static PersonalKey myKeyOrNull() { - Optional optMyKey = AccountLoader.getInstance().getPersonalKey(); - if (!optMyKey.isPresent()) { - LOGGER.log(Level.WARNING, "can't get personal key"); - return null; - } - return optMyKey.get(); - } - public static Optional contactkey(Contact contact) { if (KEY_MAP.containsKey(contact)) { PGPCoderKey key = KEY_MAP.get(contact); @@ -106,10 +95,10 @@ public static Optional contactkey(Contact contact) { byte[] rawKey = contact.getKey(); if (rawKey.length != 0) { - Optional optKey = PGPUtils.readPublicKey(rawKey); - if (optKey.isPresent()) { - KEY_MAP.put(contact, optKey.get()); - return optKey; + PGPCoderKey key = PGPUtils.readPublicKey(rawKey).orElse(null); + if (key != null) { + KEY_MAP.put(contact, key); + return Optional.of(key); } } @@ -121,8 +110,8 @@ public static Optional contactkey(Contact contact) { * Decrypt and verify the body of a message. Sets the encryption and signing * status of the message and errors that may occur are saved to the message. */ - public static boolean decryptMessage(DecryptMessage message) { - return new Decryptor(message).decryptMessage(); + public static boolean decryptMessage(PersonalKey myKey, DecryptMessage message) { + return new Decryptor(myKey, message).decryptMessage(); } /** @@ -130,8 +119,8 @@ public static boolean decryptMessage(DecryptMessage message) { * signing status of the message attachment and errors that may occur are * saved to the message. */ - public static void decryptAttachment(InMessage message, Path baseDir) { - new Decryptor(message).decryptAttachment(baseDir); + public static void decryptAttachment(PersonalKey myKey, InMessage message, Path baseDir) { + new Decryptor(myKey, message).decryptAttachment(baseDir); } /** @@ -139,15 +128,15 @@ public static void decryptAttachment(InMessage message, Path baseDir) { * Errors that may occur are saved to the message. * @return the encrypted and signed text. */ - public static Optional encryptMessage(OutMessage message) { - return new Encryptor(message).encryptMessage(); + public static Optional encryptMessage(PersonalKey myKey, OutMessage message) { + return new Encryptor(myKey, message).encryptMessage(); } - public static Optional encryptStanza(OutMessage message, String xml) { - return new Encryptor(message).encryptStanza(xml); + public static Optional encryptStanza(PersonalKey myKey, OutMessage message, String xml) { + return new Encryptor(myKey, message).encryptStanza(xml); } - public static Optional encryptAttachment(OutMessage message) { - return new Encryptor(message).encryptAttachment(); + public static Optional encryptAttachment(PersonalKey myKey, OutMessage message, File file) { + return new Encryptor(myKey, message).encryptAttachment(file); } } diff --git a/src/main/java/org/kontalk/crypto/Decryptor.java b/src/main/java/org/kontalk/crypto/Decryptor.java index 429d3af1..f7aefbc4 100644 --- a/src/main/java/org/kontalk/crypto/Decryptor.java +++ b/src/main/java/org/kontalk/crypto/Decryptor.java @@ -1,6 +1,6 @@ /* * Kontalk Java client - * Copyright (C) 2014 Kontalk Devteam + * Copyright (C) 2016 Kontalk Devteam * * 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 @@ -26,15 +26,16 @@ import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; +import java.nio.file.Files; import java.nio.file.Path; import java.text.ParseException; +import java.util.Arrays; import java.util.EnumSet; import java.util.Iterator; import java.util.Optional; import java.util.logging.Level; import java.util.logging.Logger; import org.apache.commons.io.FilenameUtils; -import org.apache.commons.lang.StringUtils; import org.apache.http.util.EncodingUtils; import org.bouncycastle.openpgp.PGPCompressedData; import org.bouncycastle.openpgp.PGPEncryptedDataList; @@ -52,15 +53,17 @@ import org.bouncycastle.openpgp.operator.bc.BcPublicKeyDataDecryptorFactory; import org.jivesoftware.smack.SmackException; import org.jivesoftware.smack.packet.Message; -import org.kontalk.client.KonMessageListener; -import org.kontalk.model.MessageContent; -import org.kontalk.model.DecryptMessage; -import org.kontalk.model.InMessage; +import org.kontalk.model.message.MessageContent; +import org.kontalk.model.message.DecryptMessage; +import org.kontalk.model.message.InMessage; import org.kontalk.util.CPIMMessage; +import org.kontalk.util.ClientUtils; +import org.kontalk.util.MediaUtils; import org.kontalk.util.XMPPUtils; import org.xmlpull.v1.XmlPullParserException; /** + * Decrypt message content. Message parameter is internally changed by methods. * * @author Alexander Bikadorov {@literal } */ @@ -72,27 +75,28 @@ private static class DecryptionResult { Coder.Signing signing = Coder.Signing.UNKNOWN; } - private final DecryptMessage message; - private PersonalKey myKey = null; - private Optional senderKey; + private final DecryptMessage mMessage; + private final PersonalKey mMyKey; + // nullable + private final PGPUtils.PGPCoderKey mSenderKey; - Decryptor(DecryptMessage message) { - this.message = message; + Decryptor(PersonalKey myKey, DecryptMessage message) { + mMyKey = myKey; + mMessage = message; + + // if sender signing key not found -> can decrypt but not verify + mSenderKey = Coder.contactkey(message.getContact()).orElse(null); } // note: signing requires also encryption boolean decryptMessage() { - if (!message.isEncrypted()) { + if (!mMessage.isEncrypted()) { LOGGER.warning("message not encrypted"); return false; } - boolean loaded = this.loadKeys(); - if (!loaded) - return false; - // decrypt - String encryptedContent = message.getContent().getEncryptedContent(); + String encryptedContent = mMessage.getContent().getEncryptedContent(); if (encryptedContent.isEmpty()) { LOGGER.warning("no encrypted data in encrypted message"); } @@ -104,33 +108,35 @@ boolean decryptMessage() { try { decResult = decryptAndVerify(encryptedIn, plainOut, - myKey.getPrivateEncryptionKey(), - senderKey.isPresent() ? Optional.of(senderKey.get().signKey) : - Optional.empty()); + mMyKey.getPrivateEncryptionKey(), + mSenderKey != null ? + Optional.of(mSenderKey.signKey) : + Optional.empty()); } catch (IOException | PGPException ex) { LOGGER.log(Level.WARNING, "can't decrypt message", ex); return false; } EnumSet allErrors = decResult.errors; - message.setSigning(decResult.signing); + mMessage.setSigning(decResult.signing); // parse decrypted CPIM content - String myUID = myKey.getUserId(); - Optional senderUID = senderKey.isPresent() ? - Optional.of(senderKey.get().userID) : - Optional.empty(); + String myUID = mMyKey.getUserId(); + String senderUID = mSenderKey != null ? + mSenderKey.userID : + null; String decrText = EncodingUtils.getString( plainOut.toByteArray(), CPIMMessage.CHARSET); - MessageContent content = this.parseCPIMOrNull(decrText, myUID, senderUID); + MessageContent content = parseCPIMOrNull(mMessage, decrText, myUID, + Optional.ofNullable(senderUID)); // set errors - message.setSecurityErrors(allErrors); + mMessage.setSecurityErrors(allErrors); if (content != null) { // everything went better than expected LOGGER.info("message decryption successful"); - message.setDecryptedContent(content); + mMessage.setDecryptedContent(content); return true; } else { LOGGER.warning("message decryption failed"); @@ -139,31 +145,26 @@ boolean decryptMessage() { } void decryptAttachment(Path baseDir) { - if (!(message instanceof InMessage)) { + // TODO ugly + if (!(mMessage instanceof InMessage)) { LOGGER.warning("message not incoming message"); return; } - InMessage inMessage = (InMessage) message; + InMessage inMessage = (InMessage) mMessage; - Optional optAttachment = inMessage.getContent().getAttachment(); - if (!optAttachment.isPresent()) { - LOGGER.warning("no attachment in in-message"); + MessageContent.Attachment attachment = inMessage.getContent().getAttachment().orElse(null); + if (attachment == null) { + LOGGER.warning("no attachment in message"); return; } - MessageContent.Attachment attachment = optAttachment.get(); - - boolean loaded = this.loadKeys(); - if (!loaded) - return; // in file - File inFile = baseDir.resolve(attachment.getFile()).toFile(); + File inFile = baseDir.resolve(attachment.getFilePath()).toFile(); // out file String base = FilenameUtils.getBaseName(inFile.getName()); - String ext = FilenameUtils.getExtension(inFile.getName()); - File outFile = baseDir.resolve(base + "_dec." + ext).toFile(); + File outFile = baseDir.resolve(base + "_dec").toFile(); if (outFile.exists()) { - LOGGER.warning("encrypted file already exists: "+outFile.getAbsolutePath()); + LOGGER.warning("decrypted file already exists: "+outFile.getAbsolutePath()); return; } @@ -173,8 +174,8 @@ void decryptAttachment(Path baseDir) { FileOutputStream plainOut = new FileOutputStream(outFile)) { decResult = decryptAndVerify(encryptedIn, plainOut, - myKey.getPrivateEncryptionKey(), - senderKey.isPresent() ? Optional.of(senderKey.get().signKey) : + mMyKey.getPrivateEncryptionKey(), + mSenderKey != null ? Optional.of(mSenderKey.signKey) : Optional.empty()); } catch (IOException | PGPException ex){ LOGGER.log(Level.WARNING, "can't decrypt attachment", ex); @@ -185,24 +186,17 @@ void decryptAttachment(Path baseDir) { inMessage.setAttachmentSigning(decResult.signing); // set new filename - inMessage.setDecryptedAttachment(outFile.getName()); - LOGGER.info("attachment decryption successful"); - } - - private boolean loadKeys() { - myKey = Coder.myKeyOrNull(); - if (myKey == null) { - message.setSecurityErrors(EnumSet.of(Coder.Error.MY_KEY_UNAVAILABLE)); - return false; + Path outPath = outFile.toPath(); + Path newPath = outPath.resolveSibling(outFile.getName() + "." + + MediaUtils.extensionForMIME(MediaUtils.mimeForFile(outPath))); + try { + outPath = Files.move(outFile.toPath(), newPath); + } catch (IOException ex) { + LOGGER.log(Level.WARNING, "can't rename file", ex); } - senderKey = Coder.contactkey(message.getContact()); - - // sender signing key not found -> continue decryption, siging will not - // be possible - if (senderKey.isPresent()) - message.setSecurityErrors(EnumSet.of(Coder.Error.KEY_UNAVAILABLE)); - return true; + inMessage.setDecryptedAttachment(outPath.toFile().getName()); + LOGGER.info("success, decrypted file: "+outPath); } /** Decrypt, verify and write input stream data to output stream. */ @@ -354,8 +348,8 @@ private static DecryptionResult verifySignature(DecryptionResult result, * * The decrypted content of a message is in CPIM format. */ - private MessageContent parseCPIMOrNull(String cpim, - String myUid, Optional senderKeyUID) { + private static MessageContent parseCPIMOrNull(DecryptMessage message, String cpim, + String myUID, Optional senderKeyUID) { CPIMMessage cpimMessage; try { @@ -377,15 +371,17 @@ private MessageContent parseCPIMOrNull(String cpim, // LOGGER.warning("MIME type mismatch"); //} - // check that the recipient matches the full uid of the personal key - if (!StringUtils.defaultString(cpimMessage.getTo()).contains(myUid)) { - LOGGER.warning("destination does not match personal key"); + // check that the recipient matches the full UID of the personal key + + if (!Arrays.asList(cpimMessage.getTo()).stream() + .anyMatch(s -> s.contains(myUID))) { + LOGGER.warning("receiver list does not include own UID"); errors.add(Coder.Error.INVALID_RECIPIENT); } - // check that the sender matches the full uid of the sender's key + // check that the sender matches the full UID of the sender's key if (senderKeyUID.isPresent() && !senderKeyUID.get().equals(cpimMessage.getFrom())) { - LOGGER.warning("sender doesn't match sender's key"); + LOGGER.warning("sender does not match UID in public key of sender"); errors.add(Coder.Error.INVALID_SENDER); } @@ -405,7 +401,7 @@ private MessageContent parseCPIMOrNull(String cpim, return null; } LOGGER.config("decrypted XML: "+m.toXML()); - decryptedContent = KonMessageListener.parseMessageContent(m); + decryptedContent = ClientUtils.parseMessageContent(m); } else { // text/plain MIME type for simple text messages decryptedContent = MessageContent.plainText(content); diff --git a/src/main/java/org/kontalk/crypto/Encryptor.java b/src/main/java/org/kontalk/crypto/Encryptor.java index b3cc2a81..7b58ce6d 100644 --- a/src/main/java/org/kontalk/crypto/Encryptor.java +++ b/src/main/java/org/kontalk/crypto/Encryptor.java @@ -1,6 +1,6 @@ /* * Kontalk Java client - * Copyright (C) 2014 Kontalk Devteam + * Copyright (C) 2016 Kontalk Devteam * * 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 @@ -28,13 +28,14 @@ import java.io.OutputStream; import java.io.UnsupportedEncodingException; import java.security.SecureRandom; -import java.util.ArrayList; import java.util.Date; import java.util.EnumSet; import java.util.List; +import java.util.Objects; import java.util.Optional; import java.util.logging.Level; import java.util.logging.Logger; +import java.util.stream.Collectors; import org.bouncycastle.bcpg.HashAlgorithmTags; import org.bouncycastle.openpgp.PGPCompressedData; import org.bouncycastle.openpgp.PGPCompressedDataGenerator; @@ -50,9 +51,7 @@ import org.bouncycastle.openpgp.operator.bc.BcPGPDataEncryptorBuilder; import org.bouncycastle.openpgp.operator.bc.BcPublicKeyKeyEncryptionMethodGenerator; import org.kontalk.model.Contact; -import org.kontalk.model.MessageContent; -import org.kontalk.model.OutMessage; -import org.kontalk.model.Transmission; +import org.kontalk.model.message.OutMessage; import org.kontalk.util.CPIMMessage; /** @@ -65,11 +64,11 @@ final class Encryptor { // should always be a power of 2 private static final int BUFFER_SIZE = 1 << 8; + private final PersonalKey myKey; private final OutMessage message; - private PersonalKey myKey = null; - private PGPUtils.PGPCoderKey[] receiverKeys = null; - Encryptor(OutMessage message) { + Encryptor(PersonalKey myKey, OutMessage message) { + this.myKey = myKey; this.message = message; } @@ -88,17 +87,17 @@ private Optional encryptData(String data, String mime) { return Optional.empty(); } - boolean loaded = this.loadKeys(); - if (!loaded) + List receiverKeys = this.loadKeysOrNull(); + if (receiverKeys == null) return Optional.empty(); // secure the message against replay attacks using Message/CPIM String from = myKey.getUserId(); - StringBuilder to = new StringBuilder(); - for (PGPUtils.PGPCoderKey k : receiverKeys) - to.append(k.userID).append("; "); + String[] tos = receiverKeys.stream() + .map(key -> key.userID) + .toArray(String[]::new); - CPIMMessage cpim = new CPIMMessage(from, to.toString(), new Date(), mime, data); + CPIMMessage cpim = new CPIMMessage(from, tos, new Date(), mime, data); byte[] plainText; try { plainText = cpim.toByteArray(); @@ -121,16 +120,9 @@ private Optional encryptData(String data, String mime) { return Optional.of(out.toByteArray()); } - Optional encryptAttachment() { - Optional optAttachment = message.getContent().getAttachment(); - if (!optAttachment.isPresent()) { - LOGGER.warning("no attachment in out-message"); - return Optional.empty(); - } - MessageContent.Attachment attachment = optAttachment.get(); - - boolean loaded = this.loadKeys(); - if (!loaded) + Optional encryptAttachment(File file) { + List receiverKeys = this.loadKeysOrNull(); + if (receiverKeys == null) return Optional.empty(); File tempFile; @@ -141,7 +133,7 @@ Optional encryptAttachment() { return Optional.empty(); } - try (FileInputStream in = new FileInputStream(attachment.getFile().toFile()); + try (FileInputStream in = new FileInputStream(file); FileOutputStream out = new FileOutputStream(tempFile)) { encryptAndSign(in, out, myKey, receiverKeys); } catch (IOException | PGPException ex) { @@ -153,32 +145,18 @@ Optional encryptAttachment() { return Optional.of(tempFile); } - private boolean loadKeys() { - myKey = Coder.myKeyOrNull(); - if (myKey == null) { - message.setSecurityErrors(EnumSet.of(Coder.Error.MY_KEY_UNAVAILABLE)); - return false; - } - List contacts = new ArrayList<>(message.getTransmissions().length); - for (Transmission t : message.getTransmissions()) - contacts.add(t.getContact()); - receiverKeys = receiverKeysOrNull(contacts.toArray(new Contact[0])); - if (receiverKeys == null) { + private List loadKeysOrNull() { + List contacts = message.getTransmissions().stream() + .map(t -> t.getContact()) + .collect(Collectors.toList()); + List receiverKeys = contacts.stream() + .map(c -> Coder.contactkey(c).orElse(null)) + .collect(Collectors.toList()); + if (receiverKeys.stream().anyMatch(Objects::isNull)) { message.setSecurityErrors(EnumSet.of(Coder.Error.KEY_UNAVAILABLE)); - return false; - } - return true; - } - - private static PGPUtils.PGPCoderKey[] receiverKeysOrNull(Contact[] contacts) { - List keys = new ArrayList<>(contacts.length); - for (Contact c : contacts) { - Optional optKey = Coder.contactkey(c); - if (!optKey.isPresent()) - return null; - keys.add(optKey.get()); + return null; } - return keys.toArray(new PGPUtils.PGPCoderKey[0]); + return receiverKeys; } /** @@ -188,7 +166,7 @@ private static PGPUtils.PGPCoderKey[] receiverKeysOrNull(Contact[] contacts) { */ private static void encryptAndSign( InputStream plainInput, OutputStream encryptedOutput, - PersonalKey myKey, PGPUtils.PGPCoderKey[] receiverKeys) + PersonalKey myKey, List receiverKeys) throws IOException, PGPException { // setup data encryptor & generator @@ -198,8 +176,8 @@ private static void encryptAndSign( // add public key recipients PGPEncryptedDataGenerator encGen = new PGPEncryptedDataGenerator(encryptor); - for (PGPUtils.PGPCoderKey key : receiverKeys) - encGen.addMethod(new BcPublicKeyKeyEncryptionMethodGenerator(key.encryptKey)); + receiverKeys.stream().forEach(key -> + encGen.addMethod(new BcPublicKeyKeyEncryptionMethodGenerator(key.encryptKey))); OutputStream encryptedOut = encGen.open(encryptedOutput, new byte[BUFFER_SIZE]); diff --git a/src/main/java/org/kontalk/crypto/PGPUtils.java b/src/main/java/org/kontalk/crypto/PGPUtils.java index 54bd6914..761e50f3 100644 --- a/src/main/java/org/kontalk/crypto/PGPUtils.java +++ b/src/main/java/org/kontalk/crypto/PGPUtils.java @@ -1,6 +1,6 @@ /* * Kontalk Java client - * Copyright (C) 2014 Kontalk Devteam + * Copyright (C) 2016 Kontalk Devteam * 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 @@ -36,7 +36,6 @@ import java.util.regex.Pattern; import org.apache.commons.io.IOUtils; import org.apache.commons.lang.StringUtils; -import org.bouncycastle.bcpg.ArmoredInputStream; import org.bouncycastle.bcpg.HashAlgorithmTags; import org.bouncycastle.jce.provider.BouncyCastleProvider; import org.bouncycastle.openpgp.PGPCompressedData; @@ -81,8 +80,7 @@ public final class PGPUtils { /** Singleton for converting a PGP key to a JCA key. */ private static JcaPGPKeyConverter sKeyConverter; - private PGPUtils() { - } + private PGPUtils() {} /** * A contacts public keys for encryption and signing together with UID and @@ -110,8 +108,8 @@ public static void registerProvider() { Security.insertProviderAt(new BouncyCastleProvider(), 1); } - public static byte[] disarm(byte[] key) throws IOException { - return IOUtils.toByteArray(new ArmoredInputStream(new ByteArrayInputStream(key))); + public static byte[] mayDisarm(InputStream input) throws IOException { + return IOUtils.toByteArray(PGPUtil.getDecoderStream(input)); } /** @@ -218,6 +216,7 @@ static PGPKeyPair decrypt(PGPSecretKey secretKey, PBESecretKeyDecryptor dec) thr try { return new PGPKeyPair(secretKey.getPublicKey(), secretKey.extractPrivateKey(dec)); } catch (PGPException ex) { + LOGGER.log(Level.WARNING, "failed", ex); throw new KonException(KonException.Error.LOAD_KEY_DECRYPT, ex); } } diff --git a/src/main/java/org/kontalk/crypto/PersonalKey.java b/src/main/java/org/kontalk/crypto/PersonalKey.java index 80aa5ba2..51bb87b4 100644 --- a/src/main/java/org/kontalk/crypto/PersonalKey.java +++ b/src/main/java/org/kontalk/crypto/PersonalKey.java @@ -1,6 +1,6 @@ /* * Kontalk Java client - * Copyright (C) 2014 Kontalk Devteam + * Copyright (C) 2016 Kontalk Devteam * 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 @@ -34,10 +34,11 @@ import org.bouncycastle.openpgp.PGPKeyPair; import org.bouncycastle.openpgp.PGPPrivateKey; import org.bouncycastle.openpgp.PGPPublicKey; +import org.bouncycastle.openpgp.PGPPublicKeyRing; import org.bouncycastle.openpgp.PGPSecretKey; import org.bouncycastle.openpgp.PGPSecretKeyRing; +import org.bouncycastle.openpgp.bc.BcPGPPublicKeyRing; import org.bouncycastle.openpgp.operator.PBESecretKeyDecryptor; -import org.bouncycastle.openpgp.operator.jcajce.JcaPGPDigestCalculatorProviderBuilder; import org.bouncycastle.openpgp.operator.jcajce.JcePBESecretKeyDecryptorBuilder; import org.bouncycastle.operator.OperatorCreationException; import org.bouncycastle.util.encoders.Hex; @@ -59,16 +60,20 @@ public final class PersonalKey { private final PGPKeyPair mEncryptKey; /** X.509 bridge certificate. */ private final X509Certificate mBridgeCert; + /** Primary user ID. */ + private final String mUID; private PersonalKey(PGPKeyPair authKP, PGPKeyPair signKP, PGPKeyPair encryptKP, - X509Certificate bridgeCert) throws PGPException { + X509Certificate bridgeCert, + String uid) throws PGPException { mAuthKey = authKP.getPublicKey(); mLoginKey = PGPUtils.convertPrivateKey(authKP.getPrivateKey()); mSignKey = signKP; mEncryptKey = encryptKP; mBridgeCert = bridgeCert; + mUID = uid; } PGPPrivateKey getPrivateEncryptionKey() { @@ -97,14 +102,11 @@ public PrivateKey getServerLoginKey() { /** Returns the first user ID in the key. */ public String getUserId() { - Iterator uidIt = mAuthKey.getUserIDs(); - if (!uidIt.hasNext()) - throw new IllegalStateException("no UID in personal key"); - return (String) uidIt.next(); + return mUID; } public String getFingerprint() { - return Hex.toHexString(mAuthKey.getFingerprint()).toUpperCase(); + return Hex.toHexString(mAuthKey.getFingerprint()); } /** Creates a {@link PersonalKey} from private keyring data. @@ -148,42 +150,53 @@ public static PersonalKey load(byte[] privateKeyData, signKey = authKey; } - if (authKey == null || signKey == null || encrKey == null) + if (authKey == null || signKey == null || encrKey == null) { + LOGGER.warning("something could not be found, " + +"sign="+signKey+ ", auth="+authKey+", encr="+encrKey); throw new KonException(KonException.Error.LOAD_KEY, new PGPException("could not find all keys in key data")); + } // decrypt private keys - PBESecretKeyDecryptor decryptor = new JcePBESecretKeyDecryptorBuilder( - // TODO need this? - new JcaPGPDigestCalculatorProviderBuilder().build() - ) + PBESecretKeyDecryptor decryptor = new JcePBESecretKeyDecryptorBuilder() .setProvider(PGPUtils.PROVIDER) .build(passphrase); PGPKeyPair authKeyPair = PGPUtils.decrypt(authKey, decryptor); PGPKeyPair signKeyPair = PGPUtils.decrypt(signKey, decryptor); PGPKeyPair encryptKeyPair = PGPUtils.decrypt(encrKey, decryptor); + // user ID + Iterator uidIt = authKey.getUserIDs(); + if (!uidIt.hasNext()) + throw new KonException(KonException.Error.LOAD_KEY, + new PGPException("no UID in key")); + String uid = (String) uidIt.next(); + // X.509 bridge certificate - X509Certificate bridgeCert = bridgeCertData == null ? - createX509Certificate(authKeyPair, signKeyPair, encryptKeyPair) : - PGPUtils.loadX509Cert(bridgeCertData); + X509Certificate bridgeCert; + if (bridgeCertData != null) { + bridgeCert = PGPUtils.loadX509Cert(bridgeCertData); + } else { + // public key ring + ByteArrayOutputStream out = new ByteArrayOutputStream(); + authKeyPair.getPublicKey().encode(out); + signKeyPair.getPublicKey().encode(out); + encryptKeyPair.getPublicKey().encode(out); + byte[] publicKeyRingData = out.toByteArray(); + PGPPublicKeyRing pubKeyRing = new BcPGPPublicKeyRing(publicKeyRingData); + + // re-create cert + bridgeCert = createX509Certificate(authKeyPair, pubKeyRing); + } - return new PersonalKey(authKeyPair, signKeyPair, encryptKeyPair, bridgeCert); + return new PersonalKey(authKeyPair, signKeyPair, encryptKeyPair, bridgeCert, uid); } - private static X509Certificate createX509Certificate( - PGPKeyPair authKP, - PGPKeyPair signKP, - PGPKeyPair encryptKP) throws IOException, KonException { - - ByteArrayOutputStream out = new ByteArrayOutputStream(); - authKP.getPublicKey().encode(out); - signKP.getPublicKey().encode(out); - encryptKP.getPublicKey().encode(out); - byte[] publicKeyRingData = out.toByteArray(); - + private static X509Certificate createX509Certificate(PGPKeyPair keyPair, + PGPPublicKeyRing keyRing) + throws KonException { try { - return X509Bridge.createCertificate(authKP, publicKeyRingData); + return X509Bridge.createCertificate(keyPair, keyRing.getEncoded()); } catch (InvalidKeyException | IllegalStateException | NoSuchAlgorithmException | SignatureException | CertificateException | NoSuchProviderException | PGPException | IOException | OperatorCreationException ex) { diff --git a/src/main/java/org/kontalk/crypto/X509Bridge.java b/src/main/java/org/kontalk/crypto/X509Bridge.java index 59eacde0..972bbdfa 100644 --- a/src/main/java/org/kontalk/crypto/X509Bridge.java +++ b/src/main/java/org/kontalk/crypto/X509Bridge.java @@ -1,6 +1,6 @@ /* * Kontalk Java client - * Copyright (C) 2014 Kontalk Devteam + * Copyright (C) 2016 Kontalk Devteam * 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 @@ -33,11 +33,15 @@ import java.security.cert.X509Certificate; import java.util.Date; import java.util.Iterator; +import java.util.LinkedList; +import java.util.List; import org.bouncycastle.asn1.ASN1Encodable; import org.bouncycastle.asn1.ASN1Object; import org.bouncycastle.asn1.ASN1ObjectIdentifier; import org.bouncycastle.asn1.ASN1Primitive; import org.bouncycastle.asn1.DERBitString; +import org.bouncycastle.asn1.DERUTF8String; +import org.bouncycastle.asn1.DLSequence; import org.bouncycastle.asn1.misc.MiscObjectIdentifiers; import org.bouncycastle.asn1.misc.NetscapeCertType; @@ -108,9 +112,14 @@ public static X509Certificate createCertificate(PGPKeyPair keyPair, byte[] publi PGPPublicKey publicKey = keyPair.getPublicKey(); + List xmppAddrs = new LinkedList<>(); for (@SuppressWarnings("unchecked") Iterator it = publicKey.getUserIDs(); it.hasNext();) { - Object attrib = it.next(); - x500NameBuilder.addRDN(BCStyle.CN, attrib.toString()); + String attrib = it.next().toString(); + x500NameBuilder.addRDN(BCStyle.CN, attrib); + // extract email for the subjectAltName + String email = PGPUtils.parseUID(attrib)[2]; + if (!email.isEmpty()) + xmppAddrs.add(email); } X500Name x509name = x500NameBuilder.build(); @@ -135,7 +144,7 @@ public static X509Certificate createCertificate(PGPKeyPair keyPair, byte[] publi PGPUtils.convertPrivateKey(keyPair.getPrivateKey()), x509name, creationTime, validTo, - null, + xmppAddrs, publicKeyRingData); } @@ -165,7 +174,7 @@ public static X509Certificate createCertificate(PGPKeyPair keyPair, byte[] publi */ private static X509Certificate createCertificate(PublicKey pubKey, PrivateKey privKey, X500Name subject, - Date startDate, Date endDate, String subjectAltName, byte[] publicKeyData) + Date startDate, Date endDate, List subjectAltNames, byte[] publicKeyData) throws InvalidKeyException, IllegalStateException, NoSuchAlgorithmException, SignatureException, CertificateException, NoSuchProviderException, IOException, OperatorCreationException { @@ -269,11 +278,14 @@ false, new NetscapeCertType( /* * Adds the subject alternative-name extension. */ - if (subjectAltName != null) { - GeneralNames subjectAltNames = new GeneralNames(new GeneralName( - GeneralName.otherName, subjectAltName)); + if (subjectAltNames != null && subjectAltNames.size() > 0) { + GeneralName[] names = new GeneralName[subjectAltNames.size()]; + for (int i = 0; i < names.length; i++) + names[i] = new GeneralName(GeneralName.otherName, + new XmppAddrIdentifier(subjectAltNames.get(i))); + certBuilder.addExtension(Extension.subjectAlternativeName, - false, subjectAltNames); + false, new GeneralNames(names)); } /* @@ -327,7 +339,16 @@ public DERBitString getPublicKeyData() public ASN1Primitive toASN1Primitive() { return keyData; } + } - } + private static class XmppAddrIdentifier extends DLSequence { + static final ASN1ObjectIdentifier OID = new ASN1ObjectIdentifier("1.3.6.1.5.5.7.8.5"); + XmppAddrIdentifier(String jid) { + super(new ASN1Encodable[] { + OID, + new DERUTF8String(jid) + }); + } + } } diff --git a/src/main/java/org/kontalk/misc/Callback.java b/src/main/java/org/kontalk/misc/Callback.java new file mode 100644 index 00000000..b39fb932 --- /dev/null +++ b/src/main/java/org/kontalk/misc/Callback.java @@ -0,0 +1,76 @@ +/* + * Kontalk Java client + * Copyright (C) 2016 Kontalk Devteam + * + * 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 . + */ + +package org.kontalk.misc; + +import java.util.Optional; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * + * @author Alexander Bikadorov {@literal } + */ +public final class Callback { + private static final Logger LOGGER = Logger.getLogger(Callback.class.getName()); + + public final V value; + public final Optional exception; + + public Callback() { + this.value = null; + this.exception = Optional.empty(); + } + + public Callback(V response) { + this.value = response; + this.exception = Optional.empty(); + } + + public Callback(Exception ex) { + this.value = null; + this.exception = Optional.of(ex); + } + + public interface Handler { + void handle(Callback callback); + } + + public static class Synchronizer { + private final CountDownLatch mLatch = new CountDownLatch(1); + + public void sync() { + mLatch.countDown(); + } + + public boolean waitForSync() { + boolean succ = false; + try { + succ = mLatch.await(5, TimeUnit.SECONDS); + } catch (InterruptedException ex) { + LOGGER.log(Level.WARNING, "interrupted", ex); + } + if (!succ) { + LOGGER.warning("await failed, timeout reached"); + } + return succ; + } + } +} diff --git a/src/main/java/org/kontalk/misc/JID.java b/src/main/java/org/kontalk/misc/JID.java index 17c850b8..3500724b 100644 --- a/src/main/java/org/kontalk/misc/JID.java +++ b/src/main/java/org/kontalk/misc/JID.java @@ -1,6 +1,6 @@ /* * Kontalk Java client - * Copyright (C) 2014 Kontalk Devteam + * Copyright (C) 2016 Kontalk Devteam * * 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 @@ -20,31 +20,48 @@ import java.util.Objects; import org.apache.commons.lang.StringUtils; +import org.jxmpp.jid.util.JidUtil; +import org.jxmpp.stringprep.simple.SimpleXmppStringprep; import org.jxmpp.util.XmppStringUtils; -import org.kontalk.system.Config; /** - * A Jabber ID (the address of an XMPP client or user). Immutable. + * A Jabber ID (the address of an XMPP entity). Immutable. + * + * NOTE: manual JID escaping (XEP-0106) is not supported here. Better mark JIDs + * e.g. with spaces in local part as invalid. * * @author Alexander Bikadorov {@literal } */ public final class JID { + static { + // good to know. For working JID validation + SimpleXmppStringprep.setup(); + } + private final String mLocal; private final String mDomain; private final String mResource; + private final boolean mValid; private JID(String local, String domain, String resource) { mLocal = local; mDomain = domain; mResource = resource; + + mValid = !mLocal.isEmpty() && !mDomain.isEmpty() + // NOTE: domain check could be stronger - compliant with RFC 6122, but + // server does not accept most special characters + // NOTE: resource not checked + && JidUtil.isValidBareJid( + XmppStringUtils.completeJidFrom(mLocal, mDomain)); } - public String local(){ + public String local() { return mLocal; } - public String domain(){ + public String domain() { return mDomain; } @@ -53,9 +70,7 @@ public String string() { } public boolean isValid() { - // TODO stronger check here. - //org.jxmpp.jid.util.JidUtil.validateBareJid(mBareJID); - return !mLocal.isEmpty() && !mDomain.isEmpty(); + return mValid; } public boolean isHash() { @@ -66,14 +81,13 @@ public boolean isFull() { return !mResource.isEmpty(); } - public boolean isMe() { - return this.isValid() && - this.equals(JID.me()); + public JID toBare() { + return new JID(mLocal, mDomain, ""); } /** * Comparing only bare JIDs. - * Case-insensitive (local and domain part, resource is case-sensitive). + * Case-insensitive. */ @Override public boolean equals(Object o) { @@ -122,9 +136,4 @@ public static JID bare(String local, String domain) { public static JID deleted(int id) { return new JID("", Integer.toString(id), ""); } - - public static JID me() { - return JID.bare(Config.getInstance().getString(Config.ACC_JID)); - } - } diff --git a/src/main/java/org/kontalk/misc/KonException.java b/src/main/java/org/kontalk/misc/KonException.java index 0c42429d..acfb11d7 100644 --- a/src/main/java/org/kontalk/misc/KonException.java +++ b/src/main/java/org/kontalk/misc/KonException.java @@ -1,6 +1,6 @@ /* * Kontalk Java client - * Copyright (C) 2014 Kontalk Devteam + * Copyright (C) 2016 Kontalk Devteam * * 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 diff --git a/src/main/java/org/kontalk/misc/ViewEvent.java b/src/main/java/org/kontalk/misc/ViewEvent.java index 27f39004..1ee0ed68 100644 --- a/src/main/java/org/kontalk/misc/ViewEvent.java +++ b/src/main/java/org/kontalk/misc/ViewEvent.java @@ -1,6 +1,6 @@ /* * Kontalk Java client - * Copyright (C) 2014 Kontalk Devteam + * Copyright (C) 2016 Kontalk Devteam * * 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 @@ -18,10 +18,13 @@ package org.kontalk.misc; +import java.util.EnumSet; +import org.kontalk.client.FeatureDiscovery; import org.kontalk.crypto.PGPUtils.PGPCoderKey; -import org.kontalk.model.InMessage; -import org.kontalk.model.KonMessage; +import org.kontalk.model.message.InMessage; +import org.kontalk.model.message.KonMessage; import org.kontalk.model.Contact; +import org.kontalk.system.Control; import org.kontalk.system.RosterHandler; /** @@ -33,7 +36,14 @@ public class ViewEvent { private ViewEvent() {} /** Application status changed. */ - public static class StatusChanged extends ViewEvent { + public static class StatusChange extends ViewEvent { + public final Control.Status status; + public final EnumSet features; + + public StatusChange(Control.Status status, EnumSet features) { + this.status = status; + this.features = features; + } } /** Key is password protected (ask for password). */ @@ -106,4 +116,13 @@ public PresenceError(Contact contact, RosterHandler.Error error) { this.error = error; } } + + /** A contact wants presence subscription (ask whattodo). */ + public static class SubscriptionRequest extends ViewEvent { + public final Contact contact; + + public SubscriptionRequest(Contact contact) { + this.contact = contact; + } + } } diff --git a/src/main/java/org/kontalk/system/AccountLoader.java b/src/main/java/org/kontalk/model/Account.java similarity index 65% rename from src/main/java/org/kontalk/system/AccountLoader.java rename to src/main/java/org/kontalk/model/Account.java index 525977c5..0b84db5d 100644 --- a/src/main/java/org/kontalk/system/AccountLoader.java +++ b/src/main/java/org/kontalk/model/Account.java @@ -1,6 +1,6 @@ /* * Kontalk Java client - * Copyright (C) 2014 Kontalk Devteam + * Copyright (C) 2016 Kontalk Devteam * 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 @@ -16,13 +16,14 @@ * along with this program. If not, see . */ -package org.kontalk.system; +package org.kontalk.model; import java.io.File; +import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.IOException; +import java.io.InputStream; import java.io.OutputStream; -import java.nio.file.Files; import java.nio.file.Path; import java.security.NoSuchProviderException; import java.security.cert.CertificateEncodingException; @@ -30,31 +31,33 @@ import java.util.Optional; import java.util.logging.Level; import java.util.logging.Logger; -import java.util.zip.ZipEntry; -import java.util.zip.ZipFile; import org.apache.commons.io.IOUtils; import org.bouncycastle.bcpg.ArmoredOutputStream; import org.bouncycastle.openpgp.PGPException; -import org.jivesoftware.smack.util.StringUtils; import org.kontalk.misc.KonException; import org.kontalk.crypto.PGPUtils; import org.kontalk.crypto.PersonalKey; import org.kontalk.crypto.X509Bridge; +import org.kontalk.persistence.Config; +import org.kontalk.util.EncodingUtils; -public final class AccountLoader { - private static final Logger LOGGER = Logger.getLogger(AccountLoader.class.getName()); +/** + * The user account. + * + * @author Alexander Bikadorov {@literal } + */ +public final class Account { + private static final Logger LOGGER = Logger.getLogger(Account.class.getName()); private static final String PRIVATE_KEY_FILENAME = "kontalk-private.asc"; private static final String BRIDGE_CERT_FILENAME = "kontalk-login.crt"; - private static AccountLoader INSTANCE = null; - private final Path mKeyDir; private final Config mConf; private PersonalKey mKey = null; - private AccountLoader(Path keyDir, Config config) { + Account(Path keyDir, Config config) { mKeyDir = keyDir; mConf = config; } @@ -63,10 +66,10 @@ public Optional getPersonalKey() { return Optional.ofNullable(mKey); } - PersonalKey load(char[] password) throws KonException { + public PersonalKey load(char[] password) throws KonException { // read key files - byte[] privateKeyData = this.readArmoredFile(PRIVATE_KEY_FILENAME); - byte[] bridgeCertData = this.readFile(BRIDGE_CERT_FILENAME); + byte[] privateKeyData = this.readFile(PRIVATE_KEY_FILENAME, true); + byte[] bridgeCertData = this.readFile(BRIDGE_CERT_FILENAME, false); // load key try { @@ -80,24 +83,11 @@ PersonalKey load(char[] password) throws KonException { return mKey; } - public void importAccount(String zipFilePath, char[] password) throws KonException { - byte[] privateKeyData; - - // read key files - try (ZipFile zipFile = new ZipFile(zipFilePath)) { - privateKeyData = AccountLoader.readBytesFromZip(zipFile, PRIVATE_KEY_FILENAME); - } catch (IOException ex) { - LOGGER.log(Level.WARNING, "can't open zip archive: ", ex); - throw new KonException(KonException.Error.IMPORT_ARCHIVE, ex); - } - + public void setAccount(byte[] privateKeyData, char[] password) throws KonException { // try to load key PersonalKey key; - byte[] encodedPrivateKey; try { - encodedPrivateKey = PGPUtils.disarm(privateKeyData); - key = PersonalKey.load(encodedPrivateKey, - password); + key = PersonalKey.load(privateKeyData, password); } catch (PGPException | IOException | CertificateException | NoSuchProviderException ex) { LOGGER.log(Level.WARNING, "can't import personal key", ex); @@ -113,7 +103,7 @@ public void importAccount(String zipFilePath, char[] password) throws KonExcepti throw new KonException(KonException.Error.IMPORT_KEY, ex); } this.writeBytesToFile(bridgeCertData, BRIDGE_CERT_FILENAME, false); - this.writePrivateKey(encodedPrivateKey, password, new char[0]); + this.writePrivateKey(privateKeyData, password, new char[0]); // success! use the new key mKey = key; @@ -122,10 +112,16 @@ public void importAccount(String zipFilePath, char[] password) throws KonExcepti // overwritten when connecting to server String address = PGPUtils.parseUID(key.getUserId())[2]; Config.getInstance().setProperty(Config.ACC_JID, address); + + LOGGER.info("new account, temporary JID: "+address); + } + + public char[] getPassword() { + return Config.getInstance().getString(Config.ACC_PASS).toCharArray(); } public void setPassword(char[] oldPassword, char[] newPassword) throws KonException { - byte[] privateKeyData = this.readArmoredFile(PRIVATE_KEY_FILENAME); + byte[] privateKeyData = this.readFile(PRIVATE_KEY_FILENAME, true); this.writePrivateKey(privateKeyData, oldPassword, newPassword); } @@ -140,7 +136,7 @@ private void writePrivateKey(byte[] privateKeyData, // new password boolean unset = newPassword.length == 0; if (unset) - newPassword = StringUtils.randomString(40).toCharArray(); + newPassword = EncodingUtils.randomString(40).toCharArray(); // write new try { @@ -157,7 +153,7 @@ private void writePrivateKey(byte[] privateKeyData, mConf.setProperty(Config.ACC_PASS, savedPass); } - boolean accountIsPresent() { + public boolean isPresent() { return this.fileExists(PRIVATE_KEY_FILENAME) && this.fileExists(BRIDGE_CERT_FILENAME); } @@ -167,21 +163,12 @@ public boolean isPasswordProtected() { return mConf.getString(Config.ACC_PASS).isEmpty(); } - private byte[] readArmoredFile(String filename) throws KonException { - try { - return PGPUtils.disarm(this.readFile(filename)); - } catch (IOException ex) { - LOGGER.warning("can't read armored key file: "+ex.getLocalizedMessage()); - throw new KonException(KonException.Error.READ_FILE, ex); - } - } - - private byte[] readFile(String filename) throws KonException { + private byte[] readFile(String filename, boolean disarm) throws KonException { byte[] bytes = null; - try { - bytes = Files.readAllBytes(new File(mKeyDir.toString(), filename).toPath()); + try (InputStream input = new FileInputStream(new File(mKeyDir.toString(), filename))) { + bytes = disarm ? PGPUtils.mayDisarm(input) : IOUtils.toByteArray(input); } catch (IOException ex) { - LOGGER.warning("can't read key file: "+ex.getLocalizedMessage()); + LOGGER.log(Level.WARNING, "can't read key file", ex); throw new KonException(KonException.Error.READ_FILE, ex); } return bytes; @@ -203,30 +190,4 @@ private void writeBytesToFile(byte[] bytes, String filename, boolean armored) th private boolean fileExists(String filename) { return new File(mKeyDir.toString(), filename).isFile(); } - - private static byte[] readBytesFromZip(ZipFile zipFile, String filename) throws KonException { - ZipEntry zipEntry = zipFile.getEntry(filename); - byte[] bytes = null; - try { - bytes = IOUtils.toByteArray(zipFile.getInputStream(zipEntry)); - } catch (IOException ex) { - LOGGER.warning("can't read key file from archive: "+ex.getLocalizedMessage()); - throw new KonException(KonException.Error.IMPORT_READ_FILE, ex); - } - return bytes; - } - - public synchronized static void initialize(Path keyDir) { - if (INSTANCE != null) { - LOGGER.warning("account loader already initialized"); - return; - } - INSTANCE = new AccountLoader(keyDir, Config.getInstance()); - } - - public synchronized static AccountLoader getInstance() { - if (INSTANCE == null) - throw new IllegalStateException("account loader not initialized"); - return INSTANCE; - } } diff --git a/src/main/java/org/kontalk/model/Avatar.java b/src/main/java/org/kontalk/model/Avatar.java new file mode 100644 index 00000000..47a4a581 --- /dev/null +++ b/src/main/java/org/kontalk/model/Avatar.java @@ -0,0 +1,188 @@ +/* + * Kontalk Java client + * Copyright (C) 2016 Kontalk Devteam + * + * 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 . + */ + +package org.kontalk.model; + +import java.awt.image.BufferedImage; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.nio.file.Path; +import java.util.Objects; +import java.util.Optional; +import java.util.logging.Level; +import java.util.logging.Logger; +import javax.imageio.ImageIO; +import org.apache.commons.codec.digest.DigestUtils; +import org.kontalk.util.MediaUtils; + + +/** + * Avatar image. Immutable. + * + * @author Alexander Bikadorov {@literal } + */ +public class Avatar { + private static final Logger LOGGER = Logger.getLogger(Avatar.class.getName()); + + private static final String DIR = "avatars"; + protected static final String FORMAT = "png"; + + /** SHA1 hash of image data. */ + private final String mID; + protected final File mFile; + + protected BufferedImage mImage = null; + + /** Saved contact avatar. Used when loading from database. */ + Avatar(String id) { + this(id, null, null); + } + + /** New contact avatar. */ + public Avatar(String id, BufferedImage image) { + this(id, null, image); + } + + private Avatar(String id, File file, BufferedImage image) { + mID = id; + mFile = file != null ? + file : + Model.appDir().resolve(DIR).resolve(id + "." + FORMAT).toFile(); + mImage = image; + + if (mImage != null) { + // save new image + boolean succ = MediaUtils.writeImage(image, FORMAT, file); + if (!succ) + LOGGER.warning("can't save avatar image: "+id); + } + } + + private Avatar(File file) { + mFile = file; + mImage = file.isFile() ? image(mFile) : null; + mID = mImage != null ? id(mImage) : ""; + } + + private static BufferedImage image(File file) { + return MediaUtils.readImage(file).orElse(null); + } + + public String getID() { + return mID; + } + + public Optional loadImage() { + if (mImage == null) + mImage = image(this.mFile); + + return Optional.ofNullable(mImage); + } + + void delete() { + boolean succ = this.mFile.delete(); + if (succ) + LOGGER.warning("could not delete avatar file: "+this.mID); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + + if (!(o instanceof Avatar)) return false; + + Avatar oAvatar = (Avatar) o; + return mID.equals(oAvatar.mID); + } + + @Override + public int hashCode() { + int hash = 7; + hash = 59 * hash + Objects.hashCode(this.mID); + return hash; + } + + public static class UserAvatar extends Avatar { + + private static final int MAX_SIZE = 150; + private static final String USER_FILENAME = "avatar"; + + private byte[] mImageData = null; + + /** Saved user Avatar. */ + UserAvatar(Path appDir) { + super(userFile(appDir)); + } + + /** New user Avatar. ID generated from image. */ + private UserAvatar(BufferedImage image, Path appDir) { + super(id(image), userFile(appDir), image); + } + + static UserAvatar create(BufferedImage image) { + image = MediaUtils.scale(image, MAX_SIZE, MAX_SIZE); + return new UserAvatar(image, Model.appDir()); + } + + @Override + public Optional loadImage() { + return mFile.isFile() ? + Optional.ofNullable(image(mFile)) : + Optional.empty(); + } + + public Optional imageData() { + if (mImageData == null) + mImageData = Avatar.imageData(mImage); + + return Optional.ofNullable(mImageData); + } + + private static File userFile(Path appDir) { + return appDir.resolve(USER_FILENAME + "." + FORMAT).toFile(); + } + } + + static void createStorageDir(Path appDir) { + boolean created = appDir.resolve(DIR).toFile().mkdir(); + if (created) + LOGGER.info("created avatar directory"); + } + + static Avatar deleted() { + return new Avatar(""); + } + + private static String id(BufferedImage image) { + byte[] imageData = imageData(image); + return imageData != null ? DigestUtils.sha1Hex(imageData) : ""; + } + + private static byte[] imageData(BufferedImage image) { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + try { + ImageIO.write(image, FORMAT, out); + } catch (IOException ex) { + LOGGER.log(Level.WARNING, "can't convert avatar", ex); + return null; + } + return out.toByteArray(); + } +} + diff --git a/src/main/java/org/kontalk/model/Contact.java b/src/main/java/org/kontalk/model/Contact.java index 2a32aa7f..7480ee17 100644 --- a/src/main/java/org/kontalk/model/Contact.java +++ b/src/main/java/org/kontalk/model/Contact.java @@ -1,6 +1,6 @@ /* * Kontalk Java client - * Copyright (C) 2014 Kontalk Devteam + * Copyright (C) 2016 Kontalk Devteam * * 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 @@ -20,10 +20,10 @@ import java.sql.ResultSet; import java.sql.SQLException; +import java.util.Arrays; import org.kontalk.misc.JID; import java.util.Date; import java.util.HashMap; -import java.util.LinkedList; import java.util.List; import java.util.Map; import java.util.Observable; @@ -31,13 +31,19 @@ import java.util.logging.Level; import java.util.logging.Logger; import org.jivesoftware.smack.packet.Presence; -import org.kontalk.system.Database; +import org.kontalk.persistence.Database; import org.kontalk.util.EncodingUtils; import org.kontalk.util.XMPPUtils; /** * A contact in the Kontalk/XMPP-Jabber network. * + * TODO group chats need some weaker entity here: not deletable, + * not shown in ui contact list(?), but with public key + * + * idea: "deletable" or / "weak" field: contact gets deleted + * when no group chat exists anymore + * * @author Alexander Bikadorov {@literal } */ public final class Contact extends Observable { @@ -64,6 +70,7 @@ public static enum Subscription { public static final String COL_ENCR = "encrypted"; public static final String COL_PUB_KEY = "public_key"; public static final String COL_KEY_FP = "key_fingerprint"; + public static final String COL_AVATAR_ID = "avatar_id"; public static final String SCHEMA = "(" + Database.SQL_ID + COL_JID + " TEXT NOT NULL UNIQUE, " + @@ -73,14 +80,15 @@ public static enum Subscription { // boolean, send messages encrypted? COL_ENCR + " INTEGER NOT NULL, " + COL_PUB_KEY + " TEXT UNIQUE, " + - COL_KEY_FP + " TEXT UNIQUE" + + COL_KEY_FP + " TEXT UNIQUE," + + COL_AVATAR_ID + " TEXT" + ")"; private final int mID; private JID mJID; private String mName; private String mStatus = ""; - private Optional mLastSeen = Optional.empty(); + private Date mLastSeen = null; private Online mAvailable = Online.UNKNOWN; private boolean mEncrypted = true; private String mKey = ""; @@ -88,42 +96,48 @@ public static enum Subscription { private boolean mBlocked = false; private Subscription mSubStatus = Subscription.UNKNOWN; //private ItemType mType; + private Avatar mAvatar = null; - // used for creating new contacts (eg from roster) + // new contact (eg from roster) Contact(JID jid, String name) { mJID = jid; mName = name; - Database db = Database.getInstance(); - List values = new LinkedList<>(); - values.add(mJID); - values.add(mName); - values.add(mStatus); - values.add(mLastSeen); - values.add(mEncrypted); - values.add(null); // key - values.add(null); // fingerprint - mID = db.execInsert(TABLE, values); + + // insert + List values = Arrays.asList( + mJID, + mName, + mStatus, + mLastSeen, + mEncrypted, + null, // key + null, // fingerprint + null); // avatar id + mID = Model.database().execInsert(TABLE, values); if (mID < 1) LOGGER.log(Level.WARNING, "could not insert contact"); } - // used for loading contacts from database - Contact(int id, + // loading from database + public Contact( + int id, JID jid, String name, String status, Optional lastSeen, boolean encrypted, String publicKey, - String fingerprint) { + String fingerprint, + String avatarID) { mID = id; mJID = jid; mName = name; mStatus = status; - mLastSeen = lastSeen; + mLastSeen = lastSeen.orElse(null); mEncrypted = encrypted; mKey = publicKey; - mFingerprint = fingerprint; + mFingerprint = fingerprint.toLowerCase(); + mAvatar = avatarID.isEmpty() ? null : new Avatar(avatarID); } public JID getJID() { @@ -144,7 +158,7 @@ void setJID(JID jid) { this.changed(mJID); } - int getID() { + public int getID() { return mID; } @@ -167,7 +181,7 @@ public String getStatus() { } public Optional getLastSeen() { - return mLastSeen; + return Optional.ofNullable(mLastSeen); } public boolean getEncrypted() { @@ -189,7 +203,7 @@ public Online getOnline() { public void setOnline(Presence.Type type, String status) { if (type == Presence.Type.available) { mAvailable = Online.YES; - mLastSeen = Optional.of(new Date()); + mLastSeen = new Date(); } else if (type == Presence.Type.unavailable) { mAvailable = Online.NO; } @@ -230,7 +244,7 @@ public void setKey(byte[] rawKey, String fingerprint) { LOGGER.info("overwriting public key of contact: "+this); mKey = EncodingUtils.bytesToBase64(rawKey); - mFingerprint = fingerprint; + mFingerprint = fingerprint.toLowerCase(); this.save(); this.changed(new byte[0]); } @@ -248,7 +262,7 @@ public Subscription getSubScription() { return mSubStatus; } - public void setSubScriptionStatus(Subscription status) { + public void setSubscriptionStatus(Subscription status) { if (status == mSubStatus) return; @@ -256,8 +270,35 @@ public void setSubScriptionStatus(Subscription status) { this.changed(mSubStatus); } + public Optional getAvatar() { + return Optional.ofNullable(mAvatar); + } + + public void setAvatar(Avatar avatar) { + // delete old + if (mAvatar != null) + mAvatar.delete(); + + // set new + mAvatar = avatar; + this.save(); + + this.changed(avatar); + } + + public void deleteAvatar() { + // delete old + if (mAvatar != null) + mAvatar.delete(); + + mAvatar = null; + this.save(); + + this.changed(Avatar.deleted()); + } + public boolean isMe() { - return mJID.isMe(); + return mJID.isValid() && mJID.equals(Model.getUserJID()); } public boolean isKontalkUser(){ @@ -272,10 +313,13 @@ void setDeleted() { mJID = JID.deleted(mID); mName = ""; mStatus = ""; - mLastSeen = Optional.empty(); + mLastSeen = null; mEncrypted = false; mKey = ""; mFingerprint = ""; + if (mAvatar != null) + mAvatar.delete(); + mAvatar = null; this.save(); this.changed(null); @@ -286,7 +330,6 @@ public boolean isDeleted() { } private void save() { - Database db = Database.getInstance(); Map set = new HashMap<>(); set.put(COL_JID, mJID); set.put(COL_NAME, mName); @@ -295,7 +338,8 @@ private void save() { set.put(COL_ENCR, mEncrypted); set.put(COL_PUB_KEY, Database.setString(mKey)); set.put(COL_KEY_FP, Database.setString(mFingerprint)); - db.execUpdate(TABLE, set, mID); + set.put(COL_AVATAR_ID, Database.setString(mAvatar != null ? mAvatar.getID() : "")); + Model.database().execUpdate(TABLE, set, mID); } private void changed(Object arg) { @@ -303,25 +347,44 @@ private void changed(Object arg) { this.notifyObservers(arg); } + @Override + public boolean equals(Object o) { + if (o == this) + return true; + + if (!(o instanceof Contact)) + return false; + + return mID == ((Contact) o).mID; + } + + @Override + public int hashCode() { + int hash = 3; + hash = 29 * hash + this.mID; + return hash; + } + @Override public String toString() { return "C:id="+mID+",jid="+mJID+",name="+mName+",fp="+mFingerprint +",subsc="+mSubStatus; } - static Contact load(ResultSet rs) throws SQLException { + static Contact load(Database db, ResultSet rs) throws SQLException { int id = rs.getInt("_id"); JID jid = JID.bare(rs.getString(Contact.COL_JID)); String name = rs.getString(Contact.COL_NAME); String status = rs.getString(Contact.COL_STAT); long l = rs.getLong(Contact.COL_LAST_SEEN); - Optional lastSeen = l == 0 ? - Optional.empty() : - Optional.of(new Date(l)); + Date lastSeen = l == 0 ? null : new Date(l); boolean encr = rs.getBoolean(Contact.COL_ENCR); String key = Database.getString(rs, Contact.COL_PUB_KEY); String fp = Database.getString(rs, Contact.COL_KEY_FP); - return new Contact(id, jid, name, status, lastSeen, encr, key, fp); + String avatarID = Database.getString(rs, Contact.COL_AVATAR_ID); + + return new Contact(id, jid, name, status, + Optional.ofNullable(lastSeen), encr, key, fp, avatarID); } } diff --git a/src/main/java/org/kontalk/model/ContactList.java b/src/main/java/org/kontalk/model/ContactList.java index 33b82bf6..fa453446 100644 --- a/src/main/java/org/kontalk/model/ContactList.java +++ b/src/main/java/org/kontalk/model/ContactList.java @@ -1,6 +1,6 @@ /* * Kontalk Java client - * Copyright (C) 2014 Kontalk Devteam + * Copyright (C) 2016 Kontalk Devteam * * 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 @@ -17,12 +17,11 @@ */ package org.kontalk.model; -import org.kontalk.misc.JID; import java.sql.ResultSet; import java.sql.SQLException; +import org.kontalk.misc.JID; import java.util.Collections; import java.util.HashMap; -import java.util.HashSet; import java.util.Iterator; import java.util.Map; import java.util.Observable; @@ -30,12 +29,13 @@ import java.util.Set; import java.util.logging.Level; import java.util.logging.Logger; -import org.kontalk.system.Database; +import java.util.stream.Collectors; +import org.kontalk.persistence.Database; /** * Global list of all contacts. * - * Does not contain deleted user (only accessible by database ID). + * Does not contain deleted user. * * @author Alexander Bikadorov {@literal } */ @@ -43,22 +43,21 @@ public final class ContactList extends Observable implements Iterable { private static final Logger LOGGER = Logger.getLogger(ContactList.class.getName()); - private static final ContactList INSTANCE = new ContactList(); - /** JID to contact. Without deleted contacts. */ private final Map mJIDMap = Collections.synchronizedMap(new HashMap()); - /** Database ID to contact. With deleted contacts. */ - private final Map mIDMap = - Collections.synchronizedMap(new HashMap()); - private ContactList() {} + ContactList() {} + + Map load() { + assert mJIDMap.isEmpty(); - public void load() { - Database db = Database.getInstance(); + Map contactMap = new HashMap<>(); + + Database db = Model.database(); try (ResultSet resultSet = db.execSelectAll(Contact.TABLE)) { while (resultSet.next()) { - Contact contact = Contact.load(resultSet); + Contact contact = Contact.load(db, resultSet); JID jid = contact.getJID(); if (mJIDMap.containsKey(jid)) { @@ -68,18 +67,20 @@ public void load() { if (!contact.isDeleted()) mJIDMap.put(jid, contact); - mIDMap.put(contact.getID(), contact); + contactMap.put(contact.getID(), contact); } } catch (SQLException ex) { LOGGER.log(Level.WARNING, "can't load contacts from db", ex); } this.changed(null); + + return contactMap; } - /** - * Create and add a new contact. - */ + /** Create and add a new contact. */ public Optional create(JID jid, String name) { + jid = jid.toBare(); + if (!this.isValid(jid)) return Optional.empty(); @@ -88,20 +89,11 @@ public Optional create(JID jid, String name) { return Optional.empty(); mJIDMap.put(newContact.getJID(), newContact); - mIDMap.put(newContact.getID(), newContact); this.changed(newContact); return Optional.of(newContact); } - Optional get(int id) { - Optional optContact = Optional.ofNullable(mIDMap.get(id)); - if (!optContact.isPresent()) { - LOGGER.warning("can't find contact with ID: " + id); - } - return optContact; - } - /** * Get the contact for a JID (if the JID is in the list). * Resource is removed for lookup. @@ -111,20 +103,23 @@ public Optional get(JID jid) { } /** - * Get the contact that represents the user itself. It is created and added - * if not yet in the list. + * Get the contact that represents the user itself. */ public Optional getMe() { - JID myJID = JID.me(); - Optional optContact = this.get(myJID); - if (optContact.isPresent()) - return optContact; + JID myJID = Model.getUserJID(); + if (!myJID.isValid()) + return Optional.empty(); - return this.create(myJID, ""); + return this.get(myJID); } - public Set getAll() { - return new HashSet<>(mJIDMap.values()); + public Set getAll(boolean withMe) { + synchronized(mJIDMap) { + return Collections.unmodifiableSet( + mJIDMap.values().stream() + .filter(c -> (withMe || !c.isMe())) + .collect(Collectors.toSet())); + } } public void delete(Contact contact) { @@ -181,8 +176,4 @@ private void changed(Object arg) { public Iterator iterator() { return mJIDMap.values().iterator(); } - - public static ContactList getInstance() { - return INSTANCE; - } } diff --git a/src/main/java/org/kontalk/model/GroupChat.java b/src/main/java/org/kontalk/model/GroupChat.java deleted file mode 100644 index 6d5e753d..00000000 --- a/src/main/java/org/kontalk/model/GroupChat.java +++ /dev/null @@ -1,335 +0,0 @@ -/* - * Kontalk Java client - * Copyright (C) 2014 Kontalk Devteam - * - * 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 . - */ - -package org.kontalk.model; - -import java.util.HashMap; -import java.util.HashSet; -import java.util.Map; -import java.util.Objects; -import java.util.Optional; -import java.util.Set; -import java.util.function.Predicate; -import java.util.logging.Level; -import java.util.logging.Logger; -import org.jivesoftware.smackx.chatstates.ChatState; -import org.json.simple.JSONObject; -import org.json.simple.JSONValue; -import org.kontalk.misc.JID; -import org.kontalk.util.EncodingUtils; - -/** - * - * @author Alexander Bikadorov {@literal } - */ -public final class GroupChat extends Chat { - private static final Logger LOGGER = Logger.getLogger(GroupChat.class.getName()); - - private final HashMap mContactMap = new HashMap<>(); - private final GID mGID; - - private String mSubject; - // TODO overwrite encryption=OFF field - private boolean mForceEncryptionOff = false; - - GroupChat(Contact[] contacts, GID gid, String subject) { - super(contacts, "", subject, gid); - - mGID = gid; - mSubject = subject; - - for (Contact contact: contacts) - this.addContactSilent(contact); - } - - // used when loading from database - GroupChat(int id, - Set contacts, - GID gid, - String subject, - boolean read, - String jsonViewSettings - ) { - super(id, read, jsonViewSettings); - - mGID = gid; - mSubject = subject; - - for (Contact contact: contacts) - this.addContactSilent(contact); - } - - /** Get all contacts (including deleted and user contact). */ - @Override - public Set getAllContacts() { - return new HashSet<>(mContactMap.keySet()); - } - - @Override - public Contact[] getValidContacts() { - //chat.getContacts().stream().filter(c -> !c.isDeleted()); - Set contacts = new HashSet<>(); - for (Contact c : mContactMap.keySet()) { - if (!c.isDeleted() && !c.isMe()) { - contacts.add(c); - } - } - return contacts.toArray(new Contact[0]); - } - - private void addContact(Contact contact) { - this.addContactSilent(contact); - this.save(); - } - - private void addContactSilent(Contact contact) { - if (mContactMap.containsKey(contact)) { - LOGGER.warning("contact already in chat: "+contact); - return; - } - - contact.addObserver(this); - mContactMap.put(contact, new KonChatState(contact)); - } - - private void removeContactSilent(Contact contact) { - contact.deleteObserver(this); - if (!mContactMap.containsKey(contact)) { - LOGGER.warning("contact not in chat: "+contact); - return; - } - - mContactMap.remove(contact); - this.save(); - } - - public GID getGID() { - return mGID; - } - - @Override - public String getSubject() { - return mSubject; - } - - public void setSubject(String subject) { - if (subject.equals(mSubject)) - return; - - mSubject = subject; - this.save(); - this.changed(subject); - } - - @Override - public void setChatState(Contact contact, ChatState chatState) { - KonChatState state = mContactMap.get(contact); - if (state == null) { - LOGGER.warning("can't find contact in contact map!?"); - return; - } - state.setState(chatState); - this.changed(state); - } - - public void applyGroupCommand(MessageContent.GroupCommand command, Contact sender) { - switch(command.getOperation()) { - case CREATE: - assert mContactMap.size() == 1; - assert mContactMap.containsKey(sender); - - boolean meIn = false; - for (JID jid: command.getAdded()) { - Optional optContact = ContactList.getInstance().get(jid); - if (!optContact.isPresent()) { - LOGGER.warning("can't find contact, jid: "+jid); - continue; - } - Contact contact = optContact.get(); - if (mContactMap.keySet().contains(contact)) { - LOGGER.warning("contact already in chat: "+contact); - continue; - } - meIn |= contact.isMe(); - this.addContact(contact); - } - - if (!meIn) - LOGGER.warning("user JID not included"); - - mSubject = command.getSubject(); - this.save(); - this.changed(command); - break; - case LEAVE: - this.removeContactSilent(sender); - this.save(); - this.changed(command); - break; - case SET: - for (JID jid : command.getAdded()) { - Optional optC = ContactList.getInstance().get(jid); - if (optC.isPresent()) { - LOGGER.warning("can't get added contact, jid="+jid); - continue; - } - this.addContactSilent(optC.get()); - } - for (JID jid : command.getRemoved()) { - Optional optC = ContactList.getInstance().get(jid); - if (optC.isPresent()) { - LOGGER.warning("can't get removed contact, jid="+jid); - continue; - } - this.removeContactSilent(optC.get()); - } - mSubject = command.getSubject(); - this.save(); - this.changed(command); - break; - default: - LOGGER.warning("unhandled operation: "+command.getOperation()); - } - } - - @Override - public String getXMPPID() { - return ""; - } - - @Override - public boolean isSendEncrypted() { - boolean encrypted = false; - for (Contact c: this.getValidContacts()) { - encrypted |= c.getEncrypted(); - } - return encrypted; - } - - @Override - public boolean canSendEncrypted() { - Contact[] contacts = this.getValidContacts(); - boolean encrypted = contacts.length != 0; - for (Contact c: contacts) { - encrypted &= c.hasKey(); - } - return encrypted; - } - - @Override - public boolean isValid() { - return this.getValidContacts().length != 0 && this.containsMe(); - } - - @Override - public boolean isAdministratable() { - return mGID.ownerJID.isMe(); - } - - private boolean containsMe() { - return mContactMap.keySet().parallelStream().anyMatch( - new Predicate() { - @Override - public boolean test(Contact t) { - return t.isMe(); - } - } - ); - } - - @Override - void save() { - this.save(mContactMap.keySet().toArray(new Contact[0]), mSubject); - } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - - if (!(o instanceof GroupChat)) return false; - - GroupChat oChat = (GroupChat) o; - return mGID.equals(oChat.mGID); - } - - @Override - public int hashCode() { - int hash = 7; - hash = 79 * hash + Objects.hashCode(this.mGID); - return hash; - } - - @Override - public String toString() { - return "GC:id="+mID+",gid="+mGID+",subject="+mSubject; - } - - /** Group ID. */ - public static class GID { - private static final String JSON_OWNER_JID = "jid"; - private static final String JSON_ID = "id"; - - public final JID ownerJID; - public final String id; - - public GID(JID ownerJID, String id) { - this.ownerJID = ownerJID; - this.id = id; - } - - // using legacy lib, raw types extend Object - @SuppressWarnings("unchecked") - protected String toJSON() { - JSONObject json = new JSONObject(); - EncodingUtils.putJSON(json, JSON_OWNER_JID, ownerJID.string()); - EncodingUtils.putJSON(json, JSON_ID, id); - return json.toJSONString(); - } - - static GID fromJSONOrNull(String json) { - Object obj = JSONValue.parse(json); - try { - Map map = (Map) obj; - JID jid = JID.bare((String) map.get(JSON_OWNER_JID)); - String id = (String) map.get(JSON_ID); - return new GID(jid, id); - } catch (NullPointerException | ClassCastException ex) { - LOGGER.log(Level.WARNING, "can't parse JSON preview", ex); - return null; - } - } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - - if (!(o instanceof GID)) return false; - - GID oGID = (GID) o; - return ownerJID.equals(oGID.ownerJID) && id.equals(oGID.id); - } - - @Override - public int hashCode() { - int hash = 7; - hash = 37 * hash + Objects.hashCode(this.ownerJID); - hash = 37 * hash + Objects.hashCode(this.id); - return hash; - } - } -} diff --git a/src/main/java/org/kontalk/model/Model.java b/src/main/java/org/kontalk/model/Model.java new file mode 100644 index 00000000..de0bfdd4 --- /dev/null +++ b/src/main/java/org/kontalk/model/Model.java @@ -0,0 +1,169 @@ +/* + * Kontalk Java client + * Copyright (C) 2016 Kontalk Devteam + * + * 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 . + */ + +package org.kontalk.model; + +import java.awt.image.BufferedImage; +import java.nio.file.Path; +import java.util.Date; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.logging.Logger; +import org.kontalk.misc.JID; +import org.kontalk.model.Avatar.UserAvatar; +import org.kontalk.model.chat.Chat; +import org.kontalk.model.chat.ChatList; +import org.kontalk.model.message.InMessage; +import org.kontalk.model.message.MessageContent; +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; + +/** + * + * @author Alexander Bikadorov {@literal } + */ +public final class Model { + private static final Logger LOGGER = Logger.getLogger(Model.class.getName()); + + private static Model INSTANCE = null; + private static Path APP_DIR; + private static Database DATABASE; + + private final ContactList mContactList; + private final ChatList mChatList; + private final Account mAccount; + + private UserAvatar mUserAvatar; + + private Model(Database db, Path appDir) { + DATABASE = db; + APP_DIR = appDir; + + mAccount = new Account(APP_DIR, Config.getInstance()); + mContactList = new ContactList(); + mChatList = new ChatList(); + + mUserAvatar = new UserAvatar(APP_DIR); + + Avatar.createStorageDir(appDir); + } + + public static Model setup(Database db, Path appDir) { + if (INSTANCE != null) { + LOGGER.warning("already set up"); + return INSTANCE; + } + + return INSTANCE = new Model(db, appDir); + } + + public Account account() { + return mAccount; + } + + public ContactList contacts() { + return mContactList; + } + + public ChatList chats() { + return mChatList; + } + + public UserAvatar userAvatar() { + return mUserAvatar; + } + + public void load() { + // order matters! + Map contactMap = mContactList.load(); + mChatList.load(contactMap); + } + + public UserAvatar setUserAvatar(BufferedImage image) { + return UserAvatar.create(image); + } + + public void deleteUserAvatar() { + mUserAvatar.delete(); + mUserAvatar = new UserAvatar(APP_DIR); + } + + public void setUserJID(JID jid) { + Config.getInstance().setProperty(Config.ACC_JID, jid.string()); + + if (!mContactList.contains(jid)) { + LOGGER.info("creating user contact, jid: "+jid); + mContactList.create(jid, ""); + } + } + + public Optional createInMessage(ProtoMessage protoMessage, + Chat chat, ClientUtils.MessageIDs ids, Optional serverDate) { + InMessage newMessage = new InMessage(protoMessage, chat, ids.jid, + ids.xmppID, serverDate); + + if (newMessage.getID() <= 0) + return Optional.empty(); + + if (chat.getMessages().contains(newMessage)) { + LOGGER.info("message already in chat, dropping this one"); + return Optional.empty(); + } + boolean added = chat.addMessage(newMessage); + if (!added) { + LOGGER.warning("can't add message to chat"); + return Optional.empty(); + } + return Optional.of(newMessage); + } + + public Optional createOutMessage(Chat chat, + List contacts, MessageContent content) { + OutMessage newMessage = new OutMessage(chat, contacts, content, + chat.isSendEncrypted()); + + boolean added = chat.addMessage(newMessage); + if (!added) { + LOGGER.warning("could not add outgoing message to chat"); + return Optional.empty(); + } + return Optional.of(newMessage); + } + + static Path appDir() { + if (APP_DIR == null) + throw new IllegalStateException("model not set up"); + + return APP_DIR; + } + + public static Database database(){ + if (DATABASE == null) + throw new IllegalStateException("model not set up"); + + return DATABASE; + } + + public static JID getUserJID() { + return JID.bare(Config.getInstance().getString(Config.ACC_JID)); + } +} diff --git a/src/main/java/org/kontalk/model/Chat.java b/src/main/java/org/kontalk/model/chat/Chat.java similarity index 51% rename from src/main/java/org/kontalk/model/Chat.java rename to src/main/java/org/kontalk/model/chat/Chat.java index d0322fc8..032a34b8 100644 --- a/src/main/java/org/kontalk/model/Chat.java +++ b/src/main/java/org/kontalk/model/chat/Chat.java @@ -1,6 +1,6 @@ /* * Kontalk Java client - * Copyright (C) 2014 Kontalk Devteam + * Copyright (C) 2016 Kontalk Devteam * * 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 @@ -16,29 +16,30 @@ * along with this program. If not, see . */ -package org.kontalk.model; +package org.kontalk.model.chat; import java.awt.Color; import java.sql.ResultSet; import java.sql.SQLException; -import java.util.Date; +import java.util.ArrayList; +import java.util.Arrays; import java.util.HashMap; -import java.util.HashSet; -import java.util.LinkedList; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Observable; import java.util.Observer; import java.util.Optional; -import java.util.Set; import java.util.logging.Level; import java.util.logging.Logger; +import org.apache.commons.lang.ObjectUtils; import org.jivesoftware.smackx.chatstates.ChatState; import org.json.simple.JSONObject; import org.json.simple.JSONValue; -import org.kontalk.model.GroupChat.GID; -import org.kontalk.system.Database; +import org.kontalk.model.Contact; +import org.kontalk.model.Model; +import org.kontalk.model.message.KonMessage; +import org.kontalk.persistence.Database; /** * A model for a conversation thread consisting of an ordered list of messages. @@ -52,7 +53,7 @@ public abstract class Chat extends Observable implements Observer { public static final String TABLE = "threads"; public static final String COL_XMPPID = "xmpp_id"; - public static final String COL_GID = "gid"; + public static final String COL_GD = "gid"; public static final String COL_SUBJ = "subject"; public static final String COL_READ = "read"; public static final String COL_VIEW_SET = "view_settings"; @@ -67,69 +68,57 @@ public abstract class Chat extends Observable implements Observer { // view settings in JSON format COL_VIEW_SET+" TEXT NOT NULL, " + // optional group id in JSON format - COL_GID+" TEXT " + - ")"; - - // many to many relationship requires additional table for receiver - public static final String RECEIVER_TABLE = "receiver"; - public static final String COL_REC_CHAT_ID = "thread_id"; - public static final String COL_REC_CONTACT_ID = "user_id"; - public static final String RECEIVER_SCHEMA = "(" + - Database.SQL_ID + - COL_REC_CHAT_ID+" INTEGER NOT NULL, " + - COL_REC_CONTACT_ID+" INTEGER NOT NULL, " + - "UNIQUE ("+COL_REC_CHAT_ID+", "+COL_REC_CONTACT_ID+"), " + - "FOREIGN KEY ("+COL_REC_CHAT_ID+") REFERENCES "+TABLE+" (_id), " + - "FOREIGN KEY ("+COL_REC_CONTACT_ID+") REFERENCES "+Contact.TABLE+" (_id) " + + COL_GD+" TEXT " + ")"; protected final int mID; private final ChatMessages mMessages; private boolean mRead; + private boolean mDeleted = false; private ViewSettings mViewSettings; - protected Chat(Contact contact, String xmppID, String subject) { - this(new Contact[]{contact}, xmppID, subject, null); - } - - protected Chat(Contact[] contacts, String xmppID, String subject, GID gid) { - mMessages = new ChatMessages(this, true); + protected Chat(List members, String xmppID, String subject, GroupMetaData gData) { + mMessages = new ChatMessages(); mRead = true; mViewSettings = new ViewSettings(); // insert - Database db = Database.getInstance(); - List values = new LinkedList<>(); - values.add(Database.setString(xmppID)); - values.add(Database.setString(subject)); - values.add(mRead); - values.add(mViewSettings.toJSONString()); - values.add(Database.setString(gid == null ? "" : gid.toJSON())); - mID = db.execInsert(TABLE, values); + List values = Arrays.asList( + Database.setString(xmppID), + Database.setString(subject), + mRead, + mViewSettings.toJSONString(), + Database.setString(gData == null ? "" : gData.toJSON())); + mID = Model.database().execInsert(TABLE, values); if (mID < 1) { LOGGER.warning("couldn't insert chat"); return; } - for (Contact contact : contacts) - this.insertReceiver(contact); + members.stream().forEach(member -> member.insert(Model.database(), mID)); } // used when loading from database protected Chat(int id, boolean read, String jsonViewSettings) { mID = id; - mMessages = new ChatMessages(this, false); + mMessages = new ChatMessages(); mRead = read; mViewSettings = new ViewSettings(this, jsonViewSettings); } + void loadMessages(Database db, Map contactMap) { + mMessages.load(db, this, contactMap); + } + public ChatMessages getMessages() { return mMessages; } public boolean addMessage(KonMessage message) { + assert message.getChat() == this; + boolean added = mMessages.add(message); if (added) { if (message.isInMessage() && mRead) { @@ -173,14 +162,16 @@ public void setViewSettings(ViewSettings settings) { } public boolean isGroupChat() { - return this instanceof GroupChat; + return (this instanceof GroupChat); } + public abstract List getAllMembers(); + /** Get all contacts (including deleted, blocked and user contact). */ - public abstract Set getAllContacts(); + public abstract List getAllContacts(); /** Get valid receiver contacts (without deleted and blocked). */ - public abstract Contact[] getValidContacts(); + public abstract List getValidContacts(); /** XMPP thread ID (empty string if not set). */ public abstract String getXMPPID(); @@ -209,65 +200,53 @@ public boolean isGroupChat() { abstract void save(); - protected void save(Contact[] contacts, String subject) { - Database db = Database.getInstance(); + protected void save(List members, String subject) { Map set = new HashMap<>(); set.put(COL_SUBJ, Database.setString(subject)); set.put(COL_READ, mRead); set.put(COL_VIEW_SET, mViewSettings.toJSONString()); + Database db = Model.database(); db.execUpdate(TABLE, set, mID); // get receiver for this chat - Map dbReceiver = loadReceiver(mID); + List oldMembers = new ArrayList<>(this.getAllMembers()); - // add missing contact - for (Contact contact : contacts) { - if (!dbReceiver.keySet().contains(contact.getID())) { - this.insertReceiver(contact); - } - dbReceiver.remove(contact.getID()); - } + // save new members + members.stream() + .filter(m -> !oldMembers.contains(m)) + .forEach(m -> m.insert(db, mID)); - // whats left is too much and can be removed - for (int id : dbReceiver.values()) { - db.execDelete(RECEIVER_TABLE, id); - } + oldMembers.removeAll(members); + // whats left is too much and can be deleted + oldMembers.stream().forEach(m -> m.delete(db)); } void delete() { - Database db = Database.getInstance(); - - String whereMessages = KonMessage.COL_CHAT_ID + " == " + mID; - - // transmissions - db.execDeleteWhereInsecure(Transmission.TABLE, - Transmission.COL_MESSAGE_ID + " IN (SELECT _id FROM " + - KonMessage.TABLE + " WHERE " + whereMessages + ")"); - // messages - db.execDeleteWhereInsecure(KonMessage.TABLE, whereMessages); + boolean succ = mMessages.getAll().stream().allMatch(m -> m.delete()); + if (!succ) + return; - // receiver - Map dbReceiver = loadReceiver(mID); - for (int id : dbReceiver.values()) { - boolean deleted = db.execDelete(RECEIVER_TABLE, id); - if (!deleted) return; - } + // members + succ = this.getAllMembers().stream().allMatch(m -> m.delete(Model.database())); + if (!succ) + return; // chat itself + Database db = Model.database(); db.execDelete(TABLE, mID); + + // all done, commmit deletions + succ = db.commit(); + if (!succ) + return; + + mDeleted = true; } - private void insertReceiver(Contact contact) { - Database db = Database.getInstance(); - List recValues = new LinkedList<>(); - recValues.add(mID); - recValues.add(contact.getID()); - int id = db.execInsert(RECEIVER_TABLE, recValues); - if (id < 1) { - LOGGER.warning("could not insert receiver"); - } + public boolean isDeleted() { + return mDeleted; } protected void changed(Object arg) { @@ -280,26 +259,19 @@ public void update(Observable o, Object arg) { this.changed(o); } - static Chat loadOrNull(ResultSet rs) throws SQLException { + static Optional load(Database db, ResultSet rs, Map contactMap) + throws SQLException { int id = rs.getInt("_id"); - String jsonGID = Database.getString(rs, Chat.COL_GID); - Optional optGID = Optional.ofNullable(jsonGID.isEmpty() ? + String jsonGD = Database.getString(rs, Chat.COL_GD); + GroupMetaData gData = jsonGD.isEmpty() ? null : - GID.fromJSONOrNull(jsonGID)); + GroupMetaData.fromJSONOrNull(jsonGD); String xmppID = Database.getString(rs, Chat.COL_XMPPID); - // get contacts for chats - Map dbReceiver = Chat.loadReceiver(id); - Set contacts = new HashSet<>(); - for (int conID: dbReceiver.keySet()) { - Optional optCon = ContactList.getInstance().get(conID); - if (optCon.isPresent()) - contacts.add(optCon.get()); - else - LOGGER.warning("can't find contact"); - } + // get members of chat + List members = Member.load(db, id, contactMap); String subject = Database.getString(rs, Chat.COL_SUBJ); @@ -308,66 +280,19 @@ static Chat loadOrNull(ResultSet rs) throws SQLException { String jsonViewSettings = Database.getString(rs, Chat.COL_VIEW_SET); - if (optGID.isPresent()) { - return new GroupChat(id, contacts, optGID.get(), subject, read, jsonViewSettings); + Chat chat; + if (gData != null) { + chat = GroupChat.create(id, members, gData, subject, read, jsonViewSettings); } else { - if (contacts.size() != 1) { + if (members.size() != 1) { LOGGER.warning("not one contact for single chat, id="+id); - return null; - } - return new SingleChat(id, contacts.iterator().next(), xmppID, read, jsonViewSettings); - } - } - - static Map loadReceiver(int chatID) { - Database db = Database.getInstance(); - String where = COL_REC_CHAT_ID + " == " + chatID; - Map dbReceiver = new HashMap<>(); - ResultSet resultSet; - try { - resultSet = db.execSelectWhereInsecure(RECEIVER_TABLE, where); - } catch (SQLException ex) { - LOGGER.log(Level.WARNING, "can't get receiver from db", ex); - return dbReceiver; - } - try { - while (resultSet.next()) { - dbReceiver.put(resultSet.getInt(COL_REC_CONTACT_ID), - resultSet.getInt("_id")); + return Optional.empty(); } - resultSet.close(); - } catch (SQLException ex) { - LOGGER.log(Level.WARNING, "can't get receiver", ex); - } - return dbReceiver; - } - - public class KonChatState { - private final Contact mContact; - private ChatState mState = ChatState.gone; - // note: the Android client does not set active states when only viewing - // the chat (not necessary according to XEP-0085), this makes the - // extra date field a bit useless - // TODO save last active date to DB - private Optional mLastActive = Optional.empty(); - - protected KonChatState(Contact contact) { - mContact = contact; - } - - public Contact getContact() { - return mContact; + chat = new SingleChat(id, members.get(0), xmppID, read, jsonViewSettings); } - public ChatState getState() { - return mState; - } - - protected void setState(ChatState state) { - mState = state; - if (mState == ChatState.active || mState == ChatState.composing) - mLastActive = Optional.of(new Date()); - } + chat.loadMessages(db, contactMap); + return Optional.of(chat); } public static class ViewSettings { @@ -375,48 +300,48 @@ public static class ViewSettings { private static final String JSON_IMAGE_PATH = "img"; // background color, if set - private final Optional mOptColor; + private final Color mColor; // custom image, if set private final String mImagePath; private ViewSettings(Chat t, String json) { Object obj = JSONValue.parse(json); - Optional optColor; + Color color; String imagePath; try { Map map = (Map) obj; - optColor = map.containsKey(JSON_BG_COLOR) ? - Optional.of(new Color(((Long) map.get(JSON_BG_COLOR)).intValue())) : - Optional.empty(); + color = map.containsKey(JSON_BG_COLOR) ? + new Color(((Long) map.get(JSON_BG_COLOR)).intValue()) : + null; imagePath = map.containsKey(JSON_IMAGE_PATH) ? (String) map.get(JSON_IMAGE_PATH) : ""; } catch (NullPointerException | ClassCastException ex) { LOGGER.log(Level.WARNING, "can't parse JSON view settings", ex); - optColor = Optional.empty(); + color = null; imagePath = ""; } - mOptColor = optColor; + mColor = color; mImagePath = imagePath; } public ViewSettings() { - mOptColor = Optional.empty(); + mColor = null; mImagePath = ""; } public ViewSettings(Color color) { - mOptColor = Optional.of(color); + mColor = null; mImagePath = ""; } public ViewSettings(String imagePath) { - mOptColor = Optional.empty(); + mColor = null; mImagePath = imagePath; } public Optional getBGColor() { - return mOptColor; + return Optional.ofNullable(mColor); } public String getImagePath() { @@ -427,28 +352,31 @@ public String getImagePath() { @SuppressWarnings("unchecked") String toJSONString() { JSONObject json = new JSONObject(); - if (mOptColor.isPresent()) - json.put(JSON_BG_COLOR, mOptColor.get().getRGB()); + if (mColor != null) + json.put(JSON_BG_COLOR, mColor.getRGB()); if (!mImagePath.isEmpty()) json.put(JSON_IMAGE_PATH, mImagePath); return json.toJSONString(); } @Override - public boolean equals(Object obj) { - if (this == obj) return true; + public boolean equals(Object o) { + if (o == this) + return true; + + if (!(o instanceof ViewSettings)) + return false; - if (!(obj instanceof ViewSettings)) return false; + ViewSettings ovs = (ViewSettings) o; - ViewSettings o = (ViewSettings) obj; - return mOptColor.equals(o.mOptColor) && - mImagePath.equals(o.mImagePath); + return ObjectUtils.equals(mColor, ovs.mColor) && + mImagePath.equals(ovs.mImagePath); } @Override public int hashCode() { int hash = 7; - hash = 37 * hash + Objects.hashCode(this.mOptColor); + hash = 37 * hash + Objects.hashCode(this.mColor); hash = 37 * hash + Objects.hashCode(this.mImagePath); return hash; } diff --git a/src/main/java/org/kontalk/model/ChatList.java b/src/main/java/org/kontalk/model/chat/ChatList.java similarity index 55% rename from src/main/java/org/kontalk/model/ChatList.java rename to src/main/java/org/kontalk/model/chat/ChatList.java index 588643c8..09003e67 100644 --- a/src/main/java/org/kontalk/model/ChatList.java +++ b/src/main/java/org/kontalk/model/chat/ChatList.java @@ -1,6 +1,6 @@ /* * Kontalk Java client - * Copyright (C) 2014 Kontalk Devteam + * Copyright (C) 2016 Kontalk Devteam * * 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 @@ -16,14 +16,14 @@ * along with this program. If not, see . */ -package org.kontalk.model; +package org.kontalk.model.chat; import java.sql.ResultSet; import java.sql.SQLException; import java.util.Collections; -import java.util.HashMap; import java.util.HashSet; import java.util.Iterator; +import java.util.List; import java.util.Map; import java.util.Observable; import java.util.Observer; @@ -31,8 +31,9 @@ import java.util.Set; import java.util.logging.Level; import java.util.logging.Logger; -import org.kontalk.model.GroupChat.GID; -import org.kontalk.system.Database; +import org.kontalk.model.Contact; +import org.kontalk.model.Model; +import org.kontalk.persistence.Database; /** * The global list of all chats. @@ -41,22 +42,17 @@ public final class ChatList extends Observable implements Observer, Iterable { private static final Logger LOGGER = Logger.getLogger(ChatList.class.getName()); - private static final ChatList INSTANCE = new ChatList(); - - private final Map mMap = - Collections.synchronizedMap(new HashMap()); + private final Set mChats = Collections.synchronizedSet(new HashSet()); private boolean mUnread = false; - private ChatList() {} - - public void load() { - assert mMap.isEmpty(); + public void load(Map contactMap) { + assert mChats.isEmpty(); - Database db = Database.getInstance(); + Database db = Model.database(); try (ResultSet chatRS = db.execSelectAll(Chat.TABLE)) { while (chatRS.next()) { - Chat chat = Chat.loadOrNull(chatRS); + Chat chat = Chat.load(db, chatRS, contactMap).orElse(null); if (chat == null) continue; this.putSilent(chat); @@ -70,103 +66,85 @@ public void load() { } public Set getAll() { - return new HashSet<>(mMap.values()); + return Collections.unmodifiableSet(mChats); } /** Get single chat with contact and XMPPID. */ public Optional get(Contact contact, String xmmpThreadID) { - for (Chat chat : mMap.values()) { - if (!(chat instanceof SingleChat)) - continue; - SingleChat singleChat = (SingleChat) chat; - - if (singleChat.getXMPPID().equals(xmmpThreadID) - && singleChat.getContact().equals(contact)) - return Optional.of(singleChat); + synchronized(mChats) { + return mChats.stream() + .filter(chat -> chat instanceof SingleChat) + .map(chat -> (SingleChat) chat) + .filter(chat -> chat.getXMPPID().equals(xmmpThreadID) + && chat.getContact().equals(contact)) + .findFirst(); } - return Optional.empty(); } - /** Get group chat with group ID and containing contact. */ - public Optional get(GID gid, Contact contact) { - for (Chat chat : mMap.values()) { - if (!(chat instanceof GroupChat)) - continue; - - GroupChat groupChat = (GroupChat) chat; - if (groupChat.getGID().equals(gid) && - groupChat.getAllContacts().contains(contact)) { - return Optional.of(groupChat); - } + public Optional get(GroupMetaData gData) { + synchronized(mChats) { + return mChats.stream() + .filter(chat -> chat instanceof GroupChat) + .map(chat -> (GroupChat) chat) + .filter(chat -> chat.getGroupData().equals(gData)) + .findFirst(); } - - return Optional.empty(); } - public Chat getOrCreate(Contact contact) { + public SingleChat getOrCreate(Contact contact) { return this.getOrCreate(contact, ""); } - /** Find group chat by GID or create a new chat. */ - public GroupChat getOrCreate(GID gid, Contact contact) { - Optional optChat = this.get(gid, contact); - if (optChat.isPresent()) - return optChat.get(); - - return this.createNew(new Contact[]{contact}, gid, ""); - } - /** Find single chat for contact and XMPP ID or creates a new chat. */ public SingleChat getOrCreate(Contact contact, String xmppThreadID) { - Optional optChat = this.get(contact, xmppThreadID); - if (optChat.isPresent()) - return optChat.get(); + SingleChat chat = this.get(contact, xmppThreadID).orElse(null); + if (chat != null) + return chat; return this.createNew(contact, xmppThreadID); } private SingleChat createNew(Contact contact, String xmppThreadID) { - SingleChat newChat = new SingleChat(contact, xmppThreadID); - LOGGER.config("created new single chat: "+newChat); + SingleChat newChat = new SingleChat(new Member(contact), xmppThreadID); + LOGGER.config("new single chat: "+newChat); this.putSilent(newChat); this.changed(newChat); return newChat; } - public GroupChat createNew(Contact[] contacts, GID gid, String subject) { - GroupChat newChat = new GroupChat(contacts, gid, subject); - LOGGER.config("created new group chat: "+newChat); + public GroupChat create(List members, GroupMetaData gData) { + return createNew(members, gData, ""); + } + + public GroupChat createNew(List members, GroupMetaData gData, String subject) { + GroupChat newChat = GroupChat.create(Model.database(), members, gData, subject); + LOGGER.config("new group chat: "+newChat); this.putSilent(newChat); this.changed(newChat); return newChat; } private void putSilent(Chat chat) { - if (mMap.containsValue(chat)) { - LOGGER.warning("chat already in chat list"); + boolean succ = mChats.add(chat); + if (!succ) { + LOGGER.warning("chat already in chat list: "+chat); return; } - - mMap.put(chat.getID(), chat); chat.addObserver(this); } - public boolean contains(int id) { - return mMap.containsKey(id); - } - public boolean contains(Contact contact) { return this.get(contact, "").isPresent(); } public boolean isEmpty() { - return mMap.isEmpty(); + return mChats.isEmpty(); } - public void delete(int id) { - Chat chat = mMap.remove(id); - if (chat == null) { - LOGGER.warning("can't delete chat, not found. id: "+id); + public void delete(Chat chat) { + boolean succ = mChats.remove(chat); + if (!succ) { + LOGGER.warning("can't delete chat, not found: "+chat); return; } chat.delete(); @@ -200,10 +178,9 @@ public void update(Observable o, Object arg) { return; } - for (Chat chat : mMap.values()) { - if (!chat.isRead()) { + synchronized(mChats) { + if (mChats.stream().anyMatch(chat -> !chat.isRead())) return; - } } mUnread = false; @@ -212,10 +189,6 @@ public void update(Observable o, Object arg) { @Override public Iterator iterator() { - return mMap.values().iterator(); - } - - public static ChatList getInstance() { - return INSTANCE; + return mChats.iterator(); } } diff --git a/src/main/java/org/kontalk/model/ChatMessages.java b/src/main/java/org/kontalk/model/chat/ChatMessages.java similarity index 53% rename from src/main/java/org/kontalk/model/ChatMessages.java rename to src/main/java/org/kontalk/model/chat/ChatMessages.java index 66d86a14..99e8071c 100644 --- a/src/main/java/org/kontalk/model/ChatMessages.java +++ b/src/main/java/org/kontalk/model/chat/ChatMessages.java @@ -1,6 +1,6 @@ /* * Kontalk Java client - * Copyright (C) 2014 Kontalk Devteam + * Copyright (C) 2016 Kontalk Devteam * * 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 @@ -16,55 +16,54 @@ * along with this program. If not, see . */ -package org.kontalk.model; +package org.kontalk.model.chat; import java.sql.ResultSet; import java.sql.SQLException; import java.util.Collections; +import java.util.Comparator; +import java.util.HashSet; +import java.util.Map; import java.util.NavigableSet; import java.util.Optional; +import java.util.Set; import java.util.SortedSet; import java.util.TreeSet; import java.util.logging.Level; import java.util.logging.Logger; -import org.kontalk.system.Database; +import java.util.stream.Collectors; +import org.kontalk.model.Contact; +import org.kontalk.model.message.KonMessage; +import org.kontalk.model.message.OutMessage; +import org.kontalk.persistence.Database; /** - * Messages of chat. + * All messages of a chat. * * @author Alexander Bikadorov {@literal } */ public final class ChatMessages { private static final Logger LOGGER = Logger.getLogger(ChatMessages.class.getName()); - private final Chat mChat; - private final NavigableSet mSet = - Collections.synchronizedNavigableSet(new TreeSet()); + private static final Comparator MESSAGE_COMPARATOR = + (KonMessage o1, KonMessage o2) -> o1.getDate().compareTo(o2.getDate()); - private boolean mLoaded; + // comparator inconsistent with .equals(); using one set for ordering... + private final NavigableSet mSortedSet = + Collections.synchronizedNavigableSet(new TreeSet(MESSAGE_COMPARATOR)); + // ... and one set for .contains() + private final Set mContainsSet = + Collections.synchronizedSet(new HashSet<>()); - ChatMessages(Chat chat, boolean newChat) { - mChat = chat; - // don't load from db if chat is just created - mLoaded = newChat; + ChatMessages() { } - private void ensureLoaded() { - if (mLoaded) - return; - - this.loadMessages(); - mLoaded = true; - } - - private void loadMessages() { - Database db = Database.getInstance(); - + void load(Database db, Chat chat, Map contactMap) { try (ResultSet messageRS = db.execSelectWhereInsecure(KonMessage.TABLE, - KonMessage.COL_CHAT_ID + " == " + mChat.getID())) { + KonMessage.COL_CHAT_ID + " == " + chat.getID())) { while (messageRS.next()) { - KonMessage message = KonMessage.load(messageRS, mChat); - if (message.getTransmissions().length == 0) + KonMessage message = KonMessage.load(db, messageRS, chat, contactMap); + if (message.getTransmissions().isEmpty()) // ignore broken message continue; this.addSilent(message); @@ -78,81 +77,59 @@ private void loadMessages() { * Add message to chat without notifying other components. */ boolean add(KonMessage message) { - this.ensureLoaded(); - return this.addSilent(message); } private boolean addSilent(KonMessage message) { - if (mSet.contains(message)) { + boolean added = mContainsSet.add(message); + if (!added) { LOGGER.warning("message already in chat: " + message); return false; } - boolean added = mSet.add(message); - return added; + mSortedSet.add(message); + return true; } - public NavigableSet getAll() { - this.ensureLoaded(); - - return mSet; + public Set getAll() { + return Collections.unmodifiableSet(mSortedSet); } /** Get all outgoing messages with status "PENDING" for this chat. */ public SortedSet getPending() { - this.ensureLoaded(); - - SortedSet s = new TreeSet<>(); - // TODO performance, probably additional map needed - // TODO use lambda in near future - for (KonMessage m : mSet) { - if (m.getStatus() == KonMessage.Status.PENDING && - m instanceof OutMessage) { - s.add((OutMessage) m); - } + synchronized(mSortedSet) { + return mSortedSet.stream() + .filter(m -> m.getStatus() == KonMessage.Status.PENDING + && m instanceof OutMessage) + .map(m -> (OutMessage) m) + .collect(Collectors.toCollection(() -> new TreeSet<>(MESSAGE_COMPARATOR))); } - return s; } /** Get the newest (ie last received) outgoing message. */ public Optional getLast(String xmppID) { - this.ensureLoaded(); - - // TODO performance - OutMessage message = null; - for (KonMessage m: mSet.descendingSet()) { - if (m.getXMPPID().equals(xmppID) && m instanceof OutMessage) { - message = (OutMessage) m; - } + synchronized(mSortedSet) { + return mSortedSet.descendingSet().stream() + .filter(m -> m.getXMPPID().equals(xmppID) && m instanceof OutMessage) + .map(m -> (OutMessage) m).findFirst(); } - - if (message == null) { - return Optional.empty(); - } - - return Optional.of(message); } /** Get the last created message. */ public Optional getLast() { - this.ensureLoaded(); - return mSet.isEmpty() ? + return mSortedSet.isEmpty() ? Optional.empty() : - Optional.of(mSet.last()); + Optional.of(mSortedSet.last()); } public boolean contains(KonMessage message) { - this.ensureLoaded(); - return mSet.contains(message); + return mContainsSet.contains(message); } public int size() { - this.ensureLoaded(); - return mSet.size(); + return mSortedSet.size(); } public boolean isEmpty() { - this.ensureLoaded(); - return mSet.isEmpty(); + return mSortedSet.isEmpty(); } } diff --git a/src/main/java/org/kontalk/model/chat/GroupChat.java b/src/main/java/org/kontalk/model/chat/GroupChat.java new file mode 100644 index 00000000..cfac9739 --- /dev/null +++ b/src/main/java/org/kontalk/model/chat/GroupChat.java @@ -0,0 +1,261 @@ +/* + * Kontalk Java client + * Copyright (C) 2016 Kontalk Devteam + * + * 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 . + */ + +package org.kontalk.model.chat; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Objects; +import java.util.logging.Logger; +import java.util.stream.Collectors; +import org.jivesoftware.smackx.chatstates.ChatState; +import org.kontalk.model.Contact; +import org.kontalk.model.chat.GroupMetaData.KonGroupData; +import org.kontalk.model.chat.GroupMetaData.MUCData; +import org.kontalk.persistence.Database; + +/** + * A long-term persistent chat conversation with multiple participants. + * + * @author Alexander Bikadorov {@literal } + */ +public abstract class GroupChat extends Chat { + private static final Logger LOGGER = Logger.getLogger(GroupChat.class.getName()); + + private final HashSet mMemberSet = new HashSet<>(); + private final D mGroupData; + + private String mSubject; + // TODO overwrite encryption=OFF field + private boolean mForceEncryptionOff = false; + + private GroupChat(List members, D gData, String subject) { + super(members, "", subject, gData); + + mGroupData = gData; + mSubject = subject; + + members.stream().forEach(m -> this.addMemberSilent(m)); + } + + // used when loading from database + private GroupChat( + int id, + List members, + D gData, + String subject, + boolean read, + String jsonViewSettings + ) { + super(id, read, jsonViewSettings); + + mGroupData = gData; + mSubject = subject; + + members.stream().forEach(m -> this.addMemberSilent(m)); + } + + @Override + public List getAllMembers() { + return new ArrayList<>(mMemberSet); + } + + /** Get all contacts (including deleted and user contact). */ + @Override + public List getAllContacts() { + return mMemberSet.stream() + .map(m -> m.getContact()) + .collect(Collectors.toList()); + } + + @Override + public List getValidContacts() { + return mMemberSet.stream() + .map(m -> m.getContact()) + .filter(c -> (!c.isDeleted() && !c.isMe())) + .collect(Collectors.toList()); + } + + private void addMemberSilent(Member member) { + if (mMemberSet.contains(member)) { + LOGGER.warning("member already in chat: "+member); + return; + } + + member.getContact().addObserver(this); + mMemberSet.add(member); + } + + private void removeMemberSilent(Member member) { + member.getContact().deleteObserver(this); + boolean succ = mMemberSet.remove(member); + if (!succ) { + LOGGER.warning("member not in chat: "+member); + } + } + + public D getGroupData() { + return mGroupData; + } + + @Override + public String getSubject() { + return mSubject; + } + + public void setSubject(String subject) { + if (subject.equals(mSubject)) + return; + + mSubject = subject; + this.save(); + this.changed(subject); + } + + @Override + public void setChatState(final Contact contact, ChatState chatState) { + Member member = mMemberSet.stream() + .filter(m -> m.getContact().equals(contact)) + .findFirst().orElse(null); + + if (member == null) { + LOGGER.warning("can't find member in member set!?"); + return; + } + + member.setState(chatState); + this.changed(member); + } + + public void applyGroupChanges(List added, List removed, String subject) { + added.stream().forEach((member) -> this.addMemberSilent(member)); + removed.stream().forEach((member) -> this.removeMemberSilent(member)); + if (!subject.isEmpty()) + mSubject = subject; + + this.save(); + + if (!added.isEmpty() || !removed.isEmpty()) + this.changed(Arrays.asList()); + if (!subject.isEmpty()) + this.changed(mSubject); + } + + @Override + public String getXMPPID() { + return ""; + } + + @Override + public boolean isSendEncrypted() { + return this.getValidContacts().stream() + .anyMatch(c -> c.getEncrypted()); + } + + @Override + public boolean canSendEncrypted() { + List contacts = this.getValidContacts(); + return !contacts.isEmpty() && + contacts.stream().allMatch(c -> c.hasKey()); + } + + @Override + public boolean isValid() { + return !this.getValidContacts().isEmpty() && this.containsMe(); + } + + @Override + public boolean isAdministratable() { + Member me = mMemberSet.stream() + .filter(m -> m.getContact().isMe()) + .findFirst().orElse(null); + if (me == null) + return false; + Member.Role myRole = me.getRole(); + return myRole == Member.Role.OWNER || myRole == Member.Role.ADMIN; + } + + private boolean containsMe() { + return mMemberSet.stream().anyMatch(m -> m.getContact().isMe()); + } + + @Override + void save() { + this.save(new ArrayList<>(mMemberSet), mSubject); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + + if (!(o instanceof GroupChat)) return false; + + GroupChat oChat = (GroupChat) o; + return mGroupData.equals(oChat.mGroupData); + } + + @Override + public int hashCode() { + int hash = 7; + hash = 79 * hash + Objects.hashCode(this.mGroupData); + return hash; + } + + @Override + public String toString() { + return "GC:id="+mID+",gd="+mGroupData+",subject="+mSubject; + } + + public static final class KonGroupChat extends GroupChat { + private KonGroupChat(List members, KonGroupData gData, String subject) { + super(members, gData, subject); + } + + private KonGroupChat(int id, List members, + KonGroupData gData, String subject, boolean read, String jsonViewSettings) { + super(id, members, gData, subject, read, jsonViewSettings); + } + } + + public static final class MUCChat extends GroupChat { + private MUCChat(List members, MUCData gData, String subject) { + super(members, gData, subject); + } + + private MUCChat(int id, List members, MUCData gData, + String subject, boolean read, String jsonViewSettings) { + super(id, members, gData, subject, read, jsonViewSettings); + } + } + + static GroupChat create(int id, List members, + GroupMetaData gData, String subject, boolean read, String jsonViewSettings) { + return (gData instanceof KonGroupData) ? + new KonGroupChat(id, members, (KonGroupData) gData, subject, read, jsonViewSettings) : + new MUCChat(id, members, (MUCData) gData, subject, read, jsonViewSettings); + } + + static GroupChat create(Database db, List members, GroupMetaData gData, String subject) { + return (gData instanceof KonGroupData) ? + new KonGroupChat(members, (KonGroupData) gData, subject) : + new MUCChat(members, (MUCData) gData, subject); + } + +} diff --git a/src/main/java/org/kontalk/model/chat/GroupMetaData.java b/src/main/java/org/kontalk/model/chat/GroupMetaData.java new file mode 100644 index 00000000..373e1fc4 --- /dev/null +++ b/src/main/java/org/kontalk/model/chat/GroupMetaData.java @@ -0,0 +1,164 @@ +/* + * Kontalk Java client + * Copyright (C) 2016 Kontalk Devteam + * + * 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 . + */ + +package org.kontalk.model.chat; + +import java.util.Map; +import java.util.Objects; +import java.util.logging.Level; +import java.util.logging.Logger; +import org.json.simple.JSONObject; +import org.json.simple.JSONValue; +import org.kontalk.misc.JID; + +/** + * Immutable meta data fields for a specific group chat protocol implementation. + * + * @author Alexander Bikadorov {@literal } + */ +public abstract class GroupMetaData { + private static final Logger LOGGER = Logger.getLogger(GroupMetaData.class.getName()); + + abstract String toJSON(); + + /** Data fields specific to a Kontalk group chat (custom protocol). */ + public static class KonGroupData extends GroupMetaData { + private static final String JSON_OWNER_JID = "jid"; + private static final String JSON_ID = "id"; + + public final JID owner; + public final String id; + + public KonGroupData(JID ownerJID, String id) { + this.owner = ownerJID; + this.id = id; + } + + // using legacy lib, raw types extend Object + @SuppressWarnings(value = "unchecked") + @Override + public String toJSON() { + JSONObject json = new JSONObject(); + json.put(JSON_OWNER_JID, owner.string()); + json.put(JSON_ID, id); + return json.toJSONString(); + } + + private static GroupMetaData fromJSON(Map map) { + JID jid = JID.bare((String) map.get(JSON_OWNER_JID)); + String id = (String) map.get(JSON_ID); + return new KonGroupData(jid, id); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof KonGroupData)) { + return false; + } + KonGroupData oGID = (KonGroupData) o; + return owner.equals(oGID.owner) && id.equals(oGID.id); + } + + @Override + public int hashCode() { + int hash = 7; + hash = 37 * hash + Objects.hashCode(this.owner); + hash = 37 * hash + Objects.hashCode(this.id); + return hash; + } + + @Override + public String toString() { + return "KGD:{id="+id+",owner="+owner+"}"; + } + } + + /** Data fields specific to a MUC (XEP-0045) chat. */ + public static class MUCData extends GroupMetaData { + private static final String JSON_ROOM = "room"; + private static final String JSON_PW = "pw"; + + public final JID room; + public final String password; + + public MUCData(JID room) { + this(room, ""); + } + + public MUCData(JID room, String password) { + this.room = room; + this.password = password; + } + + // using legacy lib, raw types extend Object + @SuppressWarnings(value = "unchecked") + @Override + public String toJSON() { + JSONObject json = new JSONObject(); + json.put(JSON_ROOM, room.string()); + json.put(JSON_PW, password); + return json.toJSONString(); + } + + private static GroupMetaData fromJSON(Map map) { + JID room = JID.bare((String) map.get(JSON_ROOM)); + String pw = (String) map.get(JSON_PW); + return new MUCData(room, pw); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof MUCData)) { + return false; + } + MUCData oData = (MUCData) o; + return room.equals(oData.room); + } + + @Override + public int hashCode() { + int hash = 7; + hash = 79 * hash + Objects.hashCode(this.room); + return hash; + } + + @Override + public String toString() { + return "MUCD:{room="+room+"}"; + } + } + + static GroupMetaData fromJSONOrNull(String json) { + Object obj = JSONValue.parse(json); + try { + Map map = (Map) obj; + return map.containsKey(MUCData.JSON_ROOM) ? + MUCData.fromJSON(map) : + KonGroupData.fromJSON(map); + } catch (NullPointerException | ClassCastException ex) { + LOGGER.log(Level.WARNING, "can't parse JSON", ex); + return null; + } + } +} \ No newline at end of file diff --git a/src/main/java/org/kontalk/model/chat/Member.java b/src/main/java/org/kontalk/model/chat/Member.java new file mode 100644 index 00000000..5698064c --- /dev/null +++ b/src/main/java/org/kontalk/model/chat/Member.java @@ -0,0 +1,195 @@ +/* + * Kontalk Java client + * Copyright (C) 2016 Kontalk Devteam + * + * 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 . + */ + +package org.kontalk.model.chat; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Date; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.logging.Level; +import java.util.logging.Logger; +import org.jivesoftware.smackx.chatstates.ChatState; +import org.kontalk.model.Contact; +import org.kontalk.persistence.Database; + +/** + * A contact association with a chat. + * Single chats have exactly one, group chats can have any number of members. + * + * @author Alexander Bikadorov {@literal } + */ +public final class Member { + private static final Logger LOGGER = Logger.getLogger(Member.class.getName()); + + /** + * Long-live authorization model of member in group. + * Called 'Affiliation' in MUC + * Do not modify, only add! Ordinal used in database + */ + public enum Role {DEFAULT, OWNER, ADMIN}; + + public static final String TABLE = "receiver"; + public static final String COL_CONTACT_ID = "user_id"; + public static final String COL_ROLE = "role"; + public static final String COL_CHAT_ID = "thread_id"; + public static final String SCHEMA = "(" + + Database.SQL_ID + + COL_CHAT_ID + " INTEGER NOT NULL, " + + COL_CONTACT_ID + " INTEGER NOT NULL, " + + COL_ROLE + " INTEGER NOT NULL, " + + "UNIQUE (" + COL_CHAT_ID + ", " + COL_CONTACT_ID + "), " + + "FOREIGN KEY (" + COL_CHAT_ID + ") REFERENCES " + Chat.TABLE + " (_id), " + + "FOREIGN KEY (" + COL_CONTACT_ID + ") REFERENCES " + Contact.TABLE + " (_id) " + + ")"; + + private final Contact mContact; + private final Role mRole; + + private int mID; + + private ChatState mState = ChatState.gone; + // note: the Android client does not set active states when only viewing + // the chat (not necessary according to XEP-0085), this makes the + // extra date field a bit useless + // TODO save last active date to DB + private Date mLastActive = null; + + public Member(Contact contact){ + this(contact, Role.DEFAULT); + } + + public Member(Contact contact, Role role) { + this(0, contact, role); + } + + private Member(int id, Contact contact, Role role) { + mID = id; + mContact = contact; + mRole = role; + } + + public Contact getContact() { + return mContact; + } + + public Role getRole() { + return mRole; + } + + @Override + public boolean equals(Object o) { + if (o == this) + return true; + + if (!(o instanceof Member)) + return false; + + return mContact.equals(((Member) o).mContact); + } + + @Override + public int hashCode() { + int hash = 7; + hash = 23 * hash + Objects.hashCode(mContact); + return hash; + } + + @Override + public String toString() { + return "Mem:cont={"+mContact+"},role="+mRole; + } + + public ChatState getState() { + return mState; + } + + boolean insert(Database db, int chatID) { + if (mID > 0) { + LOGGER.warning("already in database"); + return true; + } + + List recValues = Arrays.asList( + chatID, + getContact().getID(), + mRole); + mID = db.execInsert(TABLE, recValues); + if (mID <= 0) { + LOGGER.warning("could not insert member"); + return false; + } + return true; + } + + void save(Database db) { + // TODO + } + + boolean delete(Database db) { + if (mID <= 0) { + LOGGER.warning("not in database"); + return true; + } + + return db.execDelete(TABLE, mID); + } + + protected void setState(ChatState state) { + mState = state; + if (mState == ChatState.active || mState == ChatState.composing) + mLastActive = new Date(); + } + + /** Load Members of a chat. */ + static List load(Database db, int chatID, Map contactMap) { + String where = COL_CHAT_ID + " == " + chatID; + ResultSet resultSet; + try { + resultSet = db.execSelectWhereInsecure(TABLE, where); + } catch (SQLException ex) { + LOGGER.log(Level.WARNING, "can't get receiver from db", ex); + return Collections.emptyList(); + } + List members = new ArrayList<>(); + try { + while (resultSet.next()) { + int id = resultSet.getInt("_id"); + int contactID = resultSet.getInt(COL_CONTACT_ID); + int r = resultSet.getInt(COL_ROLE); + Role role = Role.values()[r]; + Contact c = contactMap.get(contactID); + if (c == null) { + LOGGER.warning("can't find contact, ID:"+contactID); + continue; + } + + members.add(new Member(id, c, role)); + } + resultSet.close(); + } catch (SQLException ex) { + LOGGER.log(Level.WARNING, "can't get members", ex); + } + return members; + } +} \ No newline at end of file diff --git a/src/main/java/org/kontalk/model/SingleChat.java b/src/main/java/org/kontalk/model/chat/SingleChat.java similarity index 58% rename from src/main/java/org/kontalk/model/SingleChat.java rename to src/main/java/org/kontalk/model/chat/SingleChat.java index 5a0fe3e4..75e6dbe6 100644 --- a/src/main/java/org/kontalk/model/SingleChat.java +++ b/src/main/java/org/kontalk/model/chat/SingleChat.java @@ -1,6 +1,6 @@ /* * Kontalk Java client - * Copyright (C) 2014 Kontalk Devteam + * Copyright (C) 2016 Kontalk Devteam * * 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 @@ -16,13 +16,15 @@ * along with this program. If not, see . */ -package org.kontalk.model; +package org.kontalk.model.chat; -import java.util.HashSet; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; import java.util.Objects; -import java.util.Set; import java.util.logging.Logger; import org.jivesoftware.smackx.chatstates.ChatState; +import org.kontalk.model.Contact; /** * @@ -31,55 +33,54 @@ public final class SingleChat extends Chat { private static final Logger LOGGER = Logger.getLogger(SingleChat.class.getName()); - private final Contact mContact; + private final Member mMember; private final String mXMPPID; - private final KonChatState mChatState; - SingleChat(Contact contact, String xmppID) { - super(contact, xmppID, ""); + SingleChat(Member member, String xmppID) { + super(Arrays.asList(member), xmppID, "", null); - mContact = contact; - contact.addObserver(this); - // note: Kontalk Android client is ignoring the chat id + mMember = member; + // NOTE: Kontalk Android client is ignoring the chat XMPP-ID mXMPPID = xmppID; - - mChatState = new KonChatState(contact); + mMember.getContact().addObserver(this); } // used when loading from database - SingleChat(int id, - Contact contact, + SingleChat( + int id, + Member member, String xmppID, boolean read, String jsonViewSettings ) { super(id, read, jsonViewSettings); - mContact = contact; - contact.addObserver(this); + mMember = member; mXMPPID = xmppID; - - mChatState = new KonChatState(contact); + mMember.getContact().addObserver(this); } public Contact getContact() { - return mContact; + return mMember.getContact(); } @Override - public Set getAllContacts() { - Set contacts = new HashSet<>(); - contacts.add(mContact); + public List getAllMembers() { + return Arrays.asList(mMember); + } - return contacts; + @Override + public List getAllContacts() { + return Arrays.asList(mMember.getContact()); } @Override - public Contact[] getValidContacts() { - if (mContact.isDeleted() || mContact.isBlocked() && !mContact.isMe()) - return new Contact[0]; + public List getValidContacts() { + Contact c = mMember.getContact(); + if ((c.isDeleted() || c.isBlocked()) && !c.isMe()) + return Collections.emptyList(); - return new Contact[]{mContact}; + return Arrays.asList(c); } @Override @@ -94,17 +95,19 @@ public String getSubject() { @Override public boolean isSendEncrypted() { - return mContact.getEncrypted(); + return mMember.getContact().getEncrypted(); } @Override public boolean canSendEncrypted() { - return !mContact.isDeleted() && !mContact.isBlocked() && mContact.hasKey(); + Contact c = mMember.getContact(); + return !c.isDeleted() && !c.isBlocked() && c.hasKey(); } @Override public boolean isValid() { - return !mContact.isDeleted() && !mContact.isBlocked(); + Contact c = mMember.getContact(); + return !c.isDeleted() && !c.isBlocked(); } @Override @@ -114,17 +117,17 @@ public boolean isAdministratable() { @Override public void setChatState(Contact contact, ChatState chatState) { - if (contact != mContact) { + if (!contact.equals(mMember.getContact())) { LOGGER.warning("wrong contact!?"); return; } - mChatState.setState(chatState); - this.changed(mChatState); + mMember.setState(chatState); + this.changed(mMember.getState()); } @Override void save() { - super.save(new Contact[]{mContact}, ""); + super.save(Arrays.asList(mMember), ""); } @Override @@ -134,19 +137,20 @@ public boolean equals(Object o) { if (!(o instanceof SingleChat)) return false; SingleChat oChat = (SingleChat) o; - return mContact.equals(oChat.mContact) && mXMPPID.equals(oChat.mXMPPID); + return mMember.equals(oChat.mMember) && + mXMPPID.equals(oChat.mXMPPID); } @Override public int hashCode() { int hash = 7; - hash = 41 * hash + Objects.hashCode(this.mContact); + hash = 41 * hash + Objects.hashCode(this.mMember); hash = 41 * hash + Objects.hashCode(this.mXMPPID); return hash; } @Override public String toString() { - return "SC:id="+mID+",xmppid="+mXMPPID; + return "SC:id="+mID+",xmppid="+mXMPPID+",mem="+mMember; } } diff --git a/src/main/java/org/kontalk/model/CoderStatus.java b/src/main/java/org/kontalk/model/message/CoderStatus.java similarity index 97% rename from src/main/java/org/kontalk/model/CoderStatus.java rename to src/main/java/org/kontalk/model/message/CoderStatus.java index aa4c2d17..2edca669 100644 --- a/src/main/java/org/kontalk/model/CoderStatus.java +++ b/src/main/java/org/kontalk/model/message/CoderStatus.java @@ -1,6 +1,6 @@ /* * Kontalk Java client - * Copyright (C) 2014 Kontalk Devteam + * Copyright (C) 2016 Kontalk Devteam * * 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 @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -package org.kontalk.model; +package org.kontalk.model.message; import java.util.EnumSet; import org.kontalk.crypto.Coder; diff --git a/src/main/java/org/kontalk/model/DecryptMessage.java b/src/main/java/org/kontalk/model/message/DecryptMessage.java similarity index 90% rename from src/main/java/org/kontalk/model/DecryptMessage.java rename to src/main/java/org/kontalk/model/message/DecryptMessage.java index d052be25..c7d0aeaa 100644 --- a/src/main/java/org/kontalk/model/DecryptMessage.java +++ b/src/main/java/org/kontalk/model/message/DecryptMessage.java @@ -1,6 +1,6 @@ /* * Kontalk Java client - * Copyright (C) 2014 Kontalk Devteam + * Copyright (C) 2016 Kontalk Devteam * * 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 @@ -16,11 +16,12 @@ * along with this program. If not, see . */ -package org.kontalk.model; +package org.kontalk.model.message; import java.util.EnumSet; import org.kontalk.crypto.Coder; import org.kontalk.crypto.Coder.Signing; +import org.kontalk.model.Contact; /** * Interface for decryptable messages. diff --git a/src/main/java/org/kontalk/model/InMessage.java b/src/main/java/org/kontalk/model/message/InMessage.java similarity index 70% rename from src/main/java/org/kontalk/model/InMessage.java rename to src/main/java/org/kontalk/model/message/InMessage.java index bd48e0f6..78bd0ecc 100644 --- a/src/main/java/org/kontalk/model/InMessage.java +++ b/src/main/java/org/kontalk/model/message/InMessage.java @@ -1,6 +1,6 @@ /* * Kontalk Java client - * Copyright (C) 2014 Kontalk Devteam + * Copyright (C) 2016 Kontalk Devteam * * 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 @@ -16,18 +16,24 @@ * along with this program. If not, see . */ -package org.kontalk.model; +package org.kontalk.model.message; +import java.util.Arrays; +import org.kontalk.model.chat.Chat; import org.kontalk.misc.JID; import java.util.Date; +import java.util.HashSet; +import java.util.Objects; import java.util.Optional; +import java.util.Set; import java.util.logging.Logger; import org.kontalk.crypto.Coder; -import org.kontalk.model.MessageContent.Attachment; -import org.kontalk.model.MessageContent.Preview; +import org.kontalk.model.Contact; +import org.kontalk.model.message.MessageContent.Attachment; +import org.kontalk.model.message.MessageContent.Preview; /** - * Model for an XMPP message that was sent to us. + * Model for an XMPP message sent to the user. * @author Alexander Bikadorov {@literal } */ public final class InMessage extends KonMessage implements DecryptMessage { @@ -35,9 +41,10 @@ public final class InMessage extends KonMessage implements DecryptMessage { private final Transmission mTransmission; - public InMessage(ProtoMessage proto, Chat chat, JID from, String xmppID, - Optional serverDate) { - super(chat, + public InMessage(ProtoMessage proto, Chat chat, JID from, + String xmppID, Optional serverDate) { + super( + chat, xmppID, proto.getContent(), serverDate, @@ -51,10 +58,10 @@ public InMessage(ProtoMessage proto, Chat chat, JID from, String xmppID, protected InMessage(KonMessage.Builder builder) { super(builder); - if (builder.mTransmissions.length != 1) + if (builder.mTransmissions.size() != 1) throw new IllegalArgumentException("builder does not contain one transmission"); - mTransmission = builder.mTransmissions[0]; + mTransmission = builder.mTransmissions.stream().findAny().get(); } @Override @@ -122,18 +129,37 @@ public void setDecryptedAttachment(String filename) { } public void setPreviewFilename(String filename) { - Optional optPreview = this.getContent().getPreview(); - if (!optPreview.isPresent()) { + Preview preview = this.getContent().getPreview().orElse(null); + if (preview == null) { LOGGER.warning("no preview !?"); return; } - optPreview.get().setFilename(filename); + preview.setFilename(filename); this.save(); - this.changed(optPreview.get()); + this.changed(preview); } @Override - public Transmission[] getTransmissions() { - return new Transmission[]{mTransmission}; + public Set getTransmissions() { + return new HashSet<>(Arrays.asList(mTransmission)); + } + + @Override + public boolean equals(Object o) { + if (o == this) + return true; + + if (!(o instanceof InMessage)) + return false; + + return super.equals(o) && + mTransmission.equals(((InMessage) o).mTransmission); + } + + @Override + public int hashCode() { + int hash = super.hashCode(); + hash = 67 * hash + Objects.hashCode(this.mTransmission); + return hash; } } diff --git a/src/main/java/org/kontalk/model/KonMessage.java b/src/main/java/org/kontalk/model/message/KonMessage.java similarity index 80% rename from src/main/java/org/kontalk/model/KonMessage.java rename to src/main/java/org/kontalk/model/message/KonMessage.java index 1553d8e8..18113495 100644 --- a/src/main/java/org/kontalk/model/KonMessage.java +++ b/src/main/java/org/kontalk/model/message/KonMessage.java @@ -1,6 +1,6 @@ /* * Kontalk Java client - * Copyright (C) 2014 Kontalk Devteam + * Copyright (C) 2016 Kontalk Devteam * * 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 @@ -16,26 +16,30 @@ * along with this program. If not, see . */ -package org.kontalk.model; +package org.kontalk.model.message; +import org.kontalk.model.chat.Chat; import java.sql.ResultSet; import java.sql.SQLException; import java.util.Arrays; import java.util.Date; import java.util.EnumSet; import java.util.HashMap; -import java.util.LinkedList; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Observable; import java.util.Optional; +import java.util.Set; import java.util.logging.Level; import java.util.logging.Logger; import org.json.simple.JSONObject; import org.json.simple.JSONValue; -import org.kontalk.system.Database; +import org.kontalk.persistence.Database; import org.kontalk.crypto.Coder; -import org.kontalk.model.MessageContent.Preview; +import org.kontalk.model.Contact; +import org.kontalk.model.Model; +import org.kontalk.model.message.MessageContent.Preview; import org.kontalk.util.EncodingUtils; /** @@ -43,7 +47,7 @@ * * @author Alexander Bikadorov {@literal } */ -public abstract class KonMessage extends Observable implements Comparable { +public abstract class KonMessage extends Observable { private static final Logger LOGGER = Logger.getLogger(KonMessage.class.getName()); /** @@ -112,12 +116,13 @@ public static enum Status { // last timestamp of server transmission packet // incoming: (delayed) sent; outgoing: sent or error - protected Optional mServerDate; + protected Date mServerDate; protected Status mStatus; protected CoderStatus mCoderStatus; protected ServerError mServerError; - protected KonMessage(Chat chat, + protected KonMessage( + Chat chat, String xmppID, MessageContent content, Optional serverDate, @@ -128,28 +133,27 @@ protected KonMessage(Chat chat, mDate = new Date(); mContent = content; - mServerDate = serverDate; + mServerDate = serverDate.orElse(null); mStatus = status; mCoderStatus = coderStatus; mServerError = new ServerError(); // insert - Database db = Database.getInstance(); - List values = new LinkedList<>(); - values.add(mChat.getID()); - values.add(Database.setString(mXMPPID)); - values.add(mDate); - values.add(mStatus); + List values = Arrays.asList( + mChat.getID(), + Database.setString(mXMPPID), + mDate, + mStatus, // i simply don't like to save all possible content explicitly in the // database, so we use JSON here - values.add(mContent.toJSON()); - values.add(mCoderStatus.getEncryption()); - values.add(mCoderStatus.getSigning()); - values.add(mCoderStatus.getErrors()); - values.add(mServerError.toJSON()); - values.add(mServerDate); - - mID = db.execInsert(TABLE, values); + mContent.toJSON(), + mCoderStatus.getEncryption(), + mCoderStatus.getSigning(), + mCoderStatus.getErrors(), + mServerError.toJSON(), + mServerDate); + + mID = Model.database().execInsert(TABLE, values); if (mID <= 0) { LOGGER.log(Level.WARNING, "db, could not insert message"); } @@ -181,7 +185,7 @@ public boolean isInMessage() { return mStatus == Status.IN; } - public abstract Transmission[] getTransmissions(); + public abstract Set getTransmissions(); public String getXMPPID() { return mXMPPID; @@ -192,7 +196,7 @@ public Date getDate() { } public Optional getServerDate() { - return mServerDate; + return Optional.ofNullable(mServerDate); } public Status getStatus() { @@ -213,12 +217,12 @@ public void setAttachmentErrors(EnumSet errors) { } protected MessageContent.Attachment getAttachment() { - Optional optAttachment = this.getContent().getAttachment(); - if (!optAttachment.isPresent()) { + MessageContent.Attachment att = this.getContent().getAttachment().orElse(null); + if (att == null) { LOGGER.warning("no attachment!?"); return null; } - return optAttachment.get(); + return att; } public CoderStatus getCoderStatus() { @@ -245,8 +249,7 @@ public boolean isEncrypted() { return mCoderStatus.isEncrypted(); } - public void save() { - Database db = Database.getInstance(); + protected void save() { Map set = new HashMap<>(); set.put(COL_STATUS, mStatus); set.put(COL_CONTENT, mContent.toJSON()); @@ -255,33 +258,60 @@ public void save() { set.put(COL_COD_ERR, mCoderStatus.getErrors()); set.put(COL_SERV_ERR, Database.setString(mServerError.toJSON())); set.put(COL_SERV_DATE, mServerDate); - db.execUpdate(TABLE, set, mID); + Model.database().execUpdate(TABLE, set, mID); } - protected synchronized void changed(Object arg) { + public boolean delete() { + boolean succ = this.getTransmissions().stream().allMatch(t -> t.delete()); + if (!succ) + return false; + + if (mID < 0) { + LOGGER.warning("not in database: "+this); + return true; + } + return Model.database().execDelete(TABLE, mID); + } + + protected void changed(Object arg) { this.setChanged(); this.notifyObservers(arg); } + @Override + public boolean equals(Object o) { + if (o == this) + return true; + + if (!(o instanceof KonMessage)) + return false; + + KonMessage oMessage = (KonMessage) o; + + return mChat.equals(oMessage.mChat) + && !mXMPPID.isEmpty() && mXMPPID.equals(oMessage.mXMPPID); + } + + @Override + public int hashCode() { + int hash = 7; + hash = 17 * hash + Objects.hashCode(this.mChat); + hash = 17 * hash + Objects.hashCode(this.mXMPPID); + return hash; + } + @Override public String toString() { return "M:id="+mID+",status="+mStatus+",chat="+mChat+",xmppid="+mXMPPID - +",transmissions="+Arrays.toString(this.getTransmissions()) + +",transmissions="+this.getTransmissions() +",date="+mDate+",sdate="+mServerDate +",cont="+mContent +",codstat="+mCoderStatus+",serverr="+mServerError; } - // TODO remove - @Override - public int compareTo(KonMessage o) { - if (this.equals(o)) - return 0; - - return Integer.compare(mID, o.getID()); - } - - static KonMessage load(ResultSet messageRS, Chat chat) throws SQLException { + public static KonMessage load(Database db, ResultSet messageRS, Chat chat, + Map contactMap) + throws SQLException { int id = messageRS.getInt("_id"); String xmppID = Database.getString(messageRS, KonMessage.COL_XMPP_ID); @@ -315,8 +345,8 @@ static KonMessage load(ResultSet messageRS, Chat chat) throws SQLException { Date serverDate = sDate == 0 ? null : new Date(sDate); KonMessage.Builder builder = new KonMessage.Builder(id, chat, status, date, content); - // TODO one SQL SELECT for each message, performance? - builder.transmissions(Transmission.load(id)); + // TODO one SQL SELECT for each message, performance? looks ok + builder.transmissions(Transmission.load(id, contactMap)); builder.xmppID(xmppID); if (serverDate != null) builder.serverDate(serverDate); @@ -371,10 +401,10 @@ protected static class Builder { private final Date mDate; private final MessageContent mContent; - protected Transmission[] mTransmissions = null; + protected Set mTransmissions = null; private String mXMPPID = null; - private Optional mServerDate = null; + private Date mServerDate = null; private CoderStatus mCoderStatus = null; private ServerError mServerError = null; @@ -390,17 +420,14 @@ private Builder(int id, mContent = content; } - private void transmissions(Transmission[] transmission) { mTransmissions = transmission; } + private void transmissions(Set transmission) { mTransmissions = transmission; } private void xmppID(String xmppID) { mXMPPID = xmppID; } - private void serverDate(Date date) { mServerDate = Optional.of(date); } + private void serverDate(Date date) { mServerDate = date; } private void coderStatus(CoderStatus coderStatus) { mCoderStatus = coderStatus; } private void serverError(ServerError error) { mServerError = error; } private KonMessage build() { - if (mServerDate == null) - mServerDate = Optional.empty(); - if (mTransmissions == null || mXMPPID == null || mCoderStatus == null || diff --git a/src/main/java/org/kontalk/model/MessageContent.java b/src/main/java/org/kontalk/model/message/MessageContent.java similarity index 74% rename from src/main/java/org/kontalk/model/MessageContent.java rename to src/main/java/org/kontalk/model/message/MessageContent.java index be94658f..af2efb00 100644 --- a/src/main/java/org/kontalk/model/MessageContent.java +++ b/src/main/java/org/kontalk/model/message/MessageContent.java @@ -1,6 +1,6 @@ /* * Kontalk Java client - * Copyright (C) 2014 Kontalk Devteam + * Copyright (C) 2016 Kontalk Devteam * * 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 @@ -16,23 +16,25 @@ * along with this program. If not, see . */ -package org.kontalk.model; +package org.kontalk.model.message; import org.kontalk.misc.JID; import java.net.URI; import java.nio.file.Path; import java.nio.file.Paths; -import java.util.ArrayList; +import java.util.Collections; import java.util.EnumSet; import java.util.List; import java.util.Map; import java.util.Optional; import java.util.logging.Level; import java.util.logging.Logger; +import java.util.stream.Collectors; import org.json.simple.JSONObject; import org.json.simple.JSONValue; import org.kontalk.crypto.Coder; -import org.kontalk.model.GroupChat.GID; +import org.kontalk.model.Model; +import org.kontalk.model.chat.GroupMetaData.KonGroupData; import org.kontalk.util.EncodingUtils; /** @@ -48,16 +50,18 @@ public class MessageContent { private final String mPlainText; // encrypted content, empty string if not present private String mEncryptedContent; + // temporary encrypted data, not saved to database + private byte[] mEncryptedData; // attachment (file url, path and metadata) - private final Optional mOptAttachment; + private final Attachment mAttachment; // small preview file of attachment - private Optional mOptPreview; + private Preview mPreview; // group id - private final Optional mOptGID; + private final KonGroupData mGroupData; // group command - private final Optional mOptGroupCommand; + private final GroupCommand mGroupCommand; // decrypted message content - private Optional mOptDecryptedContent; + private MessageContent mDecryptedContent; private static final String JSON_PLAIN_TEXT = "plain_text"; private static final String JSON_ENC_CONTENT = "encrypted_content"; @@ -86,11 +90,11 @@ public static MessageContent groupCommand(GroupCommand group) { private MessageContent(Builder builder) { mPlainText = builder.mPlainText; mEncryptedContent = builder.mEncrypted; - mOptAttachment = Optional.ofNullable(builder.mAttachment); - mOptPreview = Optional.ofNullable(builder.mPreview); - mOptGID = Optional.ofNullable(builder.mGID); - mOptGroupCommand = Optional.ofNullable(builder.mGroup); - mOptDecryptedContent = Optional.ofNullable(builder.mDecrypted); + mAttachment = builder.mAttachment; + mPreview = builder.mPreview; + mGroupData = builder.mGroupData; + mGroupCommand = builder.mGroup; + mDecryptedContent = builder.mDecrypted; } /** @@ -99,8 +103,8 @@ private MessageContent(Builder builder) { * plain text either return an empty string. */ public String getText() { - if (mOptDecryptedContent.isPresent()) - return mOptDecryptedContent.get().getPlainText(); + if (mDecryptedContent != null) + return mDecryptedContent.getPlainText(); else return mPlainText; } @@ -110,54 +114,62 @@ public String getPlainText() { } public Optional getAttachment() { - if (mOptDecryptedContent.isPresent() && - mOptDecryptedContent.get().getAttachment().isPresent()) { - return mOptDecryptedContent.get().getAttachment(); + if (mDecryptedContent != null && + mDecryptedContent.getAttachment().isPresent()) { + return mDecryptedContent.getAttachment(); } - return mOptAttachment; + return Optional.ofNullable(mAttachment); } public String getEncryptedContent() { return mEncryptedContent; } - public void setDecryptedContent(MessageContent decryptedContent) { - assert !mOptDecryptedContent.isPresent(); - mOptDecryptedContent = Optional.of(decryptedContent); + void setDecryptedContent(MessageContent decryptedContent) { + assert mDecryptedContent == null; + mDecryptedContent = decryptedContent; // deleting encrypted data! mEncryptedContent = ""; } + public Optional getEncryptedData() { + return Optional.ofNullable(mEncryptedData); + } + + public void setEncryptedData(byte[] encryptedData) { + mEncryptedData = encryptedData; + } + public Optional getPreview() { - if (mOptDecryptedContent.isPresent() && - mOptDecryptedContent.get().getPreview().isPresent()) { - return mOptDecryptedContent.get().getPreview(); + if (mDecryptedContent != null && + mDecryptedContent.getPreview().isPresent()) { + return mDecryptedContent.getPreview(); } - return mOptPreview; + return Optional.ofNullable(mPreview); } void setPreview(Preview preview) { - if (mOptPreview.isPresent()) { + if (mPreview != null) { LOGGER.warning("preview already present, not overwriting"); return; } - mOptPreview = Optional.of(preview); + mPreview = preview; } - public Optional getGID() { - if (mOptDecryptedContent.isPresent() && - mOptDecryptedContent.get().getGID().isPresent()) { - return mOptDecryptedContent.get().getGID(); + public Optional getGroupData() { + if (mDecryptedContent != null && + mDecryptedContent.getGroupData().isPresent()) { + return mDecryptedContent.getGroupData(); } - return mOptGID; + return Optional.ofNullable(mGroupData); } public Optional getGroupCommand() { - if (mOptDecryptedContent.isPresent() && - mOptDecryptedContent.get().getGroupCommand().isPresent()) { - return mOptDecryptedContent.get().getGroupCommand(); + if (mDecryptedContent != null && + mDecryptedContent.getGroupCommand().isPresent()) { + return mDecryptedContent.getGroupCommand(); } - return mOptGroupCommand; + return Optional.ofNullable(mGroupCommand); } /** @@ -167,21 +179,21 @@ public Optional getGroupCommand() { public boolean isEmpty() { return mPlainText.isEmpty() && mEncryptedContent.isEmpty() && - !mOptAttachment.isPresent() && - !mOptPreview.isPresent() && - !mOptDecryptedContent.isPresent() && - !mOptGroupCommand.isPresent(); + mAttachment == null && + mPreview == null && + mDecryptedContent == null && + mGroupCommand == null; } public boolean isComplex() { - return mOptAttachment.isPresent() || mOptGroupCommand.isPresent(); + return mAttachment != null || mGroupCommand != null; } @Override public String toString() { return "CONT:plain="+mPlainText+",encr="+mEncryptedContent - +",att="+mOptAttachment+",gid="+mOptGID+",gc="+mOptGroupCommand - +",decr="+mOptDecryptedContent; + +",att="+mAttachment+",gd="+mGroupData+",gc="+mGroupCommand + +",decr="+mDecryptedContent; } // using legacy lib, raw types extend Object @@ -191,19 +203,19 @@ String toJSON() { EncodingUtils.putJSON(json, JSON_PLAIN_TEXT, mPlainText); - if (mOptAttachment.isPresent()) - json.put(JSON_ATTACHMENT, mOptAttachment.get().toJSONString()); + if (mAttachment != null) + json.put(JSON_ATTACHMENT, mAttachment.toJSONString()); EncodingUtils.putJSON(json, JSON_ENC_CONTENT, mEncryptedContent); - if (mOptPreview.isPresent()) - json.put(JSON_PREVIEW, mOptPreview.get().toJSON()); + if (mPreview != null) + json.put(JSON_PREVIEW, mPreview.toJSON()); - if (mOptGroupCommand.isPresent()) - json.put(JSON_GROUP_COMMAND, mOptGroupCommand.get().toJSON()); + if (mGroupCommand != null) + json.put(JSON_GROUP_COMMAND, mGroupCommand.toJSON()); - if (mOptDecryptedContent.isPresent()) - json.put(JSON_DEC_CONTENT, mOptDecryptedContent.get().toJSON()); + if (mDecryptedContent != null) + json.put(JSON_DEC_CONTENT, mDecryptedContent.toJSON()); return json.toJSONString(); } @@ -256,30 +268,15 @@ public static class Attachment { private URI mURL; // file name of downloaded file or path to upload file, empty by default private Path mFile; - // MIME of file, empty string by default - private final String mMimeType; - // size of (decrypted) file in bytes, -1 by default - private final long mLength; + // MIME of file, only used for outgoing, empty string by default + private String mMimeType; + // size of (decrypted) upload file in bytes, -1 by default + private long mLength; // coder status of file encryption private final CoderStatus mCoderStatus; // progress downloaded of (encrypted) file in percent private int mDownloadProgress = -1; - // used for outgoing attachments - public Attachment(Path path, String mimeType, long length) { - this(URI.create(""), path, mimeType, length, - CoderStatus.createInsecure()); - } - - // used for incoming attachments - public Attachment(URI url, String mimeType, long length, - boolean encrypted) { - this(url, Paths.get(""), mimeType, length, - encrypted ? CoderStatus.createEncrypted() : - CoderStatus.createInsecure() - ); - } - // used when loading from database. private Attachment(URI url, Path file, String mimeType, long length, @@ -291,6 +288,22 @@ private Attachment(URI url, Path file, mCoderStatus = coderStatus; } + // used for incoming attachments + public static Attachment incoming(URI url, long length, + boolean encrypted) { + return new Attachment(url, Paths.get(""), "", length, + encrypted ? + CoderStatus.createEncrypted() : + CoderStatus.createInsecure() + ); + } + + // used for outgoing attachments + public static Attachment outgoing(Path path, String mimeType) { + return new Attachment(URI.create(""), path, mimeType, -1, + CoderStatus.createInsecure()); + } + public boolean hasURL() { return !mURL.toString().isEmpty(); } @@ -299,8 +312,10 @@ public URI getURL() { return mURL; } - public void setURL(URI url){ + void updateUploaded(URI url, String mime, long length){ mURL = url; + mMimeType = mime; + mLength = length; } public String getMimeType() { @@ -314,7 +329,7 @@ public long getLength() { /** * Return the filename (download) or path to the local file (upload). */ - public Path getFile() { + public Path getFilePath() { return mFile; } @@ -342,7 +357,7 @@ public int getDownloadProgress() { return mDownloadProgress; } - /** Set download progress. See .getDownloadProgress() */ + /** Set download progress. See getDownloadProgress() */ void setDownloadProgress(int p) { mDownloadProgress = p; } @@ -350,7 +365,7 @@ void setDownloadProgress(int p) { @Override public String toString() { return "{ATT:url="+mURL+",file="+mFile+",mime="+mMimeType - +",status="+mCoderStatus+"}"; + +",length="+mLength+",status="+mCoderStatus+"}"; } // using legacy lib, raw types extend Object @@ -489,29 +504,33 @@ public enum OP { } private final OP mOP; - private final JID[] mJIDsAdded; - private final JID[] mJIDsRemoved; + private final List mAdded; + private final List mRemoved; private final String mSubject; /** Group creation. */ - public static GroupCommand create(JID[] added, String subject) { - return new GroupCommand(OP.CREATE, added, new JID[0], subject); + public static GroupCommand create(List added, String subject) { + return new GroupCommand(OP.CREATE, added, Collections.emptyList(), subject); } /** Group changed. */ - public static GroupCommand set(JID[] added, JID[] removed, String subject) { + public static GroupCommand set(List added, List removed, String subject) { return new GroupCommand(OP.SET, added, removed, subject); } + public static GroupCommand set(String subject) { + return new GroupCommand(OP.SET, Collections.emptyList(), Collections.emptyList(), subject); + } + /** Member left. Identified by sender JID */ public static GroupCommand leave() { - return new GroupCommand(OP.LEAVE, new JID[0], new JID[0], ""); + return new GroupCommand(OP.LEAVE, Collections.emptyList(), Collections.emptyList(), ""); } - private GroupCommand(OP operation, JID[] added, JID[] removed, String subject) { + private GroupCommand(OP operation, List added, List removed, String subject) { mOP = operation; - mJIDsAdded = added; - mJIDsRemoved = removed; + mAdded = added; + mRemoved = removed; mSubject = subject; } @@ -519,12 +538,17 @@ public OP getOperation() { return mOP; } - public JID[] getAdded() { - return mJIDsAdded; + public List getAdded() { + return mAdded; } - public JID[] getRemoved() { - return mJIDsRemoved; + public boolean isAddingMe() { + JID myJID = Model.getUserJID(); + return mAdded.stream().anyMatch(jid -> jid.equals(myJID)); + } + + public List getRemoved() { + return mRemoved; } public String getSubject() { @@ -538,14 +562,14 @@ private String toJSON() { json.put(JSON_OP, mOP.ordinal()); EncodingUtils.putJSON(json, JSON_SUBJECT, mSubject); - List added = new ArrayList(mJIDsAdded.length); - for (JID jid: mJIDsAdded) - added.add(jid.string()); + List added = mAdded.stream() + .map(jid -> jid.string()) + .collect(Collectors.toList()); json.put(JSON_ADDED, added); - List removed = new ArrayList(mJIDsRemoved.length); - for (JID jid: mJIDsAdded) - removed.add(jid.string()); + List removed = mRemoved.stream() + .map(jid -> jid.string()) + .collect(Collectors.toList()); json.put(JSON_REMOVED, removed); return json.toJSONString(); @@ -564,19 +588,17 @@ private static GroupCommand fromJSONOrNull(String json) { String subj = EncodingUtils.getJSONString(map, JSON_SUBJECT); List a = (List) map.get(JSON_ADDED); - List added = new ArrayList<>(a.size()); - for (String s: a) - added.add(JID.bare(s)); + List added = a.stream() + .map(s -> JID.bare(s)) + .collect(Collectors.toList()); List r = (List) map.get(JSON_REMOVED); - List removed = new ArrayList<>(r.size()); - for (String s: r) - removed.add(JID.bare(s)); - - return new GroupCommand(op, - added.toArray(new JID[0]), removed.toArray(new JID[0]), - subj); - } catch (NullPointerException | ClassCastException ex) { + List removed = r.stream() + .map(s -> JID.bare(s)) + .collect(Collectors.toList()); + + return new GroupCommand(op, added, removed, subj); + } catch (NullPointerException | ClassCastException ex) { LOGGER.log(Level.WARNING, "can't parse JSON group command", ex); LOGGER.log(Level.WARNING, "JSON='"+json+"'"); return null; @@ -595,7 +617,7 @@ public static class Builder { private Attachment mAttachment = null; private Preview mPreview = null; - private GID mGID = null; + private KonGroupData mGroupData = null; private GroupCommand mGroup = null; private MessageContent mDecrypted = null; @@ -608,8 +630,8 @@ public Builder attachment(Attachment attachment) { mAttachment = attachment; return this; }; public Builder preview(Preview preview) { mPreview = preview; return this; }; - public Builder gid(GID gid) { - mGID = gid; return this; }; + public Builder groupData(KonGroupData gData) { + mGroupData = gData; return this; }; public Builder groupCommand(GroupCommand group) { mGroup = group; return this; }; private Builder decryptedContent(MessageContent decrypted) { diff --git a/src/main/java/org/kontalk/model/OutMessage.java b/src/main/java/org/kontalk/model/message/OutMessage.java similarity index 52% rename from src/main/java/org/kontalk/model/OutMessage.java rename to src/main/java/org/kontalk/model/message/OutMessage.java index 91f5ba77..e44db314 100644 --- a/src/main/java/org/kontalk/model/OutMessage.java +++ b/src/main/java/org/kontalk/model/message/OutMessage.java @@ -1,6 +1,6 @@ /* * Kontalk Java client - * Copyright (C) 2014 Kontalk Devteam + * Copyright (C) 2016 Kontalk Devteam * * 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 @@ -16,29 +16,36 @@ * along with this program. If not, see . */ -package org.kontalk.model; +package org.kontalk.model.message; +import org.kontalk.model.chat.Chat; import org.kontalk.misc.JID; import java.net.URI; +import java.util.Collections; import java.util.Date; import java.util.HashSet; +import java.util.List; import java.util.Optional; import java.util.Set; import java.util.logging.Logger; -import org.jivesoftware.smack.util.StringUtils; +import org.kontalk.crypto.Coder; +import org.kontalk.model.Contact; +import org.kontalk.util.EncodingUtils; /** - * Model for an XMPP message that we are sending. + * Model for an XMPP message from the user to a contact. * @author Alexander Bikadorov {@literal } */ public final class OutMessage extends KonMessage { private static final Logger LOGGER = Logger.getLogger(OutMessage.class.getName()); - private final Transmission[] mTransmissions; + private final Set mTransmissions; - public OutMessage(Chat chat, Contact[] contacts, MessageContent content, boolean encrypted) { - super(chat, - "Kon_" + StringUtils.randomString(8), + public OutMessage(Chat chat, List contacts, + MessageContent content, boolean encrypted) { + super( + chat, + "Kon_" + EncodingUtils.randomString(8), content, Optional.empty(), Status.PENDING, @@ -46,41 +53,37 @@ public OutMessage(Chat chat, Contact[] contacts, MessageContent content, boolean CoderStatus.createToEncrypt() : CoderStatus.createInsecure()); - Set t = new HashSet<>(); - for (Contact contact: contacts) { - t.add(new Transmission(contact, contact.getJID(), mID)); - } - mTransmissions = t.toArray(new Transmission[0]); + Set ts = new HashSet<>(); + contacts.stream().forEach(contact -> { + boolean succ = ts.add(new Transmission(contact, contact.getJID(), mID)); + if (!succ) + LOGGER.warning("duplicate contact: "+contact); + }); + mTransmissions = Collections.unmodifiableSet(ts); } // used when loading from database protected OutMessage(KonMessage.Builder builder) { super(builder); - mTransmissions = builder.mTransmissions; + mTransmissions = Collections.unmodifiableSet(builder.mTransmissions); } public void setReceived(JID jid) { - Transmission transmission = null; - for (Transmission t: mTransmissions) { - if (t.getContact().getJID().equals(jid)) { - transmission = t; - break; - } - } - - if (transmission == null) { - LOGGER.warning("can't find transmission for received status, IDs: "+jid); - return; - } - - if (transmission.isReceived()) - // probably by another client - return; - - transmission.setReceived(new Date()); - // status only dummy value - this.changed(mStatus); + Transmission transmission = mTransmissions.stream() + .filter(t -> t.getContact().getJID().equals(jid)) + .findFirst().orElse(null); + if (transmission == null) { + LOGGER.warning("can't find transmission for received status, IDs: "+jid); + return; + } + + if (transmission.isReceived()) + // probably by another client + return; + + transmission.setReceived(new Date()); + this.changed(mStatus); } public void setStatus(Status status) { @@ -94,7 +97,7 @@ public void setStatus(Status status) { mStatus = status; if (status != Status.PENDING) - mServerDate = Optional.of(new Date()); + mServerDate = new Date(); this.save(); this.changed(mStatus); } @@ -107,18 +110,41 @@ public void setServerError(String condition, String text) { this.setStatus(Status.ERROR); } - public void setAttachmentURL(URI url) { + /** Update attachment after upload. */ + public void setUpload(URI url, String mime, long length) { MessageContent.Attachment attachment = this.getAttachment(); if (attachment == null) return; - attachment.setURL(url); + attachment.updateUploaded(url, mime, length); this.save(); } + public boolean isSendEncrypted() { + return mCoderStatus.getEncryption() != Coder.Encryption.NOT || + mCoderStatus.getSigning() != Coder.Signing.NOT; + } + @Override - public Transmission[] getTransmissions() { + public Set getTransmissions() { return mTransmissions; } + @Override + public boolean equals(Object o) { + if (o == this) + return true; + + // outmessages are only equal to outmessages + if (!(o instanceof OutMessage)) + return false; + + return super.equals(o); + } + + @Override + public int hashCode() { + int hash = 97 * super.hashCode(); + return hash; + } } diff --git a/src/main/java/org/kontalk/model/ProtoMessage.java b/src/main/java/org/kontalk/model/message/ProtoMessage.java similarity index 85% rename from src/main/java/org/kontalk/model/ProtoMessage.java rename to src/main/java/org/kontalk/model/message/ProtoMessage.java index b4a5c0ce..d265e59a 100644 --- a/src/main/java/org/kontalk/model/ProtoMessage.java +++ b/src/main/java/org/kontalk/model/message/ProtoMessage.java @@ -1,6 +1,6 @@ /* * Kontalk Java client - * Copyright (C) 2014 Kontalk Devteam + * Copyright (C) 2016 Kontalk Devteam * * 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 @@ -16,13 +16,14 @@ * along with this program. If not, see . */ -package org.kontalk.model; +package org.kontalk.model.message; import java.util.EnumSet; import org.kontalk.crypto.Coder; +import org.kontalk.model.Contact; /** - * An incoming message not saved to database for decryption. + * An incoming message used for decryption. Not saved to database. * * @author Alexander Bikadorov {@literal } */ @@ -30,8 +31,7 @@ public final class ProtoMessage implements DecryptMessage { private final Contact mContact; private final CoderStatus mCoderStatus; - - private MessageContent mContent; + private final MessageContent mContent; public ProtoMessage(Contact contact, MessageContent content) { mContact = contact; @@ -83,4 +83,8 @@ public void setSecurityErrors(EnumSet errors) { mCoderStatus.setSecurityErrors(errors); } + @Override + public String toString() { + return "PM:contact="+mContact+",content="+mContent+",codstat="+mCoderStatus; + } } diff --git a/src/main/java/org/kontalk/model/Transmission.java b/src/main/java/org/kontalk/model/message/Transmission.java similarity index 61% rename from src/main/java/org/kontalk/model/Transmission.java rename to src/main/java/org/kontalk/model/message/Transmission.java index 0614680d..46500d5b 100644 --- a/src/main/java/org/kontalk/model/Transmission.java +++ b/src/main/java/org/kontalk/model/message/Transmission.java @@ -1,6 +1,6 @@ /* * Kontalk Java client - * Copyright (C) 2014 Kontalk Devteam + * Copyright (C) 2016 Kontalk Devteam * * 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 @@ -16,21 +16,26 @@ * along with this program. If not, see . */ -package org.kontalk.model; +package org.kontalk.model.message; import org.kontalk.misc.JID; import java.sql.ResultSet; import java.sql.SQLException; -import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; import java.util.Date; import java.util.HashMap; -import java.util.LinkedList; +import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Optional; +import java.util.Set; import java.util.logging.Level; import java.util.logging.Logger; -import org.kontalk.system.Database; +import org.kontalk.model.Contact; +import org.kontalk.model.Model; +import org.kontalk.persistence.Database; /** * A transmission of one message. @@ -40,7 +45,7 @@ final public class Transmission { private static final Logger LOGGER = Logger.getLogger(Transmission.class.getName()); public static final String TABLE = "transmissions"; - static final String COL_MESSAGE_ID = "message_id"; + public static final String COL_MESSAGE_ID = "message_id"; private static final String COL_CONTACT_ID = "user_id"; private static final String COL_JID = "jid"; private static final String COL_REC_DATE = "received_date"; @@ -62,21 +67,21 @@ final public class Transmission { private final Contact mContact; private final JID mJID; - protected Optional mReceivedDate; + private Date mReceivedDate; Transmission(Contact contact, JID jid, int messageID) { mContact = contact; mJID = jid; - mReceivedDate = Optional.empty(); + mReceivedDate = null; mID = this.insert(messageID); } - private Transmission(int id, Contact contact, JID jid, Date receivedDate) { + private Transmission(Database db, int id, Contact contact, JID jid, Date receivedDate) { mID = id; mContact = contact; mJID = jid; - mReceivedDate = Optional.ofNullable(receivedDate); + mReceivedDate = receivedDate; } public Contact getContact() { @@ -88,40 +93,45 @@ public JID getJID() { } public Optional getReceivedDate() { - return mReceivedDate; + return Optional.ofNullable(mReceivedDate); } public boolean isReceived() { - return mReceivedDate.isPresent(); + return mReceivedDate != null; } void setReceived(Date date) { - mReceivedDate = Optional.of(date); + mReceivedDate = date; this.save(); } private int insert(int messageID) { - Database db = Database.getInstance(); + List values = Arrays.asList( + messageID, + mContact.getID(), + mJID, + mReceivedDate); - List values = new LinkedList<>(); - values.add(messageID); - values.add(mContact.getID()); - values.add(mJID); - values.add(mReceivedDate); - - int id = db.execInsert(TABLE, values); + int id = Model.database().execInsert(TABLE, values); if (id <= 0) { - LOGGER.log(Level.WARNING, "db, could not insert"); + LOGGER.log(Level.WARNING, "could not insert"); return -2; } return id; } private void save() { - Database db = Database.getInstance(); Map set = new HashMap<>(); set.put(COL_REC_DATE, mReceivedDate); - db.execUpdate(TABLE, set, mID); + Model.database().execUpdate(TABLE, set, mID); + } + + boolean delete() { + if (mID < 0) { + LOGGER.warning("not in database: "+this); + return true; + } + return Model.database().execDelete(TABLE, mID); } @Override @@ -129,29 +139,31 @@ public String toString() { return "T:id="+mID+",contact="+mContact+",jid="+mJID+",recdate="+mReceivedDate; } - static Transmission[] load(int messageID) { - Database db = Database.getInstance(); - ArrayList ts = new ArrayList<>(); + static Set load(int messageID, Map contactMap) { + Database db = Model.database(); + HashSet ts = new HashSet<>(); try (ResultSet transmissionRS = db.execSelectWhereInsecure(TABLE, COL_MESSAGE_ID + " == " + messageID)) { while (transmissionRS.next()) { - ts.add(load(transmissionRS)); + ts.add(load(db, transmissionRS, contactMap)); } } catch (SQLException ex) { LOGGER.log(Level.WARNING, "can't load transmission(s) from db", ex); - return new Transmission[0]; + return Collections.emptySet(); } if (ts.isEmpty()) LOGGER.warning("no transmission(s) found, messageID: "+messageID); - return ts.toArray(new Transmission[0]); + return ts; } - private static Transmission load(ResultSet resultSet) throws SQLException { + private static Transmission load(Database db, ResultSet resultSet, + Map contactMap) + throws SQLException { int id = resultSet.getInt("_id"); int contactID = resultSet.getInt(COL_CONTACT_ID); - Optional optContact = ContactList.getInstance().get(contactID); - if (!optContact.isPresent()) { + Contact contact = contactMap.get(contactID); + if (contact == null) { LOGGER.warning("can't find contact in db, id: "+contactID); return null; } @@ -159,6 +171,28 @@ private static Transmission load(ResultSet resultSet) throws SQLException { long rDate = resultSet.getLong(COL_REC_DATE); Date receivedDate = rDate == 0 ? null : new Date(rDate); - return new Transmission(id, optContact.get(), jid, receivedDate); + return new Transmission(db, id, contact, jid, receivedDate); + } + + @Override + public boolean equals(Object o) { + if (o == this) + return true; + + if (!(o instanceof Transmission)) + return false; + + Transmission oTransmission = (Transmission) o; + + return mContact.equals(oTransmission.mContact) + && mJID.equals(oTransmission.mJID); + } + + @Override + public int hashCode() { + int hash = 7; + hash = 71 * hash + Objects.hashCode(this.mContact); + hash = 71 * hash + Objects.hashCode(this.mJID); + return hash; } } diff --git a/src/main/java/org/kontalk/system/Config.java b/src/main/java/org/kontalk/persistence/Config.java similarity index 76% rename from src/main/java/org/kontalk/system/Config.java rename to src/main/java/org/kontalk/persistence/Config.java index bd5d7641..8f05c0d7 100644 --- a/src/main/java/org/kontalk/system/Config.java +++ b/src/main/java/org/kontalk/persistence/Config.java @@ -1,6 +1,6 @@ /* * Kontalk Java client - * Copyright (C) 2014 Kontalk Devteam + * Copyright (C) 2016 Kontalk Devteam * * 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 @@ -16,12 +16,11 @@ * along with this program. If not, see . */ -package org.kontalk.system; +package org.kontalk.persistence; import java.nio.file.Path; import java.util.HashMap; import java.util.Map; -import java.util.Map.Entry; import java.util.logging.Level; import java.util.logging.Logger; import org.apache.commons.configuration.ConfigurationException; @@ -29,7 +28,6 @@ import org.kontalk.util.Tr; /** - * * Global configuration options. * * @author Alexander Bikadorov {@literal } @@ -39,7 +37,8 @@ public final class Config extends PropertiesConfiguration { private static Config INSTANCE = null; - public static final String FILENAME = "kontalk.properties"; + private static final String FILENAME = "kontalk.properties"; + // all configuration property keys // disable network property for now -> same as server host //public static final String SERV_NET = "server.network"; @@ -52,9 +51,13 @@ public final class Config extends PropertiesConfiguration { public static final String VIEW_FRAME_HEIGHT = "view.frame.height"; public static final String VIEW_SELECTED_CHAT = "view.thread"; public static final String VIEW_CHAT_BG = "view.thread_bg"; + public static final String VIEW_USER_CONTACT = "view.user_in_contactlist"; public static final String NET_SEND_CHAT_STATE = "net.chatstate"; public static final String NET_SEND_ROSTER_NAME = "net.roster_name"; public static final String NET_STATUS_LIST = "net.status_list"; + public static final String NET_AUTO_SUBSCRIPTION = "net.auto_subscription"; + public static final String NET_REQUEST_AVATARS = "net.request_avatars"; + public static final String NET_MAX_IMG_SIZE = "net.max_img_size"; public static final String MAIN_CONNECT_STARTUP = "main.connect_startup"; public static final String MAIN_TRAY = "main.tray"; public static final String MAIN_TRAY_CLOSE = "main.tray_close"; @@ -64,7 +67,8 @@ public final class Config extends PropertiesConfiguration { //public static final String DEFAULT_SERV_NET = "kontalk.net"; public static final String DEFAULT_SERV_HOST = "beta.kontalk.net"; public static final int DEFAULT_SERV_PORT = 5999; - private static final String DEFAULT_XMPP_STATUS = + + private final String mDefaultXMPPStatus = Tr.tr("Hey, I'm using Kontalk on my PC!"); private Config(Path configFile) { @@ -76,7 +80,7 @@ private Config(Path configFile) { try { this.load(configFile.toString()); } catch (ConfigurationException ex) { - LOGGER.info("Configuration not found. Using default values"); + LOGGER.info("configuration file not found; using default values"); } this.setFileName(configFile.toString()); @@ -93,19 +97,21 @@ private Config(Path configFile) { map.put(VIEW_FRAME_HEIGHT, 650); map.put(VIEW_SELECTED_CHAT, -1); map.put(VIEW_CHAT_BG, ""); + map.put(VIEW_USER_CONTACT, false); map.put(NET_SEND_CHAT_STATE, true); map.put(NET_SEND_ROSTER_NAME, false); - map.put(NET_STATUS_LIST, new String[]{DEFAULT_XMPP_STATUS}); + map.put(NET_STATUS_LIST, new String[]{mDefaultXMPPStatus}); + map.put(NET_AUTO_SUBSCRIPTION, false); + map.put(NET_REQUEST_AVATARS, true); + map.put(NET_MAX_IMG_SIZE, -1); map.put(MAIN_CONNECT_STARTUP, true); map.put(MAIN_TRAY, true); map.put(MAIN_TRAY_CLOSE, false); map.put(MAIN_ENTER_SENDS, true); - for(Entry e : map.entrySet()) { - if (!this.containsKey(e.getKey())) { - this.setProperty(e.getKey(), e.getValue()); - } - } + map.entrySet().stream() + .filter(e -> !this.containsKey(e.getKey())) + .forEach(e -> this.setProperty(e.getKey(), e.getValue())); } public void saveToFile() { @@ -116,17 +122,19 @@ public void saveToFile() { } } - public synchronized static void initialize(Path configFile) { + public static void initialize(Path appDir) { if (INSTANCE != null) { - LOGGER.warning("configuration already initialized"); + LOGGER.warning("already initialized"); return; } - INSTANCE = new Config(configFile); + + INSTANCE = new Config(appDir.resolve(Config.FILENAME)); } - public synchronized static Config getInstance() { + public static Config getInstance() { if (INSTANCE == null) - throw new IllegalStateException("configuration not initialized"); + throw new IllegalStateException("not initialized"); + return INSTANCE; } } diff --git a/src/main/java/org/kontalk/system/Database.java b/src/main/java/org/kontalk/persistence/Database.java similarity index 85% rename from src/main/java/org/kontalk/system/Database.java rename to src/main/java/org/kontalk/persistence/Database.java index e4b4e333..6037aa62 100644 --- a/src/main/java/org/kontalk/system/Database.java +++ b/src/main/java/org/kontalk/persistence/Database.java @@ -1,6 +1,6 @@ /* * Kontalk Java client - * Copyright (C) 2014 Kontalk Devteam + * Copyright (C) 2016 Kontalk Devteam * * 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 @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -package org.kontalk.system; +package org.kontalk.persistence; import java.nio.file.Path; import java.sql.Connection; @@ -34,13 +34,15 @@ import java.util.Optional; import java.util.logging.Level; import java.util.logging.Logger; +import java.util.stream.Collectors; import org.apache.commons.lang.StringUtils; import org.kontalk.misc.JID; import org.kontalk.misc.KonException; -import org.kontalk.model.KonMessage; -import org.kontalk.model.Chat; +import org.kontalk.model.message.KonMessage; +import org.kontalk.model.chat.Chat; import org.kontalk.model.Contact; -import org.kontalk.model.Transmission; +import org.kontalk.model.chat.Member; +import org.kontalk.model.message.Transmission; import org.kontalk.util.EncodingUtils; import org.sqlite.SQLiteConfig; @@ -57,19 +59,17 @@ public final class Database { private static final Logger LOGGER = Logger.getLogger(Database.class.getName()); - private static Database INSTANCE = null; - - public static final String FILENAME = "kontalk_db.sqlite"; public static final String SQL_ID = "_id INTEGER PRIMARY KEY AUTOINCREMENT, "; - private static final int DB_VERSION = 3; + private static final String FILENAME = "kontalk_db.sqlite"; + private static final int DB_VERSION = 5; private static final String SQL_CREATE = "CREATE TABLE IF NOT EXISTS "; private static final String SV = "schema_version"; private static final String UV = "user_version"; private Connection mConn = null; - private Database(Path path) throws KonException { + public Database(Path appDir) throws KonException { // load the sqlite-JDBC driver using the current class loader try { Class.forName("org.sqlite.JDBC"); @@ -79,6 +79,7 @@ private Database(Path path) throws KonException { } // create database connection + Path path = appDir.resolve(FILENAME); SQLiteConfig config = new SQLiteConfig(); config.enforceForeignKeys(true); try { @@ -91,8 +92,8 @@ private Database(Path path) throws KonException { } try { - // this is already the default - mConn.setAutoCommit(true); + // setting to false! + mConn.setAutoCommit(false); } catch (SQLException ex) { LOGGER.log(Level.WARNING, "can't set autocommit", ex); } @@ -110,9 +111,10 @@ private Database(Path path) throws KonException { try (Statement stat = mConn.createStatement()) { // set version mConn.createStatement().execute("PRAGMA "+UV+" = "+DB_VERSION); + this.commit(); this.createTable(stat, Contact.TABLE, Contact.SCHEMA); this.createTable(stat, Chat.TABLE, Chat.SCHEMA); - this.createTable(stat, Chat.RECEIVER_TABLE, Chat.RECEIVER_SCHEMA); + this.createTable(stat, Member.TABLE, Member.SCHEMA); this.createTable(stat, KonMessage.TABLE, KonMessage.SCHEMA); this.createTable(stat, Transmission.TABLE, Transmission.SCHEMA); } catch (SQLException ex) { @@ -131,10 +133,12 @@ private Database(Path path) throws KonException { return; } LOGGER.config("version: "+version); - try { - this.update(version); - } catch (SQLException ex) { - LOGGER.log(Level.WARNING, "can't update db", ex); + if (version < DB_VERSION) { + try { + this.update(version); + } catch (SQLException ex) { + LOGGER.log(Level.WARNING, "can't update db", ex); + } } } @@ -143,9 +147,6 @@ private void createTable(Statement stat, String table, String schema) throws SQL } private void update(int fromVersion) throws SQLException { - if (fromVersion >= DB_VERSION) - return; - if (fromVersion < 1) { mConn.createStatement().execute("ALTER TABLE "+Chat.TABLE+ " ADD COLUMN "+Chat.COL_VIEW_SET+" NOT NULL DEFAULT '{}'"); @@ -173,20 +174,29 @@ private void update(int fromVersion) throws SQLException { mConn.createStatement().execute("PRAGMA foreign_keys=ON"); mConn.createStatement().execute("ALTER TABLE "+Chat.TABLE+ - " ADD COLUMN "+Chat.COL_GID+" DEFAULT NULL"); + " ADD COLUMN "+Chat.COL_GD+" DEFAULT NULL"); + } + if (fromVersion < 4) { + mConn.createStatement().execute("ALTER TABLE "+Contact.TABLE+ + " ADD COLUMN "+Contact.COL_AVATAR_ID+" DEFAULT NULL"); + } + if (fromVersion < 5) { + mConn.createStatement().execute("ALTER TABLE "+Member.TABLE+ + " ADD COLUMN "+Member.COL_ROLE+" DEFAULT 0"); } // set new version mConn.createStatement().execute("PRAGMA "+UV+" = "+DB_VERSION); + this.commit(); LOGGER.info("updated to version "+DB_VERSION); } - synchronized void close() { + public synchronized void close() { try { if(mConn == null || mConn.isClosed()) return; - if (!mConn.getAutoCommit()) - mConn.commit(); + // just to be sure + mConn.commit(); mConn.close(); } catch(SQLException ex) { LOGGER.log(Level.WARNING, "can't close db", ex); @@ -243,6 +253,7 @@ public synchronized int execInsert(String table, List values) { Statement.RETURN_GENERATED_KEYS)) { insertValues(stat, values); stat.executeUpdate(); + mConn.commit(); ResultSet keys = stat.getGeneratedKeys(); return keys.getInt(1); } catch (SQLException ex) { @@ -261,9 +272,9 @@ public synchronized int execUpdate(String table, Map set, int id List keyList = new ArrayList<>(set.keySet()); - List vList = new ArrayList<>(keyList.size()); - for (String key : keyList) - vList.add(key + " = ?"); + List vList = keyList.stream() + .map(key -> key + " = ?") + .collect(Collectors.toCollection(ArrayList::new)); update += StringUtils.join(vList, ", ") + " WHERE _id == " + id ; // note: looks like driver doesn't support "LIMIT" @@ -272,6 +283,7 @@ public synchronized int execUpdate(String table, Map set, int id try (PreparedStatement stat = mConn.prepareStatement(update, Statement.RETURN_GENERATED_KEYS)) { insertValues(stat, keyList, set); stat.executeUpdate(); + mConn.commit(); ResultSet keys = stat.getGeneratedKeys(); return keys.getInt(1); } catch (SQLException ex) { @@ -280,15 +292,11 @@ public synchronized int execUpdate(String table, Map set, int id } } + /** Delete one row. Not commited! Call commit() after deletions. */ public boolean execDelete(String table, int id) { - return this.execDeleteWhereInsecure(table, "_id = " + id); - } - - public boolean execDeleteWhereInsecure(String table, String where) { - LOGGER.info("deleting from table "+table+" where "+where); + LOGGER.info("deletion, table: " + table + "; id: " + id); try (Statement stat = mConn.createStatement()) { - int c = stat.executeUpdate("DELETE FROM " + table + " WHERE " + where); - LOGGER.config("...deleted "+c+" rows"); + stat.executeUpdate("DELETE FROM " + table + " WHERE _id = " + id); } catch (SQLException ex) { LOGGER.log(Level.WARNING, "can't delete", ex); return false; @@ -296,6 +304,16 @@ public boolean execDeleteWhereInsecure(String table, String where) { return true; } + public boolean commit() { + try { + mConn.commit(); + } catch (SQLException ex) { + LOGGER.log(Level.WARNING, "can't commit", ex); + return false; + } + return true; + } + private static void insertValues(PreparedStatement stat, List keys, Map map) throws SQLException { @@ -326,8 +344,7 @@ private static void setValue(PreparedStatement stat, int i, Object value) } else if (value instanceof EnumSet) { stat.setInt(i+1, EncodingUtils.enumSetToInt(((EnumSet) value))); } else if (value instanceof Optional) { - Optional o = (Optional) value; - setValue(stat, i, o.orElse(null)); + setValue(stat, i, ((Optional) value).orElse(null)); } else if (value instanceof JID) { stat.setString(i+1, ((JID) value).string()); } else if (value == null) { @@ -355,15 +372,4 @@ public static String getString(ResultSet r, String columnLabel){ public static String setString(String s) { return s.isEmpty() ? null : s; } - - public static void initialize(Path dbFile) throws KonException { - INSTANCE = new Database(dbFile); - } - - public static Database getInstance() { - if (INSTANCE == null) - throw new IllegalStateException("database not initialized"); - - return INSTANCE; - } } diff --git a/src/main/java/org/kontalk/system/AccountImporter.java b/src/main/java/org/kontalk/system/AccountImporter.java new file mode 100644 index 00000000..4d76012d --- /dev/null +++ b/src/main/java/org/kontalk/system/AccountImporter.java @@ -0,0 +1,128 @@ +/* + * Kontalk Java client + * Copyright (C) 2016 Kontalk Devteam + * + * 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 . + */ + +package org.kontalk.system; + +import org.kontalk.model.Account; +import java.io.IOException; +import java.util.Observable; +import java.util.logging.Level; +import java.util.logging.Logger; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; +import org.kontalk.client.EndpointServer; +import org.kontalk.client.PrivateKeyReceiver; +import org.kontalk.crypto.PGPUtils; +import org.kontalk.misc.Callback; +import org.kontalk.misc.KonException; +import org.kontalk.util.EncodingUtils; + +/** + * Import and set user account from various sources. + * @author Alexander Bikadorov {@literal } + */ +public final class AccountImporter extends Observable implements Callback.Handler{ + private static final Logger LOGGER = Logger.getLogger(AccountImporter.class.getName()); + + static final String PRIVATE_KEY_FILENAME = "kontalk-private.asc"; + + private final Account mAccount; + + private char[] mPassword = null; + private boolean mAborted = false; + + AccountImporter(Account account) { + mAccount = account; + } + + public void fromZipFile(String zipFilePath, char[] password) { + // read key files + byte[] privateKeyData; + try (ZipFile zipFile = new ZipFile(zipFilePath)) { + privateKeyData = readBytesFromZip(zipFile, PRIVATE_KEY_FILENAME); + } catch (IOException ex) { + LOGGER.log(Level.WARNING, "can't open zip archive: ", ex); + this.changed(new KonException(KonException.Error.IMPORT_ARCHIVE, ex)); + return; + } catch (KonException ex) { + this.changed(ex); + return; + } + + this.set(privateKeyData, password); + } + + // note: with disarming if needed + private static byte[] readBytesFromZip(ZipFile zipFile, String filename) throws KonException { + ZipEntry zipEntry = zipFile.getEntry(filename); + byte[] bytes = null; + try { + bytes = PGPUtils.mayDisarm(zipFile.getInputStream(zipEntry)); + } catch (IOException ex) { + LOGGER.log(Level.WARNING, "can't read key file from archive: ", ex); + throw new KonException(KonException.Error.IMPORT_READ_FILE, ex); + } + return bytes; + } + + public void fromServer(String host, int port, boolean validateCertificate, + String token, char[] password) { + mPassword = password; + // send private key request + EndpointServer server = new EndpointServer(host, port); + PrivateKeyReceiver receiver = new PrivateKeyReceiver(this); + mAborted = false; + receiver.sendRequest(server, validateCertificate, token); + + // wait for response... continue with handle callback + } + + public void abort() { + // receiver will always terminate after some time, just ignore response + mAborted = true; + } + + @Override + public void handle(Callback callback) { + if (mAborted) + return; + + if (callback.exception.isPresent()) { + this.changed(callback.exception); + return; + } + + this.set(EncodingUtils.base64ToBytes(callback.value), mPassword); + } + + private void set(byte[] privateKeyData, char[] password) { + try { + mAccount.setAccount(privateKeyData, password); + } catch (KonException ex) { + this.changed(ex); + return; + } + // report success + this.changed(null); + } + + private void changed(Object arg) { + this.setChanged(); + this.notifyObservers(arg); + } +} diff --git a/src/main/java/org/kontalk/system/AttachmentManager.java b/src/main/java/org/kontalk/system/AttachmentManager.java index f3433754..0e98c73d 100644 --- a/src/main/java/org/kontalk/system/AttachmentManager.java +++ b/src/main/java/org/kontalk/system/AttachmentManager.java @@ -1,6 +1,6 @@ /* * Kontalk Java client - * Copyright (C) 2014 Kontalk Devteam + * Copyright (C) 2016 Kontalk Devteam * * 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 @@ -18,6 +18,7 @@ package org.kontalk.system; +import org.kontalk.persistence.Config; import java.awt.Dimension; import java.awt.Image; import java.awt.image.BufferedImage; @@ -26,25 +27,24 @@ import java.net.URI; import java.nio.file.Files; import java.nio.file.Path; -import java.nio.file.Paths; -import java.util.Arrays; -import java.util.List; import java.util.Optional; import java.util.concurrent.LinkedBlockingQueue; import java.util.logging.Level; import java.util.logging.Logger; import org.apache.commons.io.FileUtils; +import org.apache.commons.lang.StringUtils; +import org.kontalk.client.Client; import org.kontalk.client.HTTPFileClient; import org.kontalk.crypto.Coder; import org.kontalk.crypto.Coder.Encryption; import org.kontalk.crypto.PersonalKey; import org.kontalk.misc.KonException; -import org.kontalk.model.InMessage; -import org.kontalk.model.KonMessage; -import org.kontalk.model.MessageContent; -import org.kontalk.model.MessageContent.Attachment; -import org.kontalk.model.MessageContent.Preview; -import org.kontalk.model.OutMessage; +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.Preview; +import org.kontalk.model.message.OutMessage; import org.kontalk.util.MediaUtils; /** @@ -59,30 +59,32 @@ public class AttachmentManager implements Runnable { private static final String ATT_DIRNAME = "attachments"; private static final String PREVIEW_DIRNAME = "preview"; + private static final String RESIZED_IMG_MIME = "image/jpeg"; public static final Dimension THUMBNAIL_DIM = new Dimension(300, 200); public static final String THUMBNAIL_MIME = "image/jpeg"; - public static final int MAX_ATT_SIZE = 10 * 1024 * 1024; - - // server and Android client do not want other types - public static final List SUPPORTED_MIME_TYPES = Arrays.asList( - "text/plain", - "text/x-vcard", - "text/vcard", - "image/gif", - "image/png", - "image/jpeg", - "image/jpg", - "audio/3gpp", - "audio/mpeg3", - "audio/wav"); - - // TODO get this from server - private static final String UPLOAD_URL = "https://beta.kontalk.net:5980/upload"; + public static final int MAX_ATT_SIZE = 20 * 1024 * 1024; - private final LinkedBlockingQueue mQueue = new LinkedBlockingQueue<>(); + public static class Slot { + final URI uploadURL; + final URI downloadURL; + + public Slot() { + this(URI.create(""), URI.create("")); + } + + public Slot(URI uploadURI, URI downloadURL) { + this.uploadURL = uploadURI; + this.downloadURL = downloadURL; + } + } + + private static final String ENCRYPT_MIME = "application/octet-stream"; private final Control mControl; + private final Client mClient; + + private final LinkedBlockingQueue mQueue = new LinkedBlockingQueue<>(); private final Path mAttachmentDir; private final Path mPreviewDir; @@ -107,8 +109,9 @@ public DownloadTask(InMessage message) { } } - private AttachmentManager(Path baseDir, Control control) { + private AttachmentManager(Control control, Client client, Path baseDir) { mControl = control; + mClient = client; mAttachmentDir = baseDir.resolve(ATT_DIRNAME); if (mAttachmentDir.toFile().mkdir()) LOGGER.info("created attachment directory"); @@ -118,6 +121,16 @@ private AttachmentManager(Path baseDir, Control control) { LOGGER.info("created preview directory"); } + static AttachmentManager create(Control control, Client client, Path appDir) { + AttachmentManager manager = new AttachmentManager(control, client, appDir); + + Thread thread = new Thread(manager, "Attachment Transfer"); + thread.setDaemon(true); + thread.start(); + + return manager; + } + void queueUpload(OutMessage message) { boolean added = mQueue.offer(new Task.UploadTask(message)); if (!added) { @@ -133,53 +146,93 @@ void queueDownload(InMessage message) { } private void uploadAsync(OutMessage message) { - Optional optAttachment = message.getContent().getAttachment(); - if (!optAttachment.isPresent()) { + Attachment attachment = message.getContent().getAttachment().orElse(null); + if (attachment == null) { LOGGER.warning("no attachment in message to upload"); return; } - Attachment attachment = optAttachment.get(); + + if (!mClient.isConnected()) { + LOGGER.info("can't upload, not connected"); + return; + } + + File original; + File file = original = attachment.getFilePath().toFile(); + String mime = attachment.getMimeType(); + + // maybe resize image for smaller payload + if(isImage(mime)) { + int maxImgSize = Config.getInstance().getInt(Config.NET_MAX_IMG_SIZE); + if (maxImgSize > 0) { + BufferedImage img = MediaUtils.readImage(file).orElse(null); + if (img == null) { + LOGGER.warning("can't load image"); + return; + } + if (img.getWidth() * img.getHeight() > maxImgSize) { + // image needs to be resized + BufferedImage resized = MediaUtils.scale(img, maxImgSize); + try { + file = File.createTempFile("kontalk_resized_img_att", ".dat"); + } catch (IOException ex) { + LOGGER.log(Level.WARNING, "can't create temporary file", ex); + return; + } + mime = RESIZED_IMG_MIME; + boolean succ = MediaUtils.writeImage(resized, + MediaUtils.extensionForMIME(mime), + file); + if (!succ) + return; + } + } + } // if text will be encrypted, always encrypt attachment too boolean encrypt = message.getCoderStatus().getEncryption() == Encryption.DECRYPTED; - File file; - if (encrypt){ - Optional optFile = Coder.encryptAttachment(message); - if (!optFile.isPresent()) + if (encrypt) { + PersonalKey myKey = mControl.myKey().orElse(null); + File encryptFile = myKey == null ? + null : + Coder.encryptAttachment(myKey, message, file).orElse(null); + if (!file.equals(original)) + file.delete(); + if (encryptFile == null) return; - file = optFile.get(); - } else - file = attachment.getFile().toFile(); + file = encryptFile; + // Note: continue using original MIME type, Android client needs it + //mime = ENCRYPT_MIME; + } - HTTPFileClient client = createClientOrNull(); + HTTPFileClient client = this.clientOrNull(); if (client == null) return; - URI url; + long length = file.length(); + Slot uploadSlot = mClient.getUploadSlot(file.getName(), length, mime); + + if (uploadSlot.uploadURL.toString().isEmpty() || + uploadSlot.downloadURL.toString().isEmpty()) { + LOGGER.warning("empty slot: "+attachment); + return; + } + try { - url = client.upload(file, URI.create(UPLOAD_URL), - // this isn't correct, but the server can't handle the truth - /*encrypt ? "application/octet-stream" :*/ attachment.getMimeType(), - encrypt); + client.upload(file, uploadSlot.uploadURL, mime, encrypt); } catch (KonException ex) { LOGGER.warning("upload failed, attachment: "+attachment); message.setStatus(KonMessage.Status.ERROR); - mControl.handleException(ex); + mControl.onException(ex); return; } - // delete temp file - if (encrypt) + if (!file.equals(original)) file.delete(); - if (url.toString().isEmpty()) { - LOGGER.warning("url empty: "+attachment); - return; - } - - message.setAttachmentURL(url); + message.setUpload(uploadSlot.downloadURL, mime, length); - LOGGER.info("upload successful, URL="+url); + LOGGER.info("upload successful, URL="+uploadSlot.downloadURL); // make sure not to loop if (attachment.hasURL()) @@ -187,14 +240,13 @@ private void uploadAsync(OutMessage message) { } private void downloadAsync(final InMessage message) { - Optional optAttachment = message.getContent().getAttachment(); - if (!optAttachment.isPresent()) { + Attachment attachment = message.getContent().getAttachment().orElse(null); + if (attachment == null) { LOGGER.warning("no attachment in message to download"); return; } - Attachment attachment = optAttachment.get(); - HTTPFileClient client = createClientOrNull(); + HTTPFileClient client = this.clientOrNull(); if (client == null) return; @@ -210,7 +262,7 @@ public void updateProgress(int p) { path = client.download(attachment.getURL(), mAttachmentDir, listener); } catch (KonException ex) { LOGGER.warning("download failed, URL="+attachment.getURL()); - mControl.handleException(ex); + mControl.onException(ex); return; } @@ -225,52 +277,55 @@ public void updateProgress(int p) { // decrypt file if (attachment.getCoderStatus().isEncrypted()) { - Coder.decryptAttachment(message, mAttachmentDir); + mControl.myKey().ifPresent(mk -> + Coder.decryptAttachment(mk, message, mAttachmentDir)); } // create preview if not in message if (!message.getContent().getPreview().isPresent()) - this.createImagePreview(message); + this.mayCreateImagePreview(message); } - public void savePreview(InMessage message) { - Optional optPreview = message.getContent().getPreview(); - if (!optPreview.isPresent()) { + void savePreview(InMessage message) { + Preview preview = message.getContent().getPreview().orElse(null); + if (preview == null) { LOGGER.warning("no preview in message: "+message); return; } - Preview preview = optPreview.get(); String id = Integer.toString(message.getID()); - String dotExt = MediaUtils.extensionForMIME(preview.getMimeType()); - String filename = id + "_bob" + dotExt; + String ext = MediaUtils.extensionForMIME(preview.getMimeType()); + String filename = id + "_bob." + ext; this.writePreview(preview, filename); message.setPreviewFilename(filename); } - boolean createImagePreview(KonMessage message) { - Optional optAtt = message.getContent().getAttachment(); - if (!optAtt.isPresent()) { + boolean mayCreateImagePreview(KonMessage message) { + Attachment att = message.getContent().getAttachment().orElse(null); + if (att == null) { LOGGER.warning("no attachment in message: "+message); return false; } - Attachment att = optAtt.get(); - Path path = filePath(att); + Path path = absoluteFilePath(att); + + String mime = StringUtils.defaultIfEmpty(att.getMimeType(), + // guess from file + MediaUtils.mimeForFile(path)); - if (!isImage(att.getMimeType())) + if (!isImage(mime)) return false; - BufferedImage image = MediaUtils.readImage(path.toString()); + BufferedImage image = MediaUtils.readImage(path); if (image.getWidth() <= THUMBNAIL_DIM.width && image.getHeight() <= THUMBNAIL_DIM.height) return false; - Image thumb = MediaUtils.scale(image, + Image thumb = MediaUtils.scaleAsync(image, THUMBNAIL_DIM.width , THUMBNAIL_DIM.height, false); - String format = MediaUtils.extensionForMIME(THUMBNAIL_MIME).substring(1); + String format = MediaUtils.extensionForMIME(THUMBNAIL_MIME); byte[] bytes = MediaUtils.imageToByteArray(thumb, format); if (bytes.length <= 0) @@ -288,19 +343,18 @@ boolean createImagePreview(KonMessage message) { return true; } - Path filePath(Attachment attachment) { - Path path = attachment.getFile(); - if (path.toString().isEmpty()) - return Paths.get(""); - return path.isAbsolute() ? path : mAttachmentDir.resolve(path); + Path absoluteFilePath(Attachment attachment) { + Path path = attachment.getFilePath(); + return path.toString().isEmpty() || path.isAbsolute() ? + path : + mAttachmentDir.resolve(path); } Optional imagePreviewPath(KonMessage message) { - Optional optPreview = message.getContent().getPreview(); - if (!optPreview.isPresent()) + MessageContent.Preview preview = message.getContent().getPreview().orElse(null); + if (preview == null) return Optional.empty(); - MessageContent.Preview preview = optPreview.get(); String fn = preview.getFilename(); if (fn.isEmpty() || !isImage(preview.getMimeType())) return Optional.empty(); @@ -317,7 +371,17 @@ private void writePreview(Preview preview, String filename) { return; } - LOGGER.info("to file: "+newFile); + LOGGER.config("to file: "+newFile); + } + + private HTTPFileClient clientOrNull(){ + PersonalKey key = mControl.myKey().orElse(null); + if (key == null) + return null; + + return new HTTPFileClient(key.getServerLoginKey(), + key.getBridgeCertificate(), + Config.getInstance().getBoolean(Config.SERV_CERT_VALIDATION)); } @Override @@ -339,52 +403,25 @@ public void run() { } } - public static boolean isImage(String mimeType) { - return mimeType.startsWith("image"); - } - - static AttachmentManager create(Path baseDir, Control control) { - AttachmentManager downloader = new AttachmentManager(baseDir, control); - - new Thread(downloader).start(); - - return downloader; - } - /** * Create a new attachment for a given file denoted by its path. */ - static Attachment attachmentOrNull(Path path) { - File file = path.toFile(); - if (!file.isFile() || !file.canRead()) { - LOGGER.warning("invalid attachment file: "+path); + static Attachment createAttachmentOrNull(Path path) { + if (!Files.isReadable(path)) { + LOGGER.warning("file not readable: "+path); return null; } - String mimeType; - try { - mimeType = Files.probeContentType(path); - } catch (IOException ex) { - LOGGER.log(Level.WARNING, "can't get attachment mime type", ex); - return null; - } - long length = file.length(); - if (length <= 0) { - LOGGER.warning("invalid attachment file size: "+length); - return null; - } - return new Attachment(path, mimeType, length); - } - private static HTTPFileClient createClientOrNull(){ - Optional optKey = AccountLoader.getInstance().getPersonalKey(); - if (!optKey.isPresent()) { - LOGGER.log(Level.WARNING, "personal key not loaded"); + String mimeType = MediaUtils.mimeForFile(path); + if (mimeType.isEmpty()) { + LOGGER.warning("no mime type for file: "+path); return null; } - PersonalKey key = optKey.get(); - return new HTTPFileClient(key.getServerLoginKey(), - key.getBridgeCertificate(), - Config.getInstance().getBoolean(Config.SERV_CERT_VALIDATION)); + return Attachment.outgoing(path, mimeType); + } + + private static boolean isImage(String mimeType) { + return mimeType.startsWith("image"); } } diff --git a/src/main/java/org/kontalk/system/AvatarHandler.java b/src/main/java/org/kontalk/system/AvatarHandler.java new file mode 100644 index 00000000..c1837070 --- /dev/null +++ b/src/main/java/org/kontalk/system/AvatarHandler.java @@ -0,0 +1,101 @@ +/* + * Kontalk Java client + * Copyright (C) 2016 Kontalk Devteam + * + * 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 . + */ + +package org.kontalk.system; + +import org.kontalk.persistence.Config; +import java.awt.image.BufferedImage; +import java.util.Arrays; +import java.util.List; +import java.util.logging.Logger; +import javax.imageio.ImageIO; +import org.apache.commons.codec.digest.DigestUtils; +import org.kontalk.client.Client; +import org.kontalk.misc.JID; +import org.kontalk.model.Avatar; +import org.kontalk.model.Contact; +import org.kontalk.model.Model; +import org.kontalk.util.MediaUtils; + +/** + * Process incoming avatar events + * @author Alexander Bikadorov {@literal } + */ +public final class AvatarHandler { + private static final Logger LOGGER = Logger.getLogger(AvatarHandler.class.getName()); + + public static final List SUPPORTED_TYPES = Arrays.asList(ImageIO.getReaderMIMETypes()); + + private static final int MAX_SIZE = 1024 * 250; + + private final Client mClient; + private final Model mModel; + + AvatarHandler(Client client, Model model) { + mClient = client; + mModel = model; + } + + public void onNotify(JID jid, String id) { + if (Config.getInstance().getBoolean(Config.NET_REQUEST_AVATARS)) + // disabled by user + return; + + Contact contact = mModel.contacts().get(jid).orElse(null); + if (contact == null) { + LOGGER.warning("can't find contact with jid:" + jid); + return; + } + + if (id.isEmpty()) { + // contact disabled avatar publishing + contact.deleteAvatar(); + } + + Avatar avatar = contact.getAvatar().orElse(null); + if (avatar != null && avatar.getID().equals(id)) + // avatar is not new + return; + + mClient.requestAvatar(jid, id); + } + + public void onData(JID jid, String id, byte[] avatarData) { + LOGGER.info("new avatar, jid: "+jid+" id: "+id); + + if (avatarData.length > MAX_SIZE) + LOGGER.info("avatar data too long: "+avatarData.length); + + Contact contact = mModel.contacts().get(jid).orElse(null); + if (contact == null) { + LOGGER.warning("can't find contact with jid:" + jid); + return; + } + + if (!id.equals(DigestUtils.sha1Hex(avatarData))) { + LOGGER.warning("this is not what we wanted"); + return; + } + + BufferedImage img = MediaUtils.readImage(avatarData).orElse(null); + if (img == null) + return; + + contact.setAvatar(new Avatar(id, img)); + } +} diff --git a/src/main/java/org/kontalk/system/ChatStateManager.java b/src/main/java/org/kontalk/system/ChatStateManager.java index 468435ab..a0b7eea6 100644 --- a/src/main/java/org/kontalk/system/ChatStateManager.java +++ b/src/main/java/org/kontalk/system/ChatStateManager.java @@ -1,6 +1,6 @@ /* * Kontalk Java client - * Copyright (C) 2014 Kontalk Devteam + * Copyright (C) 2016 Kontalk Devteam * * 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 @@ -18,16 +18,17 @@ package org.kontalk.system; -import java.util.HashMap; +import org.kontalk.persistence.Config; import java.util.Map; import java.util.Timer; import java.util.TimerTask; +import java.util.WeakHashMap; import java.util.concurrent.TimeUnit; import org.jivesoftware.smackx.chatstates.ChatState; import org.kontalk.client.Client; -import org.kontalk.model.Chat; +import org.kontalk.model.chat.Chat; import org.kontalk.model.Contact; -import org.kontalk.model.SingleChat; +import org.kontalk.model.chat.SingleChat; /** * Manager handling own chat status for all chats. @@ -38,8 +39,8 @@ final class ChatStateManager { private static final int COMPOSING_TO_PAUSED = 15; // seconds private final Client mClient; - private final Map mChatStateCache = new HashMap<>(); - private final Timer mTimer = new Timer(); + private final Map mChatStateCache = new WeakHashMap<>(); + private final Timer mTimer = new Timer("Chat State Timer", true); public ChatStateManager(Client client) { mClient = client; @@ -57,8 +58,8 @@ void handleOwnChatStateEvent(Chat chat, ChatState state) { } void imGone() { - for (MyChatState chatState : mChatStateCache.values()) - chatState.handleState(ChatState.gone); + mChatStateCache.values().stream() + .forEach(chatState -> chatState.handleState(ChatState.gone)); } private class MyChatState { @@ -82,8 +83,8 @@ private void handleState(ChatState state) { mScheduledStateSet = new TimerTask() { @Override public void run() { - // TODO use 'inactive' instead of 'paused' for now as - // 'inactive' currently wont be send at all + // NOTE: using 'inactive' instead of 'paused' here as + // 'inactive' isn't send at all MyChatState.this.handleState(ChatState.inactive); } }; diff --git a/src/main/java/org/kontalk/system/Control.java b/src/main/java/org/kontalk/system/Control.java index efcf1cd9..1bbed8a4 100644 --- a/src/main/java/org/kontalk/system/Control.java +++ b/src/main/java/org/kontalk/system/Control.java @@ -1,6 +1,6 @@ /* * Kontalk Java client - * Copyright (C) 2014 Kontalk Devteam + * Copyright (C) 2016 Kontalk Devteam * * 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 @@ -18,44 +18,52 @@ package org.kontalk.system; +import org.kontalk.persistence.Config; +import org.kontalk.persistence.Database; +import org.kontalk.model.Account; +import java.awt.image.BufferedImage; import java.nio.file.Path; import java.nio.file.Paths; import java.util.ArrayList; +import java.util.Arrays; import java.util.Date; import java.util.EnumSet; import java.util.List; import java.util.Observable; import java.util.Optional; 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.Kontalk; 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.KonException; import org.kontalk.misc.ViewEvent; -import org.kontalk.model.InMessage; -import org.kontalk.model.KonMessage; -import org.kontalk.model.Chat; -import org.kontalk.model.GroupChat.GID; -import org.kontalk.model.MessageContent; -import org.kontalk.model.OutMessage; -import org.kontalk.model.ChatList; +import org.kontalk.model.message.InMessage; +import org.kontalk.model.message.KonMessage; +import org.kontalk.model.chat.Chat; +import org.kontalk.model.message.MessageContent; +import org.kontalk.model.message.OutMessage; import org.kontalk.model.Contact; -import org.kontalk.model.ContactList; import org.kontalk.misc.JID; -import org.kontalk.model.GroupChat; -import org.kontalk.model.MessageContent.Attachment; -import org.kontalk.model.MessageContent.GroupCommand; -import org.kontalk.model.MessageContent.GroupCommand.OP; -import org.kontalk.model.ProtoMessage; -import org.kontalk.model.SingleChat; +import org.kontalk.model.Avatar; +import org.kontalk.model.Model; +import org.kontalk.model.chat.GroupChat; +import org.kontalk.model.chat.Member; +import org.kontalk.model.message.MessageContent.Attachment; +import org.kontalk.model.message.MessageContent.GroupCommand; +import org.kontalk.model.message.ProtoMessage; +import org.kontalk.model.chat.SingleChat; import org.kontalk.util.ClientUtils.MessageIDs; import org.kontalk.util.XMPPUtils; +import org.kontalk.view.View; /** * Application control logic. @@ -77,167 +85,229 @@ public enum Status { ERROR } + 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 final ViewControl mViewControl; - - private Status mCurrentStatus = Status.DISCONNECTED; + private boolean mShuttingDown = false; - private Control(Path appDir) { + public Control(Path appDir) throws KonException { mViewControl = new ViewControl(); - mClient = new Client(this); + 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(appDir, this); - mRosterHandler = new RosterHandler(this, 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(false); + return; + } + 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))); + 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 setStatus(Status status) { - mCurrentStatus = status; - mViewControl.changed(new ViewEvent.StatusChanged()); + public void onStatusChange(Status status, EnumSet features) { + mViewControl.changed(new ViewEvent.StatusChange(status, features)); if (status == Status.CONNECTED) { + String[] strings = Config.getInstance().getStringArray(Config.NET_STATUS_LIST); + mClient.sendUserPresence(strings.length > 0 ? strings[0] : ""); // send all pending messages - for (Chat chat: ChatList.getInstance()) - for (OutMessage m : chat.getMessages().getPending()) - this.sendMessage(m); + for (Chat chat: mModel.chats()) + chat.getMessages().getPending().stream() + .forEach(m -> this.sendMessage(m)); // send public key requests for Kontalk contacts with missing key - for (Contact contact : ContactList.getInstance()) + for (Contact contact : mModel.contacts()) if (contact.getFingerprint().isEmpty()) 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 : ContactList.getInstance()) + for (Contact contact : mModel.contacts()) contact.setOffline(); } } - public void handleException(KonException ex) { + public void onAuthenticated(JID jid) { + mModel.setUserJID(jid); + } + + public void onException(KonException ex) { mViewControl.changed(new ViewEvent.Exception(ex)); } // TODO unused - public void handleEncryptionErrors(KonMessage message, Contact contact) { + public void onEncryptionErrors(KonMessage message, Contact contact) { EnumSet 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.maySendKeyRequest(contact); + this.sendKeyRequest(contact); } - this.handleSecurityErrors(message); + this.onSecurityErrors(message); } - public void handleSecurityErrors(KonMessage message) { + public 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. - * @return true on success or message is a duplicate, false on unexpected failure */ - public boolean newInMessage(MessageIDs ids, + public void onNewInMessage(MessageIDs ids, Optional serverDate, MessageContent content) { LOGGER.info("new incoming message, "+ids); - ContactList contactList = ContactList.getInstance(); - Optional optContact = contactList.contains(ids.jid) ? - contactList.get(ids.jid) : - this.createContact(ids.jid, ""); - if (!optContact.isPresent()) { + Contact sender = this.getOrCreateContact(ids.jid).orElse(null); + if (sender == null) { LOGGER.warning("can't get contact for message"); - return false; + return; } - Contact contact = optContact.get(); - // decrypt message now to get group id - ProtoMessage protoMessage = new ProtoMessage(contact, content); + // decrypt message now to get possible group data + ProtoMessage protoMessage = new ProtoMessage(sender, content); if (protoMessage.isEncrypted()) { - Coder.decryptMessage(protoMessage); + this.myKey().ifPresent(mk -> Coder.decryptMessage(mk, protoMessage)); } - // note: decryption must be successful to select group chat - Optional optGID = protoMessage.getContent().getGID(); - - Chat chat = optGID.isPresent() ? - ChatList.getInstance().getOrCreate(optGID.get(), contact) : - ChatList.getInstance().getOrCreate(contact, ids.xmppThreadID); - InMessage newMessage = new InMessage(protoMessage, chat, ids.jid, - ids.xmppID, serverDate); - - if (newMessage.getID() <= 0) - return false; - - // TODO implement equals() - if (chat.getMessages().contains(newMessage)) { - LOGGER.info("message already in chat, dropping this one"); - return true; + // NOTE: decryption must be successful to select group chat + Chat chat = content.getGroupData().isPresent() ? + mGroupControl.getGroupChat(content, sender).orElse(null) : + mModel.chats().getOrCreate(sender, ids.xmppThreadID); + if (chat == null) { + LOGGER.warning("no chat found, message lost: "+protoMessage); + return; } - boolean added = chat.addMessage(newMessage); - if (!added) { - LOGGER.warning("can't add message to chat"); - return false; - } + InMessage newMessage = mModel.createInMessage( + protoMessage, chat, ids, serverDate).orElse(null); + if (newMessage == null) + return; - Optional optCom = newMessage.getContent().getGroupCommand(); - if (optCom.isPresent()) { - if (chat instanceof GroupChat) - this.processGroupCommand(optCom.get(), (GroupChat) chat, contact); - else + 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)); - - return newMessage.getID() >= -1; } - public void setSent(MessageIDs ids) { - Optional optMessage = findMessage(ids); - if (!optMessage.isPresent()) + public void onMessageSent(MessageIDs ids) { + OutMessage message = this.findMessage(ids).orElse(null); + if (message == null) return; - optMessage.get().setStatus(KonMessage.Status.SENT); + message.setStatus(KonMessage.Status.SENT); } - public void setReceived(MessageIDs ids) { - Optional optMessage = findMessage(ids); - if (!optMessage.isPresent()) + public void onMessageReceived(MessageIDs ids) { + OutMessage message = this.findMessage(ids).orElse(null); + if (message == null) return; - optMessage.get().setReceived(ids.jid); + message.setReceived(ids.jid); } - public void setMessageError(MessageIDs ids, Condition condition, String errorText) { - Optional optMessage = findMessage(ids); - if (!optMessage.isPresent()) + public void onMessageError(MessageIDs ids, Condition condition, String errorText) { + OutMessage message = this.findMessage(ids).orElse(null); + if (message == null) return ; - optMessage.get().setServerError(condition.toString(), errorText); + message.setServerError(condition.toString(), errorText); } /** * Inform model (and view) about a received chat state notification. */ - public void processChatState(JID jid, - String xmppThreadID, + public void onChatStateNotification(MessageIDs ids, Optional serverDate, ChatState chatState) { if (serverDate.isPresent()) { @@ -247,34 +317,36 @@ public void processChatState(JID jid, return; } } - Optional optContact = ContactList.getInstance().get(jid); - if (!optContact.isPresent()) { - LOGGER.info("can't find contact with jid: "+jid); + Contact contact = mModel.contacts().get(ids.jid).orElse(null); + if (contact == null) { + LOGGER.info("can't find contact with jid: "+ids.jid); return; } - Contact contact = optContact.get(); - // TODO chat states for group chats? - Optional optChat = ChatList.getInstance().get(contact, xmppThreadID); - if (!optChat.isPresent()) + // 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; - optChat.get().setChatState(contact, chatState); + chat.setChatState(contact, chatState); } - public void handlePGPKey(JID jid, byte[] rawKey) { - Optional optContact = ContactList.getInstance().get(jid); - if (!optContact.isPresent()) { + 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; } - Contact contact = optContact.get(); - Optional optKey = PGPUtils.readPublicKey(rawKey); - if (!optKey.isPresent()) { + 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; } - PGPCoderKey key = optKey.get(); if (!key.userID.contains("<"+contact.getJID().string()+">")) { LOGGER.warning("UID does not contain contact JID"); @@ -285,45 +357,30 @@ public void handlePGPKey(JID jid, byte[] rawKey) { // same key return; - if (contact.hasKey()) + if (contact.hasKey()) { // ask before overwriting mViewControl.changed(new ViewEvent.NewKey(contact, key)); - else - this.setKey(contact, key); - } - - public void setKey(Contact contact, PGPCoderKey key) { - 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); + } else { + setKey(contact, key); } } - public void setBlockedContacts(JID[] jids) { + public void onBlockList(JID[] jids) { for (JID jid : jids) { if (jid.isFull()) { LOGGER.info("ignoring blocking of JID with resource"); return; } - this.setContactBlocking(jid, true); + this.onContactBlocked(jid, true); } } - public void setContactBlocking(JID jid, boolean blocking) { - Optional optContact = ContactList.getInstance().get(jid); - if (!optContact.isPresent()) { + 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; } - Contact contact = optContact.get(); LOGGER.info("set contact blocking: "+contact+" "+blocking); contact.setBlocked(blocking); @@ -331,37 +388,120 @@ public void setContactBlocking(JID jid, boolean blocking) { /* 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 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().getAttachment().isPresent()) + mAttachmentManager.mayCreateImagePreview(newMessage); + + return this.sendMessage(newMessage); + } + boolean sendMessage(OutMessage message) { - if (message.getContent().getAttachment().isPresent() && - !message.getContent().getAttachment().get().hasURL()) { + MessageContent content = message.getContent(); + if (content.getAttachment().isPresent() && + !content.getAttachment().get().hasURL()) { // continue later... mAttachmentManager.queueUpload(message); return false; } + // prepare encrypted content + if (message.isSendEncrypted()) { + PersonalKey myKey = this.myKey().orElse(null); + if (myKey == null) + return false; + + Chat chat = message.getChat(); + byte[] encryptedData; + if (content.isComplex() || chat.isGroupChat()) { + String stanza = KonMessageSender.rawMessage(content, chat, true).toXML().toString(); + encryptedData = Coder.encryptStanza(myKey, message, stanza).orElse(null); + } else { + encryptedData = Coder.encryptMessage(myKey, message).orElse(null); + } + // check also for security errors just to be sure + if (encryptedData == null || !message.getCoderStatus().getErrors().isEmpty()) { + LOGGER.warning("encryption failed"); + message.setStatus(KonMessage.Status.ERROR); + this.onSecurityErrors(message); + return false; + } + content.setEncryptedData(encryptedData); + } + boolean sent = mClient.sendMessage(message, Config.getInstance().getBoolean(Config.NET_SEND_CHAT_STATE)); 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 (!contact.isKontalkUser()) - return; + if (canSendKeyRequest(contact)) + this.sendKeyRequest(contact); + } - if (contact.getSubScription() == Contact.Subscription.UNSUBSCRIBED || - contact.getSubScription() == Contact.Subscription.PENDING) { - LOGGER.info("no presence subscription, not sending key request, contact: "+contact); + void sendKeyRequest(Contact contact) { + if (!canSendKeyRequest(contact)) { + LOGGER.warning("better do not, contact: "+contact); return; } + mClient.sendPublicKeyRequest(contact.getJID()); } + Optional getOrCreateContact(JID jid) { + Contact contact = mModel.contacts().get(jid).orElse(null); + if (contact != null) + return Optional.of(contact); + + return this.createContact(jid, ""); + } Optional createContact(JID jid, String name) { return this.createContact(jid, name, XMPPUtils.isKontalkJID(jid)); } - Optional createContact(JID jid, String name, boolean encrypted) { + void sendPresenceSubscription(JID jid, Client.PresenceCommand command) { + mClient.sendPresenceSubscription(jid, command); + } + + Optional myKey() { + Optional myKey = mModel.account().getPersonalKey(); + if (!myKey.isPresent()) { + LOGGER.log(Level.WARNING, "can't get personal key"); + } + return myKey; + } + + /* private */ + + private Optional createContact(JID jid, String name, boolean encrypted) { if (!mClient.isConnected()) { // workaround: create only if contact can be added to roster return Optional.empty(); @@ -371,13 +511,12 @@ Optional createContact(JID jid, String name, boolean encrypted) { name = jid.local(); } - Optional optNewContact = ContactList.getInstance().create(jid, name); - if (!optNewContact.isPresent()) { + 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(); } - Contact newContact = optNewContact.get(); newContact.setEncrypted(encrypted); @@ -388,24 +527,37 @@ Optional createContact(JID jid, String name, boolean encrypted) { return Optional.of(newContact); } - /* private */ - private void decryptAndProcess(InMessage message) { if (!message.isEncrypted()) { LOGGER.info("message not encrypted"); } else { - Coder.decryptMessage(message); + this.myKey().ifPresent(mk -> Coder.decryptMessage(mk, message)); } this.processContent(message); } + private static void setKey(Contact contact, PGPCoderKey key) { + 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.handleSecurityErrors(message); + this.onSecurityErrors(message); } if (message.getContent().getPreview().isPresent()) { @@ -443,54 +595,13 @@ private void removeFromRoster(JID jid) { } } - // warning: call this only once for each group command! - private void processGroupCommand(GroupCommand command, GroupChat chat, Contact sender) { - // note: chat was selected/created by GID so we can be sure message and - // chat GIDs match - GID gid = chat.getGID(); - OP op = command.getOperation(); - - // validation check - if (op != OP.LEAVE) { - // sender must be owner - if (!gid.ownerJID.equals(sender.getJID())) { - LOGGER.warning("sender not owner"); - return; - } - } - - if (op == OP.CREATE || op == OP.SET) { - // add contacts if necessary - // TODO design problem here: we need at least the public keys, but user - // might dont wanna have group members in contact list - ContactList contactList = ContactList.getInstance(); - for (JID jid : command.getAdded()) { - if (!contactList.contains(jid)) { - boolean succ = this.createContact(jid, "").isPresent(); - if (!succ) - LOGGER.warning("can't create contact, JID: "+jid); - } - } - } - - chat.applyGroupCommand(command, sender); - } - - /* static */ - - public static ViewControl create(Path appDir) { - return new Control(appDir).mViewControl; - } - - private static Optional findMessage(MessageIDs ids) { - ChatList cl = ChatList.getInstance(); - + private Optional findMessage(MessageIDs ids) { // get chat by jid -> thread ID -> message id - Optional optContact = ContactList.getInstance().get(ids.jid); - if (optContact.isPresent()) { - Optional optChat = cl.get(optContact.get(), ids.xmppThreadID); - if (optChat.isPresent()) { - Optional optM = optChat.get().getMessages().getLast(ids.xmppID); + 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 optM = chat.getMessages().getLast(ids.xmppID); if (optM.isPresent()) return optM; } @@ -498,7 +609,7 @@ private static Optional findMessage(MessageIDs ids) { // fallback: search everywhere LOGGER.info("fallback search, IDs: "+ids); - for (Chat chat: cl) { + for (Chat chat: mModel.chats()) { Optional optM = chat.getMessages().getLast(ids.xmppID); if (optM.isPresent()) return optM; @@ -512,33 +623,8 @@ private static Optional findMessage(MessageIDs ids) { public class ViewControl extends Observable { - public void launch() { - new Thread(mClient).start(); - - boolean connect = Config.getInstance().getBoolean(Config.MAIN_CONNECT_STARTUP); - if (!AccountLoader.getInstance().accountIsPresent()) { - LOGGER.info("no account found, asking for import..."); - this.changed(new ViewEvent.MissingAccount(connect)); - return; - } - - if (connect) - this.connect(); - } - public void shutDown() { - this.disconnect(); - LOGGER.info("Shutting down..."); - mCurrentStatus = Status.SHUTTING_DOWN; - this.changed(new ViewEvent.StatusChanged()); - try { - Database.getInstance().close(); - } catch (RuntimeException ex) { - // ignore - } - Config.getInstance().saveToFile(); - - Kontalk.exit(); + Control.this.shutDown(true); } public void connect() { @@ -555,21 +641,30 @@ public void connect(char[] password) { public void disconnect() { mChatStateManager.imGone(); - mCurrentStatus = Status.DISCONNECTING; - this.changed(new ViewEvent.StatusChanged()); mClient.disconnect(); } - public Status getCurrentStatus() { - return mCurrentStatus; + public void setStatusText(String newStatus) { + Config conf = Config.getInstance(); + String[] strings = conf.getStringArray(Config.NET_STATUS_LIST); + List stats = new ArrayList<>(Arrays.asList(strings)); + + stats.remove(newStatus); + stats.add(0, newStatus); + + if (stats.size() > 20) + stats = stats.subList(0, 20); + + conf.setProperty(Config.NET_STATUS_LIST, stats.toArray()); + mClient.sendUserPresence(newStatus); } - public void sendStatusText() { - mClient.sendInitialPresence(); + public void setAccountPassword(char[] oldPass, char[] newPass) throws KonException { + mModel.account().setPassword(oldPass, newPass); } public Path getFilePath(Attachment attachment) { - return mAttachmentManager.filePath(attachment); + return mAttachmentManager.absoluteFilePath(attachment); } public Optional getImagePath(KonMessage message) { @@ -584,7 +679,7 @@ public Optional createContact(JID jid, String name, boolean encrypted) public void deleteContact(Contact contact) { JID jid = contact.getJID(); - ContactList.getInstance().delete(contact); + mModel.contacts().delete(contact); Control.this.removeFromRoster(jid); } @@ -599,7 +694,7 @@ public void changeJID(Contact contact, JID newJID) { if (oldJID.equals(newJID)) return; - boolean succ = ContactList.getInstance().changeJID(contact, newJID); + boolean succ = mModel.contacts().changeJID(contact, newJID); if (!succ) return; @@ -616,74 +711,67 @@ public void changeName(Contact contact, String name) { } public void requestKey(Contact contact) { - Control.this.maySendKeyRequest(contact); + Control.this.sendKeyRequest(contact); } public void acceptKey(Contact contact, PGPCoderKey key) { - Control.this.setKey(contact, key); + setKey(contact, key); } public void declineKey(Contact contact) { this.sendContactBlocking(contact, true); } + 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); + } + /* chats */ public Chat getOrCreateSingleChat(Contact contact) { - return ChatList.getInstance().getOrCreate(contact); + return mModel.chats().getOrCreate(contact); } - public void createGroupChat(List contacts, String subject) { - Optional optMe = ContactList.getInstance().getMe(); - if (!optMe.isPresent()) { + public Optional createGroupChat(List contacts, String subject) { + // user is part of the group + List members = contacts.stream() + .map(c -> new Member(c)) + .collect(Collectors.toCollection(ArrayList::new)); + Contact me = mModel.contacts().getMe().orElse(null); + if (me == null) { LOGGER.warning("can't find myself"); - return; + return Optional.empty(); } - Contact me = optMe.get(); + members.add(new Member(me, Member.Role.OWNER)); - // user should be part of the group - List withMe = new ArrayList<>(contacts); - withMe.add(me); - - Chat chat = ChatList.getInstance().createNew(withMe.toArray(new Contact[0]), - new GID(me.getJID(), - org.jivesoftware.smack.util.StringUtils.randomString(8)), + GroupChat chat = mModel.chats().createNew(members, + GroupControl.newKonGroupData(me.getJID()), subject); - // send create group command - List jids = new ArrayList<>(contacts.size()); - for (Contact c: contacts) - jids.add(c.getJID()); - this.createAndSendMessage(chat, - MessageContent.groupCommand( - GroupCommand.create(jids.toArray(new JID[0]),subject) - ) - ); + mGroupControl.getInstanceFor(chat).onCreate(); + return Optional.of(chat); } public void deleteChat(Chat chat) { - if (chat.isGroupChat() && chat.isValid()) { - // note: group chats are not 'deleted', were just leaving them - boolean sent = this.createAndSendMessage(chat, - MessageContent.groupCommand(GroupCommand.leave())); - - if (!sent) - // TODO tell view (and/or delete chat when message was sent) + if (chat instanceof GroupChat) { + boolean succ = mGroupControl.getInstanceFor((GroupChat) chat).beforeDelete(); + if (!succ) return; } - ChatList.getInstance().delete(chat.getID()); + mModel.chats().delete(chat); } public void setChatSubject(GroupChat chat, String subject) { - if (!chat.isAdministratable()) { - LOGGER.warning("not admin"); - return; - } - this.createAndSendMessage(chat, MessageContent.groupCommand( - GroupCommand.set(new JID[0], new JID[0], subject))); - - chat.setSubject(subject); + mGroupControl.getInstanceFor(chat).onSetSubject(subject); } public void handleOwnChatStateEvent(Chat chat, ChatState state) { @@ -701,19 +789,43 @@ public void downloadAgain(InMessage message) { } public void sendText(Chat chat, String text) { - this.sendTextMessage(chat, text, Paths.get("")); + this.sendNewMessage(chat, text, Paths.get("")); } public void sendAttachment(Chat chat, Path file){ - this.sendTextMessage(chat, "", file); + this.sendNewMessage(chat, "", file); + } + + public void sendAgain(OutMessage outMessage) { + Control.this.sendMessage(outMessage); + } + + /* avatar */ + + public void setUserAvatar(BufferedImage image) { + Avatar.UserAvatar newAvatar = mModel.setUserAvatar(image); + byte[] avatarData = newAvatar.imageData().orElse(null); + if (avatarData == null || newAvatar.getID().isEmpty()) + return; + + mClient.publishAvatar(newAvatar.getID(), avatarData); + } + + public void unsetUserAvatar(){ + if (mModel.userAvatar().getID().isEmpty()) + return; + + boolean succ = mClient.deleteAvatar(); + if (succ) + mModel.deleteUserAvatar(); } /* private */ - private void sendTextMessage(Chat chat, String text, Path file) { + private void sendNewMessage(Chat chat, String text, Path file) { Attachment attachment = null; if (!file.toString().isEmpty()) { - attachment = AttachmentManager.attachmentOrNull(file); + attachment = AttachmentManager.createAttachmentOrNull(file); if (attachment == null) return; } @@ -722,14 +834,14 @@ private void sendTextMessage(Chat chat, String text, Path file) { MessageContent.plainText(text) : MessageContent.outgoing(text, attachment); - this.createAndSendMessage(chat, content); + Control.this.createAndSendMessage(chat, content); } private PersonalKey keyOrNull(char[] password) { - AccountLoader account = AccountLoader.getInstance(); - Optional optKey = account.getPersonalKey(); - if (optKey.isPresent()) - return optKey.get(); + Account account = mModel.account(); + PersonalKey key = account.getPersonalKey().orElse(null); + if (key != null) + return key; if (password.length == 0) { if (account.isPasswordProtected()) { @@ -737,51 +849,26 @@ private PersonalKey keyOrNull(char[] password) { return null; } - password = Config.getInstance().getString(Config.ACC_PASS).toCharArray(); + password = account.getPassword(); } try { return account.load(password); } catch (KonException ex) { // something wrong with the account, tell view - Control.this.handleException(ex); + Control.this.onException(ex); return null; } } - /** - * All-in-one method for a new outgoing message: Create, - * save, process and send message. - */ - private boolean createAndSendMessage(Chat chat, MessageContent content) { - LOGGER.config("chat: "+chat+" content: "+content); - - if (!chat.isValid()) { - LOGGER.warning("invalid chat"); - return false; - } - - Contact[] contacts = chat.getValidContacts(); - if (contacts.length == 0) { - LOGGER.warning("can't send message, no (valid) contact(s)"); - return false; - } - - OutMessage newMessage = new OutMessage(chat, contacts, content, - chat.isSendEncrypted()); - if (newMessage.getContent().getAttachment().isPresent()) - mAttachmentManager.createImagePreview(newMessage); - boolean added = chat.addMessage(newMessage); - if (!added) { - LOGGER.warning("could not add outgoing message to chat"); - } - - return Control.this.sendMessage(newMessage); - } - void changed(ViewEvent event) { this.setChanged(); this.notifyObservers(event); } + + // TODO + public AccountImporter createAccountImporter() { + return new AccountImporter(mModel.account()); + } } } diff --git a/src/main/java/org/kontalk/system/GroupControl.java b/src/main/java/org/kontalk/system/GroupControl.java new file mode 100644 index 00000000..c472b11b --- /dev/null +++ b/src/main/java/org/kontalk/system/GroupControl.java @@ -0,0 +1,235 @@ +/* + * Kontalk Java client + * Copyright (C) 2016 Kontalk Devteam + * + * 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 . + */ + +package org.kontalk.system; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; +import java.util.logging.Logger; +import java.util.stream.Collectors; +import org.kontalk.misc.JID; +import org.kontalk.model.Contact; +import org.kontalk.model.Model; +import org.kontalk.model.chat.GroupChat; +import org.kontalk.model.chat.GroupChat.KonGroupChat; +import org.kontalk.model.chat.GroupMetaData.KonGroupData; +import org.kontalk.model.chat.Member; +import org.kontalk.model.message.MessageContent; +import org.kontalk.model.message.MessageContent.GroupCommand; +import org.kontalk.util.EncodingUtils; + +/** + * Control logic for group chat management. + * + * @author Alexander Bikadorov {@literal } + */ +final class GroupControl { + private static final Logger LOGGER = Logger.getLogger(GroupControl.class.getName()); + + private final Control mControl; + private final Model mModel; + + GroupControl(Control control, Model model) { + mControl = control; + mModel = model; + } + + abstract class ChatControl { + + protected final C mChat; + + private ChatControl(C chat) { + mChat = chat; + } + + abstract void onCreate(); + + abstract void onSetSubject(String subject); + + abstract boolean beforeDelete(); + + // + abstract void onInMessage(GroupCommand command, Contact sender); + } + + final class KonChatControl extends ChatControl { + + private KonChatControl(KonGroupChat chat) { + super(chat); + } + + @Override + void onCreate() { + // send create group command + List jids = mChat.getValidContacts().stream() + .map(contact -> contact.getJID()) + .collect(Collectors.toList()); + + mControl.createAndSendMessage(mChat, + MessageContent.groupCommand( + MessageContent.GroupCommand.create( + jids, + mChat.getSubject()) + ) + ); + } + + @Override + public void onSetSubject(String subject) { + if (!mChat.isAdministratable()) { + LOGGER.warning("not admin"); + return; + } + + mControl.createAndSendMessage(mChat, MessageContent.groupCommand( + GroupCommand.set(subject))); + + mChat.setSubject(subject); + } + + @Override + public boolean beforeDelete() { + if (!mChat.isValid()) + return true; + + // note: group chats are not 'deleted', were just leaving them + return mControl.createAndSendMessage(mChat, + MessageContent.groupCommand(GroupCommand.leave())); + } + + @Override + public void onInMessage(GroupCommand command, Contact sender) { + // TODO ignore message if it contains unexpected group command (?) + + // NOTE: chat was selected/created by GID so we can be sure message + // and chat GIDs match + KonGroupData gid = mChat.getGroupData(); + MessageContent.GroupCommand.OP op = command.getOperation(); + + // validation check + if (op != MessageContent.GroupCommand.OP.LEAVE) { + // sender must be owner + if (!gid.owner.equals(sender.getJID())) { + LOGGER.warning("sender not owner"); + return; + } + } + + // apply group command + List added = new ArrayList<>(); + List removed = new ArrayList<>(); + String subject = ""; + + switch(command.getOperation()) { + case CREATE: + //assert mMemberSet.size() == 1; + //assert mMemberSet.contains(new Member(sender)); + added.addAll(JIDsToMembers(command.getAdded())); + if (!added.stream().anyMatch(m -> m.getContact().isMe())) + LOGGER.warning("user not included in new chat"); + + subject = command.getSubject(); + break; + case LEAVE: + removed.add(new Member(sender)); + break; + case SET: + added.addAll(JIDsToMembers(command.getAdded())); + for (JID jid : command.getRemoved()) { + Contact contact = mModel.contacts().get(jid).orElse(null); + if (contact == null) { + LOGGER.warning("can't get removed contact, jid="+jid); + continue; + } + removed.add(new Member(contact)); + } + subject = command.getSubject(); + break; + default: + LOGGER.warning("unhandled operation: "+command.getOperation()); + } + + mChat.applyGroupChanges(added, removed, subject); + } + } + + private List JIDsToMembers(List jids) { + List members = new ArrayList<>(); + for (JID jid: jids) { + // add contacts if necessary + // TODO design problem here: we need at least the public keys, but user + // might dont wanna have group members in contact list + Contact contact = mControl.getOrCreateContact(jid).orElse(null); + if (contact == null) { + LOGGER.warning("can't get contact, jid: "+jid); + continue; + } + members.add(new Member(contact)); + } + return members; + } + + + ChatControl getInstanceFor(GroupChat chat) { + if (chat instanceof KonGroupChat) + return new KonChatControl((KonGroupChat) chat); + throw new IllegalArgumentException("Not implemented for "+chat); + } + + Optional getGroupChat(MessageContent content, Contact sender) { + KonGroupData gData = content.getGroupData().orElse(null); + if (gData == null) { + LOGGER.warning("message does not contain group data"); + return Optional.empty(); + } + + // get old... + GroupChat chat = mModel.chats().get(gData).orElse(null); + if (chat != null) { + if (!chat.getAllContacts().contains(sender)) { + LOGGER.warning("chat does not include sender: "+chat); + // TODO we should ask owner to confirm member list + return Optional.empty(); + } + return Optional.of(chat); + } + + // ...or create new + if (!gData.owner.equals(sender.getJID())) { + LOGGER.warning("sender is not owner for new group chat: "+gData); + return Optional.empty(); + } + + GroupCommand command = content.getGroupCommand().orElse(null); + if (command == null || !command.isAddingMe()) { + LOGGER.warning("ignoring unexpected message of unknown group"); + return Optional.empty(); + } + + return Optional.of( + mModel.chats().create( + Arrays.asList(new Member(sender, Member.Role.OWNER)), + gData)); + } + + static KonGroupData newKonGroupData(JID myJID) { + return new KonGroupData(myJID, EncodingUtils.randomString(8)); + } +} diff --git a/src/main/java/org/kontalk/system/RosterHandler.java b/src/main/java/org/kontalk/system/RosterHandler.java index eae91895..f85f2a8c 100644 --- a/src/main/java/org/kontalk/system/RosterHandler.java +++ b/src/main/java/org/kontalk/system/RosterHandler.java @@ -1,6 +1,6 @@ /* * Kontalk Java client - * Copyright (C) 2014 Kontalk Devteam + * Copyright (C) 2016 Kontalk Devteam * * 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 @@ -18,9 +18,9 @@ package org.kontalk.system; +import org.kontalk.persistence.Config; import java.util.Arrays; import java.util.List; -import java.util.Optional; import java.util.logging.Logger; import org.jivesoftware.smack.packet.Presence; import org.jivesoftware.smack.packet.XMPPError; @@ -31,8 +31,9 @@ import org.kontalk.crypto.PGPUtils; import org.kontalk.misc.ViewEvent; import org.kontalk.model.Contact; -import org.kontalk.model.ContactList; import org.kontalk.misc.JID; +import org.kontalk.model.Contact.Subscription; +import org.kontalk.model.Model; /** * Process incoming roster and presence changes. @@ -44,6 +45,7 @@ public final class RosterHandler { private final Control mControl; private final Client mClient; + private final Model mModel; private static final List KEY_SERVERS = Arrays.asList( "pgp.mit.edu" @@ -55,18 +57,17 @@ public enum Error { SERVER_NOT_FOUND } - RosterHandler(Control control, Client client) { + RosterHandler(Control control, Client client, Model model) { mControl = control; mClient = client; + mModel = model; } - /* from client */ - public void onEntryAdded(JID jid, String name, RosterPacket.ItemType type, RosterPacket.ItemStatus itemStatus) { - if (ContactList.getInstance().contains(jid)) { + if (mModel.contacts().contains(jid)) { this.onEntryUpdate(jid, name, type, itemStatus); return; } @@ -78,69 +79,88 @@ public void onEntryAdded(JID jid, name = ""; } - Optional optNewContact = mControl.createContact(jid, name); - if (!optNewContact.isPresent()) + Contact newContact = mControl.createContact(jid, name).orElse(null); + if (newContact == null) return; Contact.Subscription status = rosterToModelSubscription(itemStatus, type); - optNewContact.get().setSubScriptionStatus(status); + newContact.setSubscriptionStatus(status); + + mControl.maySendKeyRequest(newContact); if (status == Contact.Subscription.UNSUBSCRIBED) - mClient.sendPresenceSubscriptionRequest(jid); + mControl.sendPresenceSubscription(jid, Client.PresenceCommand.REQUEST); } public void onEntryDeleted(JID jid) { // note: also called on rename - Optional optContact = ContactList.getInstance().get(jid); - if (!optContact.isPresent()) { + Contact contact = mModel.contacts().get(jid).orElse(null); + if (contact == null) { LOGGER.info("can't find contact with jid: "+jid); return; } - mControl.getViewControl().changed(new ViewEvent.ContactDeleted(optContact.get())); + mControl.getViewControl().changed(new ViewEvent.ContactDeleted(contact)); } public void onEntryUpdate(JID jid, String name, RosterPacket.ItemType type, RosterPacket.ItemStatus itemStatus) { - Optional optContact = ContactList.getInstance().get(jid); - if (!optContact.isPresent()) { - LOGGER.warning("can't find contact with jid: "+jid); + Contact contact = mModel.contacts().get(jid).orElse(null); + if (contact == null) { + LOGGER.info("can't find contact with jid: "+jid); return; } - Contact contact = optContact.get(); // subcription may have changed - contact.setSubScriptionStatus(rosterToModelSubscription(itemStatus, type)); + contact.setSubscriptionStatus(rosterToModelSubscription(itemStatus, type)); + + // maybe subscribed now + mControl.maySendKeyRequest(contact); // name may have changed if (contact.getName().isEmpty() && !name.equals(jid.local())) contact.setName(name); } + public void onSubscriptionRequest(JID jid, byte[] rawKey) { + Contact contact = mModel.contacts().get(jid).orElse(null); + if (contact == null) + return; + + if (Config.getInstance().getBoolean(Config.NET_AUTO_SUBSCRIPTION)) { + mControl.sendPresenceSubscription(jid, Client.PresenceCommand.GRANT); + } else { + // ask user + mControl.getViewControl().changed(new ViewEvent.SubscriptionRequest(contact)); + } + + if (rawKey.length > 0) + mControl.onPGPKey(contact, rawKey); + } + public void onPresenceUpdate(JID jid, Presence.Type type, String status) { - if (this.isMe(jid) && !ContactList.getInstance().contains(jid)) + if (this.isMe(jid) && !mModel.contacts().contains(jid)) // don't wanna see myself return; - Optional optContact = ContactList.getInstance().get(jid); - if (!optContact.isPresent()) { - LOGGER.warning("can't find contact with jid: "+jid); + Contact contact = mModel.contacts().get(jid).orElse(null); + if (contact == null) { + LOGGER.info("can't find contact with jid: "+jid); return; } - optContact.get().setOnline(type, status); + contact.setOnline(type, status); } public void onFingerprintPresence(JID jid, String fingerprint) { - Optional optContact = ContactList.getInstance().get(jid); - if (!optContact.isPresent()) { - if (!this.isMe(jid)) - LOGGER.warning("can't find contact with jid:" + jid); + Contact contact = mModel.contacts().get(jid).orElse(null); + if (contact == null) { + LOGGER.info("can't find contact with jid: "+jid); return; } - Contact contact = optContact.get(); - if (!contact.getFingerprint().equals(fingerprint)) { + if (!fingerprint.isEmpty() && + !fingerprint.equalsIgnoreCase(contact.getFingerprint())) { LOGGER.info("detected public key change, requesting new key..."); mControl.maySendKeyRequest(contact); } @@ -148,21 +168,19 @@ public void onFingerprintPresence(JID jid, String fingerprint) { // TODO key IDs can be forged, searching by it is defective by design public void onSignaturePresence(JID jid, String signature) { - Optional optContact = ContactList.getInstance().get(jid); - if (!optContact.isPresent()) { - if (!this.isMe(jid)) - LOGGER.warning("can't find contact with jid:" + jid); + Contact contact = mModel.contacts().get(jid).orElse(null); + if (contact == null) { + LOGGER.info("can't find contact with jid: "+jid); return; } - Contact contact = optContact.get(); long keyID = PGPUtils.parseKeyIDFromSignature(signature); if (keyID == 0) return; if (contact.hasKey()) { - Optional optKey = Coder.contactkey(contact); - if (optKey.isPresent() && optKey.get().signKey.getKeyID() == keyID) + PGPUtils.PGPCoderKey key = Coder.contactkey(contact).orElse(null); + if (key != null && key.signKey.getKeyID() == keyID) // already have this key return; } @@ -178,18 +196,16 @@ public void onSignaturePresence(JID jid, String signature) { if (foundKey.isEmpty()) return; - Optional optKey = PGPUtils.readPublicKey(foundKey); - if (!optKey.isPresent()) + PGPUtils.PGPCoderKey key = PGPUtils.readPublicKey(foundKey).orElse(null); + if (key == null) return; - PGPUtils.PGPCoderKey key = optKey.get(); - if (key.signKey.getKeyID() != keyID) { LOGGER.warning("key ID is not what we were searching for"); return; } - mControl.getViewControl().changed(new ViewEvent.NewKey(optContact.get(), key)); + mControl.getViewControl().changed(new ViewEvent.NewKey(contact, key)); } public void onPresenceError(JID jid, XMPPError.Type type, XMPPError.Condition condition) { @@ -207,13 +223,11 @@ public void onPresenceError(JID jid, XMPPError.Type type, XMPPError.Condition co return; } - Optional optContact = ContactList.getInstance().get(jid); - if (!optContact.isPresent()) { - if (!this.isMe(jid)) - LOGGER.warning("can't find contact with jid:" + jid); + Contact contact = mModel.contacts().get(jid).orElse(null); + if (contact == null) { + LOGGER.info("can't find contact with jid: "+jid); return; } - Contact contact = optContact.get(); if (contact.getOnline() == Contact.Online.ERROR) // we already know this @@ -227,20 +241,20 @@ public void onPresenceError(JID jid, XMPPError.Type type, XMPPError.Condition co /* private */ private boolean isMe(JID jid) { - Optional optMyJID = mClient.getOwnJID(); - return optMyJID.isPresent() ? optMyJID.get().equals(jid) : false; + JID myJID = mClient.getOwnJID().orElse(null); + return myJID != null ? myJID.equals(jid) : false; } - private static Contact.Subscription rosterToModelSubscription( + private static Subscription rosterToModelSubscription( RosterPacket.ItemStatus status, RosterPacket.ItemType type) { if (type == RosterPacket.ItemType.both || type == RosterPacket.ItemType.to || type == RosterPacket.ItemType.remove) - return Contact.Subscription.SUBSCRIBED; + return Subscription.SUBSCRIBED; if (status == RosterPacket.ItemStatus.SUBSCRIPTION_PENDING) - return Contact.Subscription.PENDING; + return Subscription.PENDING; - return Contact.Subscription.UNSUBSCRIBED; + return Subscription.UNSUBSCRIBED; } } diff --git a/src/main/java/org/kontalk/util/ClientUtils.java b/src/main/java/org/kontalk/util/ClientUtils.java index 4a1115e7..86f9d082 100644 --- a/src/main/java/org/kontalk/util/ClientUtils.java +++ b/src/main/java/org/kontalk/util/ClientUtils.java @@ -1,6 +1,6 @@ /* * Kontalk Java client - * Copyright (C) 2014 Kontalk Devteam + * Copyright (C) 2016 Kontalk Devteam * * 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 @@ -18,25 +18,37 @@ package org.kontalk.util; +import java.net.URI; +import java.net.URISyntaxException; import java.util.ArrayList; +import java.util.Collections; import java.util.HashSet; import java.util.List; import java.util.Optional; import java.util.Set; +import java.util.logging.Level; import java.util.logging.Logger; import org.apache.commons.lang.StringUtils; +import org.jivesoftware.smack.packet.ExtensionElement; import org.jivesoftware.smack.packet.Message; +import org.kontalk.client.BitsOfBinary; +import org.kontalk.client.E2EEncryption; import org.kontalk.client.GroupExtension; -import org.kontalk.client.GroupExtension.Command; +import org.kontalk.client.GroupExtension.Type; import org.kontalk.client.GroupExtension.Member; -import org.kontalk.model.GroupChat.GID; +import org.kontalk.client.OutOfBandData; import org.kontalk.model.Contact; import org.kontalk.misc.JID; -import org.kontalk.model.GroupChat; -import org.kontalk.model.MessageContent.GroupCommand; -import org.kontalk.model.MessageContent.GroupCommand.OP; +import org.kontalk.model.chat.GroupChat.KonGroupChat; +import org.kontalk.model.chat.GroupMetaData.KonGroupData; +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.GroupCommand.OP; +import org.kontalk.model.message.MessageContent.Preview; /** + * Static utilities as interface between client and control. * * @author Alexander Bikadorov {@literal } */ @@ -44,13 +56,13 @@ public final class ClientUtils { private static final Logger LOGGER = Logger.getLogger(ClientUtils.class.getName()); /** - * Message attributes to identify the chat for a message. + * Message attributes for identifying the chat for a message. + * KonGroupData is missing here as this could be part of the encrypted content. */ public static class MessageIDs { public final JID jid; public final String xmppID; public final String xmppThreadID; - //public final Optional groupID; private MessageIDs(JID jid, String xmppID, String threadID) { this.jid = jid; @@ -63,9 +75,16 @@ public static MessageIDs from(Message m) { } public static MessageIDs from(Message m, String receiptID) { + return create(m, m.getFrom(), receiptID); + } + + public static MessageIDs to(Message m) { + return create(m, m.getTo(), ""); + } + + private static MessageIDs create(Message m, String jid, String receiptID) { return new MessageIDs( - // TODO - JID.full(StringUtils.defaultString(m.getFrom())), + JID.full(StringUtils.defaultString(jid)), !receiptID.isEmpty() ? receiptID : StringUtils.defaultString(m.getStanzaId()), StringUtils.defaultString(m.getThread())); @@ -77,75 +96,147 @@ public String toString() { } } - public static GroupExtension groupCommandToGroupExtension(GroupChat chat, + public static MessageContent parseMessageContent(Message m) { + // default body + String plainText = StringUtils.defaultString(m.getBody()); + + // encryption extension (RFC 3923), decrypted later + String encrypted = ""; + ExtensionElement encryptionExt = m.getExtension(E2EEncryption.ELEMENT_NAME, E2EEncryption.NAMESPACE); + if (encryptionExt instanceof E2EEncryption) { + if (m.getBody() != null) + LOGGER.config("message contains encryption and body (ignoring body): "+m.getBody()); + E2EEncryption encryption = (E2EEncryption) encryptionExt; + encrypted = EncodingUtils.bytesToBase64(encryption.getData()); + } + + // Bits of Binary: preview for file attachment + Preview preview = null; + ExtensionElement bobExt = m.getExtension(BitsOfBinary.ELEMENT_NAME, BitsOfBinary.NAMESPACE); + if (bobExt instanceof BitsOfBinary) { + BitsOfBinary bob = (BitsOfBinary) bobExt; + String mime = StringUtils.defaultString(bob.getType()); + byte[] bits = bob.getContents(); + if (bits == null) + bits = new byte[0]; + if (mime.isEmpty() || bits.length <= 0) + LOGGER.warning("invalid BOB data: "+bob.toXML()); + else + preview = new Preview(bits, mime); + } + + // Out of Band Data: a URI to a file + Attachment attachment = null; + ExtensionElement oobExt = m.getExtension(OutOfBandData.ELEMENT_NAME, OutOfBandData.NAMESPACE); + if (oobExt instanceof OutOfBandData) { + OutOfBandData oobData = (OutOfBandData) oobExt; + URI url; + try { + url = new URI(oobData.getUrl()); + } catch (URISyntaxException ex) { + LOGGER.log(Level.WARNING, "can't parse URL", ex); + url = URI.create(""); + } + attachment = MessageContent.Attachment.incoming(url, + oobData.getLength(), + oobData.isEncrypted()); + + // body text is maybe URI, for clients that dont understand OOB, + // but we do, don't save it twice + if (plainText.equals(url.toString())); + plainText = ""; + } + + // group command + KonGroupData gid = null; + GroupCommand groupCommand = null; + ExtensionElement groupExt = m.getExtension(GroupExtension.ELEMENT_NAME, + GroupExtension.NAMESPACE); + if (groupExt instanceof GroupExtension) { + GroupExtension group = (GroupExtension) groupExt; + gid = new KonGroupData(JID.bare(group.getOwner()), group.getID()); + groupCommand = ClientUtils.groupExtensionToGroupCommand( + group.getType(), group.getMembers(), group.getSubject()).orElse(null); + } + + return new MessageContent.Builder(plainText, encrypted) + .attachment(attachment) + .preview(preview) + .groupData(gid) + .groupCommand(groupCommand).build(); + } + + /* Internal to external */ + public static GroupExtension groupCommandToGroupExtension(KonGroupChat chat, GroupCommand groupCommand) { assert chat.isGroupChat(); - GID gid = chat.getGID(); + KonGroupData gid = chat.getGroupData(); OP op = groupCommand.getOperation(); switch (op) { case LEAVE: // weare leaving - return new GroupExtension(gid.id, gid.ownerJID.string(), Command.LEAVE); + return new GroupExtension(gid.id, gid.owner.string(), Type.PART); case CREATE: case SET: - Command command; - Set member = new HashSet<>(); + default: + Type command; + Set members = new HashSet<>(); String subject = groupCommand.getSubject(); if (op == OP.CREATE) { - command = Command.CREATE; - for (JID added : groupCommand.getAdded()) - member.add(new Member(added.string())); + command = Type.CREATE; + groupCommand.getAdded().stream().forEach(added -> + members.add(new Member(added.string()))); } else { - command = Command.SET; + command = Type.SET; Set incl = new HashSet<>(); for (JID added : groupCommand.getAdded()) { incl.add(added); - member.add(new Member(added.string(), Member.Type.ADD)); + members.add(new Member(added.string(), Member.Operation.ADD)); } for (JID removed : groupCommand.getRemoved()) { incl.add(removed); - member.add(new Member(removed.string(), Member.Type.REMOVE)); + members.add(new Member(removed.string(), Member.Operation.REMOVE)); } - if (groupCommand.getAdded().length > 0) { + if (!groupCommand.getAdded().isEmpty()) { // list all remaining member for the new member for (Contact c : chat.getValidContacts()) { JID old = c.getJID(); if (!incl.contains(old)) - member.add(new Member(old.string())); + members.add(new Member(old.string())); } } } return new GroupExtension(gid.id, - gid.ownerJID.string(), + gid.owner.string(), command, - member.toArray(new Member[0]), - subject); - default: - // can not happen - return null; + subject, + members); } } + /* External to internal */ public static Optional groupExtensionToGroupCommand( - Command com, - Member[] members, + Type com, + List members, String subject) { switch (com) { case NONE: return Optional.empty(); case CREATE: - List jids = new ArrayList<>(members.length); + List jids = new ArrayList<>(members.size()); for (Member m: members) jids.add(JID.bare(m.jid)); - return Optional.of(GroupCommand.create(jids.toArray(new JID[0]), subject)); - case LEAVE: + return Optional.of(GroupCommand.create(jids, subject)); + case PART: return Optional.of(GroupCommand.leave()); case SET: // TODO - return Optional.of(GroupCommand.set(new JID[0], new JID[0], subject)); + return Optional.of(GroupCommand.set( + Collections.emptyList(), + Collections.emptyList(), subject)); case GET: case RESULT: default: diff --git a/src/main/java/org/kontalk/util/CryptoUtils.java b/src/main/java/org/kontalk/util/CryptoUtils.java index 1d379dab..537fc42c 100644 --- a/src/main/java/org/kontalk/util/CryptoUtils.java +++ b/src/main/java/org/kontalk/util/CryptoUtils.java @@ -1,6 +1,6 @@ /* * Kontalk Java client - * Copyright (C) 2014 Kontalk Devteam + * Copyright (C) 2016 Kontalk Devteam * * 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 @@ -38,11 +38,11 @@ public class CryptoUtils { * Ugly hack to get “unlimited strength” for the Java Encryption Extension. * Source: https://stackoverflow.com/a/22492582 */ - public static void removeCryptographyRestrictions() { + public static boolean removeCryptographyRestrictions() { try { if (Cipher.getMaxAllowedKeyLength("RC5") >= 256) { LOGGER.config("cryptography restrictions removal not needed"); - return; + return true; } } catch (NoSuchAlgorithmException ex) { LOGGER.log(Level.WARNING, "can't check for crypto restriction", ex); } @@ -82,6 +82,8 @@ public static void removeCryptographyRestrictions() { IllegalArgumentException | IllegalAccessException ex) { LOGGER.log(Level.WARNING, "can't remove cryptography restrictions", ex); + return false; } + return true; } } diff --git a/src/main/java/org/kontalk/util/EncodingUtils.java b/src/main/java/org/kontalk/util/EncodingUtils.java index 07d6e7a2..fe43faaf 100644 --- a/src/main/java/org/kontalk/util/EncodingUtils.java +++ b/src/main/java/org/kontalk/util/EncodingUtils.java @@ -1,6 +1,6 @@ /* * Kontalk Java client - * Copyright (C) 2014 Kontalk Devteam + * Copyright (C) 2016 Kontalk Devteam * 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 @@ -18,13 +18,21 @@ package org.kontalk.util; +import java.net.URI; +import java.net.URISyntaxException; import java.util.Base64; import java.util.EnumSet; import java.util.Map; +import java.util.logging.Level; +import java.util.logging.Logger; +import org.apache.commons.lang.RandomStringUtils; import org.apache.commons.lang.StringUtils; import org.json.simple.JSONObject; public final class EncodingUtils { + private static final Logger LOGGER = Logger.getLogger(EncodingUtils.class.getName()); + + public static final String EOL = System.getProperty("line.separator"); private static final char[] HEX_ARRAY = "0123456789ABCDEF".toCharArray(); @@ -78,4 +86,17 @@ public static byte[] base64ToBytes(String base64) { public static String bytesToBase64(byte[] bytes) { return Base64.getEncoder().encodeToString(bytes); } + + public static URI toURI(String str) { + try { + return new URI(str); + } catch (URISyntaxException ex) { + LOGGER.log(Level.WARNING, "invalid URI", ex); + } + return URI.create(""); + } + + public static String randomString(int length) { + return RandomStringUtils.randomAlphanumeric(length); + } } \ No newline at end of file diff --git a/src/main/java/org/kontalk/util/MediaUtils.java b/src/main/java/org/kontalk/util/MediaUtils.java index 6ae643bc..5726797c 100644 --- a/src/main/java/org/kontalk/util/MediaUtils.java +++ b/src/main/java/org/kontalk/util/MediaUtils.java @@ -1,6 +1,6 @@ /* * Kontalk Java client - * Copyright (C) 2014 Kontalk Devteam + * Copyright (C) 2016 Kontalk Devteam * * 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 @@ -21,9 +21,18 @@ import java.awt.Graphics2D; import java.awt.Image; import java.awt.image.BufferedImage; +import java.awt.image.ImageObserver; +import java.io.BufferedInputStream; +import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.File; +import java.io.FileInputStream; import java.io.IOException; +import java.io.InputStream; +import java.net.URLConnection; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Optional; import java.util.logging.Level; import java.util.logging.Logger; import javax.imageio.ImageIO; @@ -31,6 +40,7 @@ import org.apache.tika.mime.MimeType; import org.apache.tika.mime.MimeTypeException; import org.apache.tika.mime.MimeTypes; +import org.kontalk.misc.Callback; /** * @@ -39,22 +49,53 @@ public class MediaUtils { private static final Logger LOGGER = Logger.getLogger(MediaUtils.class.getName()); - private static OggClip mAudioClip = null; + private MediaUtils() {} - /* contains dot! */ public static String extensionForMIME(String mimeType) { + if (mimeType.isEmpty()) + return "unk"; + MimeType mime = null; try { mime = MimeTypes.getDefaultMimeTypes().forName(mimeType); } catch (MimeTypeException ex) { LOGGER.log(Level.WARNING, "can't find mimetype", ex); } - return StringUtils.defaultIfEmpty(mime != null ? mime.getExtension() : "", ".dat"); + + String m = mime != null ? mime.getExtension() : ""; + // remove dot + if (!m.isEmpty()) + m = m.substring(1); + return StringUtils.defaultIfEmpty(m, "dat"); + } + + public static String mimeForFile(Path path) { + String mime = null; + try { + mime = Files.probeContentType(path); + } catch (IOException ex) { + LOGGER.log(Level.WARNING, "can't probe type", ex); + } + + if (mime == null) { + // method above is buggy on windows, try something else + try(FileInputStream fis = new FileInputStream(path.toFile())) { + InputStream is = new BufferedInputStream(fis); + mime = URLConnection.guessContentTypeFromStream(is); + } catch (IOException ex) { + LOGGER.log(Level.WARNING, "can't guess content type", ex); + } + } + + if (mime == null) + LOGGER.warning("can't determine content type: "+path); + + return StringUtils.defaultString(mime); } public enum Sound{NOTIFICATION} - private MediaUtils() {} + private static OggClip mAudioClip = null; public static void playSound(Sound sound) { switch (sound) { @@ -77,20 +118,50 @@ private static void play(String fileName) { mAudioClip.play(); } - public static BufferedImage readImage(String path) { + public static BufferedImage readImage(Path path) { + BufferedImage img = readImage(path.toFile()).orElse(null); + return img != null ? + img : + new BufferedImage(20, 20, BufferedImage.TYPE_INT_RGB); + } + + public static Optional readImage(File file) { + if (!file.exists()) { + LOGGER.warning("image file does not exist: "+file); + return Optional.empty(); + } + try { - BufferedImage image = ImageIO.read(new File(path)); - if (image != null) { - return image; - } + return Optional.ofNullable(ImageIO.read(file)); } catch (IOException ex) { - LOGGER.log(Level.WARNING, "can't read image, path: "+path, ex); + LOGGER.log(Level.WARNING, "can't read image, path: "+file.getPath(), ex); } - return new BufferedImage(20, 20, BufferedImage.TYPE_INT_RGB); + return Optional.empty(); } - public static byte[] imageToByteArray(Image image, String format) { + public static Optional readImage(byte[] imgData) { + try { + return Optional.ofNullable(ImageIO.read(new ByteArrayInputStream(imgData))); + } catch (IOException ex) { + LOGGER.log(Level.WARNING, "can't read image data", ex); + } + return Optional.empty(); + } + + public static boolean writeImage(BufferedImage img, String format, File output) { + boolean succ; + try { + succ = ImageIO.write(img, format, output); + } catch (IOException ex) { + LOGGER.log(Level.WARNING, "can't save image", ex); + return false; + } + if (!succ) + LOGGER.warning("can't find writer for format: "+format); + return succ; + } + public static byte[] imageToByteArray(Image image, String format) { BufferedImage bufImage = new BufferedImage( image.getWidth(null), image.getHeight(null), BufferedImage.TYPE_INT_RGB); @@ -113,12 +184,74 @@ public static byte[] imageToByteArray(Image image, String format) { return out.toByteArray(); } + /** + * Scale image down to max pixels preserving ratio. + * Blocking + */ + public static BufferedImage scale(BufferedImage image, int maxPixels) { + int iw = image.getWidth(); + int ih = image.getHeight(); + + double scale = Math.sqrt(maxPixels / (iw * ih * 1.0)); + + return toBufferedImage(scaleAsync(image, (int) (iw * scale), (int) (ih * scale))); + } + + /** + * Scale image down to max width/height preserving ratio. + * Blocking. + */ + public static BufferedImage scale(Image image, int width, int height) { + return toBufferedImage(scaleAsync(image, width, height, true)); + } + + private static BufferedImage toBufferedImage(Image image) { + final Callback.Synchronizer syncer = new Callback.Synchronizer(); + + ImageObserver observer = new ImageObserver() { + @Override + public boolean imageUpdate(Image img, int infoflags, int x, int y, int width, int height) { + // ignore if image is not completely loaded + if ((infoflags & ImageObserver.ALLBITS) == 0) { + return true; + } + + // scaling done, continue with calling thread + syncer.sync(); + return false; + } + }; + + if (image.getWidth(observer) == -1) { + syncer.waitForSync(); + } + + // convert to buffered image, source: https://stackoverflow.com/a/13605411 + if (image instanceof BufferedImage) + return (BufferedImage) image; + + int iw = image.getWidth(null); + int ih = image.getHeight(null); + if (iw == -1) { + LOGGER.warning("image not loaded yet"); + } + + BufferedImage bimage = new BufferedImage(iw, ih, BufferedImage.TYPE_3BYTE_BGR); + + Graphics2D bGr = bimage.createGraphics(); + bGr.drawImage(image, 0, 0, null); + bGr.dispose(); + + return bimage; + } + /** * Scale image down to maximum or minimum of width or height, preserving ratio. + * Async: returned image may not fully loaded. + * * @param max specifies if image is scaled to maximum or minimum of width/height - * @return the scaled image, loaded async */ - public static Image scale(Image image, int width, int height, boolean max) { + public static Image scaleAsync(Image image, int width, int height, boolean max) { int iw = image.getWidth(null); int ih = image.getHeight(null); if (iw == -1) { @@ -130,6 +263,10 @@ public static Image scale(Image image, int width, int height, boolean max) { double sw = width / (iw * 1.0); double sh = height / (ih * 1.0); double scale = max ? Math.max(sw, sh) : Math.min(sw, sh); - return image.getScaledInstance((int) (iw * scale), (int) (ih * scale), Image.SCALE_FAST); + return scaleAsync(image, (int) (iw * scale), (int) (ih * scale)); + } + + private static Image scaleAsync(Image image, int width, int height) { + return image.getScaledInstance(width, height, Image.SCALE_FAST); } } diff --git a/src/main/java/org/kontalk/util/OggClip.java b/src/main/java/org/kontalk/util/OggClip.java index 0ab90e56..80edc962 100644 --- a/src/main/java/org/kontalk/util/OggClip.java +++ b/src/main/java/org/kontalk/util/OggClip.java @@ -1,627 +1,622 @@ -package org.kontalk.util; - -import java.io.BufferedInputStream; -import java.io.IOException; -import java.io.InputStream; - -import javax.sound.sampled.AudioFormat; -import javax.sound.sampled.AudioSystem; -import javax.sound.sampled.DataLine; -import javax.sound.sampled.FloatControl; -import javax.sound.sampled.LineUnavailableException; -import javax.sound.sampled.SourceDataLine; - -import com.jcraft.jogg.Packet; -import com.jcraft.jogg.Page; -import com.jcraft.jogg.StreamState; -import com.jcraft.jogg.SyncState; -import com.jcraft.jorbis.Block; -import com.jcraft.jorbis.Comment; -import com.jcraft.jorbis.DspState; -import com.jcraft.jorbis.Info; - -/** - * Simple Clip like player for OGG's. Code is mostly taken from the example - * provided with JOrbis. - * - * Source: http://www.cokeandcode.com/main/code/ - * - * @author kevin - */ -class OggClip { - - private final int BUFSIZE = 4096 * 2; - private int convsize = BUFSIZE * 2; - private byte[] convbuffer = new byte[convsize]; - private SyncState oy; - private StreamState os; - private Page og; - private Packet op; - private Info vi; - private Comment vc; - private DspState vd; - private Block vb; - private SourceDataLine outputLine; - private int rate; - private int channels; - private BufferedInputStream bitStream = null; - private byte[] buffer = null; - private int bytes = 0; - private Thread player = null; - - private float balance; - private float gain = -1; - private boolean paused; - private float oldGain; - - /** - * Create a new clip based on a reference into the class path - * - * @param ref The reference into the class path which the ogg can be read - * from - * @throws IOException Indicated a failure to find the resource - */ - public OggClip(String ref) throws IOException { - try { - init(Thread.currentThread().getContextClassLoader().getResourceAsStream(ref)); - } catch (IOException e) { - throw new IOException("Couldn't find: " + ref); - } - } - - /** - * Create a new clip based on a reference into the class path - * - * @param in The stream from which the ogg can be read from - * @throws IOException Indicated a failure to read from the stream - */ - public OggClip(InputStream in) throws IOException { - init(in); - } - - /** - * Set the default gain value (default volume) - */ - public void setDefaultGain() { - setGain(-1); - } - - /** - * Attempt to set the global gain (volume ish) for the play back. If the - * control is not supported this method has no effect. 1.0 will set maximum - * gain, 0.0 minimum gain - * - * @param gain The gain value - */ - public void setGain(float gain) { - if (gain != -1) { - if ((gain < 0) || (gain > 1)) { - throw new IllegalArgumentException("Volume must be between 0.0 and 1.0"); - } - } - - this.gain = gain; - - if (outputLine == null) { - return; - } - - try { - FloatControl control = (FloatControl) outputLine.getControl(FloatControl.Type.MASTER_GAIN); - if (gain == -1) { - control.setValue(0); - } else { - float max = control.getMaximum(); - float min = control.getMinimum(); // negative values all seem to be zero? - float range = max - min; - - control.setValue(min + (range * gain)); - } - } catch (IllegalArgumentException e) { - // gain not supported - e.printStackTrace(); - } - } - - /** - * Attempt to set the balance between the two speakers. -1.0 is full left - * speak, 1.0 if full right speaker. Anywhere in between moves between the - * two speakers. If the control is not supported this method has no effect - * - * @param balance The balance value - */ - public void setBalance(float balance) { - this.balance = balance; - - if (outputLine == null) { - return; - } - - try { - FloatControl control = (FloatControl) outputLine.getControl(FloatControl.Type.BALANCE); - control.setValue(balance); - } catch (IllegalArgumentException e) { - // balance not supported - } - } - - /** - * Check the state of the play back - * - * @return True if the playback has been stopped - */ - private boolean checkState() { - while (paused && (player != null)) { - synchronized (player) { - if (player != null) { - try { - player.wait(); - } catch (InterruptedException e) { - // ignored - } - } - } - } - - return stopped(); - } - - /** - * Pause the play back - */ - public void pause() { - paused = true; - oldGain = gain; - setGain(0); - } - - /** - * Check if the stream is paused - * - * @return True if the stream is paused - */ - public boolean isPaused() { - return paused; - } - - /** - * Resume the play back - */ - public void resume() { - if (!paused) { - play(); - return; - } - - paused = false; - - synchronized (player) { - if (player != null) { - player.notify(); - } - } - setGain(oldGain); - } - - /** - * Check if the clip has been stopped - * - * @return True if the clip has been stopped - */ - public boolean stopped() { - return ((player == null) || (!player.isAlive())); - } - - /** - * Initialise the ogg clip - * - * @param in The stream we're going to read from - * @throws IOException Indicates a failure to read from the stream - */ - private void init(InputStream in) throws IOException { - if (in == null) { - throw new IOException("Couldn't find input source"); - } - bitStream = new BufferedInputStream(in); - bitStream.mark(Integer.MAX_VALUE); - } - - /** - * Play the clip once - */ - public void play() { - stop(); - - try { - bitStream.reset(); - } catch (IOException e) { - // ignore if no mark - } - - player = new Thread() { - public void run() { - try { - playStream(Thread.currentThread()); - } catch (InternalException e) { - e.printStackTrace(); - } - - try { - bitStream.reset(); - } catch (IOException e) { - e.printStackTrace(); - } - } - ; - }; - player.setDaemon(true); - player.start(); - } - - /** - * Loop the clip - maybe for background music - */ - public void loop() { - stop(); - - try { - bitStream.reset(); - } catch (IOException e) { - // ignore if no mark - } - - player = new Thread() { - @Override - public void run() { - while (player == Thread.currentThread()) { - try { - playStream(Thread.currentThread()); - } catch (InternalException e) { - e.printStackTrace(); - player = null; - } - - try { - bitStream.reset(); - } catch (IOException e) { - } - } - } - ; - }; - player.setDaemon(true); - player.start(); - } - - /** - * Stop the clip playing - */ - public void stop() { - if (stopped()) { - return; - } - - player = null; - outputLine.drain(); - } - - /** - * Close the stream being played from - */ - public void close() { - try { - if (bitStream != null) { - bitStream.close(); - } - } catch (IOException e) { - } - } - - /* - * Taken from the JOrbis Player - */ - private void initJavaSound(int channels, int rate) { - try { - AudioFormat audioFormat = new AudioFormat(rate, 16, - channels, true, // PCM_Signed - false // littleEndian - ); - DataLine.Info info = new DataLine.Info(SourceDataLine.class, - audioFormat, AudioSystem.NOT_SPECIFIED); - if (!AudioSystem.isLineSupported(info)) { - throw new Exception("Line " + info + " not supported."); - } - - try { - outputLine = (SourceDataLine) AudioSystem.getLine(info); - // outputLine.addLineListener(this); - outputLine.open(audioFormat); - } catch (LineUnavailableException ex) { - throw new Exception("Unable to open the sourceDataLine: " + ex); - } catch (IllegalArgumentException ex) { - throw new Exception("Illegal Argument: " + ex); - } - - this.rate = rate; - this.channels = channels; - - setBalance(balance); - setGain(gain); - } catch (Exception ee) { - System.out.println(ee); - } - } - - /* - * Taken from the JOrbis Player - */ - private SourceDataLine getOutputLine(int channels, int rate) { - if (outputLine == null || this.rate != rate - || this.channels != channels) { - if (outputLine != null) { - outputLine.drain(); - outputLine.stop(); - outputLine.close(); - } - initJavaSound(channels, rate); - outputLine.start(); - } - return outputLine; - } - - /* - * Taken from the JOrbis Player - */ - private void initJOrbis() { - oy = new SyncState(); - os = new StreamState(); - og = new Page(); - op = new Packet(); - - vi = new Info(); - vc = new Comment(); - vd = new DspState(); - vb = new Block(vd); - - buffer = null; - bytes = 0; - - oy.init(); - } - - /* - * Taken from the JOrbis Player - */ - private void playStream(Thread me) throws InternalException { - boolean chained = false; - - initJOrbis(); - - while (true) { - if (checkState()) { - return; - } - - int eos = 0; - - int index = oy.buffer(BUFSIZE); - buffer = oy.data; - try { - bytes = bitStream.read(buffer, index, BUFSIZE); - } catch (Exception e) { - throw new InternalException(e); - } - oy.wrote(bytes); - - if (chained) { - chained = false; - } else { - if (oy.pageout(og) != 1) { - if (bytes < BUFSIZE) { - break; - } - throw new InternalException("Input does not appear to be an Ogg bitstream."); - } - } - os.init(og.serialno()); - os.reset(); - - vi.init(); - vc.init(); - - if (os.pagein(og) < 0) { - // error; stream version mismatch perhaps - throw new InternalException("Error reading first page of Ogg bitstream data."); - } - - if (os.packetout(op) != 1) { - // no page? must not be vorbis - throw new InternalException("Error reading initial header packet."); - } - - if (vi.synthesis_headerin(vc, op) < 0) { - // error case; not a vorbis header - throw new InternalException("This Ogg bitstream does not contain Vorbis audio data."); - } - - int i = 0; - - while (i < 2) { - while (i < 2) { - if (checkState()) { - return; - } - - int result = oy.pageout(og); - if (result == 0) { - break; // Need more data - } - if (result == 1) { - os.pagein(og); - while (i < 2) { - result = os.packetout(op); - if (result == 0) { - break; - } - if (result == -1) { - throw new InternalException("Corrupt secondary header. Exiting."); - } - vi.synthesis_headerin(vc, op); - i++; - } - } - } - - index = oy.buffer(BUFSIZE); - buffer = oy.data; - try { - bytes = bitStream.read(buffer, index, BUFSIZE); - } catch (Exception e) { - throw new InternalException(e); - } - if (bytes == 0 && i < 2) { - throw new InternalException("End of file before finding all Vorbis headers!"); - } - oy.wrote(bytes); - } - - convsize = BUFSIZE / vi.channels; - - vd.synthesis_init(vi); - vb.init(vd); - - float[][][] _pcmf = new float[1][][]; - int[] _index = new int[vi.channels]; - - getOutputLine(vi.channels, vi.rate); - - while (eos == 0) { - while (eos == 0) { - if (player != me) { - return; - } - - int result = oy.pageout(og); - if (result == 0) { - break; // need more data - } - if (result == -1) { // missing or corrupt data at this page - // position - // System.err.println("Corrupt or missing data in - // bitstream; - // continuing..."); - } else { - os.pagein(og); - - if (og.granulepos() == 0) { // - chained = true; // - eos = 1; // - break; // - } // - - while (true) { - if (checkState()) { - return; - } - - result = os.packetout(op); - if (result == 0) { - break; // need more data - } - if (result == -1) { // missing or corrupt data at - // this page position - // no reason to complain; already complained - // above - - // System.err.println("no reason to complain; - // already complained above"); - } else { - // we have a packet. Decode it - int samples; - if (vb.synthesis(op) == 0) { // test for - // success! - vd.synthesis_blockin(vb); - } - while ((samples = vd.synthesis_pcmout(_pcmf, - _index)) > 0) { - if (checkState()) { - return; - } - - float[][] pcmf = _pcmf[0]; - int bout = (samples < convsize ? samples - : convsize); - - // convert doubles to 16 bit signed ints - // (host order) and - // interleave - for (i = 0; i < vi.channels; i++) { - int ptr = i * 2; - // int ptr=i; - int mono = _index[i]; - for (int j = 0; j < bout; j++) { - int val = (int) (pcmf[i][mono + j] * 32767.); - if (val > 32767) { - val = 32767; - } - if (val < -32768) { - val = -32768; - } - if (val < 0) { - val = val | 0x8000; - } - convbuffer[ptr] = (byte) (val); - convbuffer[ptr + 1] = (byte) (val >>> 8); - ptr += 2 * (vi.channels); - } - } - outputLine.write(convbuffer, 0, 2 - * vi.channels * bout); - vd.synthesis_read(bout); - } - } - } - if (og.eos() != 0) { - eos = 1; - } - } - } - - if (eos == 0) { - index = oy.buffer(BUFSIZE); - buffer = oy.data; - try { - bytes = bitStream.read(buffer, index, BUFSIZE); - } catch (Exception e) { - throw new InternalException(e); - } - if (bytes == -1) { - break; - } - oy.wrote(bytes); - if (bytes == 0) { - eos = 1; - } - } - } - - os.clear(); - vb.clear(); - vd.clear(); - vi.clear(); - } - - oy.clear(); - } - - private class InternalException extends Exception { - - public InternalException(Exception e) { - super(e); - } - - public InternalException(String msg) { - super(msg); - } - } -} +package org.kontalk.util; + +import java.io.BufferedInputStream; +import java.io.IOException; +import java.io.InputStream; + +import javax.sound.sampled.AudioFormat; +import javax.sound.sampled.AudioSystem; +import javax.sound.sampled.DataLine; +import javax.sound.sampled.FloatControl; +import javax.sound.sampled.SourceDataLine; + +import com.jcraft.jogg.Packet; +import com.jcraft.jogg.Page; +import com.jcraft.jogg.StreamState; +import com.jcraft.jogg.SyncState; +import com.jcraft.jorbis.Block; +import com.jcraft.jorbis.Comment; +import com.jcraft.jorbis.DspState; +import com.jcraft.jorbis.Info; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * Simple Clip like player for OGG's. Code is mostly taken from the example + * provided with JOrbis. + * + * Source: http://www.cokeandcode.com/main/code/ + * + * @author kevin + */ +class OggClip { + private static final Logger LOGGER = Logger.getLogger(OggClip.class.getName()); + + private final int BUFSIZE = 4096 * 2; + private int convsize = BUFSIZE * 2; + private byte[] convbuffer = new byte[convsize]; + private SyncState oy; + private StreamState os; + private Page og; + private Packet op; + private Info vi; + private Comment vc; + private DspState vd; + private Block vb; + private SourceDataLine outputLine; + private int rate; + private int channels; + private BufferedInputStream bitStream = null; + private byte[] buffer = null; + private int bytes = 0; + private Thread player = null; + + private float balance; + private float gain = -1; + private boolean paused; + private float oldGain; + + /** + * Create a new clip based on a reference into the class path + * + * @param ref The reference into the class path which the ogg can be read + * from + * @throws IOException Indicated a failure to find the resource + */ + public OggClip(String ref) throws IOException { + try { + init(Thread.currentThread().getContextClassLoader().getResourceAsStream(ref)); + } catch (IOException e) { + throw new IOException("Couldn't find: " + ref); + } + } + + /** + * Create a new clip based on a reference into the class path + * + * @param in The stream from which the ogg can be read from + * @throws IOException Indicated a failure to read from the stream + */ + public OggClip(InputStream in) throws IOException { + init(in); + } + + /** + * Set the default gain value (default volume) + */ + public void setDefaultGain() { + setGain(-1); + } + + /** + * Attempt to set the global gain (volume ish) for the play back. If the + * control is not supported this method has no effect. 1.0 will set maximum + * gain, 0.0 minimum gain + * + * @param gain The gain value + */ + public void setGain(float gain) { + if (gain != -1) { + if ((gain < 0) || (gain > 1)) { + throw new IllegalArgumentException("Volume must be between 0.0 and 1.0"); + } + } + + this.gain = gain; + + if (outputLine == null) { + return; + } + + try { + FloatControl control = (FloatControl) outputLine.getControl(FloatControl.Type.MASTER_GAIN); + if (gain == -1) { + control.setValue(0); + } else { + float max = control.getMaximum(); + float min = control.getMinimum(); // negative values all seem to be zero? + float range = max - min; + + control.setValue(min + (range * gain)); + } + } catch (IllegalArgumentException e) { + // gain not supported + e.printStackTrace(); + } + } + + /** + * Attempt to set the balance between the two speakers. -1.0 is full left + * speak, 1.0 if full right speaker. Anywhere in between moves between the + * two speakers. If the control is not supported this method has no effect + * + * @param balance The balance value + */ + public void setBalance(float balance) { + this.balance = balance; + + if (outputLine == null) { + return; + } + + try { + FloatControl control = (FloatControl) outputLine.getControl(FloatControl.Type.BALANCE); + control.setValue(balance); + } catch (IllegalArgumentException e) { + // balance not supported + } + } + + /** + * Check the state of the play back + * + * @return True if the playback has been stopped + */ + private boolean checkState() { + while (paused && (player != null)) { + synchronized (player) { + if (player != null) { + try { + player.wait(); + } catch (InterruptedException e) { + // ignored + } + } + } + } + + return stopped(); + } + + /** + * Pause the play back + */ + public void pause() { + paused = true; + oldGain = gain; + setGain(0); + } + + /** + * Check if the stream is paused + * + * @return True if the stream is paused + */ + public boolean isPaused() { + return paused; + } + + /** + * Resume the play back + */ + public void resume() { + if (!paused) { + play(); + return; + } + + paused = false; + + synchronized (player) { + if (player != null) { + player.notify(); + } + } + setGain(oldGain); + } + + /** + * Check if the clip has been stopped + * + * @return True if the clip has been stopped + */ + public boolean stopped() { + return ((player == null) || (!player.isAlive())); + } + + /** + * Initialise the ogg clip + * + * @param in The stream we're going to read from + * @throws IOException Indicates a failure to read from the stream + */ + private void init(InputStream in) throws IOException { + if (in == null) { + throw new IOException("Couldn't find input source"); + } + bitStream = new BufferedInputStream(in); + bitStream.mark(Integer.MAX_VALUE); + } + + /** + * Play the clip once + */ + public void play() { + stop(); + + try { + bitStream.reset(); + } catch (IOException e) { + // ignore if no mark + } + + player = new Thread("OGG Play") { + @Override + public void run() { + try { + playStream(Thread.currentThread()); + } catch (InternalException e) { + e.printStackTrace(); + } + + try { + bitStream.reset(); + } catch (IOException e) { + e.printStackTrace(); + } + }; + }; + player.setDaemon(true); + player.start(); + } + + /** + * Loop the clip - maybe for background music + */ + public void loop() { + stop(); + + try { + bitStream.reset(); + } catch (IOException e) { + // ignore if no mark + } + + player = new Thread("OGG Loop") { + @Override + public void run() { + while (player == Thread.currentThread()) { + try { + playStream(Thread.currentThread()); + } catch (InternalException e) { + e.printStackTrace(); + player = null; + } + + try { + bitStream.reset(); + } catch (IOException e) { + } + } + }; + }; + player.setDaemon(true); + player.start(); + } + + /** + * Stop the clip playing + */ + public void stop() { + if (stopped()) { + return; + } + + player = null; + outputLine.drain(); + } + + /** + * Close the stream being played from + */ + public void close() { + try { + if (bitStream != null) { + bitStream.close(); + } + } catch (IOException e) { + } + } + + /* + * Taken from the JOrbis Player + */ + private void initJavaSound(int channels, int rate) { + try { + AudioFormat audioFormat = new AudioFormat(rate, 16, + channels, true, // PCM_Signed + false // littleEndian + ); + DataLine.Info info = new DataLine.Info(SourceDataLine.class, + audioFormat, AudioSystem.NOT_SPECIFIED); + if (!AudioSystem.isLineSupported(info)) { + throw new Exception("Line " + info + " not supported."); + } + + outputLine = (SourceDataLine) AudioSystem.getLine(info); + // outputLine.addLineListener(this); + outputLine.open(audioFormat); + + this.rate = rate; + this.channels = channels; + + setBalance(balance); + setGain(gain); + } catch (Exception ex) { + LOGGER.log(Level.WARNING, "can not init sound system", ex); + } + } + + /* + * Taken from the JOrbis Player + */ + private SourceDataLine getOutputLine(int channels, int rate) { + if (outputLine == null || this.rate != rate + || this.channels != channels) { + if (outputLine != null) { + outputLine.drain(); + outputLine.stop(); + outputLine.close(); + } + initJavaSound(channels, rate); + outputLine.start(); + } + return outputLine; + } + + /* + * Taken from the JOrbis Player + */ + private void initJOrbis() { + oy = new SyncState(); + os = new StreamState(); + og = new Page(); + op = new Packet(); + + vi = new Info(); + vc = new Comment(); + vd = new DspState(); + vb = new Block(vd); + + buffer = null; + bytes = 0; + + oy.init(); + } + + /* + * Taken from the JOrbis Player + */ + private void playStream(Thread me) throws InternalException { + boolean chained = false; + + initJOrbis(); + + while (true) { + if (checkState()) { + return; + } + + int eos = 0; + + int index = oy.buffer(BUFSIZE); + buffer = oy.data; + try { + bytes = bitStream.read(buffer, index, BUFSIZE); + } catch (Exception e) { + throw new InternalException(e); + } + oy.wrote(bytes); + + if (chained) { + chained = false; + } else { + if (oy.pageout(og) != 1) { + if (bytes < BUFSIZE) { + break; + } + throw new InternalException("Input does not appear to be an Ogg bitstream."); + } + } + os.init(og.serialno()); + os.reset(); + + vi.init(); + vc.init(); + + if (os.pagein(og) < 0) { + // error; stream version mismatch perhaps + throw new InternalException("Error reading first page of Ogg bitstream data."); + } + + if (os.packetout(op) != 1) { + // no page? must not be vorbis + throw new InternalException("Error reading initial header packet."); + } + + if (vi.synthesis_headerin(vc, op) < 0) { + // error case; not a vorbis header + throw new InternalException("This Ogg bitstream does not contain Vorbis audio data."); + } + + int i = 0; + + while (i < 2) { + while (i < 2) { + if (checkState()) { + return; + } + + int result = oy.pageout(og); + if (result == 0) { + break; // Need more data + } + if (result == 1) { + os.pagein(og); + while (i < 2) { + result = os.packetout(op); + if (result == 0) { + break; + } + if (result == -1) { + throw new InternalException("Corrupt secondary header. Exiting."); + } + vi.synthesis_headerin(vc, op); + i++; + } + } + } + + index = oy.buffer(BUFSIZE); + buffer = oy.data; + try { + bytes = bitStream.read(buffer, index, BUFSIZE); + } catch (Exception e) { + throw new InternalException(e); + } + if (bytes == 0 && i < 2) { + throw new InternalException("End of file before finding all Vorbis headers!"); + } + oy.wrote(bytes); + } + + convsize = BUFSIZE / vi.channels; + + vd.synthesis_init(vi); + vb.init(vd); + + float[][][] _pcmf = new float[1][][]; + int[] _index = new int[vi.channels]; + + getOutputLine(vi.channels, vi.rate); + + while (eos == 0) { + while (eos == 0) { + if (player != me) { + return; + } + + int result = oy.pageout(og); + if (result == 0) { + break; // need more data + } + if (result == -1) { // missing or corrupt data at this page + // position + // System.err.println("Corrupt or missing data in + // bitstream; + // continuing..."); + } else { + os.pagein(og); + + if (og.granulepos() == 0) { // + chained = true; // + eos = 1; // + break; // + } // + + while (true) { + if (checkState()) { + return; + } + + result = os.packetout(op); + if (result == 0) { + break; // need more data + } + if (result == -1) { // missing or corrupt data at + // this page position + // no reason to complain; already complained + // above + + // System.err.println("no reason to complain; + // already complained above"); + } else { + // we have a packet. Decode it + int samples; + if (vb.synthesis(op) == 0) { // test for + // success! + vd.synthesis_blockin(vb); + } + while ((samples = vd.synthesis_pcmout(_pcmf, + _index)) > 0) { + if (checkState()) { + return; + } + + float[][] pcmf = _pcmf[0]; + int bout = (samples < convsize ? samples + : convsize); + + // convert doubles to 16 bit signed ints + // (host order) and + // interleave + for (i = 0; i < vi.channels; i++) { + int ptr = i * 2; + // int ptr=i; + int mono = _index[i]; + for (int j = 0; j < bout; j++) { + int val = (int) (pcmf[i][mono + j] * 32767.); + if (val > 32767) { + val = 32767; + } + if (val < -32768) { + val = -32768; + } + if (val < 0) { + val = val | 0x8000; + } + convbuffer[ptr] = (byte) (val); + convbuffer[ptr + 1] = (byte) (val >>> 8); + ptr += 2 * (vi.channels); + } + } + outputLine.write(convbuffer, 0, 2 + * vi.channels * bout); + vd.synthesis_read(bout); + } + } + } + if (og.eos() != 0) { + eos = 1; + } + } + } + + if (eos == 0) { + index = oy.buffer(BUFSIZE); + buffer = oy.data; + try { + bytes = bitStream.read(buffer, index, BUFSIZE); + } catch (Exception e) { + throw new InternalException(e); + } + if (bytes == -1) { + break; + } + oy.wrote(bytes); + if (bytes == 0) { + eos = 1; + } + } + } + + os.clear(); + vb.clear(); + vd.clear(); + vi.clear(); + } + + oy.clear(); + } + + private class InternalException extends Exception { + + public InternalException(Exception e) { + super(e); + } + + public InternalException(String msg) { + super(msg); + } + } +} diff --git a/src/main/java/org/kontalk/util/Tr.java b/src/main/java/org/kontalk/util/Tr.java index 37c4465e..607e122e 100644 --- a/src/main/java/org/kontalk/util/Tr.java +++ b/src/main/java/org/kontalk/util/Tr.java @@ -1,6 +1,6 @@ /* * Kontalk Java client - * Copyright (C) 2014 Kontalk Devteam + * Copyright (C) 2016 Kontalk Devteam * * 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 @@ -48,15 +48,14 @@ public class Tr { private static final String WIKI_HOME = "Home"; private static final List WIKI_LANGS = Arrays.asList("de"); - /** Map default (English) strings to translated strings. **/ private static Map TR_MAP = null; /** * Translate string used in user interface. * Spaces at beginning or end of string not supported! - * @param s string thats wants to be translated (in English) - * @return translation of input string (depending of platform language) + * @param s string that wants to be translated (in English) + * @return translation of input string (depending on platform language) */ public static String tr(String s) { if (TR_MAP == null || !TR_MAP.containsKey(s)) @@ -67,6 +66,8 @@ public static String tr(String s) { public static void init() { // get language String lang = Locale.getDefault().getLanguage(); + // for testing + //String lang = new Locale("zh").getLanguage(); if (lang.equals(DEFAULT_LANG)) { return; } diff --git a/src/main/java/org/kontalk/util/TrustUtils.java b/src/main/java/org/kontalk/util/TrustUtils.java index 7cc2e3bb..da819a6b 100644 --- a/src/main/java/org/kontalk/util/TrustUtils.java +++ b/src/main/java/org/kontalk/util/TrustUtils.java @@ -1,6 +1,6 @@ /* * Kontalk Java client - * Copyright (C) 2014 Kontalk Devteam + * Copyright (C) 2016 Kontalk Devteam * * 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 diff --git a/src/main/java/org/kontalk/util/XMPPUtils.java b/src/main/java/org/kontalk/util/XMPPUtils.java index 2696d58d..dbfff0bd 100644 --- a/src/main/java/org/kontalk/util/XMPPUtils.java +++ b/src/main/java/org/kontalk/util/XMPPUtils.java @@ -1,6 +1,6 @@ /* * Kontalk Java client - * Copyright (C) 2014 Kontalk Devteam + * Copyright (C) 2016 Kontalk Devteam * 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 diff --git a/src/main/java/org/kontalk/view/AvatarLoader.java b/src/main/java/org/kontalk/view/AvatarLoader.java new file mode 100644 index 00000000..e047baf1 --- /dev/null +++ b/src/main/java/org/kontalk/view/AvatarLoader.java @@ -0,0 +1,195 @@ +/* + * Kontalk Java client + * Copyright (C) 2016 Kontalk Devteam + * + * 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 . + */ + +package org.kontalk.view; + +import java.awt.Color; +import java.awt.Font; +import java.awt.FontMetrics; +import java.awt.Graphics2D; +import java.awt.Image; +import java.awt.RenderingHints; +import java.awt.geom.Rectangle2D; +import java.awt.image.BufferedImage; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import org.apache.commons.lang.ObjectUtils; +import org.kontalk.model.Avatar; +import org.kontalk.model.chat.Chat; +import org.kontalk.model.Contact; +import org.kontalk.util.MediaUtils; +import org.kontalk.util.Tr; + +/** + * Static functions for loading avatar pictures. + * @author Alexander Bikadorov {@literal } + */ +final class AvatarLoader { + + private static final int IMG_SIZE = 40; + + private static final Color LETTER_COLOR = new Color(255, 255, 255); + private static final Color FALLBACK_COLOR = new Color(220, 220, 220); + private static final Color GROUP_COLOR = new Color(160, 160, 160); + + private static final Map CACHE = new HashMap<>(); + + static Image load(Chat chat) { + return load(new Item(chat)); + } + + static Image load(Contact contact) { + return load(new Item(contact)); + } + + static BufferedImage createFallback(int size) { + return fallback(fallbackLetter(), FALLBACK_COLOR, size); + } + + private AvatarLoader() {}; + + private static Image load(Item item) { + if (!CACHE.containsKey(item)) { + CACHE.put(item, item.createImage()); + } + return CACHE.get(item); + } + + private static class Item { + private final Avatar avatar; + + private final String letter; + private final Color color; + + Item(Contact contact) { + avatar = contact.getAvatar().orElse(null); + + if (avatar == null) { + String name = contact.getName(); + letter = labelToLetter(name); + int colorcode = name.isEmpty()? 0 : hash(contact.getID()); + int hue = Math.abs(colorcode) % 360; + color = Color.getHSBColor(hue / 360.0f, 0.8f, 1); + } else { + letter = ""; + color = new Color(0); + } + } + + Item(Chat chat) { + String l = ""; + if (chat.isGroupChat()) { + // nice to have: group picture + avatar = null; + // or use number of contacts here? + l = chat.getSubject(); + color = GROUP_COLOR; + } else { + Contact c = chat.getValidContacts().stream().findFirst().orElse(null); + if (c != null) { + Item i = new Item(c); + avatar = i.avatar; + l = i.letter; + color = i.color; + } else { + avatar = null; + color = FALLBACK_COLOR; + } + } + letter = labelToLetter(l); + } + + private String labelToLetter(String label) { + return label.length() >= 1 ? + label.substring(0, 1).toUpperCase() : + fallbackLetter(); + } + + private Image createImage() { + if (avatar != null) { + BufferedImage img = avatar.loadImage().orElse(null); + if (img != null) + return MediaUtils.scaleAsync(img, IMG_SIZE, IMG_SIZE, true); + } + + return fallback(letter, color, IMG_SIZE); + } + + @Override + public boolean equals(Object o) { + if (o == this) + return true; + + if (!(o instanceof Item)) + return false; + + Item oItem = (Item) o; + return ObjectUtils.equals(avatar, oItem.avatar) && + letter.equals(oItem.letter) && + color.equals(oItem.color); + } + + @Override + public int hashCode() { + int hash = 7; + hash = 71 * hash + Objects.hashCode(this.avatar); + hash = 71 * hash + Objects.hashCode(this.letter); + hash = 71 * hash + Objects.hashCode(this.color); + return hash; + } + } + + private static String fallbackLetter() { + return Tr.tr("?"); + } + + // uniform hash + // Source: https://stackoverflow.com/a/12996028 + private static int hash(int x) { + x = ((x >> 16) ^ x) * 0x45d9f3b; + x = ((x >> 16) ^ x) * 0x45d9f3b; + x = ((x >> 16) ^ x); + return x; + } + + private static BufferedImage fallback(String letter, Color color, int size) { + BufferedImage img = new BufferedImage(size, size, BufferedImage.TYPE_INT_RGB); + + Graphics2D graphics = img.createGraphics(); + graphics.setColor(color); + graphics.fillRect(0, 0, size, size); + + graphics.setFont(new Font(Font.DIALOG, Font.PLAIN, size)); + graphics.setColor(LETTER_COLOR); + graphics.setRenderingHint(RenderingHints.KEY_ANTIALIASING, + RenderingHints.VALUE_ANTIALIAS_ON); + + FontMetrics fm = graphics.getFontMetrics(); + Rectangle2D r = fm.getStringBounds(letter, graphics); + + graphics.drawString(letter, + (size - (int) r.getWidth()) / 2.0f, + // adjust to font baseline + // Note: not centered for letters with descent (drawing under + // the baseline), dont know how to get that + (size - (int) r.getHeight()) / 2.0f + fm.getAscent()); + + return img; + } +} diff --git a/src/main/java/org/kontalk/view/ChatDetails.java b/src/main/java/org/kontalk/view/ChatDetails.java index 56ec6e91..9827c3d3 100644 --- a/src/main/java/org/kontalk/view/ChatDetails.java +++ b/src/main/java/org/kontalk/view/ChatDetails.java @@ -1,6 +1,6 @@ /* * Kontalk Java client - * Copyright (C) 2014 Kontalk Devteam + * Copyright (C) 2016 Kontalk Devteam * * 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 @@ -27,6 +27,8 @@ import com.alee.laf.radiobutton.WebRadioButton; import com.alee.laf.separator.WebSeparator; import com.alee.laf.slider.WebSlider; +import com.alee.laf.text.WebTextArea; +import com.alee.managers.tooltip.TooltipManager; import com.alee.utils.swing.UnselectableButtonGroup; import java.awt.BorderLayout; import java.awt.Color; @@ -36,14 +38,14 @@ import java.awt.event.ItemEvent; import java.awt.event.ItemListener; import java.util.List; -import java.util.Optional; import javax.swing.event.ChangeEvent; import javax.swing.event.ChangeListener; -import org.kontalk.model.Chat; -import org.kontalk.model.Contact; -import org.kontalk.model.GroupChat; +import org.apache.commons.lang.StringUtils; +import org.kontalk.model.chat.Chat; +import org.kontalk.model.chat.GroupChat; +import org.kontalk.model.chat.Member; import org.kontalk.util.Tr; -import org.kontalk.view.ComponentUtils.ParticipantsList; +import org.kontalk.view.ComponentUtils.MemberList; /** * Show and edit thread/chat settings. @@ -83,10 +85,10 @@ final class ChatDetails extends WebPanel { new WebLabel(Tr.tr("Subject:")), mSubjectField)); groupPanel.add(new WebLabel(Tr.tr("Participants:"))); - ParticipantsList mParticipantsList = new ParticipantsList(false); - List chatContacts = Utils.contactList(mChat); - mParticipantsList.setContacts(chatContacts); - mParticipantsList.setVisibleRowCount(Math.min(chatContacts.size(), 5)); + MemberList mParticipantsList = new MemberList(false); + List chatMember = Utils.memberList(mChat); + mParticipantsList.setMembers(chatMember); + mParticipantsList.setVisibleRowCount(Math.min(chatMember.size(), 5)); groupPanel.add(new ComponentUtils.ScrollPane(mParticipantsList, false).setPreferredWidth(160)); groupPanel.add(new WebSeparator(true, true)); @@ -95,8 +97,8 @@ final class ChatDetails extends WebPanel { groupPanel.add(new WebLabel(Tr.tr("Custom Background"))); mColorOpt = new WebRadioButton(Tr.tr("Color:") + " "); - Optional optBGColor = mChat.getViewSettings().getBGColor(); - mColorOpt.setSelected(optBGColor.isPresent()); + Color bgColor = mChat.getViewSettings().getBGColor().orElse(null); + mColorOpt.setSelected(bgColor != null); mColorOpt.addItemListener(new ItemListener() { @Override public void itemStateChanged(ItemEvent e) { @@ -105,7 +107,7 @@ public void itemStateChanged(ItemEvent e) { }); mColor = new WebButton(); mColor.setMinimumHeight(25); - Color oldColor = optBGColor.orElse(DEFAULT_BG); + Color oldColor = bgColor != null ? bgColor : DEFAULT_BG; mColor.setBottomBgColor(oldColor); groupPanel.add(new GroupPanel(GroupingType.fillLast, mColorOpt, @@ -115,7 +117,7 @@ public void itemStateChanged(ItemEvent e) { colorSlider.setMaximum(100); colorSlider.setPaintTicks(false); colorSlider.setPaintLabels(false); - colorSlider.setEnabled(optBGColor.isPresent()); + colorSlider.setEnabled(bgColor != null); final GradientData gradientData = GradientData.getDefaultValue(); // TODO set location for color gradientData.getColor(0); @@ -148,6 +150,19 @@ public void itemStateChanged(ItemEvent e) { UnselectableButtonGroup.group(mColorOpt, mImgOpt); groupPanel.add(new WebSeparator()); + String xmppID = mChat.getXMPPID(); + if (!xmppID.isEmpty()) { + WebTextArea xmppIDArea = new WebTextArea().setBoldFont(); + xmppIDArea.setEditable(false); + xmppIDArea.setOpaque(false); + xmppIDArea.setText(StringUtils.abbreviate(xmppID, 30)); + TooltipManager.addTooltip(xmppIDArea, + Tr.tr("XMPP chat ID:") + " " + xmppID); + WebLabel xmppIDLabel = new WebLabel(Tr.tr("Chat ID:")); + groupPanel.add(new GroupPanel(View.GAP_DEFAULT, + xmppIDLabel, xmppIDArea)); + } + final WebButton saveButton = new WebButton(Tr.tr("Save")); this.add(groupPanel, BorderLayout.CENTER); diff --git a/src/main/java/org/kontalk/view/ChatListView.java b/src/main/java/org/kontalk/view/ChatListView.java index 11f2394a..511c689f 100644 --- a/src/main/java/org/kontalk/view/ChatListView.java +++ b/src/main/java/org/kontalk/view/ChatListView.java @@ -1,6 +1,6 @@ /* * Kontalk Java client - * Copyright (C) 2014 Kontalk Devteam + * Copyright (C) 2016 Kontalk Devteam * * 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 @@ -18,6 +18,10 @@ package org.kontalk.view; +import com.alee.extended.image.DisplayType; +import com.alee.extended.image.WebImage; +import com.alee.extended.panel.GroupPanel; +import com.alee.extended.panel.GroupingType; import com.alee.laf.label.WebLabel; import com.alee.laf.menu.WebMenuItem; import com.alee.laf.menu.WebPopupMenu; @@ -25,22 +29,18 @@ import java.awt.Color; import java.awt.event.ActionEvent; import java.awt.event.ActionListener; -import java.awt.event.MouseAdapter; -import java.awt.event.MouseEvent; -import java.util.HashSet; -import java.util.Optional; -import java.util.Set; import java.util.Timer; +import javax.swing.Box; import javax.swing.ListSelectionModel; -import javax.swing.event.ListSelectionEvent; -import javax.swing.event.ListSelectionListener; -import org.kontalk.system.Config; -import org.kontalk.model.KonMessage; -import org.kontalk.model.Chat; -import org.kontalk.model.Chat.KonChatState; -import org.kontalk.model.ChatList; +import org.kontalk.persistence.Config; +import org.kontalk.model.message.KonMessage; +import org.kontalk.model.chat.Chat; +import org.kontalk.model.chat.ChatList; import org.kontalk.model.Contact; -import org.kontalk.model.MessageContent.GroupCommand; +import org.kontalk.model.chat.GroupChat; +import org.kontalk.model.chat.Member; +import org.kontalk.model.message.MessageContent.GroupCommand; +import org.kontalk.model.chat.SingleChat; import org.kontalk.util.Tr; import org.kontalk.view.ChatListView.ChatItem; @@ -48,10 +48,9 @@ * Show a brief list of all chats. * @author Alexander Bikadorov {@literal } */ -final class ChatListView extends Table { +final class ChatListView extends ListView { private final ChatList mChatList; - private final WebPopupMenu mPopupMenu; ChatListView(final View view, ChatList chatList) { super(view, true); @@ -59,75 +58,17 @@ final class ChatListView extends Table { this.setSelectionMode(ListSelectionModel.SINGLE_SELECTION); - // right click popup menu - mPopupMenu = new WebPopupMenu(); - - WebMenuItem deleteMenuItem = new WebMenuItem(Tr.tr("Delete Chat")); - deleteMenuItem.setToolTipText(Tr.tr("Delete this chat")); - deleteMenuItem.addActionListener(new ActionListener() { - @Override - public void actionPerformed(ActionEvent event) { - ChatListView.this.deleteSelectedChat(); - } - }); - mPopupMenu.add(deleteMenuItem); - - // actions triggered by selection - this.getSelectionModel().addListSelectionListener(new ListSelectionListener() { - - Chat lastChat = null; - - @Override - public void valueChanged(ListSelectionEvent e) { - if (e.getValueIsAdjusting()) - return; - - Optional optChat = ChatListView.this.getSelectedValue(); - if (!optChat.isPresent()) { - // note: this happens also on righ-click for some reason - return; - } - - // if event is caused by filtering, dont do anything - if (lastChat == optChat.get()) - return; - - mView.clearSearch(); - mView.showChat(optChat.get()); - lastChat = optChat.get(); - } - }); - - // actions triggered by mouse events - this.addMouseListener(new MouseAdapter() { - @Override - public void mousePressed(MouseEvent e) { - check(e); - } - @Override - public void mouseReleased(MouseEvent e) { - check(e); - } - private void check(MouseEvent e) { - if (e.isPopupTrigger()) { - int row = ChatListView.this.rowAtPoint(e.getPoint()); - ChatListView.this.setSelectedItem(row); - ChatListView.this.showPopupMenu(e); - } - } - }); - this.updateOnEDT(null); } @Override protected void updateOnEDT(Object arg) { - Set newItems = new HashSet<>(); - Set chats = mChatList.getAll(); - for (Chat chat: chats) - if (!this.containsValue(chat)) - newItems.add(new ChatItem(chat)); - this.sync(chats, newItems); + this.sync(mChatList.getAll()); + } + + @Override + protected ChatItem newItem(Chat value) { + return new ChatItem(value); } void selectLastChat() { @@ -141,15 +82,10 @@ void save() { this.getSelectedRow()); } - private void showPopupMenu(MouseEvent e) { - mPopupMenu.show(this, e.getX(), e.getY()); - } - - private void deleteSelectedChat() { - ChatItem t = this.getSelectedItem(); - if (!t.mValue.getMessages().isEmpty()) { + private void deleteChat(ChatItem item) { + if (!item.mValue.getMessages().isEmpty()) { String text = Tr.tr("Permanently delete all messages in this chat?"); - if (t.mValue.isGroupChat() && t.mValue.isValid()) + if (item.mValue.isGroupChat() && item.mValue.isValid()) text += "\n\n"+Tr.tr("You will automatically leave this group."); if (!Utils.confirmDeletion(this, text)) return; @@ -158,8 +94,60 @@ private void deleteSelectedChat() { mView.getControl().deleteChat(chatItem.mValue); } - protected final class ChatItem extends Table.TableItem { + @Override + protected void selectionChanged(Chat value) { + mView.selectedChatChanged(value); + } + + @Override + protected WebPopupMenu rightClickMenu(ChatItem item) { + WebPopupMenu menu = new WebPopupMenu(); + + Chat chat = item.mValue; + if (chat instanceof SingleChat) { + final Contact contact = ((SingleChat) chat).getContact(); + if (!contact.isDeleted()) { + WebMenuItem editItem = new WebMenuItem(Tr.tr("Edit Contact")); + editItem.setToolTipText(Tr.tr("Edit contact settings")); + editItem.addActionListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent event) { + mView.showContactDetails(contact); + } + }); + menu.add(editItem); + } + } + + WebMenuItem deleteItem = new WebMenuItem(Tr.tr("Delete Chat")); + deleteItem.setToolTipText(Tr.tr("Delete this chat")); + deleteItem.addActionListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent event) { + ChatListView.this.deleteChat(ChatListView.this.getSelectedItem()); + } + }); + menu.add(deleteItem); + + return menu; + } + + @Override + protected void onRenameEvent() { + Chat chat = this.getSelectedValue().orElse(null); + if (chat instanceof SingleChat) { + mView.requestRenameFocus(((SingleChat) chat).getContact()); + return; + } + if (chat instanceof GroupChat) { + // TODO + } + } + + protected final class ChatItem extends ListView.TableItem { + + private final WebImage mAvatar; private final WebLabel mTitleLabel; private final WebLabel mStatusLabel; private final WebLabel mChatStateLabel; @@ -168,26 +156,36 @@ protected final class ChatItem extends Table.TableItem { ChatItem(Chat chat) { super(chat); - this.setLayout(new BorderLayout(View.GAP_DEFAULT, View.GAP_SMALL)); - this.setMargin(View.MARGIN_SMALL); + this.setLayout(new BorderLayout(View.GAP_DEFAULT, 0)); + this.setMargin(View.MARGIN_DEFAULT); + + mAvatar = new WebImage().setDisplayType(DisplayType.fitComponent); + mAvatar.setPreferredSize(View.AVATAR_LIST_DIM); + this.add(mAvatar, BorderLayout.WEST); mTitleLabel = new WebLabel(); - mTitleLabel.setFontSize(14); + mTitleLabel.setFontSize(View.FONT_SIZE_BIG); + mTitleLabel.setDrawShade(true); if (mValue.isGroupChat()) mTitleLabel.setForeground(View.DARK_GREEN); - this.add(mTitleLabel, BorderLayout.NORTH); mStatusLabel = new WebLabel(); mStatusLabel.setForeground(Color.GRAY); - mStatusLabel.setFontSize(11); + mStatusLabel.setFontSize(View.FONT_SIZE_TINY); this.add(mStatusLabel, BorderLayout.EAST); mChatStateLabel = new WebLabel(); mChatStateLabel.setForeground(View.GREEN); - mChatStateLabel.setFontSize(13); + mChatStateLabel.setFontSize(View.FONT_SIZE_NORMAL); mChatStateLabel.setBoldFont(); //mChatStateLabel.setMargin(0, 5, 0, 5); - this.add(mChatStateLabel, BorderLayout.WEST); + + this.add( + new GroupPanel(View.GAP_SMALL, false, + mTitleLabel, + new GroupPanel(GroupingType.fillFirst, + Box.createGlue(), mStatusLabel, mChatStateLabel) + ), BorderLayout.CENTER); this.updateView(null); @@ -222,6 +220,11 @@ private void updateView(Object arg) { mTitleLabel.setText(Utils.chatTitle(mValue)); } + // avatar may change when subject or contact name changes + if (arg == null || arg instanceof Contact || arg instanceof String) { + Utils.fixedSetWebImageImage(mAvatar, AvatarLoader.load(mValue)); + } + if (arg == null || arg instanceof KonMessage) { this.updateBG(); mStatusLabel.setText(lastActivity(mValue, true)); @@ -232,24 +235,23 @@ private void updateView(Object arg) { mStatusLabel.setText(lastActivity(mValue, true)); } - if (arg instanceof Chat.KonChatState) { - KonChatState state = (KonChatState) arg; - String stateText = null; - switch(state.getState()) { + String stateText = ""; + if (arg instanceof Member) { + Member member = (Member) arg; + switch(member.getState()) { case composing: stateText = Tr.tr("is writing…"); break; //case paused: activity = T/r.tr("stopped typing"); break; //case inactive: stateText = T/r.tr("is inactive"); break; } - if (stateText == null) { - // 'inactive' is default - mChatStateLabel.setText(""); - return; - } - - if (mValue.isGroupChat()) - stateText = state.getContact().getName() + " " + stateText; - - mChatStateLabel.setText(stateText + " "); + if (!stateText.isEmpty() && mValue.isGroupChat()) + stateText = member.getContact().getName() + ": " + stateText; + } + if (stateText.isEmpty()) { + mChatStateLabel.setText(""); + mStatusLabel.setVisible(true); + } else { + mChatStateLabel.setText(stateText); + mStatusLabel.setVisible(false); } } @@ -260,8 +262,8 @@ private void updateBG() { @Override protected boolean contains(String search) { // always show entry for current chat - Optional optChat = mView.getCurrentShownChat(); - if (optChat.isPresent() && optChat.get() == mValue) + Chat chat = mView.getCurrentShownChat().orElse(null); + if (chat != null && chat == mValue) return true; for (Contact contact: mValue.getAllContacts()) { @@ -274,20 +276,20 @@ protected boolean contains(String search) { @Override public int compareTo(TableItem o) { - Optional m = this.mValue.getMessages().getLast(); - Optional oM = o.mValue.getMessages().getLast(); - if (m.isPresent() && oM.isPresent()) - return -m.get().getDate().compareTo(oM.get().getDate()); + KonMessage m = this.mValue.getMessages().getLast().orElse(null); + KonMessage oM = o.mValue.getMessages().getLast().orElse(null); + if (m != null && oM != null) + return -m.getDate().compareTo(oM.getDate()); return -Integer.compare(this.mValue.getID(), o.mValue.getID()); } } private static String lastActivity(Chat chat, boolean pretty) { - Optional optM = chat.getMessages().getLast(); - String lastActivity = !optM.isPresent() ? Tr.tr("no messages yet") : - pretty ? Utils.PRETTY_TIME.format(optM.get().getDate()) : - Utils.MID_DATE_FORMAT.format(optM.get().getDate()); + KonMessage m = chat.getMessages().getLast().orElse(null); + String lastActivity = m == null ? Tr.tr("no messages yet") : + pretty ? Utils.PRETTY_TIME.format(m.getDate()) : + Utils.MID_DATE_FORMAT.format(m.getDate()); return lastActivity; } } diff --git a/src/main/java/org/kontalk/view/ChatView.java b/src/main/java/org/kontalk/view/ChatView.java index 1e3be2d7..264f84f6 100644 --- a/src/main/java/org/kontalk/view/ChatView.java +++ b/src/main/java/org/kontalk/view/ChatView.java @@ -1,6 +1,6 @@ /* * Kontalk Java client - * Copyright (C) 2014 Kontalk Devteam + * Copyright (C) 2016 Kontalk Devteam * * 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 @@ -18,6 +18,7 @@ package org.kontalk.view; +import com.alee.extended.image.WebImage; import com.alee.extended.panel.GroupPanel; import com.alee.extended.panel.GroupingType; import com.alee.laf.button.WebButton; @@ -55,6 +56,7 @@ import java.awt.image.BufferedImage; import java.awt.image.ImageObserver; import java.io.File; +import java.util.EnumSet; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -70,17 +72,19 @@ import org.apache.commons.io.FileUtils; import org.apache.tika.Tika; import org.jivesoftware.smackx.chatstates.ChatState; -import org.kontalk.model.Chat; -import org.kontalk.model.ChatList; +import org.kontalk.client.FeatureDiscovery; +import org.kontalk.model.chat.Chat; import org.kontalk.model.Contact; import org.kontalk.system.AttachmentManager; -import org.kontalk.system.Config; +import org.kontalk.persistence.Config; +import org.kontalk.system.Control; +import org.kontalk.util.EncodingUtils; import org.kontalk.util.MediaUtils; import org.kontalk.util.Tr; import static org.kontalk.view.View.MARGIN_SMALL; /** - * Pane that shows the currently selected chat. + * Panel showing the currently selected chat. * @author Alexander Bikadorov {@literal } */ final class ChatView extends WebPanel implements Observer { @@ -89,15 +93,17 @@ final class ChatView extends WebPanel implements Observer { private final View mView; + private final WebImage mAvatar; private final WebLabel mTitleLabel; private final WebLabel mSubTitleLabel; private final WebScrollPane mScrollPane; private final WebTextArea mSendTextArea; private final WebLabel mEncryptionStatus; private final WebButton mSendButton; - private final WebFileChooser fileChooser; + private final WebFileChooser mFileChooser; + private final WebButton mFileButton; - private final Map mChatCache = new HashMap<>(); + private final Map mMessageListCache = new HashMap<>(); private ComponentUtils.ModalPopup mPopup = null; private Background mDefaultBG; @@ -108,13 +114,17 @@ final class ChatView extends WebPanel implements Observer { mView = view; WebPanel titlePanel = new WebPanel(false, - new BorderLayout(View.GAP_SMALL, View.GAP_SMALL)); + new BorderLayout(View.GAP_DEFAULT, 0)); titlePanel.setMargin(View.MARGIN_DEFAULT); + + mAvatar = new WebImage(); + titlePanel.add(mAvatar, BorderLayout.WEST); + mTitleLabel = new WebLabel(); - mTitleLabel.setFontSize(16); + mTitleLabel.setFontSize(View.FONT_SIZE_HUGE); mTitleLabel.setDrawShade(true); mSubTitleLabel = new WebLabel(); - mSubTitleLabel.setFontSize(11); + mSubTitleLabel.setFontSize(View.FONT_SIZE_TINY); mSubTitleLabel.setForeground(Color.GRAY); titlePanel.add(new GroupPanel(View.GAP_SMALL, false, mTitleLabel, mSubTitleLabel), BorderLayout.CENTER); @@ -152,11 +162,11 @@ public void adjustmentValueChanged(AdjustmentEvent e) { @Override public void paintComponent(Graphics g) { super.paintComponent(g); - Optional optBG = - ChatView.this.getCurrentBackground().updateNowOrLater(); + BufferedImage bg = + ChatView.this.getCurrentBackground().updateNowOrLater().orElse(null); // if there is something to draw, draw it now even if its old - if (optBG.isPresent()) - g.drawImage(optBG.get(), 0, 0, this.getWidth(), this.getHeight(), null); + if (bg != null) + g.drawImage(bg, 0, 0, this.getWidth(), this.getHeight(), null); } }); @@ -165,7 +175,7 @@ public void paintComponent(Graphics g) { mSendTextArea.setMargin(View.MARGIN_SMALL); mSendTextArea.setLineWrap(true); mSendTextArea.setWrapStyleWord(true); - mSendTextArea.setFontSize(13); + mSendTextArea.setFontSize(View.FONT_SIZE_NORMAL); mSendTextArea.getDocument().addDocumentListener(new DocumentChangeListener() { @Override public void documentChanged(DocumentEvent e) { @@ -204,27 +214,23 @@ public void actionPerformed(ActionEvent e) { } }); // file chooser button - fileChooser = new WebFileChooser(); - fileChooser.setMultiSelectionEnabled(false); - fileChooser.setFileSelectionMode(JFileChooser.FILES_ONLY); - fileChooser.setFileFilter(new CustomFileFilter(AllFilesFilter.ICON, + mFileChooser = new WebFileChooser(); + mFileChooser.setMultiSelectionEnabled(false); + mFileChooser.setFileSelectionMode(JFileChooser.FILES_ONLY); + mFileChooser.setFileFilter(new CustomFileFilter(AllFilesFilter.ICON, Tr.tr("Supported files")) { @Override public boolean accept(File file) { - return file.length() <= AttachmentManager.MAX_ATT_SIZE && - AttachmentManager.SUPPORTED_MIME_TYPES.contains( - TIKA_INSTANCE.detect(file.getName())); + return file.length() <= AttachmentManager.MAX_ATT_SIZE; } }); // mAttField.setPreferredWidth(150); - WebButton fileButton = new WebButton(Tr.tr("File"), Utils.getIcon("ic_ui_attach.png")) + mFileButton = new WebButton(Tr.tr("File"), Utils.getIcon("ic_ui_attach.png")) .setRound(0) .setBottomBgColor(titlePanel.getBackground()) .setMargin(1, MARGIN_SMALL, 1, MARGIN_SMALL); - TooltipManager.addTooltip(fileButton, - Tr.tr("Send File - max. size:") + " " + - FileUtils.byteCountToDisplaySize(AttachmentManager.MAX_ATT_SIZE)); - fileButton.addActionListener(new ActionListener() { + + mFileButton.addActionListener(new ActionListener() { @Override public void actionPerformed(ActionEvent e) { ChatView.this.showFileDialog(); @@ -234,7 +240,7 @@ public void actionPerformed(ActionEvent e) { mEncryptionStatus = new WebLabel(); WebPanel textBarPanel = new GroupPanel(GroupingType.fillMiddle, 0, - fileButton, Box.createGlue(), new GroupPanel(View.GAP_DEFAULT, + mFileButton, Box.createGlue(), new GroupPanel(View.GAP_DEFAULT, mEncryptionStatus, mSendButton)) .setUndecorated(false) .setWebColoredBackground(false) @@ -278,19 +284,19 @@ void filterCurrentChat(String searchText) { } void showChat(Chat chat) { - Optional optOldChat = this.getCurrentChat(); - if (optOldChat.isPresent()) - optOldChat.get().deleteObserver(this); + Chat oldChat = this.getCurrentChat().orElse(null); + if (oldChat != null) + oldChat.deleteObserver(this); chat.addObserver(this); - if (!mChatCache.containsKey(chat.getID())) { + if (!mMessageListCache.containsKey(chat)) { MessageList newMessageList = new MessageList(mView, this, chat); chat.addObserver(newMessageList); - mChatCache.put(chat.getID(), newMessageList); + mMessageListCache.put(chat, newMessageList); } // set to current chat - mScrollPane.getViewport().setView(mChatCache.get(chat.getID())); + mScrollPane.getViewport().setView(mMessageListCache.get(chat)); this.onChatChange(); chat.setRead(); @@ -312,13 +318,11 @@ private Background getCurrentBackground() { MessageList view = this.currentMessageListOrNull(); if (view == null) return mDefaultBG; - Optional optBG = view.getBG(); - if (!optBG.isPresent()) - return mDefaultBG; - return optBG.get(); + Background bg = view.getBG().orElse(null); + return bg == null ? mDefaultBG : bg; } - Optional createBG(Chat.ViewSettings s){ + Optional createBG(Chat.ViewSettings s) { JViewport p = this.mScrollPane.getViewport(); if (s.getBGColor().isPresent()) { Color c = s.getBGColor().get(); @@ -344,7 +348,7 @@ public void keyPressed(KeyEvent e) { if (enterSends && e.getKeyCode() == KeyEvent.VK_ENTER && e.getModifiersEx() == KeyEvent.CTRL_DOWN_MASK) { e.consume(); - mSendTextArea.append(System.getProperty("line.separator")); + mSendTextArea.append(EncodingUtils.EOL); } if (enterSends && e.getKeyCode() == KeyEvent.VK_ENTER && e.getModifiers() == 0) { @@ -365,6 +369,30 @@ public void keyTyped(KeyEvent e) { mSendButton.addHotkey(sendHotkey, TooltipWay.up); } + void onStatusChange(Control.Status status, EnumSet serverFeature) { + Boolean supported = null; + switch(status) { + case CONNECTED: + this.setColor(Color.WHITE); + supported = serverFeature.contains( + FeatureDiscovery.Feature.HTTP_FILE_UPLOAD); + break; + case DISCONNECTED: + case ERROR: + this.setColor(Color.LIGHT_GRAY); + // don't know, but assume it + supported = true; + break; + } + if (supported != null) { + TooltipManager.setTooltip(mFileButton, Tr.tr("Send File") + " - " + (supported ? + Tr.tr("max. size:") + " " + + FileUtils.byteCountToDisplaySize(AttachmentManager.MAX_ATT_SIZE) : + mView.tr_not_supported)); + mFileButton.setForeground(supported ? Color.BLACK : Color.RED); + } + } + @Override public void update(Observable o, final Object arg) { if (SwingUtilities.isEventDispatchThread()) { @@ -382,14 +410,12 @@ public void run() { private void updateOnEDT(Object arg) { if (arg instanceof Chat) { Chat chat = (Chat) arg; - if (!ChatList.getInstance().contains(chat.getID())) { - // chat was deleted - MessageList viewList = mChatCache.get(chat.getID()); + if (chat.isDeleted()) { + MessageList viewList = mMessageListCache.remove(chat); if (viewList != null) { viewList.clearItems(); chat.deleteObserver(viewList); } - mChatCache.remove(chat.getID()); if(this.getCurrentChat().orElse(null) == chat) { mScrollPane.setViewportView(null); mView.showNothing(); @@ -403,11 +429,13 @@ private void updateOnEDT(Object arg) { } private void onChatChange() { - Optional optChat = this.getCurrentChat(); - if (!optChat.isPresent()) + Chat chat = this.getCurrentChat().orElse(null); + if (chat == null) return; - Chat chat = optChat.get(); + // update if chat changes... + // avatar + mAvatar.setImage(AvatarLoader.load(chat)); // chat titles mTitleLabel.setText(Utils.chatTitle(chat)); @@ -440,34 +468,33 @@ private void onChatChange() { } private void showPopup(final WebToggleButton invoker) { - Optional optChat = ChatView.this.getCurrentChat(); - if (!optChat.isPresent()) + Chat chat = ChatView.this.getCurrentChat().orElse(null); + if (chat == null) return; if (mPopup == null) mPopup = new ComponentUtils.ModalPopup(invoker); mPopup.removeAll(); - mPopup.add(new ChatDetails(mView, mPopup, optChat.get())); + mPopup.add(new ChatDetails(mView, mPopup, chat)); mPopup.showPopup(); } private void onKeyTypeEvent(boolean empty) { this.updateSendButton(); - Optional optChat = this.getCurrentChat(); - if (!optChat.isPresent()) + Chat chat = this.getCurrentChat().orElse(null); + if (chat == null) return; // workaround: clearing the text area is not a key event if (!empty) - mView.getControl().handleOwnChatStateEvent(optChat.get(), ChatState.composing); + mView.getControl().handleOwnChatStateEvent(chat, ChatState.composing); } private void updateSendButton() { - Optional optChat = this.getCurrentChat(); - if (!optChat.isPresent()) + Chat chat = this.getCurrentChat().orElse(null); + if (chat == null) return; - Chat chat = optChat.get(); // enable if chat is valid... mSendButton.setEnabled(chat.isValid() && @@ -478,8 +505,8 @@ private void updateSendButton() { } private void sendMsg() { - Optional optChat = this.getCurrentChat(); - if (!optChat.isPresent()) + Chat chat = this.getCurrentChat().orElse(null); + if (chat == null) // now current chat return; @@ -487,23 +514,23 @@ private void sendMsg() { // if (!attachments.isEmpty()) // mView.getControl().sendAttachment(optChat.get(), attachments.get(0).toPath()); // else - mView.getControl().sendText(optChat.get(), mSendTextArea.getText()); + mView.getControl().sendText(chat, mSendTextArea.getText()); mSendTextArea.setText(""); } private void showFileDialog() { - if (fileChooser.showOpenDialog(ChatView.this) != WebFileChooser.APPROVE_OPTION) + if (mFileChooser.showOpenDialog(ChatView.this) != WebFileChooser.APPROVE_OPTION) return; - File file = fileChooser.getSelectedFile(); - fileChooser.setCurrentDirectory(file.toPath().getParent().toString()); + File file = mFileChooser.getSelectedFile(); + mFileChooser.setCurrentDirectory(file.toPath().getParent().toString()); - Optional optChat = this.getCurrentChat(); - if (!optChat.isPresent()) + Chat chat = this.getCurrentChat().orElse(null); + if (chat == null) return; - mView.getControl().sendAttachment(optChat.get(), file.toPath()); + mView.getControl().sendAttachment(chat, file.toPath()); } /** A background image of chat view with efficient async reloading. */ @@ -571,7 +598,7 @@ private boolean scaleOrigin() { this.updateCachedBG(null); return true; } - Image scaledImage = MediaUtils.scale(mOrigin, + Image scaledImage = MediaUtils.scaleAsync(mOrigin, mParent.getWidth(), mParent.getHeight(), true); diff --git a/src/main/java/org/kontalk/view/ComponentUtils.java b/src/main/java/org/kontalk/view/ComponentUtils.java index b5fa047c..1897aaa2 100644 --- a/src/main/java/org/kontalk/view/ComponentUtils.java +++ b/src/main/java/org/kontalk/view/ComponentUtils.java @@ -1,6 +1,6 @@ /* * Kontalk Java client - * Copyright (C) 2014 Kontalk Devteam + * Copyright (C) 2016 Kontalk Devteam * * 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 @@ -18,16 +18,17 @@ package org.kontalk.view; +import com.alee.extended.image.WebImage; import com.alee.extended.label.WebLinkLabel; import com.alee.extended.layout.FormLayout; import com.alee.extended.panel.GroupPanel; +import com.alee.extended.panel.GroupingType; import com.alee.laf.button.WebButton; import com.alee.laf.button.WebToggleButton; import com.alee.laf.checkbox.WebCheckBox; import com.alee.laf.label.WebLabel; import com.alee.laf.list.WebList; import com.alee.laf.panel.WebPanel; -import com.alee.laf.rootpane.WebDialog; import com.alee.laf.scroll.WebScrollPane; import com.alee.laf.separator.WebSeparator; import com.alee.laf.tabbedpane.WebTabbedPane; @@ -57,16 +58,16 @@ import java.awt.event.WindowEvent; import java.awt.event.WindowStateListener; import java.nio.file.Path; -import java.util.ArrayList; +import java.nio.file.Paths; import java.util.Arrays; import java.util.Comparator; import java.util.LinkedList; import java.util.List; import java.util.Locale; import java.util.Optional; -import java.util.Set; import javax.swing.AbstractButton; import javax.swing.BorderFactory; +import javax.swing.Box; import javax.swing.DefaultListModel; import javax.swing.DefaultListSelectionModel; import javax.swing.Icon; @@ -87,8 +88,10 @@ import javax.swing.text.PlainDocument; import org.kontalk.misc.JID; import org.kontalk.model.Contact; -import org.kontalk.model.ContactList; -import org.kontalk.system.Config; +import org.kontalk.model.Model; +import org.kontalk.model.chat.GroupChat; +import org.kontalk.model.chat.Member; +import org.kontalk.persistence.Config; import org.kontalk.util.Tr; import org.kontalk.util.XMPPUtils; @@ -119,94 +122,6 @@ static class ScrollPane extends WebScrollPane { } } - static class StatusDialog extends WebDialog { - - private final View mView; - private final WebTextField mStatusField; - private final WebList mStatusList; - - StatusDialog(View view) { - mView = view; - - this.setTitle(Tr.tr("Status")); - this.setResizable(false); - this.setModal(true); - - GroupPanel groupPanel = new GroupPanel(View.GAP_DEFAULT, false); - groupPanel.setMargin(View.MARGIN_BIG); - - String[] strings = Config.getInstance().getStringArray(Config.NET_STATUS_LIST); - List stats = new ArrayList<>(Arrays.asList(strings)); - String currentStatus = ""; - if (!stats.isEmpty()) - currentStatus = stats.remove(0); - - stats.remove(""); - - groupPanel.add(new WebLabel(Tr.tr("Your current status:"))); - mStatusField = new WebTextField(currentStatus, 30); - groupPanel.add(mStatusField); - groupPanel.add(new WebSeparator(true, true)); - - groupPanel.add(new WebLabel(Tr.tr("Previously used:"))); - mStatusList = new WebList(stats); - mStatusList.setMultiplySelectionAllowed(false); - mStatusList.addListSelectionListener(new ListSelectionListener() { - @Override - public void valueChanged(ListSelectionEvent e) { - if (e.getValueIsAdjusting()) - return; - mStatusField.setText(mStatusList.getSelectedValue().toString()); - } - }); - WebScrollPane listScrollPane = new ScrollPane(mStatusList); - groupPanel.add(listScrollPane); - this.add(groupPanel, BorderLayout.CENTER); - - // buttons - WebButton cancelButton = new WebButton(Tr.tr("Cancel")); - cancelButton.addActionListener(new ActionListener() { - @Override - public void actionPerformed(ActionEvent e) { - StatusDialog.this.dispose(); - } - }); - final WebButton saveButton = new WebButton(Tr.tr("Save")); - saveButton.addActionListener(new ActionListener() { - @Override - public void actionPerformed(ActionEvent e) { - StatusDialog.this.saveStatus(); - StatusDialog.this.dispose(); - } - }); - this.getRootPane().setDefaultButton(saveButton); - - GroupPanel buttonPanel = new GroupPanel(2, cancelButton, saveButton); - buttonPanel.setLayout(new FlowLayout(FlowLayout.TRAILING)); - this.add(buttonPanel, BorderLayout.SOUTH); - - this.pack(); - } - - private void saveStatus() { - String newStatus = mStatusField.getText(); - - Config conf = Config.getInstance(); - String[] strings = conf.getStringArray(Config.NET_STATUS_LIST); - List stats = new ArrayList<>(Arrays.asList(strings)); - - stats.remove(newStatus); - - stats.add(0, newStatus); - - if (stats.size() > 20) - stats = stats.subList(0, 20); - - conf.setProperty(Config.NET_STATUS_LIST, stats.toArray()); - mView.getControl().sendStatusText(); - } - } - static abstract class PopupPanel extends WebPanel { abstract void onShow(); @@ -395,13 +310,15 @@ void onShow() { static class AddGroupChatPanel extends PopupPanel { private final View mView; + private final Model mModel; private final WebTextField mSubjectField; private final ParticipantsList mList; private final WebButton mCreateButton; - AddGroupChatPanel(View view, final Component focusGainer) { + AddGroupChatPanel(View view, Model model, final Component focusGainer) { mView = view; + mModel = model; GroupPanel groupPanel = new GroupPanel(View.GAP_BIG, false); groupPanel.setMargin(View.MARGIN_BIG); @@ -456,17 +373,20 @@ private void checkSaveButton() { } private void createGroup() { - mView.getControl().createGroupChat(mList.getSelectedContacts(), - mSubjectField.getText()); + GroupChat newChat = mView.getControl().createGroupChat( + mList.getSelectedContacts(), + mSubjectField.getText()).orElse(null); + + if (newChat != null) + mView.showChat(newChat); mSubjectField.setText(""); } @Override void onShow() { - Set allContacts = ContactList.getInstance().getAll(); List contacts = new LinkedList<>(); - for (Contact c : allContacts) { + for (Contact c : Utils.allContacts(mModel.contacts())) { if (c.isKontalkUser() && !c.isMe()) contacts.add(c); } @@ -487,12 +407,66 @@ static class ParticipantsList extends WebList { private final DefaultListModel mModel; - public ParticipantsList() { + @SuppressWarnings("unchecked") + ParticipantsList() { + mModel = new DefaultListModel<>(); + this.setModel(mModel); + this.setFixedCellHeight(25); + + this.setSelectionMode(ListSelectionModel.MULTIPLE_INTERVAL_SELECTION); + this.setSelectionModel(new DefaultListSelectionModel() { + @Override + public void setSelectionInterval(int index0, int index1) { + if(super.isSelectedIndex(index0)) { + super.removeSelectionInterval(index0, index1); + } else { + super.addSelectionInterval(index0, index1); + } + } + }); + + this.setCellRenderer(new CellRenderer()); + } + + void setContacts(List contacts) { + mModel.clear(); + + for (Contact contact : contacts) + mModel.addElement(contact); + } + + @SuppressWarnings("unchecked") + List getSelectedContacts() { + return this.getSelectedValuesList(); + } + + private class CellRenderer extends WebLabel implements ListCellRenderer { + @Override + public Component getListCellRendererComponent(JList list, + Contact contact, + int index, + boolean isSelected, + boolean cellHasFocus) { + this.setText(" " + Utils.displayName(contact)); + + this.setBorder(BorderFactory.createMatteBorder(0, 0, 1, 0,Color.LIGHT_GRAY)); + + return this; + } + } + } + + // NOTE: https://github.com/mgarin/weblaf/issues/153 + static class MemberList extends WebList { + + private final DefaultListModel mModel; + + public MemberList() { this(true); } @SuppressWarnings("unchecked") - ParticipantsList(boolean selectable) { + MemberList(boolean selectable) { mModel = new DefaultListModel<>(); this.setModel(mModel); this.setFixedCellHeight(25); @@ -515,28 +489,38 @@ public void setSelectionInterval(int index0, int index1) { this.setCellRenderer(new CellRenderer()); } - void setContacts(List contacts) { + void setMembers(List members) { mModel.clear(); - for (Contact contact : contacts) - mModel.addElement(contact); + for (Member member : members) + mModel.addElement(member); } - @SuppressWarnings("unchecked") - List getSelectedContacts() { - return this.getSelectedValuesList(); - } + private class CellRenderer extends WebPanel implements ListCellRenderer { + private final WebLabel mNameLabel; + private final WebLabel mRoleLabel; + + public CellRenderer() { + mNameLabel = new WebLabel(); + mRoleLabel = new WebLabel(); + mRoleLabel.setForeground(View.DARK_GREEN); + + this.setMargin(View.MARGIN_DEFAULT); + this.setBorder(BorderFactory.createMatteBorder(0, 0, 1, 0, Color.LIGHT_GRAY)); + + this.add(new GroupPanel(GroupingType.fillMiddle, View.GAP_DEFAULT, + mNameLabel, Box.createGlue(), mRoleLabel), + BorderLayout.CENTER); + } - private class CellRenderer extends WebLabel implements ListCellRenderer { @Override public Component getListCellRendererComponent(JList list, - Contact contact, + Member member, int index, boolean isSelected, boolean cellHasFocus) { - this.setText(" " + Utils.displayName(contact)); - - this.setBorder(BorderFactory.createMatteBorder(0, 0, 1, 0,Color.LIGHT_GRAY)); + mNameLabel.setText(Utils.displayName(member.getContact(), 25)); + mRoleLabel.setText(Utils.role(member.getRole())); return this; } @@ -670,6 +654,8 @@ static class EditableTextField extends WebTextField { int columns, final Component focusGainer) { super(new ComponentUtils.TextLimitDocument(maxTextLength), text, columns); + this.setTrailingComponent(new WebImage(Utils.getIcon("ic_ui_edit.png"))); + this.setEditable(editable); this.setFocusable(editable); @@ -703,11 +689,13 @@ private void switchToEditMode() { this.setInputPrompt(text); this.setText(text); this.setDrawBorder(true); + this.getTrailingComponent().setVisible(false); } private void switchToLabelMode() { this.setText(this.labelText()); this.setDrawBorder(false); + this.getTrailingComponent().setVisible(true); } protected String labelText() { @@ -727,7 +715,6 @@ static class ModalPopup extends WebPopup { private final WebPanel layerPanel; ModalPopup(AbstractButton invokerButton) { - super(); mInvoker = invokerButton; layerPanel = new WebPanel(); @@ -792,7 +779,7 @@ static class AttachmentPanel extends GroupPanel { private final WebLabel mStatus; private final WebLinkLabel mAttLabel; - private String mImagePath = ""; + private Path mImagePath = Paths.get(""); AttachmentPanel() { super(View.GAP_SMALL, false); @@ -804,7 +791,7 @@ static class AttachmentPanel extends GroupPanel { this.add(mAttLabel); } - void setImage(String path) { + void setImage(Path path) { if (path.equals(mImagePath)) return; @@ -828,7 +815,6 @@ static class TextLimitDocument extends PlainDocument { private final int mLimit; TextLimitDocument(int limit) { - super(); this.mLimit = limit; } diff --git a/src/main/java/org/kontalk/view/ConfigurationDialog.java b/src/main/java/org/kontalk/view/ConfigurationDialog.java index 562fe999..b6664365 100644 --- a/src/main/java/org/kontalk/view/ConfigurationDialog.java +++ b/src/main/java/org/kontalk/view/ConfigurationDialog.java @@ -1,6 +1,6 @@ /* * Kontalk Java client - * Copyright (C) 2014 Kontalk Devteam + * Copyright (C) 2016 Kontalk Devteam * * 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 @@ -23,6 +23,7 @@ import com.alee.extended.panel.GroupingType; import com.alee.laf.button.WebButton; import com.alee.laf.checkbox.WebCheckBox; +import com.alee.laf.combobox.WebComboBox; import com.alee.laf.label.WebLabel; import com.alee.laf.panel.WebPanel; import com.alee.laf.rootpane.WebDialog; @@ -42,16 +43,20 @@ import java.awt.event.ItemListener; import java.text.DecimalFormat; import java.text.NumberFormat; -import java.util.Optional; +import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.logging.Level; import java.util.logging.Logger; import javax.swing.Box; import javax.swing.JFrame; import javax.swing.text.NumberFormatter; -import org.kontalk.system.Config; +import org.apache.commons.lang.StringUtils; +import org.kontalk.persistence.Config; import org.kontalk.crypto.PersonalKey; import org.kontalk.misc.KonException; -import org.kontalk.system.AccountLoader; +import org.kontalk.model.Account; +import org.kontalk.model.Model; +import org.kontalk.system.Control.ViewControl; import org.kontalk.util.Tr; /** @@ -61,27 +66,30 @@ final class ConfigurationDialog extends WebDialog { private static final Logger LOGGER = Logger.getLogger(ConfigurationDialog.class.getName()); - private static enum ConfPage {MAIN, ACCOUNT}; - private final Config mConf = Config.getInstance(); private final View mView; + private final Model mModel; - ConfigurationDialog(JFrame owner, final View view) { + ConfigurationDialog(JFrame owner, View view, Model model) { super(owner); mView = view; + mModel = model; + this.setTitle(Tr.tr("Preferences")); - this.setSize(550, 500); + this.setSize(550, 450); this.setResizable(false); this.setModal(true); this.setLayout(new BorderLayout(View.GAP_SMALL, View.GAP_SMALL)); WebTabbedPane tabbedPane = new WebTabbedPane(WebTabbedPane.LEFT); - tabbedPane.setFontSize(13); + tabbedPane.setFontSize(View.FONT_SIZE_NORMAL); final MainPanel mainPanel = new MainPanel(); + final NetworkPanel networkPanel = new NetworkPanel(); final AccountPanel accountPanel = new AccountPanel(); final PrivacyPanel privacyPanel = new PrivacyPanel(); tabbedPane.addTab(Tr.tr("Main"), mainPanel); + tabbedPane.addTab(Tr.tr("Network"), networkPanel); tabbedPane.addTab(Tr.tr("Account"), accountPanel); tabbedPane.addTab(Tr.tr("Privacy"), privacyPanel); @@ -102,6 +110,8 @@ public void actionPerformed(ActionEvent e) { mainPanel.saveConfiguration(); accountPanel.saveConfiguration(); privacyPanel.saveConfiguration(); + networkPanel.saveConfiguration(); + ConfigurationDialog.this.dispose(); } }); @@ -112,11 +122,10 @@ public void actionPerformed(ActionEvent e) { } private class MainPanel extends WebPanel { - - private final WebCheckBox mConnectStartupBox; private final WebCheckBox mTrayBox; private final WebCheckBox mCloseTrayBox; private final WebCheckBox mEnterSendsBox; + private final WebCheckBox mUserContact; private final WebCheckBox mBGBox; private final WebFileChooserField mBGChooser; @@ -127,11 +136,6 @@ private class MainPanel extends WebPanel { groupPanel.add(new WebLabel(Tr.tr("Main Settings")).setBoldFont()); groupPanel.add(new WebSeparator(true, true)); - mConnectStartupBox = createCheckBox(Tr.tr("Connect on startup"), - "", - mConf.getBoolean(Config.MAIN_CONNECT_STARTUP)); - groupPanel.add(mConnectStartupBox); - mTrayBox = createCheckBox(Tr.tr("Show tray icon"), "", mConf.getBoolean(Config.MAIN_TRAY)); @@ -152,6 +156,11 @@ public void itemStateChanged(ItemEvent e) { mConf.getBoolean(Config.MAIN_ENTER_SENDS)); groupPanel.add(new GroupPanel(mEnterSendsBox, new WebSeparator())); + mUserContact = createCheckBox(Tr.tr("Show yourself in contacts"), + Tr.tr("Show yourself in the contact list"), + mConf.getBoolean(Config.VIEW_USER_CONTACT)); + groupPanel.add(new GroupPanel(mUserContact, new WebSeparator())); + String bgPath = mConf.getString(Config.VIEW_CHAT_BG); mBGBox = createCheckBox(Tr.tr("Custom background:")+" ", "", @@ -172,12 +181,13 @@ public void itemStateChanged(ItemEvent e) { } private void saveConfiguration() { - mConf.setProperty(Config.MAIN_CONNECT_STARTUP, mConnectStartupBox.isSelected()); mConf.setProperty(Config.MAIN_TRAY, mTrayBox.isSelected()); mConf.setProperty(Config.MAIN_TRAY_CLOSE, mCloseTrayBox.isSelected()); mView.updateTray(); mConf.setProperty(Config.MAIN_ENTER_SENDS, mEnterSendsBox.isSelected()); mView.setHotkeys(); + mConf.setProperty(Config.VIEW_USER_CONTACT, mUserContact.isSelected()); + mView.updateContactList(); String bgPath; if (mBGBox.isSelected() && !mBGChooser.getSelectedFiles().isEmpty()) { bgPath = mBGChooser.getSelectedFiles().get(0).getAbsolutePath(); @@ -192,11 +202,66 @@ private void saveConfiguration() { } } + private class NetworkPanel extends WebPanel { + + private final WebCheckBox mConnectStartupBox; + private final WebCheckBox mRequestAvatars; + private final WebComboBox mMaxImgSizeBox; + private final LinkedHashMap mImgResizeMap; + + public NetworkPanel() { + GroupPanel groupPanel = new GroupPanel(View.GAP_DEFAULT, false); + groupPanel.setMargin(View.MARGIN_BIG); + + groupPanel.add(new WebLabel(Tr.tr("Network Settings")).setBoldFont()); + groupPanel.add(new WebSeparator(true, true)); + + mConnectStartupBox = createCheckBox(Tr.tr("Connect on startup"), + "", + mConf.getBoolean(Config.MAIN_CONNECT_STARTUP)); + groupPanel.add(mConnectStartupBox); + + mRequestAvatars = createCheckBox(Tr.tr("Download profile pictures"), + Tr.tr("Download contact profile pictures"), + mConf.getBoolean(Config.NET_REQUEST_AVATARS)); + groupPanel.add(new GroupPanel(mRequestAvatars, new WebSeparator())); + + mImgResizeMap = new LinkedHashMap<>(); + mImgResizeMap.put(-1, Tr.tr("Original")); + mImgResizeMap.put(300 * 1000, Tr.tr("Small (0.3MP)")); + mImgResizeMap.put(500 * 1000, Tr.tr("Medium (0.5MP)")); + mImgResizeMap.put(800 * 1000, Tr.tr("Large (0.8MP)")); + + mMaxImgSizeBox = new WebComboBox(new ArrayList<>(mImgResizeMap.values()).toArray()); + int maxImgSize = mConf.getInt(Config.NET_MAX_IMG_SIZE); + int maxImgIndex = new ArrayList<>(mImgResizeMap.keySet()).indexOf(maxImgSize); + if (maxImgSize >= 0) + mMaxImgSizeBox.setSelectedIndex(maxImgIndex); + TooltipManager.addTooltip(mMaxImgSizeBox, Tr.tr("Reduce size of images before sending")); + + groupPanel.add(new GroupPanel(View.GAP_DEFAULT, + new WebLabel(Tr.tr("Resize image attachments:")), + mMaxImgSizeBox, + new WebSeparator())); + + this.add(groupPanel); + } + + private void saveConfiguration() { + mConf.setProperty(Config.MAIN_CONNECT_STARTUP, mConnectStartupBox.isSelected()); + mConf.setProperty(Config.NET_REQUEST_AVATARS, mRequestAvatars.isSelected()); + + mConf.setProperty(Config.NET_MAX_IMG_SIZE, + new ArrayList<>(mImgResizeMap.keySet()).get(mMaxImgSizeBox.getSelectedIndex())); + } + } + private class AccountPanel extends WebPanel { private final WebTextField mServerField; private final WebFormattedTextField mPortField; private final WebCheckBox mDisableCertBox; + private final WebTextArea mUserIDArea; private final WebTextArea mFingerprintArea; AccountPanel() { @@ -231,20 +296,31 @@ private class AccountPanel extends WebPanel { groupPanel.add(new GroupPanel(mDisableCertBox, new WebSeparator())); groupPanel.add(new WebSeparator(true, true)); + + mUserIDArea = new WebTextArea().setBoldFont(); + mUserIDArea.setEditable(false); + mUserIDArea.setOpaque(false); + groupPanel.add(new GroupPanel(View.GAP_DEFAULT, + new WebLabel(Tr.tr("Key user ID:")), + mUserIDArea)); + WebLabel fpLabel = new WebLabel(Tr.tr("Key fingerprint:")+" "); fpLabel.setAlignmentY(Component.TOP_ALIGNMENT); GroupPanel fpLabelPanel = new GroupPanel(false, fpLabel, Box.createGlue()); mFingerprintArea = Utils.createFingerprintArea(); - this.updateFingerprint(); + this.updateKey(); groupPanel.add(new GroupPanel(View.GAP_DEFAULT, fpLabelPanel, mFingerprintArea)); - final WebButton passButton = new WebButton(getPassTitle()); + final WebButton passButton = new WebButton(getPassTitle(mModel.account())); passButton.addActionListener(new ActionListener() { @Override public void actionPerformed(ActionEvent e) { - WebDialog passDialog = createPassDialog(ConfigurationDialog.this); + WebDialog passDialog = createPassDialog( + ConfigurationDialog.this, + mModel.account(), + mView.getControl()); passDialog.setVisible(true); - passButton.setText(getPassTitle()); + passButton.setText(getPassTitle(mModel.account())); } }); groupPanel.add(passButton); @@ -254,15 +330,14 @@ public void actionPerformed(ActionEvent e) { @Override public void actionPerformed(ActionEvent e) { mView.showImportWizard(false); - AccountPanel.this.updateFingerprint(); - passButton.setText(getPassTitle()); + AccountPanel.this.updateKey(); + passButton.setText(getPassTitle(mModel.account())); } }); groupPanel.add(importButton); this.add(groupPanel, BorderLayout.CENTER); - WebButton okButton = new WebButton(Tr.tr("Save & Connect")); okButton.addActionListener(new ActionListener() { @Override @@ -278,11 +353,17 @@ public void actionPerformed(ActionEvent e) { this.add(buttonPanel, BorderLayout.SOUTH); } - private void updateFingerprint() { - Optional optKey = AccountLoader.getInstance().getPersonalKey(); - mFingerprintArea.setText(optKey.isPresent() ? - Utils.fingerprint(optKey.get().getFingerprint()) : - "- " + Tr.tr("no key loaded") + " -"); + private void updateKey() { + PersonalKey key = mModel.account().getPersonalKey().orElse(null); + String uid = key != null ? key.getUserId() : null; + mUserIDArea.setText(uid != null ? + StringUtils.abbreviate(uid, 30) : + "- "+Tr.tr("no key loaded")+" -"); + if (uid != null) + TooltipManager.addTooltip(mUserIDArea, uid); + mFingerprintArea.setText(key != null ? + Utils.fingerprint(key.getFingerprint()) : + "---"); } private void saveConfiguration() { @@ -297,6 +378,7 @@ private class PrivacyPanel extends WebPanel { private final WebCheckBox mChatStateBox; private final WebCheckBox mRosterNameBox; + private final WebCheckBox mSubscriptionBox; PrivacyPanel() { GroupPanel groupPanel = new GroupPanel(View.GAP_DEFAULT, false); @@ -305,8 +387,13 @@ private class PrivacyPanel extends WebPanel { groupPanel.add(new WebLabel(Tr.tr("Privacy Settings")).setBoldFont()); groupPanel.add(new WebSeparator(true, true)); + mSubscriptionBox = createCheckBox(Tr.tr("Automatically grant authorization"), + Tr.tr("Automatically grant online status authorization requests from other users"), + mConf.getBoolean(Config.NET_AUTO_SUBSCRIPTION)); + groupPanel.add(new GroupPanel(mSubscriptionBox, new WebSeparator())); + mChatStateBox = createCheckBox(Tr.tr("Send chatstate notification"), - Tr.tr("Send chat activity (typing,…) to other user"), + Tr.tr("Send chat activity (typing,…) to other users"), mConf.getBoolean(Config.NET_SEND_CHAT_STATE)); groupPanel.add(new GroupPanel(mChatStateBox, new WebSeparator())); @@ -321,6 +408,7 @@ private class PrivacyPanel extends WebPanel { private void saveConfiguration() { mConf.setProperty(Config.NET_SEND_CHAT_STATE, mChatStateBox.isSelected()); mConf.setProperty(Config.NET_SEND_ROSTER_NAME, mRosterNameBox.isSelected()); + mConf.setProperty(Config.NET_AUTO_SUBSCRIPTION, mSubscriptionBox.isSelected()); } } @@ -334,20 +422,20 @@ private static WebCheckBox createCheckBox(String title, String tooltip, boolean return checkBox; } - private static String getPassTitle() { - return AccountLoader.getInstance().isPasswordProtected() ? + private static String getPassTitle(Account account) { + return account.isPasswordProtected() ? Tr.tr("Change key password") : Tr.tr("Set key password"); } - private static WebDialog createPassDialog(WebDialog parent) { - final WebDialog passDialog = new WebDialog(parent, getPassTitle(), true); + private static WebDialog createPassDialog(WebDialog parent, Account account, ViewControl control) { + final WebDialog passDialog = new WebDialog(parent, getPassTitle(account), true); passDialog.setLayout(new BorderLayout(View.GAP_DEFAULT, View.GAP_DEFAULT)); passDialog.setResizable(false); final WebButton saveButton = new WebButton(Tr.tr("Save")); - boolean passSet = AccountLoader.getInstance().isPasswordProtected(); + boolean passSet = account.isPasswordProtected(); final ComponentUtils.PassPanel passPanel = new ComponentUtils.PassPanel(passSet) { @Override void onValidInput() { @@ -364,14 +452,13 @@ void onInvalidInput() { @Override public void actionPerformed(ActionEvent e) { char[] oldPassword = passPanel.getOldPassword(); - Optional optNewPass = passPanel.getNewPassword(); - if (!optNewPass.isPresent()) { + char[] newPassword = passPanel.getNewPassword().orElse(null); + if (newPassword == null) { LOGGER.warning("can't get new password"); return; } - char[] newPassword = optNewPass.get(); try { - AccountLoader.getInstance().setPassword(oldPassword, newPassword); + control.setAccountPassword(oldPassword, newPassword); } catch(KonException ex) { LOGGER.log(Level.WARNING, "can't set new password", ex); if (ex.getError() == KonException.Error.CHANGE_PASS_COPY) diff --git a/src/main/java/org/kontalk/view/ContactDetails.java b/src/main/java/org/kontalk/view/ContactDetails.java index f0b8c43c..406974ef 100644 --- a/src/main/java/org/kontalk/view/ContactDetails.java +++ b/src/main/java/org/kontalk/view/ContactDetails.java @@ -1,6 +1,6 @@ /* * Kontalk Java client - * Copyright (C) 2014 Kontalk Devteam + * Copyright (C) 2016 Kontalk Devteam * * 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 @@ -39,6 +39,8 @@ import java.awt.event.ActionEvent; import java.awt.event.ActionListener; import java.awt.image.BufferedImage; +import java.util.HashMap; +import java.util.Map; import java.util.Observable; import java.util.Observer; import javax.swing.Box; @@ -56,16 +58,20 @@ */ final class ContactDetails extends WebPanel implements Observer { + private static final Map CACHE = new HashMap<>(); + private final View mView; private final Contact mContact; private final WebTextField mNameField; - private final WebLabel mAuthorization; + private final WebLabel mSubscrStatus; + private final WebButton mSubscrButton; private final WebLabel mKeyStatus; private final WebLabel mFPLabel; + private final WebButton mUpdateButton; private final WebTextArea mFPArea; private final WebCheckBox mEncryptionBox; - ContactDetails(View view, Contact contact) { + private ContactDetails(View view, Contact contact) { mView = view; mContact = contact; @@ -117,10 +123,21 @@ protected void onFocusLost() { mainPanel.add(jidField); mainPanel.add(new WebLabel(Tr.tr("Authorization:"))); - mAuthorization = new WebLabel(); - String authText = Tr.tr("Permission to view presence status and public key"); - TooltipManager.addTooltip(mAuthorization, authText); - mainPanel.add(mAuthorization); + mSubscrStatus = new WebLabel(); + String subscrText = Tr.tr("Permission to view presence status and public key"); + TooltipManager.addTooltip(mSubscrStatus, subscrText); + + mSubscrButton = new WebButton(Tr.tr("Request")); + String reqText = Tr.tr("Request status authorization from contact"); + TooltipManager.addTooltip(mSubscrButton, reqText); + mSubscrButton.addActionListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent e) { + mView.getControl().sendSubscriptionRequest(mContact); + } + }); + mainPanel.add(new GroupPanel(GroupingType.fillFirst, + View.GAP_DEFAULT, mSubscrStatus, mSubscrButton)); groupPanel.add(mainPanel); @@ -130,17 +147,17 @@ protected void onFocusLost() { keyPanel.add(new WebLabel(Tr.tr("Public Key")+":")); mKeyStatus = new WebLabel(); - WebButton updButton = new WebButton(Tr.tr("Update")); + mUpdateButton = new WebButton(Tr.tr("Update")); String updText = Tr.tr("Update key"); - TooltipManager.addTooltip(updButton, updText); - updButton.addActionListener(new ActionListener() { + TooltipManager.addTooltip(mUpdateButton, updText); + mUpdateButton.addActionListener(new ActionListener() { @Override public void actionPerformed(ActionEvent e) { mView.getControl().requestKey(ContactDetails.this.mContact); } }); keyPanel.add(new GroupPanel(GroupingType.fillFirst, - View.GAP_DEFAULT, mKeyStatus, updButton)); + View.GAP_DEFAULT, mKeyStatus, mUpdateButton)); mFPLabel = new WebLabel(Tr.tr("Fingerprint:")); keyPanel.add(mFPLabel); @@ -189,6 +206,10 @@ public void paintComponent(Graphics g) { this.add(gradientPanel, BorderLayout.CENTER); } + void setRenameFocus() { + mNameField.requestFocusInWindow(); + } + @Override public void update(Observable o, final Object arg) { if (SwingUtilities.isEventDispatchThread()) { @@ -204,7 +225,7 @@ public void run() { } private void updateOnEDT() { - // may have changed: contact name and/or key + // may have changed: contact name, subscription and/or key mNameField.setText(mContact.getName()); mNameField.setInputPrompt(mContact.getName()); Contact.Subscription subscription = mContact.getSubScription(); @@ -214,7 +235,9 @@ private void updateOnEDT() { case SUBSCRIBED: auth = Tr.tr("Authorized"); break; case UNSUBSCRIBED: auth = Tr.tr("Not authorized"); break; } - mAuthorization.setText(auth); + mSubscrButton.setVisible(subscription != Contact.Subscription.SUBSCRIBED); + mSubscrButton.setEnabled(subscription == Contact.Subscription.UNSUBSCRIBED); + mSubscrStatus.setText(auth); String hasKey = ""; if (mContact.hasKey()) { hasKey += Tr.tr("Available")+""; @@ -230,6 +253,8 @@ private void updateOnEDT() { mFPArea.setVisible(false); } mKeyStatus.setText(hasKey); + mUpdateButton.setEnabled(mContact.isKontalkUser() && + subscription == Contact.Subscription.SUBSCRIBED); } private void saveName(String name) { @@ -255,7 +280,13 @@ private void saveJID(JID jid) { mView.getControl().changeJID(mContact, jid); } - void onClose() { - this.mContact.deleteObserver(this); + static ContactDetails instance(View view, Contact contact) { + if (!CACHE.containsKey(contact)) { + ContactDetails newContactDetails = new ContactDetails(view, contact); + contact.addObserver(newContactDetails); + CACHE.put(contact, newContactDetails); + } + + return CACHE.get(contact); } } diff --git a/src/main/java/org/kontalk/view/ContactListView.java b/src/main/java/org/kontalk/view/ContactListView.java index 9b0cdf9c..ede27261 100644 --- a/src/main/java/org/kontalk/view/ContactListView.java +++ b/src/main/java/org/kontalk/view/ContactListView.java @@ -1,6 +1,6 @@ /* * Kontalk Java client - * Copyright (C) 2014 Kontalk Devteam + * Copyright (C) 2016 Kontalk Devteam * * 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 @@ -18,6 +18,8 @@ package org.kontalk.view; +import com.alee.extended.image.DisplayType; +import com.alee.extended.image.WebImage; import com.alee.extended.panel.GroupPanel; import com.alee.extended.panel.GroupingType; import com.alee.laf.label.WebLabel; @@ -25,128 +27,177 @@ import com.alee.laf.menu.WebPopupMenu; import java.awt.BorderLayout; import java.awt.Color; -import java.awt.Component; import java.awt.event.ActionEvent; import java.awt.event.ActionListener; import java.awt.event.MouseAdapter; import java.awt.event.MouseEvent; -import java.util.HashSet; import java.util.Observer; -import java.util.Optional; -import java.util.Set; import javax.swing.Box; -import javax.swing.ListSelectionModel; -import javax.swing.event.ListSelectionEvent; -import javax.swing.event.ListSelectionListener; import org.apache.commons.lang.StringEscapeUtils; -import org.kontalk.model.ChatList; import org.kontalk.model.Contact; -import org.kontalk.model.ContactList; +import org.kontalk.model.Model; +import org.kontalk.model.chat.Chat; import org.kontalk.system.Control; import org.kontalk.util.Tr; import org.kontalk.view.ContactListView.ContactItem; /** - * Display all contact (aka contacts) in a brief list. + * Display all contacts in a brief list. * @author Alexander Bikadorov {@literal } */ -final class ContactListView extends Table implements Observer { +final class ContactListView extends ListView implements Observer { - private final ContactList mContactList; - private final ContactPopupMenu mPopupMenu; + private final Model mModel; - ContactListView(final View view, ContactList contactList) { - super(view, true); + ContactListView(final View view, Model model) { + super(view, false); - mContactList = contactList; - - this.setSelectionMode(ListSelectionModel.SINGLE_SELECTION); - //this.setDragEnabled(true); - - // right click popup menu - mPopupMenu = new ContactPopupMenu(); - - // actions triggered by selection - this.getSelectionModel().addListSelectionListener(new ListSelectionListener() { - @Override - public void valueChanged(ListSelectionEvent e) { - Optional optContact = ContactListView.this.getSelectedValue(); - if (!optContact.isPresent()) - return; - - mView.showContactDetails(optContact.get()); - } - }); + mModel = model; // actions triggered by mouse events this.addMouseListener(new MouseAdapter() { @Override public void mouseClicked(MouseEvent e) { if (e.getClickCount() == 2) { - Optional optContact = ContactListView.this.getSelectedValue(); - if (optContact.isPresent()) - mView.showChat(optContact.get()); + Contact contact = ContactListView.this.getSelectedValue().orElse(null); + if (contact != null) + mView.showChat(contact); } } + }); + + this.updateOnEDT(null); + } + + @Override + protected void updateOnEDT(Object arg) { + this.sync(Utils.allContacts(mModel.contacts())); + } + + @Override + protected ContactItem newItem(Contact value) { + return new ContactItem(value); + } + + @Override + protected void selectionChanged(Contact value) { + mView.showContactDetails(value); + } + + @Override + protected WebPopupMenu rightClickMenu(ContactItem item) { + WebPopupMenu menu = new WebPopupMenu(); + + WebMenuItem newItem = new WebMenuItem(Tr.tr("New Chat")); + newItem.setToolTipText(Tr.tr("Creates a new chat for this contact")); + newItem.addActionListener(new ActionListener() { @Override - public void mousePressed(MouseEvent e) { - check(e); + public void actionPerformed(ActionEvent event) { + Chat chat = mView.getControl().getOrCreateSingleChat( + ContactListView.this.getSelectedItem().mValue); + mView.showChat(chat); } + }); + menu.add(newItem); + + WebMenuItem blockItem = new WebMenuItem(Tr.tr("Block Contact")); + blockItem.setToolTipText(Tr.tr("Block all messages from this contact")); + blockItem.addActionListener(new ActionListener() { @Override - public void mouseReleased(MouseEvent e) { - check(e); + public void actionPerformed(ActionEvent event) { + mView.getControl().sendContactBlocking( + ContactListView.this.getSelectedItem().mValue, true); } - private void check(MouseEvent e) { - if (e.isPopupTrigger()) { - int row = ContactListView.this.rowAtPoint(e.getPoint()); - ContactListView.this.setSelectedItem(row); - ContactListView.this.showPopupMenu(e); - } + }); + menu.add(blockItem); + + WebMenuItem unblockItem = new WebMenuItem(Tr.tr("Unblock Contact")); + unblockItem.setToolTipText(Tr.tr("Unblock this contact")); + unblockItem.addActionListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent event) { + mView.getControl().sendContactBlocking( + ContactListView.this.getSelectedItem().mValue, false); } }); + menu.add(unblockItem); - this.updateOnEDT(null); + WebMenuItem deleteItem = new WebMenuItem(Tr.tr("Delete Contact")); + deleteItem.setToolTipText(Tr.tr("Delete this contact")); + deleteItem.addActionListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent event) { + String text = Tr.tr("Permanently delete this contact?") + "\n" + + mView.tr_remove_contact; + if (!Utils.confirmDeletion(ContactListView.this, text)) + return; + mView.getControl().deleteContact( + ContactListView.this.getSelectedItem().mValue); + mView.showNothing(); + } + }); + menu.add(deleteItem); + + // dont allow creation of more than one chat for a contact + newItem.setVisible(!mModel.chats().contains(item.mValue)); + + if (item.mValue.isBlocked()) { + blockItem.setVisible(false); + unblockItem.setVisible(true); + } else { + blockItem.setVisible(true); + unblockItem.setVisible(false); + } + + Control.Status status = mView.currentStatus(); + boolean connected = status == Control.Status.CONNECTED; + blockItem.setEnabled(connected); + unblockItem.setEnabled(connected); + deleteItem.setEnabled(connected); + + return menu; } @Override - protected void updateOnEDT(Object arg) { - Set newItems = new HashSet<>(); - Set contacts = mContactList.getAll(); - for (Contact contact: contacts) - if (!this.containsValue(contact)) - newItems.add(new ContactItem(contact)); - this.sync(contacts, newItems); - } + protected void onRenameEvent() { + Contact contact = this.getSelectedValue().orElse(null); + if (contact == null) + return; - private void showPopupMenu(MouseEvent e) { - // note: only work when right click does also selection - mPopupMenu.show(this.getSelectedItem(), this, e.getX(), e.getY()); + mView.requestRenameFocus(contact); } /** One item in the contact list representing a contact. */ - final class ContactItem extends Table.TableItem { + final class ContactItem extends ListView.TableItem { + private final WebImage mAvatar; private final WebLabel mNameLabel; private final WebLabel mStatusLabel; - private Color mBackround; + private Color mBackground; ContactItem(Contact contact) { super(contact); //this.setPaintFocus(true); - this.setLayout(new BorderLayout(View.GAP_DEFAULT, View.GAP_SMALL)); + this.setLayout(new BorderLayout(View.GAP_DEFAULT, 0)); this.setMargin(View.MARGIN_SMALL); - mNameLabel = new WebLabel("foo"); - mNameLabel.setFontSize(14); - this.add(mNameLabel, BorderLayout.CENTER); + mAvatar = new WebImage().setDisplayType(DisplayType.fitComponent); + mAvatar.setPreferredSize(View.AVATAR_LIST_DIM); + this.add(mAvatar, BorderLayout.WEST); - mStatusLabel = new WebLabel("foo"); + mNameLabel = new WebLabel(); + mNameLabel.setFontSize(View.FONT_SIZE_BIG); + + mStatusLabel = new WebLabel(); mStatusLabel.setForeground(Color.GRAY); - mStatusLabel.setFontSize(11); - this.add(new GroupPanel(GroupingType.fillFirst, - Box.createGlue(), mStatusLabel), - BorderLayout.SOUTH); + mStatusLabel.setFontSize(View.FONT_SIZE_TINY); + this.add( + new GroupPanel(View.GAP_SMALL, false, + mNameLabel, + new GroupPanel(GroupingType.fillFirst, + Box.createGlue(), mStatusLabel) + ), BorderLayout.CENTER); this.updateOnEDT(null); } @@ -177,7 +228,7 @@ protected void render(int tableWidth, boolean isSelected) { if (isSelected) this.setBackground(View.BLUE); else - this.setBackground(mBackround); + this.setBackground(mBackground); } @Override @@ -188,24 +239,34 @@ protected boolean contains(String search) { @Override protected void updateOnEDT(Object arg) { - // name - String name = Utils.displayName(mValue); - if (!name.equals(mNameLabel.getText())) { - mNameLabel.setText(name); - ContactListView.this.updateSorting(); + if (arg == null || arg instanceof String) { + // avatar + Utils.fixedSetWebImageImage(mAvatar, AvatarLoader.load(mValue)); + + // name + String name = Utils.displayName(mValue); + if (!name.equals(mNameLabel.getText())) { + mNameLabel.setText(name); + ContactListView.this.updateSorting(); + } } // status - mStatusLabel.setText(Utils.mainStatus(mValue, false)); + if (arg == null || arg instanceof Contact.Subscription || + arg instanceof Contact.Online) { + mStatusLabel.setText(Utils.mainStatus(mValue, false)); + } // online status - Contact.Subscription subStatus = mValue.getSubScription(); - mBackround = mValue.getOnline() == Contact.Online.YES ? View.LIGHT_BLUE: - subStatus == Contact.Subscription.UNSUBSCRIBED || - subStatus == Contact.Subscription.PENDING || - mValue.isBlocked() ? View.LIGHT_GREY : - Color.WHITE; - this.setBackground(mBackround); + if (arg == null || arg instanceof Contact.Subscription) { + Contact.Subscription subStatus = mValue.getSubScription(); + mBackground = mValue.getOnline() == Contact.Online.YES ? View.LIGHT_BLUE: + subStatus == Contact.Subscription.UNSUBSCRIBED || + subStatus == Contact.Subscription.PENDING || + mValue.isBlocked() ? View.LIGHT_GREY : + Color.WHITE; + this.setBackground(mBackground); + } ContactListView.this.repaint(); } @@ -215,82 +276,4 @@ public int compareTo(TableItem o) { return Utils.compareContacts(mValue, o.mValue); } } - - private class ContactPopupMenu extends WebPopupMenu { - - ContactItem mItem; - WebMenuItem mNewMenuItem; - WebMenuItem mBlockMenuItem; - WebMenuItem mUnblockMenuItem; - WebMenuItem mDeleteMenuItem; - - ContactPopupMenu() { - mNewMenuItem = new WebMenuItem(Tr.tr("New Chat")); - mNewMenuItem.setToolTipText(Tr.tr("Creates a new chat for this contact")); - mNewMenuItem.addActionListener(new ActionListener() { - @Override - public void actionPerformed(ActionEvent event) { - mView.getControl().getOrCreateSingleChat(mItem.mValue); - } - }); - this.add(mNewMenuItem); - - mBlockMenuItem = new WebMenuItem(Tr.tr("Block Contact")); - mBlockMenuItem.setToolTipText(Tr.tr("Block all messages from this contact")); - mBlockMenuItem.addActionListener(new ActionListener() { - @Override - public void actionPerformed(ActionEvent event) { - mView.getControl().sendContactBlocking(mItem.mValue, true); - } - }); - this.add(mBlockMenuItem); - - mUnblockMenuItem = new WebMenuItem(Tr.tr("Unblock Contact")); - mUnblockMenuItem.setToolTipText(Tr.tr("Unblock this contact")); - mUnblockMenuItem.addActionListener(new ActionListener() { - @Override - public void actionPerformed(ActionEvent event) { - mView.getControl().sendContactBlocking(mItem.mValue, false); - } - }); - this.add(mUnblockMenuItem); - - mDeleteMenuItem = new WebMenuItem(Tr.tr("Delete Contact")); - mDeleteMenuItem.setToolTipText(Tr.tr("Delete this contact")); - mDeleteMenuItem.addActionListener(new ActionListener() { - @Override - public void actionPerformed(ActionEvent event) { - String text = Tr.tr("Permanently delete this contact?") + "\n" + - View.REMOVE_CONTACT_NOTE; - if (!Utils.confirmDeletion(ContactListView.this, text)) - return; - mView.getControl().deleteContact(mItem.mValue); - } - }); - this.add(mDeleteMenuItem); - } - - void show(ContactItem item, Component invoker, int x, int y) { - mItem = item; - - // dont allow creation of more than one chat for a contact - mNewMenuItem.setVisible(!ChatList.getInstance().contains(item.mValue)); - - if (mItem.mValue.isBlocked()) { - mBlockMenuItem.setVisible(false); - mUnblockMenuItem.setVisible(true); - } else { - mBlockMenuItem.setVisible(true); - mUnblockMenuItem.setVisible(false); - } - - Control.Status status = ContactListView.this.mView.getCurrentStatus(); - boolean connected = status == Control.Status.CONNECTED; - mBlockMenuItem.setEnabled(connected); - mUnblockMenuItem.setEnabled(connected); - mDeleteMenuItem.setEnabled(connected); - - this.show(invoker, x, y); - } - } } diff --git a/src/main/java/org/kontalk/view/Content.java b/src/main/java/org/kontalk/view/Content.java index fa5573ae..a4f4a191 100644 --- a/src/main/java/org/kontalk/view/Content.java +++ b/src/main/java/org/kontalk/view/Content.java @@ -1,6 +1,6 @@ /* * Kontalk Java client - * Copyright (C) 2014 Kontalk Devteam + * Copyright (C) 2016 Kontalk Devteam * * 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 @@ -24,7 +24,7 @@ import java.awt.Component; import java.awt.GridBagLayout; import java.util.Optional; -import org.kontalk.model.Chat; +import org.kontalk.model.chat.Chat; import org.kontalk.model.Contact; /** @@ -56,7 +56,7 @@ void showChat(Chat chat) { } void showContact(Contact contact) { - this.show(new ContactDetails(mView, contact)); + this.show(ContactDetails.instance(mView, contact)); } void showNothing() { @@ -69,9 +69,6 @@ void showNothing() { } private void show(Component comp) { - if (mCurrent instanceof ContactDetails) { - ((ContactDetails) mCurrent).onClose(); - } // Swing... this.removeAll(); this.add(comp, BorderLayout.CENTER); @@ -80,4 +77,10 @@ private void show(Component comp) { mCurrent = comp; } + + void requestRenameFocus() { + if (mCurrent instanceof ContactDetails) { + ((ContactDetails) mCurrent).setRenameFocus(); + } + } } diff --git a/src/main/java/org/kontalk/view/ImageLoader.java b/src/main/java/org/kontalk/view/ImageLoader.java index fa2f1363..64bc3939 100644 --- a/src/main/java/org/kontalk/view/ImageLoader.java +++ b/src/main/java/org/kontalk/view/ImageLoader.java @@ -1,6 +1,6 @@ /* * Kontalk Java client - * Copyright (C) 2014 Kontalk Devteam + * Copyright (C) 2016 Kontalk Devteam * * 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 @@ -21,7 +21,7 @@ import com.alee.extended.label.WebLinkLabel; import java.awt.Image; import java.awt.image.BufferedImage; -import java.awt.image.ImageObserver; +import java.nio.file.Path; import javax.swing.ImageIcon; import javax.swing.SwingUtilities; import org.kontalk.system.AttachmentManager; @@ -36,19 +36,19 @@ class ImageLoader { private ImageLoader() {} // TODO Swing + async == a damn mess - static void setImageIconAsync(WebLinkLabel view, String path) { + static void setImageIconAsync(WebLinkLabel view, Path path) { AsyncLoader run = new AsyncLoader(view, path); // TODO all at once? queue not that good either //new Chat(run).start(); run.run(); } - private static final class AsyncLoader implements Runnable, ImageObserver { + private static final class AsyncLoader implements Runnable { private final WebLinkLabel view; - private final String path; + private final Path path; - AsyncLoader(WebLinkLabel view, String path) { + AsyncLoader(WebLinkLabel view, Path path) { this.view = view; this.path = path; } @@ -56,24 +56,15 @@ private static final class AsyncLoader implements Runnable, ImageObserver { @Override public void run() { BufferedImage image = MediaUtils.readImage(path); - Image scaledImage = MediaUtils.scale(image, + Image scaledImage = MediaUtils.scaleAsync(image, AttachmentManager.THUMBNAIL_DIM.width, AttachmentManager.THUMBNAIL_DIM.height, false); + if (scaledImage.getWidth(view) == -1) return; - this.setOnEDT(scaledImage); - } - @Override - public boolean imageUpdate(Image img, int infoflags, int x, int y, int width, int height) { - // ignore if image is not completely loaded - if ((infoflags & ImageObserver.ALLBITS) == 0) { - return true; - } - - this.setOnEDT(img); - return false; + this.setOnEDT(scaledImage); } private void setOnEDT(final Image image) { diff --git a/src/main/java/org/kontalk/view/ImportDialog.java b/src/main/java/org/kontalk/view/ImportDialog.java index 09e48f2f..94313c61 100644 --- a/src/main/java/org/kontalk/view/ImportDialog.java +++ b/src/main/java/org/kontalk/view/ImportDialog.java @@ -1,6 +1,6 @@ /* * Kontalk Java client - * Copyright (C) 2014 Kontalk Devteam + * Copyright (C) 2016 Kontalk Devteam * * 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 @@ -39,13 +39,15 @@ import java.io.File; import java.util.EnumMap; import java.util.List; -import java.util.Optional; +import java.util.Observable; +import java.util.Observer; import java.util.logging.Level; import java.util.logging.Logger; +import javax.swing.SwingUtilities; import javax.swing.event.DocumentEvent; import javax.swing.filechooser.FileNameExtensionFilter; import org.kontalk.misc.KonException; -import org.kontalk.system.AccountLoader; +import org.kontalk.system.AccountImporter; import org.kontalk.util.Tr; /** @@ -67,6 +69,8 @@ private static enum Direction {BACK, FORTH}; private final View mView; private final boolean mConnect; + private final ResultPanel mResultPanel; + private ImportPage mCurrentPage; // exchanged between panels @@ -90,6 +94,7 @@ private static enum Direction {BACK, FORTH}; mBackButton.addActionListener(new ActionListener() { @Override public void actionPerformed(ActionEvent e) { + mResultPanel.mayAbort(); ImportDialog.this.switchPage(Direction.BACK); } }); @@ -104,6 +109,7 @@ public void actionPerformed(ActionEvent e) { mCancelButton.addActionListener(new ActionListener() { @Override public void actionPerformed(ActionEvent e) { + mResultPanel.mayAbort(); ImportDialog.this.dispose(); } }); @@ -125,7 +131,8 @@ public void actionPerformed(ActionEvent e) { mPanels = new EnumMap<>(ImportPage.class); mPanels.put(ImportPage.INTRO, new IntroPanel()); mPanels.put(ImportPage.SETTINGS, new SettingsPanel()); - mPanels.put(ImportPage.RESULT, new ResultPanel()); + mResultPanel = new ResultPanel(); + mPanels.put(ImportPage.RESULT, mResultPanel); this.setPage(ImportPage.INTRO); } @@ -257,13 +264,20 @@ protected void onNext() { } } - private class ResultPanel extends ImportPanel { + private class ResultPanel extends ImportPanel implements Observer { + + private final AccountImporter mImporter; private final WebLabel mResultLabel; private final WebLabel mErrorLabel; private final ComponentUtils.PassPanel mPassPanel; + private boolean mWaiting = false; + ResultPanel() { + mImporter = mView.getControl().createAccountImporter(); + mImporter.addObserver(this); + GroupPanel groupPanel = new GroupPanel(View.GAP_DEFAULT, false); groupPanel.setMargin(View.MARGIN_BIG); @@ -290,45 +304,82 @@ void onInvalidInput() { this.add(groupPanel); } - private boolean importAccount() { + @Override + protected void onShow() { + mNextButton.setVisible(false); + mCancelButton.setVisible(true); + this.importAccount(); + } + + private void importAccount() { if (mZipPath.isEmpty()) { LOGGER.warning("no zip file path"); - return false; + return; + } + + mResultLabel.setText(Tr.tr("Waiting...")); + mWaiting = true; + mImporter.fromZipFile(mZipPath, mPasswd); + } + + @Override + public void update(Observable o, final Object arg) { + if (SwingUtilities.isEventDispatchThread()) { + this.updateOnEDT(arg); + return; + } + SwingUtilities.invokeLater(new Runnable() { + @Override + public void run() { + ResultPanel.this.updateOnEDT(arg); + } + }); + } + + private void updateOnEDT(Object arg) { + if (arg == null) { + this.onResult(null); + } else if (arg instanceof KonException) { + this.onResult((KonException) arg); + } else { + LOGGER.warning("unexpected argument: "+arg); } + } + + private void onResult(KonException ex) { + mWaiting = false; String errorText = null; - try { - AccountLoader.getInstance().importAccount(mZipPath, mPasswd); - } catch (KonException ex) { + if (ex != null) { errorText = Utils.getErrorText(ex); + } else { + mCancelButton.setVisible(false); + mFinishButton.setVisible(true); } - mPassPanel.setVisible(errorText == null); + mPassPanel.setVisible(ex == null); - String result = errorText == null ? Tr.tr("Success!") : Tr.tr("Error"); + String result = ex == null ? Tr.tr("Success!") : Tr.tr("Error"); mResultLabel.setText(Tr.tr("Import process finished with:")+" "+result); mErrorLabel.setText(errorText == null ? "" : ""+Tr.tr("Error description:")+" \n\n"+errorText+""); - return errorText == null; } - @Override - protected void onShow() { - mNextButton.setVisible(false); - boolean success = this.importAccount(); - if (success) { - mCancelButton.setVisible(false); - mFinishButton.setVisible(true); - } + private void mayAbort() { + if (!mWaiting) + return; + + mImporter.abort(); + mWaiting = false; } @Override protected void onNext() { - Optional optNewPass = mPassPanel.getNewPassword(); - if (optNewPass.isPresent() && optNewPass.get().length > 0) { + char[] newPass = mPassPanel.getNewPassword().orElse(null); + if (newPass != null && newPass.length > 0) { try { - AccountLoader.getInstance().setPassword(new char[0], optNewPass.get()); + mView.getControl().setAccountPassword(new char[0], newPass); } catch (KonException ex) { LOGGER.log(Level.WARNING, "can't set password", ex); return; diff --git a/src/main/java/org/kontalk/view/LinkUtils.java b/src/main/java/org/kontalk/view/LinkUtils.java index 3ade6fe1..0ad0d6e1 100644 --- a/src/main/java/org/kontalk/view/LinkUtils.java +++ b/src/main/java/org/kontalk/view/LinkUtils.java @@ -1,6 +1,6 @@ /* * Kontalk Java client - * Copyright (C) 2014 Kontalk Devteam + * Copyright (C) 2016 Kontalk Devteam * * 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 @@ -54,12 +54,12 @@ final class LinkUtils { = StyleContext.getDefaultStyleContext().getStyle(StyleContext.DEFAULT_STYLE); /** Undoubtedly the best URL regex ever made. */ private static final Pattern URL_PATTERN = Pattern.compile( - "(http[s]?://)?" + // scheme - "(\\w+(-+\\w+)*\\.)+" + // sub- and host-level(s) - "[a-zA]{2,}" + // TLD - "(/[^\\s?#/]*)*" + // path - "(\\?[^\\s?#]*)*" + // query - "(\\#[^\\s?#]*)*", // fragment + "(http[s]?://)?" + // scheme; group 1 + "(\\w+[a-zA-Z_0-9-]*\\w+\\.)+" + // sub- and host-level(s); group 2 + "[a-z]{2,}(:[0-9]+)?" + // TLD and port; group 3 + "(/[^\\s?#/]*)*" + // path; group 4 + "(\\?[^\\s?#]*)*" + // query; group 5 + "(\\#[^\\s?#]*)*", // fragment; group 6 Pattern.CASE_INSENSITIVE); private static final String URL_ATT_NAME = "URL"; @@ -118,7 +118,7 @@ public void run() { WebUtils.browseSiteSafely(fixProto(url)); } }; - new Thread(run).start(); + new Thread(run, "Link Browser").start(); } } diff --git a/src/main/java/org/kontalk/view/Table.java b/src/main/java/org/kontalk/view/ListView.java similarity index 75% rename from src/main/java/org/kontalk/view/Table.java rename to src/main/java/org/kontalk/view/ListView.java index b4e84a94..b6b0eb2b 100644 --- a/src/main/java/org/kontalk/view/Table.java +++ b/src/main/java/org/kontalk/view/ListView.java @@ -1,6 +1,6 @@ /* * Kontalk Java client - * Copyright (C) 2014 Kontalk Devteam + * Copyright (C) 2016 Kontalk Devteam * * 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 @@ -18,6 +18,7 @@ package org.kontalk.view; +import com.alee.laf.menu.WebPopupMenu; import com.alee.laf.panel.WebPanel; import com.alee.laf.table.WebTable; import com.alee.laf.table.renderers.WebTableCellRenderer; @@ -28,6 +29,8 @@ import java.awt.Component; import java.awt.Point; import java.awt.Rectangle; +import java.awt.event.KeyAdapter; +import java.awt.event.KeyEvent; import java.awt.event.MouseAdapter; import java.awt.event.MouseEvent; import java.awt.event.MouseMotionListener; @@ -50,6 +53,8 @@ import javax.swing.RowSorter; import javax.swing.SortOrder; import javax.swing.SwingUtilities; +import javax.swing.event.ListSelectionEvent; +import javax.swing.event.ListSelectionListener; import javax.swing.table.DefaultTableModel; import javax.swing.table.TableRowSorter; @@ -61,8 +66,8 @@ * @param the view item in this list * @param the value of one view item */ -abstract class Table.TableItem, V extends Observable> extends WebTable implements Observer { - private static final Logger LOGGER = Logger.getLogger(Table.class.getName()); +abstract class ListView.TableItem, V extends Observable> extends WebTable implements Observer { + private static final Logger LOGGER = Logger.getLogger(ListView.class.getName()); protected final View mView; @@ -80,7 +85,7 @@ abstract class Table.TableItem, V extends Observable> exte // using legacy lib, raw types extend Object @SuppressWarnings("unchecked") - Table(View view, boolean activateTimer) { + ListView(View view, boolean activateTimer) { mView = view; // model @@ -88,7 +93,7 @@ abstract class Table.TableItem, V extends Observable> exte // row sorter needs this @Override public Class getColumnClass(int columnIndex) { - return Table.this.getColumnClass(columnIndex); + return ListView.this.getColumnClass(columnIndex); } }; this.setModel(mModel); @@ -121,6 +126,32 @@ public boolean include(Entry ent // use custom renderer this.setDefaultRenderer(TableItem.class, new TableRenderer()); + // actions triggered by selection + this.getSelectionModel().addListSelectionListener(new ListSelectionListener() { + + private V lastValue = null; + + @Override + public void valueChanged(ListSelectionEvent e) { + if (e.getValueIsAdjusting()) + return; + + V value = ListView.this.getSelectedValue().orElse(null); + if (value == null) { + // note: this happens also on right-click for some reason + return; + } + // if event is caused by filtering, dont do anything + if (lastValue == value) + return; + + lastValue = value; + mView.clearSearch(); + + ListView.this.selectionChanged(value); + } + }); + // trigger editing to forward mouse events this.addMouseMotionListener(new MouseMotionListener() { @Override @@ -128,15 +159,30 @@ public void mouseDragged(MouseEvent e) { } @Override public void mouseMoved(MouseEvent e) { - int row = Table.this.rowAtPoint(e.getPoint()); + int row = ListView.this.rowAtPoint(e.getPoint()); if (row >= 0) { - Table.this.editCellAt(row, 0); + ListView.this.editCellAt(row, 0); } } }); // actions triggered by mouse events this.addMouseListener(new MouseAdapter() { + @Override + public void mousePressed(MouseEvent e) { + check(e); + } + @Override + public void mouseReleased(MouseEvent e) { + check(e); + } + private void check(MouseEvent e) { + if (e.isPopupTrigger()) { + int row = ListView.this.rowAtPoint(e.getPoint()); + ListView.this.setSelectedItem(row); + ListView.this.showPopupMenu(e, ListView.this.getSelectedItem()); + } + } @Override public void mouseExited(MouseEvent e) { if (mTip != null) @@ -144,13 +190,23 @@ public void mouseExited(MouseEvent e) { } }); + // actions triggered by key events + this.addKeyListener(new KeyAdapter() { + @Override + public void keyReleased(KeyEvent e) { + if (e.getKeyCode() == KeyEvent.VK_F2){ + ListView.this.onRenameEvent(); + } + } + }); + if (activateTimer) { mTimer = new Timer(); // update periodically items to be up-to-date with 'last seen' text TimerTask statusTask = new TimerTask() { @Override public void run() { - Table.this.timerUpdate(); + ListView.this.timerUpdate(); } }; long timerInterval = TimeUnit.SECONDS.toMillis(60); @@ -160,12 +216,17 @@ public void run() { } } - protected boolean containsValue(V value) { - return mItems.containsKey(value); + private void showPopupMenu(MouseEvent e, I item) { + WebPopupMenu menu = this.rightClickMenu(item); + menu.show(this, e.getX(), e.getY()); } + protected void selectionChanged(V value){}; + + protected abstract WebPopupMenu rightClickMenu(I item); + @SuppressWarnings("unchecked") - protected void sync(Set values, Set newItems) { + protected boolean sync(Set values) { // TODO performance // remove old for (int i=0; i < mModel.getRowCount(); i++) { @@ -175,16 +236,25 @@ protected void sync(Set values, Set newItems) { item.mValue.deleteObserver(item); mModel.removeRow(i); i--; + mItems.remove(item.mValue); } } // add new - for (I item : newItems) { - item.mValue.addObserver(item); - mItems.put(item.mValue, item); - mModel.addRow(new Object[]{item}); + boolean added = false; + for (V v: values) { + if (!mItems.containsKey(v)) { + I item = this.newItem(v); + item.mValue.addObserver(item); + mItems.put(item.mValue, item); + mModel.addRow(new Object[]{item}); + added = true; + } } + return added; } + protected abstract I newItem(V value); + @SuppressWarnings("unchecked") protected I getDisplayedItemAt(int i) { return (I) mModel.getValueAt(mRowSorter.convertRowIndexToModel(i), 0); @@ -228,6 +298,10 @@ void setSelectedItem(V value) { protected void setSelectedItem(int i) { if (i >= mModel.getRowCount()) return; + + if (i == this.getSelectedRow()) + return; + this.setSelectedRow(i); } @@ -240,7 +314,7 @@ void filterItems(String search) { private void timerUpdate() { for (int i = 0; i < mModel.getRowCount(); i++) { I item = (I) mModel.getValueAt(i, 0); - item.update(null, mTimer); + item.updateOnEDT(mTimer); } } @@ -297,13 +371,15 @@ public void update(Observable o, final Object arg) { SwingUtilities.invokeLater(new Runnable() { @Override public void run() { - Table.this.updateOnEDT(arg); + ListView.this.updateOnEDT(arg); } }); } abstract protected void updateOnEDT(Object arg); + protected void onRenameEvent() {} + abstract class TableItem extends WebPanel implements Observer, Comparable { protected final V mValue; @@ -347,7 +423,7 @@ public void run() { // directly to the item, but the behaviour is buggy so we keep this @Override public String getToolTipText(MouseEvent event) { - Table.this.showTooltip(this); + ListView.this.showTooltip(this); return null; } @@ -357,6 +433,7 @@ public String getToolTipText(MouseEvent event) { private class TableRenderer extends WebTableCellRenderer { // return for each item (value) in the list/table the component to // render - which is the item itself here + // NOTE: table and value can be NULL @Override @SuppressWarnings("unchecked") public Component getTableCellRendererComponent(JTable table, @@ -366,6 +443,9 @@ public Component getTableCellRendererComponent(JTable table, int row, int column) { TableItem item = (TableItem) value; + // hopefully return value is not used + if (table == null || item == null) + return item; item.render(table.getWidth(), isSelected); @@ -373,7 +453,7 @@ public Component getTableCellRendererComponent(JTable table, // view item needs a little more then it preferres height += 1; if (height != table.getRowHeight(row)) - // note: this calls resizeAndRepaint() + // NOTE: this calls resizeAndRepaint() table.setRowHeight(row, height); return item; } diff --git a/src/main/java/org/kontalk/view/MainFrame.java b/src/main/java/org/kontalk/view/MainFrame.java index 3da5b812..049aa5c6 100644 --- a/src/main/java/org/kontalk/view/MainFrame.java +++ b/src/main/java/org/kontalk/view/MainFrame.java @@ -1,6 +1,6 @@ /* * Kontalk Java client - * Copyright (C) 2014 Kontalk Devteam + * Copyright (C) 2016 Kontalk Devteam * * 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 @@ -54,8 +54,9 @@ import static javax.swing.WindowConstants.DO_NOTHING_ON_CLOSE; import javax.swing.event.ChangeEvent; import javax.swing.event.ChangeListener; -import org.kontalk.system.Config; +import org.kontalk.persistence.Config; import org.kontalk.Kontalk; +import org.kontalk.model.Model; import org.kontalk.system.Control; import org.kontalk.util.Tr; @@ -74,9 +75,10 @@ static enum Tab {CHATS, CONTACT}; private final WebToggleButton mAddGroupButton; private final WebToggleButton mAddContactButton; - MainFrame(final View view, - Table contactList, - Table chatList, + MainFrame(View view, + Model model, + ListView contactList, + ListView chatList, Component content, WebPanel searchPanel, Component statusBar) { @@ -134,13 +136,13 @@ public void actionPerformed(ActionEvent event) { konNetMenu.add(mDisconnectMenuItem); konNetMenu.addSeparator(); - WebMenuItem statusMenuItem = new WebMenuItem(Tr.tr("Set status")); + WebMenuItem statusMenuItem = new WebMenuItem(Tr.tr("Profile")); statusMenuItem.setAccelerator(Hotkey.ALT_S); - statusMenuItem.setToolTipText(Tr.tr("Set status text send to other user")); + statusMenuItem.setToolTipText(Tr.tr("Set your user profile")); statusMenuItem.addActionListener(new ActionListener() { @Override public void actionPerformed(ActionEvent event) { - WebDialog statusDialog = new ComponentUtils.StatusDialog(mView); + WebDialog statusDialog = new ProfileDialog(mView, model); statusDialog.setVisible(true); } }); @@ -212,7 +214,7 @@ public void actionPerformed(ActionEvent event) { // TODO different button Utils.getIcon("ic_ui_add.png"), Tr.tr("Create a new group chat"), - new ComponentUtils.AddGroupChatPanel(mView, this)); + new ComponentUtils.AddGroupChatPanel(mView, model, this)); WebPanel chatPanel = new GroupPanel(GroupingType.fillFirst, false, // temporarily disabling group creation chatPane/*, mAddGroupButton*/); @@ -234,7 +236,7 @@ public void actionPerformed(ActionEvent event) { mTabbedPane.setTabComponentAt(Tab.CONTACT.ordinal(), new WebVerticalLabel(Tr.tr("Contacts"))); // setSize() does not work, whatever - mTabbedPane.setPreferredSize(new Dimension(240, -1)); + mTabbedPane.setPreferredSize(new Dimension(View.LISTS_WIDTH, -1)); mTabbedPane.addChangeListener(new ChangeListener() { @Override public void stateChanged(ChangeEvent e) { @@ -318,10 +320,10 @@ private void showAboutDialog() { icon); } - private static WebScrollPane createTablePane(final Table table, + private static WebScrollPane createTablePane(final ListView list, String overlayText) { - WebScrollPane scrollPane = new ComponentUtils.ScrollPane(table); + WebScrollPane scrollPane = new ComponentUtils.ScrollPane(list); scrollPane.setDrawBorder(false); // overlay for empty list WebOverlay listOverlayPanel = new WebOverlay(scrollPane); diff --git a/src/main/java/org/kontalk/view/MessageList.java b/src/main/java/org/kontalk/view/MessageList.java index 41e25005..0c82a9f4 100644 --- a/src/main/java/org/kontalk/view/MessageList.java +++ b/src/main/java/org/kontalk/view/MessageList.java @@ -1,6 +1,6 @@ /* * Kontalk Java client - * Copyright (C) 2014 Kontalk Devteam + * Copyright (C) 2016 Kontalk Devteam * * 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 @@ -33,11 +33,9 @@ import java.awt.FlowLayout; import java.awt.event.ActionEvent; import java.awt.event.ActionListener; -import java.awt.event.MouseAdapter; -import java.awt.event.MouseEvent; import java.nio.file.Path; +import java.nio.file.Paths; import java.util.Date; -import java.util.HashSet; import java.util.Optional; import java.util.Set; import java.util.logging.Level; @@ -58,15 +56,17 @@ import javax.swing.text.StyledEditorKit; import javax.swing.text.ViewFactory; import org.kontalk.crypto.Coder; -import org.kontalk.model.InMessage; -import org.kontalk.model.KonMessage; -import org.kontalk.model.Chat; -import org.kontalk.model.CoderStatus; -import org.kontalk.model.MessageContent; +import org.kontalk.model.message.InMessage; +import org.kontalk.model.message.KonMessage; +import org.kontalk.model.chat.Chat; +import org.kontalk.model.message.CoderStatus; +import org.kontalk.model.message.MessageContent; import org.kontalk.model.Contact; -import org.kontalk.model.MessageContent.Attachment; -import org.kontalk.model.MessageContent.GroupCommand; -import org.kontalk.model.Transmission; +import org.kontalk.model.chat.Member; +import org.kontalk.model.message.MessageContent.Attachment; +import org.kontalk.model.message.MessageContent.GroupCommand; +import org.kontalk.model.message.OutMessage; +import org.kontalk.model.message.Transmission; import org.kontalk.util.Tr; import org.kontalk.view.ChatView.Background; import org.kontalk.view.ComponentUtils.AttachmentPanel; @@ -74,9 +74,10 @@ /** * View all messages of one chat in a left/right MIM style list. + * * @author Alexander Bikadorov {@literal } */ -final class MessageList extends Table { +final class MessageList extends ListView { private static final Logger LOGGER = Logger.getLogger(MessageList.class.getName()); private static final Icon PENDING_ICON = Utils.getIcon("ic_msg_pending.png");; @@ -96,8 +97,11 @@ final class MessageList extends Table { mChatView = chatView; mChat = chat; + // disable selection + this.setSelectionModel(new UnselectableListModel()); + // use custom editor (for mouse events) - this.setDefaultEditor(Table.TableItem.class, new TableEditor()); + this.setDefaultEditor(ListView.TableItem.class, new TableEditor()); //this.setEditable(false); //this.setAutoscrolls(true); @@ -106,26 +110,6 @@ final class MessageList extends Table { // hide grid this.setShowGrid(false); - // disable selection - this.setSelectionModel(new UnselectableListModel()); - - // actions triggered by mouse events - this.addMouseListener(new MouseAdapter() { - @Override - public void mousePressed(MouseEvent e) { - check(e); - } - @Override - public void mouseReleased(MouseEvent e) { - check(e); - } - private void check(MouseEvent e) { - if (e.isPopupTrigger()) { - MessageList.this.showPopupMenu(e); - } - } - }); - this.setBackground(mChat.getViewSettings()); this.setVisible(false); @@ -146,7 +130,7 @@ protected void updateOnEDT(Object arg) { if (arg instanceof Set || arg instanceof String || arg instanceof Boolean || - arg instanceof Chat.KonChatState) { + arg instanceof Member) { // contacts, subject, read status or chat state changed, nothing // to do here return; @@ -161,48 +145,26 @@ protected void updateOnEDT(Object arg) { return; } - if (arg instanceof KonMessage) { - this.insertMessage((KonMessage) arg); - } else { - // check for new messages to add - if (this.getModel().getRowCount() < mChat.getMessages().size()) - this.insertMessages(); + // check for new messages to add + if (this.getModel().getRowCount() < mChat.getMessages().size()) { + this.insertMessages(); } - if (mChatView.getCurrentChat().orElse(null) == mChat) { + if (!mChat.isRead() && mChatView.getCurrentChat().orElse(null) == mChat) { mChat.setRead(); } } private void insertMessages() { - Set newItems = new HashSet<>(); - Set messages = mChat.getMessages().getAll(); - for (KonMessage message: messages) { - if (!this.containsValue(message)) { - newItems.add(new MessageItem(message)); - // trigger scrolling - mChatView.setScrolling(); - } - } - this.sync(messages, newItems); + boolean newAdded = this.sync(mChat.getMessages().getAll()); + if (newAdded) + // trigger scrolling + mChatView.setScrolling(); } - private void insertMessage(KonMessage message) { - Set newItems = new HashSet<>(); - newItems.add(new MessageItem(message)); - this.sync(mChat.getMessages().getAll(), newItems); - // trigger scrolling - mChatView.setScrolling(); - } - - private void showPopupMenu(MouseEvent e) { - int row = this.rowAtPoint(e.getPoint()); - if (row < 0) - return; - - MessageItem messageView = this.getDisplayedItemAt(row); - WebPopupMenu popupMenu = messageView.getPopupMenu(); - popupMenu.show(this, e.getX(), e.getY()); + @Override + protected MessageItem newItem(KonMessage value) { + return new MessageItem(value); } private void setBackground(Chat.ViewSettings s) { @@ -210,12 +172,65 @@ private void setBackground(Chat.ViewSettings s) { mBackground = mChatView.createBG(s); } + @Override + protected WebPopupMenu rightClickMenu(MessageItem item) { + WebPopupMenu menu = new WebPopupMenu(); + + final KonMessage m = item.mValue; + if (m instanceof InMessage) { + InMessage im = (InMessage) m; + if (m.isEncrypted()) { + WebMenuItem decryptMenuItem = new WebMenuItem(Tr.tr("Decrypt")); + decryptMenuItem.setToolTipText(Tr.tr("Retry decrypting message")); + decryptMenuItem.addActionListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent event) { + mView.getControl().decryptAgain(im); + } + }); + menu.add(decryptMenuItem); + } + Attachment att = m.getContent().getAttachment().orElse(null); + if (att != null && + att.getFilePath().toString().isEmpty()) { + WebMenuItem attMenuItem = new WebMenuItem(Tr.tr("Load")); + attMenuItem.setToolTipText(Tr.tr("Retry downloading attachment")); + attMenuItem.addActionListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent event) { + mView.getControl().downloadAgain(im); + } + }); + menu.add(attMenuItem); + } + } else if (m instanceof OutMessage) { + if (m.getStatus() == KonMessage.Status.ERROR) { + WebMenuItem sendMenuItem = new WebMenuItem(Tr.tr("Retry")); + sendMenuItem.setToolTipText(Tr.tr("Retry sending message")); + sendMenuItem.addActionListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent event) { + mView.getControl().sendAgain((OutMessage) m); + } + }); + menu.add(sendMenuItem); + } + } + + WebMenuItem cItem = Utils.createCopyMenuItem( + toCopyString(m), + Tr.tr("Copy message content")); + menu.add(cItem); + + return menu; + } + /** * View for one message. * The content is added to a panel inside this panel. For performance * reasons the content is created when the item is rendered in the table */ - final class MessageItem extends Table.TableItem { + final class MessageItem extends ListView.TableItem { private WebPanel mPanel; private WebLabel mFromLabel = null; @@ -250,7 +265,7 @@ private void createContent() { // from label if (mValue.isInMessage() && mValue.getChat().isGroupChat()) { mFromLabel = new WebLabel(); - mFromLabel.setFontSize(12); + mFromLabel.setFontSize(View.FONT_SIZE_SMALL); mFromLabel.setForeground(Color.BLUE); mFromLabel.setItalicFont(); mPanel.add(mFromLabel, BorderLayout.NORTH); @@ -263,7 +278,7 @@ private void createContent() { mTextPane = new WebTextPane(); mTextPane.setEditable(false); mTextPane.setOpaque(false); - //mTextPane.setFontSize(12); + //mTextPane.setFontSize(View.FONT_SIZE_SMALL); // sets default font mTextPane.putClientProperty(WebEditorPane.HONOR_DISPLAY_PROPERTIES, true); //for detecting clicks @@ -297,9 +312,13 @@ private void createContent() { } mStatusPanel.add(encryptIconLabel); // date label - WebLabel dateLabel = new WebLabel(Utils.SHORT_DATE_FORMAT.format(mValue.getDate())); + Date statusDate = mValue.isInMessage() ? + mValue.getServerDate().orElse(mValue.getDate()) : + mValue.getDate(); + WebLabel dateLabel = new WebLabel( + Utils.SHORT_DATE_FORMAT.format(statusDate)); dateLabel.setForeground(Color.GRAY); - dateLabel.setFontSize(11); + dateLabel.setFontSize(View.FONT_SIZE_TINY); mStatusPanel.add(dateLabel); WebPanel southPanel = new WebPanel(); @@ -407,9 +426,9 @@ private void updateStatus() { boolean isOut = !mValue.isInMessage(); Date deliveredDate = null; - Transmission[] transmissions = mValue.getTransmissions(); - if (transmissions.length == 1) - deliveredDate = transmissions[0].getReceivedDate().orElse(null); + Set transmissions = mValue.getTransmissions(); + if (transmissions.size() == 1) + deliveredDate = transmissions.stream().findFirst().get().getReceivedDate().orElse(null); // status icon if (isOut) { @@ -482,9 +501,9 @@ private void updateStatus() { } else { // IN message Date receivedDate = mValue.getDate(); String rec = Utils.MID_DATE_FORMAT.format(receivedDate); - Optional sentDate = mValue.getServerDate(); - if (sentDate.isPresent()) { - String sent = Utils.MID_DATE_FORMAT.format(sentDate.get()); + Date sentDate = mValue.getServerDate().orElse(null); + if (sentDate != null) { + String sent = Utils.MID_DATE_FORMAT.format(sentDate); if (!sent.equals(rec)) html += Tr.tr("Sent:")+ " " + sent + "
"; } @@ -513,7 +532,7 @@ else if (enc == Coder.Encryption.DECRYPTED && String verification = Tr.tr("Unknown"); switch (sign) { case NOT: verification = Tr.tr("Not signed"); break; - case SIGNED: verification = Tr.tr("Signed"); break; + case SIGNED: verification = Tr.tr("Not verified"); break; case VERIFIED: verification = Tr.tr("Verified"); break; } sec = encryption + " / " + verification; @@ -543,10 +562,9 @@ else if (enc == Coder.Encryption.DECRYPTED && // attachment / image, note: loading many images is very slow private void updateAttachment() { - Optional optAttachment = mValue.getContent().getAttachment(); - if (!optAttachment.isPresent()) + Attachment att = mValue.getContent().getAttachment().orElse(null); + if (att == null) return; - Attachment att = optAttachment.get(); if (mAttPanel == null) { mAttPanel = new AttachmentPanel(); @@ -554,14 +572,13 @@ private void updateAttachment() { } // image thumbnail preview - Optional optImagePath = mView.getControl().getImagePath(mValue); - String imagePath = optImagePath.isPresent() ? optImagePath.get().toString() : ""; + Path imagePath = mView.getControl().getImagePath(mValue).orElse(Paths.get("")); mAttPanel.setImage(imagePath); // link to the file Path linkPath = mView.getControl().getFilePath(att); if (!linkPath.toString().isEmpty()) { - mAttPanel.setLink(imagePath.isEmpty() ? + mAttPanel.setLink(imagePath.toString().isEmpty() ? linkPath.getFileName().toString() : "", linkPath); @@ -578,51 +595,6 @@ private void updateAttachment() { } } - private WebPopupMenu getPopupMenu() { - WebPopupMenu popupMenu = new WebPopupMenu(); - final KonMessage m = MessageItem.this.mValue; - if (m instanceof InMessage) { - if (m.isEncrypted()) { - WebMenuItem decryptMenuItem = new WebMenuItem(Tr.tr("Decrypt")); - decryptMenuItem.setToolTipText(Tr.tr("Retry decrypting message")); - decryptMenuItem.addActionListener(new ActionListener() { - @Override - public void actionPerformed(ActionEvent event) { - mView.getControl().decryptAgain((InMessage) m); - } - }); - popupMenu.add(decryptMenuItem); - } - Optional optAtt = m.getContent().getAttachment(); - if (optAtt.isPresent() && - optAtt.get().getFile().toString().isEmpty()) { - WebMenuItem attMenuItem = new WebMenuItem(Tr.tr("Load")); - attMenuItem.setToolTipText(Tr.tr("Retry downloading attachment")); - attMenuItem.addActionListener(new ActionListener() { - @Override - public void actionPerformed(ActionEvent event) { - mView.getControl().downloadAgain((InMessage) m); - } - }); - popupMenu.add(attMenuItem); - } - } - - WebMenuItem cItem = Utils.createCopyMenuItem( - this.toCopyString(), - Tr.tr("Copy message content")); - popupMenu.add(cItem); - return popupMenu; - } - - private String toCopyString() { - String date = Utils.LONG_DATE_FORMAT.format(mValue.getDate()); - String from = mValue instanceof InMessage ? - getFromString((InMessage) mValue) : - Tr.tr("me"); - return date + " - " + from + " : " + mValue.getContent().getText(); - } - @Override protected boolean contains(String search) { if (mValue.getContent().getText().toLowerCase().contains(search)) @@ -654,14 +626,14 @@ public int compareTo(TableItem o) { // needed for correct mouse behaviour for components in items // (and breaks selection behaviour somehow) private class TableEditor extends AbstractCellEditor implements TableCellEditor { - private Table.TableItem mValue; + private ListView.TableItem mValue; @Override public Component getTableCellEditorComponent(JTable table, Object value, boolean isSelected, int row, int column) { - mValue = (Table.TableItem) value; + mValue = (ListView.TableItem) value; return mValue; } @Override @@ -674,6 +646,14 @@ private static String getFromString(InMessage message) { return Utils.displayName(message.getContact(), message.getJID(), 40); } + private static String toCopyString(KonMessage m) { + String date = Utils.LONG_DATE_FORMAT.format(m.getDate()); + String from = m instanceof InMessage ? + getFromString((InMessage) m) : + Tr.tr("me"); + return date + " - " + from + " : " + m.getContent().getText(); + } + private static final WrapEditorKit FIX_WRAP_KIT = new WrapEditorKit(); /** diff --git a/src/main/java/org/kontalk/view/Notifier.java b/src/main/java/org/kontalk/view/Notifier.java index e0a11da4..4d8824f0 100644 --- a/src/main/java/org/kontalk/view/Notifier.java +++ b/src/main/java/org/kontalk/view/Notifier.java @@ -1,6 +1,6 @@ /* * Kontalk Java client - * Copyright (C) 2014 Kontalk Devteam + * Copyright (C) 2016 Kontalk Devteam * * 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 @@ -41,9 +41,10 @@ import org.kontalk.crypto.Coder; import org.kontalk.crypto.PGPUtils; import org.kontalk.misc.KonException; +import org.kontalk.misc.ViewEvent; import org.kontalk.model.Contact; -import org.kontalk.model.InMessage; -import org.kontalk.model.KonMessage; +import org.kontalk.model.message.InMessage; +import org.kontalk.model.message.KonMessage; import org.kontalk.system.RosterHandler; import org.kontalk.util.MediaUtils; import org.kontalk.util.Tr; @@ -111,14 +112,7 @@ void showSecurityErrors(KonMessage message) { } void showPresenceError(Contact contact, RosterHandler.Error error) { - WebPanel panel = new GroupPanel(GAP_DEFAULT, false); - panel.setOpaque(false); - - panel.add(new WebLabel(Tr.tr("Contact error")).setBoldFont()); - panel.add(new WebSeparator(true, true)); - - panel.add(new WebLabel(Tr.tr("Contact:")).setBoldFont()); - panel.add(new WebLabel(contactText(contact))); + WebPanel panel = panel(Tr.tr("Contact error"), contact); panel.add(new WebLabel(Tr.tr("Error:")).setBoldFont()); String errorText = Tr.tr(error.toString()); @@ -134,14 +128,7 @@ void showPresenceError(Contact contact, RosterHandler.Error error) { } void confirmNewKey(final Contact contact, final PGPUtils.PGPCoderKey key) { - WebPanel panel = new GroupPanel(GAP_DEFAULT, false); - panel.setOpaque(false); - - panel.add(new WebLabel(Tr.tr("Received new key for contact")).setBoldFont()); - panel.add(new WebSeparator(true, true)); - - panel.add(new WebLabel(Tr.tr("Contact:")).setBoldFont()); - panel.add(new WebLabel(contactText(contact))); + WebPanel panel = panel(Tr.tr("Received new key for contact"), contact); panel.add(new WebLabel(Tr.tr("Key fingerprint:"))); WebTextArea fpArea = Utils.createFingerprintArea(); @@ -174,16 +161,10 @@ public void closed() {} } void confirmContactDeletion(final Contact contact) { - WebPanel panel = new GroupPanel(GAP_DEFAULT, false); - panel.setOpaque(false); - - panel.add(new WebLabel(Tr.tr("Contact was deleted on server")).setBoldFont()); - panel.add(new WebSeparator(true, true)); - - panel.add(new WebLabel(contactText(contact)).setBoldFont()); + WebPanel panel = panel(Tr.tr("Contact was deleted on server"), contact); String expl = Tr.tr("Remove this contact from your contact list?") + "\n" + - View.REMOVE_CONTACT_NOTE; + mView.tr_remove_contact; panel.add(textArea(expl)); WebNotificationPopup popup = NotificationManager.showNotification(panel, @@ -205,6 +186,36 @@ public void closed() {} }); } + void confirmSubscription(ViewEvent.SubscriptionRequest event){ + final Contact contact = event.contact; + + WebPanel panel = panel(Tr.tr("Authorization request"), contact); + + String expl = Tr.tr("When accepting, this contact will be able to see your online status."); + panel.add(textArea(expl)); + + WebNotificationPopup popup = NotificationManager.showNotification(panel, + NotificationOption.accept, NotificationOption.decline, + NotificationOption.cancel); + popup.setClickToClose(false); + popup.addNotificationListener(new NotificationListener() { + @Override + public void optionSelected(NotificationOption option) { + switch (option) { + case accept : + mView.getControl().sendSubscriptionResponse(contact, true); + break; + case decline : + mView.getControl().sendSubscriptionResponse(contact, false); + } + } + @Override + public void accepted() {} + @Override + public void closed() {} + }); + } + // TODO not used private void showNotification() { final WebDialog dialog = new WebDialog(); @@ -234,7 +245,7 @@ public void closed() { panel.setMargin(View.MARGIN_DEFAULT); panel.setOpaque(false); WebLabel title = new WebLabel("A new Message!"); - title.setFontSize(14); + title.setFontSize(View.FONT_SIZE_BIG); title.setForeground(Color.WHITE); panel.add(title, BorderLayout.NORTH); String text = "this is some message, and some longer text was added"; @@ -262,6 +273,19 @@ public void closed() { NotificationManager.showNotification(dialog, popup); } + private static WebPanel panel(String title, Contact contact) { + WebPanel panel = new GroupPanel(GAP_DEFAULT, false); + panel.setOpaque(false); + + panel.add(new WebLabel(title).setBoldFont()); + panel.add(new WebSeparator(true, true)); + + panel.add(new WebLabel(Tr.tr("Contact:")).setBoldFont()); + panel.add(new WebLabel(contactText(contact))); + + return panel; + } + private static String contactText(Contact contact){ return Utils.name(contact, 20) + " < " + Utils.jid(contact.getJID(), 30)+" >"; } diff --git a/src/main/java/org/kontalk/view/ProfileDialog.java b/src/main/java/org/kontalk/view/ProfileDialog.java new file mode 100644 index 00000000..6cc968a4 --- /dev/null +++ b/src/main/java/org/kontalk/view/ProfileDialog.java @@ -0,0 +1,232 @@ +/* + * Kontalk Java client + * Copyright (C) 2016 Kontalk Devteam + * + * 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 . + */ + +package org.kontalk.view; + +import com.alee.extended.image.WebImage; +import com.alee.extended.panel.GroupPanel; +import com.alee.laf.button.WebButton; +import com.alee.laf.filechooser.WebFileChooser; +import com.alee.laf.label.WebLabel; +import com.alee.laf.list.WebList; +import com.alee.laf.menu.WebMenuItem; +import com.alee.laf.menu.WebPopupMenu; +import com.alee.laf.rootpane.WebDialog; +import com.alee.laf.scroll.WebScrollPane; +import com.alee.laf.separator.WebSeparator; +import com.alee.laf.text.WebTextField; +import com.alee.managers.tooltip.TooltipManager; +import com.alee.utils.ImageUtils; +import com.alee.utils.filefilter.ImageFilesFilter; +import java.awt.BorderLayout; +import java.awt.FlowLayout; +import java.awt.event.ActionEvent; +import java.awt.event.ActionListener; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.awt.image.BufferedImage; +import java.io.File; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import javax.swing.event.ListSelectionEvent; +import javax.swing.event.ListSelectionListener; +import org.apache.commons.lang.ObjectUtils; +import org.kontalk.client.FeatureDiscovery; +import org.kontalk.model.Model; +import org.kontalk.persistence.Config; +import org.kontalk.system.Control; +import org.kontalk.util.MediaUtils; +import org.kontalk.util.Tr; + +/** + * The User profile page. With avatar and status text. + * @author Alexander Bikadorov {@literal } + */ +final class ProfileDialog extends WebDialog { + + private static final int AVATAR_SIZE = 150; + + private final View mView; + private final WebFileChooser mImgChooser; + private final WebImage mAvatarImage; + private final BufferedImage mOldImage; + private final WebTextField mStatusField; + private final WebList mStatusList; + + private BufferedImage mNewImage = null; + + ProfileDialog(View view, Model model) { + mView = view; + + this.setTitle(Tr.tr("User Profile")); + this.setResizable(false); + this.setModal(true); + + GroupPanel groupPanel = new GroupPanel(View.GAP_DEFAULT, false); + groupPanel.setMargin(View.MARGIN_BIG); + + groupPanel.add(new WebLabel(Tr.tr("Edit your profile")).setBoldFont()); + groupPanel.add(new WebSeparator(true, true)); + + // avatar + mImgChooser = new WebFileChooser(); + mImgChooser.setFileFilter(new ImageFilesFilter()); + + groupPanel.add(new WebLabel(Tr.tr("Your profile picture:"))); + + mAvatarImage = new WebImage(); + mOldImage = mNewImage = model.userAvatar().loadImage().orElse(null); + this.setImage(mOldImage); + + //mAvatarImage.setDisplayType(DisplayType.fitComponent); + //setTransferHandler ( new ImageDragHandler ( image1, i1 ) ); + + // permanent, user has to re-open the dialog on change + final boolean supported = mView.serverFeatures().contains(FeatureDiscovery.Feature.USER_AVATAR); + mAvatarImage.addMouseListener(new MouseAdapter() { + @Override + public void mousePressed(MouseEvent e) { + check(e); + } + @Override + public void mouseReleased(MouseEvent e) { + check(e); + } + private void check(MouseEvent e) { + if (supported && e.isPopupTrigger()) { + ProfileDialog.this.showPopupMenu(e); + } + } + @Override + public void mouseClicked(MouseEvent e) { + if (supported && e.getButton() == MouseEvent.BUTTON1) { + ProfileDialog.this.chooseAvatar(); + } + } + }); + mAvatarImage.setEnabled(supported); + if (!supported) + TooltipManager.addTooltip(mAvatarImage, + mView.currentStatus() != Control.Status.CONNECTED ? + Tr.tr("Not connected") : + mView.tr_not_supported); + + groupPanel.add(mAvatarImage); + groupPanel.add(new WebSeparator(true, true)); + + // status text + String[] strings = Config.getInstance().getStringArray(Config.NET_STATUS_LIST); + List stats = new ArrayList<>(Arrays.asList(strings)); + String currentStatus = !stats.isEmpty() ? stats.remove(0) : ""; + + groupPanel.add(new WebLabel(Tr.tr("Your current status:"))); + mStatusField = new WebTextField(currentStatus, 30); + mStatusField.setToolTipText(Tr.tr("Set status text send to other user")); + groupPanel.add(mStatusField); + groupPanel.add(new WebSeparator(true, true)); + + groupPanel.add(new WebLabel(Tr.tr("Previously used:"))); + mStatusList = new WebList(stats); + mStatusList.setMultiplySelectionAllowed(false); + mStatusList.addListSelectionListener(new ListSelectionListener() { + @Override + public void valueChanged(ListSelectionEvent e) { + if (e.getValueIsAdjusting()) + return; + mStatusField.setText(mStatusList.getSelectedValue().toString()); + } + }); + WebScrollPane listScrollPane = new ComponentUtils.ScrollPane(mStatusList); + groupPanel.add(listScrollPane); + this.add(groupPanel, BorderLayout.CENTER); + + // buttons + WebButton cancelButton = new WebButton(Tr.tr("Cancel")); + cancelButton.addActionListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent e) { + ProfileDialog.this.dispose(); + } + }); + final WebButton saveButton = new WebButton(Tr.tr("Save")); + saveButton.addActionListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent e) { + ProfileDialog.this.save(); + ProfileDialog.this.dispose(); + } + }); + this.getRootPane().setDefaultButton(saveButton); + + GroupPanel buttonPanel = new GroupPanel(2, cancelButton, saveButton); + buttonPanel.setLayout(new FlowLayout(FlowLayout.TRAILING)); + this.add(buttonPanel, BorderLayout.SOUTH); + + this.pack(); + } + + private void setImage(BufferedImage avatar) { + mAvatarImage.setImage(avatar != null ? + avatar : + AvatarLoader.createFallback(AVATAR_SIZE)); + } + + private void chooseAvatar() { + int state = mImgChooser.showOpenDialog(this); + if (state != WebFileChooser.APPROVE_OPTION) + return; + + File imgFile = mImgChooser.getSelectedFile(); + if (!imgFile.isFile()) + return; + + BufferedImage img = MediaUtils.readImage(imgFile).orElse(null); + if (img == null) + return; + + mNewImage = ImageUtils.createPreviewImage(img, AVATAR_SIZE); + mAvatarImage.setImage(mNewImage); + } + + private void save() { + if (!ObjectUtils.equals(mOldImage, mNewImage)) { + if (mNewImage != null) { + mView.getControl().setUserAvatar(mNewImage); + } else { + mView.getControl().unsetUserAvatar(); + } + } + + mView.getControl().setStatusText(mStatusField.getText()); + } + + private void showPopupMenu(MouseEvent e) { + WebPopupMenu menu = new WebPopupMenu(); + WebMenuItem removeItem = new WebMenuItem(Tr.tr("Remove")); + removeItem.addActionListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent event) { + ProfileDialog.this.setImage(mNewImage = null); + } + }); + removeItem.setEnabled(mNewImage != null); + menu.add(removeItem); + menu.show(mAvatarImage, e.getX(), e.getY()); + } +} diff --git a/src/main/java/org/kontalk/view/SearchPanel.java b/src/main/java/org/kontalk/view/SearchPanel.java index 48247e5d..0b901667 100644 --- a/src/main/java/org/kontalk/view/SearchPanel.java +++ b/src/main/java/org/kontalk/view/SearchPanel.java @@ -1,6 +1,6 @@ /* * Kontalk Java client - * Copyright (C) 2014 Kontalk Devteam + * Copyright (C) 2016 Kontalk Devteam * * 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 @@ -36,7 +36,7 @@ final class SearchPanel extends WebPanel { private final WebTextField mSearchField; - SearchPanel(final Table[] tables, final ChatView chatView) { + SearchPanel(final ListView[] lists, final ChatView chatView) { mSearchField = new WebTextField(); mSearchField.setInputPrompt(Tr.tr("Search…")); mSearchField.getDocument().addDocumentListener(new DocumentListener() { @@ -54,8 +54,8 @@ public void changedUpdate(DocumentEvent e) { } private void filterList() { String searchText = mSearchField.getText().toLowerCase(); - for (Table table : tables) - table.filterItems(searchText); + for (ListView list : lists) + list.filterItems(searchText); chatView.filterCurrentChat(searchText); } }); diff --git a/src/main/java/org/kontalk/view/TrayManager.java b/src/main/java/org/kontalk/view/TrayManager.java index 57a915ca..b311a216 100644 --- a/src/main/java/org/kontalk/view/TrayManager.java +++ b/src/main/java/org/kontalk/view/TrayManager.java @@ -1,6 +1,6 @@ /* * Kontalk Java client - * Copyright (C) 2014 Kontalk Devteam + * Copyright (C) 2016 Kontalk Devteam * * 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 @@ -35,8 +35,8 @@ import java.util.logging.Level; import java.util.logging.Logger; import javax.swing.SwingUtilities; -import org.kontalk.model.ChatList; -import org.kontalk.system.Config; +import org.kontalk.model.Model; +import org.kontalk.persistence.Config; import org.kontalk.util.Tr; /** @@ -50,11 +50,13 @@ final class TrayManager implements Observer { static final Image NOTIFICATION_TRAY = Utils.getImage("kontalk_notification.png"); private final View mView; + private final Model mModel; private final MainFrame mMainFrame; private TrayIcon mTrayIcon = null; - TrayManager(View view, MainFrame mainFrame) { + TrayManager(View view, Model model, MainFrame mainFrame) { mView = view; + mModel = model; mMainFrame = mainFrame; this.setTray(); } @@ -71,7 +73,7 @@ void setTray() { } if (mTrayIcon == null) - mTrayIcon = createTrayIcon(mView, mMainFrame); + mTrayIcon = this.createTrayIcon(); SystemTray tray = SystemTray.getSystemTray(); if (tray.getTrayIcons().length > 0) @@ -111,20 +113,20 @@ private void updateOnEDT(Object arg) { mTrayIcon.setImage(getTrayImage()); } - private static Image getTrayImage() { - return ChatList.getInstance().isUnread() ? + private Image getTrayImage() { + return mModel.chats().isUnread() ? NOTIFICATION_TRAY : NORMAL_TRAY ; } - private static TrayIcon createTrayIcon(final View view, final MainFrame mainFrame) { + private TrayIcon createTrayIcon() { // popup menu outside of frame, officially not supported final WebPopupMenu popup = new WebPopupMenu(); WebMenuItem quitItem = new WebMenuItem(Tr.tr("Quit")); quitItem.addActionListener(new ActionListener() { @Override public void actionPerformed(ActionEvent event) { - view.callShutDown(); + mView.callShutDown(); } }); popup.add(quitItem); @@ -143,7 +145,7 @@ public void mousePressed(MouseEvent e) { @Override public void mouseReleased(MouseEvent e) { if (e.getButton() == MouseEvent.BUTTON1) - mainFrame.toggleState(); + mMainFrame.toggleState(); else check(e); } @@ -161,7 +163,7 @@ private void check(MouseEvent e) { } }; - TrayIcon trayIcon = new TrayIcon(getTrayImage(), "Kontalk" /*, popup*/); + TrayIcon trayIcon = new TrayIcon(this.getTrayImage(), "Kontalk" /*, popup*/); trayIcon.setImageAutoSize(true); trayIcon.addMouseListener(listener); return trayIcon; diff --git a/src/main/java/org/kontalk/view/Utils.java b/src/main/java/org/kontalk/view/Utils.java index 5ee72b91..ed36385f 100644 --- a/src/main/java/org/kontalk/view/Utils.java +++ b/src/main/java/org/kontalk/view/Utils.java @@ -1,6 +1,6 @@ /* * Kontalk Java client - * Copyright (C) 2014 Kontalk Devteam + * Copyright (C) 2016 Kontalk Devteam * * 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 @@ -19,6 +19,7 @@ package org.kontalk.view; import com.alee.extended.filechooser.WebFileChooserField; +import com.alee.extended.image.WebImage; import com.alee.laf.menu.WebMenuItem; import com.alee.laf.menu.WebPopupMenu; import com.alee.laf.optionpane.WebOptionPane; @@ -46,6 +47,7 @@ import java.util.ArrayList; import java.util.Comparator; import java.util.List; +import java.util.Set; import java.util.logging.Level; import java.util.logging.Logger; import javax.net.ssl.SSLHandshakeException; @@ -57,8 +59,12 @@ import org.jxmpp.util.XmppStringUtils; import org.kontalk.misc.JID; import org.kontalk.misc.KonException; -import org.kontalk.model.Chat; +import org.kontalk.model.chat.Chat; import org.kontalk.model.Contact; +import org.kontalk.model.ContactList; +import org.kontalk.model.chat.Member; +import org.kontalk.persistence.Config; +import org.kontalk.util.EncodingUtils; import org.kontalk.util.Tr; import org.ocpsoft.prettytime.PrettyTime; @@ -136,7 +142,7 @@ static WebTextArea createFingerprintArea() { WebTextArea area = new WebTextArea(); area.setEditable(false); area.setOpaque(false); - area.setFontName(Font.MONOSPACED); + area.setFontName(Font.DIALOG); area.setFontSizeAndStyle(13, true, false); return area; } @@ -170,6 +176,16 @@ static Image getImage(String fileName) { return Toolkit.getDefaultToolkit().createImage(imageUrl); } + static void fixedSetWebImageImage(WebImage webImage, Image img) { + // works cause caching + if (!img.equals(webImage.getImage())) { + // + webImage.setEnabled(false); + webImage.setImage(img); + webImage.setEnabled(true); + } + } + /* strings */ static String name(Contact contact, int maxLength) { @@ -182,7 +198,7 @@ static String displayName(Contact contact) { return displayName(contact, Integer.MAX_VALUE); } - private static String displayName(Contact contact, int maxLength) { + static String displayName(Contact contact, int maxLength) { return displayName(contact, contact.getJID(), maxLength); } @@ -225,11 +241,12 @@ static String chatTitle(Chat chat) { String subj = chat.getSubject(); return !subj.isEmpty() ? subj : Tr.tr("Group Chat"); } else { - return Utils.displayNames(new ArrayList<>(chat.getAllContacts())); + return Utils.displayNames(chat.getAllContacts()); } } static String fingerprint(String fp) { + fp = fp.toUpperCase(); int m = fp.length() / 2; return group(fp.substring(0, m)) + "\n" + group(fp.substring(m)); } @@ -238,6 +255,13 @@ private static String group(String s) { return StringUtils.join(s.split("(?<=\\G.{" + 4 + "})"), " "); } + static String role(Member.Role role) { + switch (role) { + case OWNER : return "[" + Tr.tr("Group Owner") + "]"; + default: return ""; + } + } + static String mainStatus(Contact c, boolean pre) { Contact.Subscription subStatus = c.getSubScription(); return c.isMe() ? Tr.tr("Myself") : @@ -257,7 +281,7 @@ static String lastSeen(Contact contact, boolean pretty, boolean pre) { } static String getErrorText(KonException ex) { - String eol = " " + System.getProperty("line.separator"); + String eol = " " + EncodingUtils.EOL; String errorText; switch (ex.getError()) { case IMPORT_ARCHIVE: @@ -357,6 +381,11 @@ static boolean confirmDeletion(Component parent, String text) { return selectedOption == WebOptionPane.OK_OPTION; } + static Set allContacts(ContactList contactList) { + boolean showMe = Config.getInstance().getBoolean(Config.VIEW_USER_CONTACT); + return contactList.getAll(showMe); + } + static List contactList(Chat chat) { List contacts = new ArrayList<>(chat.getAllContacts()); contacts.sort(new Comparator() { @@ -368,6 +397,17 @@ public int compare(Contact c1, Contact c2) { return contacts; } + static List memberList(Chat chat) { + List members = new ArrayList<>(chat.getAllMembers()); + members.sort(new Comparator() { + @Override + public int compare(Member m1, Member m2) { + return Utils.compareContacts(m1.getContact(), m2.getContact()); + } + }); + return members; + } + static int compareContacts(Contact c1, Contact c2) { if (c1.isMe()) return +1; if (c2.isMe()) return -1; diff --git a/src/main/java/org/kontalk/view/View.java b/src/main/java/org/kontalk/view/View.java index 080237fc..41d7d6eb 100644 --- a/src/main/java/org/kontalk/view/View.java +++ b/src/main/java/org/kontalk/view/View.java @@ -1,6 +1,6 @@ /* * Kontalk Java client - * Copyright (C) 2014 Kontalk Devteam + * Copyright (C) 2016 Kontalk Devteam * * 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 @@ -33,22 +33,25 @@ import java.util.Observer; import java.util.Optional; import java.util.concurrent.Callable; -import java.util.concurrent.ExecutionException; import java.util.concurrent.FutureTask; -import java.util.logging.Level; import java.util.logging.Logger; import javax.swing.JDialog; import javax.swing.SwingUtilities; import javax.swing.ToolTipManager; import java.awt.BorderLayout; -import org.kontalk.system.Config; +import java.awt.Dimension; +import java.util.EnumSet; +import java.util.concurrent.ExecutionException; +import java.util.logging.Level; +import org.kontalk.client.FeatureDiscovery; +import org.kontalk.persistence.Config; import org.kontalk.misc.ViewEvent; -import org.kontalk.model.Chat; -import org.kontalk.model.ChatList; +import org.kontalk.model.chat.Chat; import org.kontalk.model.Contact; -import org.kontalk.model.ContactList; +import org.kontalk.model.Model; import org.kontalk.system.Control; import org.kontalk.system.Control.ViewControl; +import org.kontalk.util.EncodingUtils; import org.kontalk.util.Tr; /** @@ -59,6 +62,8 @@ public final class View implements Observer { private static final Logger LOGGER = Logger.getLogger(View.class.getName()); + static final int LISTS_WIDTH = 270; + static final int GAP_DEFAULT = 10; static final int GAP_BIG = 15; static final int GAP_SMALL = 5; @@ -66,6 +71,12 @@ public final class View implements Observer { static final int MARGIN_BIG = 15; static final int MARGIN_SMALL = 5; + static final int FONT_SIZE_TINY = 11; + static final int FONT_SIZE_SMALL = 12; + static final int FONT_SIZE_NORMAL = 13; + static final int FONT_SIZE_BIG = 14; + static final int FONT_SIZE_HUGE = 16; + static final int MAX_SUBJ_LENGTH = 30; static final int MAX_NAME_LENGTH = 60; static final int MAX_JID_LENGTH = 100; @@ -77,11 +88,12 @@ public final class View implements Observer { static final Color LIGHT_GREEN = new Color(220, 250, 220); static final Color DARK_GREEN = new Color(0, 100, 0); - static final String REMOVE_CONTACT_NOTE = Tr.tr("Chats and messages will not be deleted."); + static final Dimension AVATAR_LIST_DIM = new Dimension(30, 30); private final ViewControl mControl; - private final TrayManager mTrayManager; + private final Model mModel; + private final TrayManager mTrayManager; private final Notifier mNotifier; private final SearchPanel mSearchPanel; @@ -92,51 +104,71 @@ public final class View implements Observer { private final WebStatusLabel mStatusBarLabel; private final MainFrame mMainFrame; - private View(ViewControl control) { + final String tr_remove_contact = Tr.tr("Chats and messages will not be deleted."); + final String tr_not_supported = Tr.tr("Not supported by server"); + + private Control.Status mCurrentStatus; + private EnumSet mServerFeatures; + + private View(ViewControl control, Model model) { mControl = control; + mModel = model; WebLookAndFeel.install(); - ToolTipManager.sharedInstance().setInitialDelay(200); - mContactListView = new ContactListView(this, ContactList.getInstance()); - ContactList.getInstance().addObserver(mContactListView); - mChatListView = new ChatListView(this, ChatList.getInstance()); - ChatList.getInstance().addObserver(mChatListView); - // chat view mChatView = new ChatView(this); - ChatList.getInstance().addObserver(mChatView); - // content area mContent = new Content(this, mChatView); + mContactListView = new ContactListView(this, mModel); + mChatListView = new ChatListView(this, mModel.chats()); + // search panel mSearchPanel = new SearchPanel( - new Table[]{mContactListView, mChatListView}, + new ListView[]{mContactListView, mChatListView}, mChatView); - // status bar WebStatusBar statusBar = new WebStatusBar(); mStatusBarLabel = new WebStatusLabel(" "); statusBar.add(mStatusBarLabel); - // main frame - mMainFrame = new MainFrame(this, mContactListView, mChatListView, + mMainFrame = new MainFrame(this, mModel, mContactListView, mChatListView, mContent, mSearchPanel, statusBar); - mMainFrame.setVisible(true); - // tray - mTrayManager = new TrayManager(this, mMainFrame); - ChatList.getInstance().addObserver(mTrayManager); + mTrayManager = new TrayManager(this, mModel, mMainFrame); + // notifier + mNotifier = new Notifier(this); + + // register observer + mModel.contacts().addObserver(mContactListView); + mModel.chats().addObserver(mChatListView); + mModel.chats().addObserver(mChatView); + mModel.chats().addObserver(mTrayManager); - // hotkeys this.setHotkeys(); - // notifier - mNotifier = new Notifier(this); + this.statusChanged(Control.Status.DISCONNECTED, EnumSet.noneOf(FeatureDiscovery.Feature.class)); + + mMainFrame.setVisible(true); + } - this.statusChanged(); + public static Optional create(ViewControl control, Model model) { + View view; + try { + view = invokeAndWait(new Callable() { + @Override + public View call() throws Exception { + return new View(control, model); + } + }); + } catch (ExecutionException | InterruptedException ex) { + LOGGER.log(Level.WARNING, "can't start view", ex); + return Optional.empty(); + } + control.addObserver(view); + return Optional.of(view); } void setHotkeys() { @@ -153,18 +185,22 @@ public void init() { public void run() { View.this.mChatListView.selectLastChat(); - if (ChatList.getInstance().isEmpty()) + if (mModel.chats().isEmpty()) mMainFrame.selectTab(MainFrame.Tab.CONTACT); } }); } - Control.Status getCurrentStatus() { - return mControl.getCurrentStatus(); + Control.Status currentStatus() { + return mCurrentStatus; + } + + EnumSet serverFeatures() { + return mServerFeatures; } void showConfig() { - JDialog configFrame = new ConfigurationDialog(mMainFrame, this); + JDialog configFrame = new ConfigurationDialog(mMainFrame, this, mModel); configFrame.setVisible(true); } @@ -185,47 +221,52 @@ public void run() { } private void updateOnEDT(Object arg) { - if (arg instanceof ViewEvent.StatusChanged) { - this.statusChanged(); - } else if (arg instanceof ViewEvent.PasswordSet) { - this.showPasswordDialog(false); - } else if (arg instanceof ViewEvent.MissingAccount) { - ViewEvent.MissingAccount missAccount = (ViewEvent.MissingAccount) arg; - this.showImportWizard(missAccount.connect); - } else if (arg instanceof ViewEvent.Exception) { - ViewEvent.Exception exception = (ViewEvent.Exception) arg; - mNotifier.showException(exception.exception); - } else if (arg instanceof ViewEvent.SecurityError) { - ViewEvent.SecurityError error = (ViewEvent.SecurityError) arg; - mNotifier.showSecurityErrors(error.message); - } else if (arg instanceof ViewEvent.NewMessage) { - ViewEvent.NewMessage newMessage = (ViewEvent.NewMessage) arg; - mNotifier.onNewMessage(newMessage.message); - } else if (arg instanceof ViewEvent.NewKey) { - ViewEvent.NewKey newKey = (ViewEvent.NewKey) arg; - if (!newKey.contact.hasKey()) - // TODO webkey, disabling for now - return; - mNotifier.confirmNewKey(newKey.contact, newKey.key); - } else if (arg instanceof ViewEvent.ContactDeleted) { - ViewEvent.ContactDeleted contactDeleted = (ViewEvent.ContactDeleted) arg; - mNotifier.confirmContactDeletion(contactDeleted.contact); - } else if (arg instanceof ViewEvent.PresenceError) { - ViewEvent.PresenceError presenceError = (ViewEvent.PresenceError) arg; - mNotifier.showPresenceError(presenceError.contact, presenceError.error); - } else { - LOGGER.warning("unexpected argument"); - } + if (arg instanceof ViewEvent.StatusChange) { + ViewEvent.StatusChange statChange = (ViewEvent.StatusChange) arg; + this.statusChanged(statChange.status, statChange.features); + } else if (arg instanceof ViewEvent.PasswordSet) { + this.showPasswordDialog(false); + } else if (arg instanceof ViewEvent.MissingAccount) { + ViewEvent.MissingAccount missAccount = (ViewEvent.MissingAccount) arg; + this.showImportWizard(missAccount.connect); + } else if (arg instanceof ViewEvent.Exception) { + ViewEvent.Exception exception = (ViewEvent.Exception) arg; + mNotifier.showException(exception.exception); + } else if (arg instanceof ViewEvent.SecurityError) { + ViewEvent.SecurityError error = (ViewEvent.SecurityError) arg; + mNotifier.showSecurityErrors(error.message); + } else if (arg instanceof ViewEvent.NewMessage) { + ViewEvent.NewMessage newMessage = (ViewEvent.NewMessage) arg; + mNotifier.onNewMessage(newMessage.message); + } else if (arg instanceof ViewEvent.NewKey) { + ViewEvent.NewKey newKey = (ViewEvent.NewKey) arg; + if (!newKey.contact.hasKey()) + // TODO webkey, disabling for now + return; + mNotifier.confirmNewKey(newKey.contact, newKey.key); + } else if (arg instanceof ViewEvent.ContactDeleted) { + ViewEvent.ContactDeleted contactDeleted = (ViewEvent.ContactDeleted) arg; + mNotifier.confirmContactDeletion(contactDeleted.contact); + } else if (arg instanceof ViewEvent.PresenceError) { + ViewEvent.PresenceError presenceError = (ViewEvent.PresenceError) arg; + mNotifier.showPresenceError(presenceError.contact, presenceError.error); + } else if (arg instanceof ViewEvent.SubscriptionRequest) { + mNotifier.confirmSubscription((ViewEvent.SubscriptionRequest) arg); + } else { + LOGGER.warning("unexpected argument: "+arg); + } } - private void statusChanged() { - Control.Status status = mControl.getCurrentStatus(); + private void statusChanged(Control.Status status, EnumSet features) { + mCurrentStatus = status; + mServerFeatures = features; + + mChatView.onStatusChange(status, features); switch (status) { case CONNECTING: mStatusBarLabel.setText(Tr.tr("Connecting…")); break; case CONNECTED: - mChatView.setColor(Color.WHITE); mStatusBarLabel.setText(Tr.tr("Connected")); NotificationManager.hideAllNotifications(); break; @@ -233,7 +274,6 @@ private void statusChanged() { mStatusBarLabel.setText(Tr.tr("Disconnecting…")); break; case DISCONNECTED: - mChatView.setColor(Color.LIGHT_GRAY); mStatusBarLabel.setText(Tr.tr("Not connected")); //if (mTrayIcon != null) // trayIcon.setImage(updatedImage); @@ -249,7 +289,6 @@ private void statusChanged() { mStatusBarLabel.setText(Tr.tr("Connecting failed")); break; case ERROR: - mChatView.setColor(Color.lightGray); mStatusBarLabel.setText(Tr.tr("Connection error")); break; } @@ -308,19 +347,16 @@ void callShutDown() { /* view internal */ void showChat(Contact contact) { - this.selectChat(mControl.getOrCreateSingleChat(contact)); + this.showChat(mControl.getOrCreateSingleChat(contact)); } - private void selectChat(Chat chat) { + void showChat(Chat chat) { + // show by selecting it mMainFrame.selectTab(MainFrame.Tab.CHATS); mChatListView.setSelectedItem(chat); } - void showContactDetails(Contact contact) { - mContent.showContact(contact); - } - - void showChat(Chat chat) { + void selectedChatChanged(Chat chat) { if (mMainFrame.getCurrentTab() != MainFrame.Tab.CHATS) return; mContent.showChat(chat); @@ -330,21 +366,38 @@ void showNothing() { mContent.showNothing(); } + void showContactDetails(Contact contact) { + if (contact.isDeleted()) + return; + + mMainFrame.selectTab(MainFrame.Tab.CONTACT); + mContactListView.setSelectedItem(contact); + mContent.showContact(contact); + } + + void requestRenameFocus(Contact contact) { + if (contact.isDeleted()) + return; + + this.showContactDetails(contact); + mContent.requestRenameFocus(); + } + void clearSearch() { mSearchPanel.clear(); } void tabPaneChanged(MainFrame.Tab tab) { if (tab == MainFrame.Tab.CHATS) { - Optional optChat = mChatListView.getSelectedValue(); - if (optChat.isPresent()) { - mContent.showChat(optChat.get()); + Chat chat = mChatListView.getSelectedValue().orElse(null); + if (chat != null) { + mContent.showChat(chat); return; } } else { - Optional optContact = mContactListView.getSelectedValue(); - if (optContact.isPresent()) { - mContent.showContact(optContact.get()); + Contact contact = mContactListView.getSelectedValue().orElse(null); + if (contact != null) { + mContent.showContact(contact); return; } } @@ -363,37 +416,22 @@ void reloadChatBG() { mChatView.loadDefaultBG(); } + void updateContactList() { + mContactListView.updateOnEDT(null); + } + void updateTray() { mTrayManager.setTray(); } /* static */ - public static Optional create(final ViewControl control) { - Optional optView = invokeAndWait(new Callable() { - @Override - public View call() throws Exception { - return new View(control); - } - }); - if(!optView.isPresent()) { - LOGGER.log(Level.SEVERE, "can't start view"); - return optView; - } - control.addObserver(optView.get()); - return optView; - } - - private static Optional invokeAndWait(Callable callable) { - try { - FutureTask task = new FutureTask<>(callable); - SwingUtilities.invokeLater(task); - // blocking - return Optional.of(task.get()); - } catch (ExecutionException | InterruptedException ex) { - LOGGER.log(Level.WARNING, "can't execute task", ex); - } - return Optional.empty(); + private static T invokeAndWait(Callable callable) + throws InterruptedException, ExecutionException { + FutureTask task = new FutureTask<>(callable); + SwingUtilities.invokeLater(task); + // blocking + return task.get(); } public static void showWrongJavaVersionDialog() { @@ -401,7 +439,7 @@ public static void showWrongJavaVersionDialog() { if (jVersion.length() >= 3) jVersion = jVersion.substring(2, 3); String errorText = Tr.tr("The installed Java version is too old")+": " + jVersion; - errorText += System.getProperty("line.separator"); + errorText += EncodingUtils.EOL; errorText += Tr.tr("Please install Java 8."); WebOptionPane.showMessageDialog(null, errorText, diff --git a/src/main/resources/i18n/strings.properties b/src/main/resources/i18n/strings.properties index 21cace71..b0bea7e1 100644 --- a/src/main/resources/i18n/strings.properties +++ b/src/main/resources/i18n/strings.properties @@ -37,7 +37,6 @@ s_VTOO = Connect s_J8B9 = Connect to Server s_2939 = Disconnect s_XW05 = Disconnect from Server -s_RYMX = Set status s_66AA = Set status text send to other user s_AN83 = Exit s_NLZ0 = Exit application @@ -183,11 +182,9 @@ s_RX28 = Secure s_9CW2 = Encrypted s_5VTT = Decrypted s_PBTX = Not signed -s_AUZG = Signed s_SIR1 = Verified s_WN8A = Supported files s_CQEO = File -s_1PWT = Send File - max. size: s_7MCH = Received new key for contact s_2B02 = The key for this contact could not yet be received s_KGM6 = stalled @@ -222,9 +219,47 @@ s_OOS3 = Connecting… s_0WD8 = Disconnecting… s_Z1GT = Can't load all keyfiles from archive. s_MMBD = Search… -s_ZZWW = Send chat activity (typing,…) to other user s_PC1G = is writing… s_KRQ6 = Enter password… s_2R2P = loading… s_307W = downloading… s_4WC0 = The server certificate could not be validated. +s_68LN = Request +s_7MDX = Request status authorization from contact +s_OTEU = Automatically grant authorization +s_MFZY = Automatically grant online status authorization requests from other users +s_FAS6 = Send chat activity (typing,…) to other users +s_29CW = Edit Contact +s_AGYA = Edit contact settings +s_MNIJ = Waiting... +s_0WUC = Not verified +s_9S4L = Authorization request +s_FDTC = When accepting, this contact will be able to see your online status. +s_TAG1 = User Profile +s_84VP = Edit your profile +s_4QXX = Your profile picture: +s_1R7I = Profile +s_O2UH = Set your user profile +s_6SC9 = Key user ID: +s_6K2I = Download profile pictures +s_08GL = Download contact profile pictures +s_T2TO = Show yourself in contacts +s_F1X0 = Show yourself in the contact list +s_3PFR = Remove +s_D7FH = Not supported by server +s_WUDV = Group Owner +s_RCDY = Network +s_LPE1 = Network Settings +s_R4F8 = Original +s_K83J = Small (0.3MP) +s_RAMV = Medium (0.5MP) +s_7CPG = Large (0.8MP) +s_5RTB = Reduce size of images before sending +s_13FX = Resize image attachments: +s_8OBK = Send File +s_7YWF = max. size: +s_7NP3 = ? +s_VR5N = XMPP chat ID: +s_UICM = Chat ID: +s_VEBZ = Retry +s_QNW6 = Retry sending message diff --git a/src/main/resources/i18n/strings_ca.properties b/src/main/resources/i18n/strings_ca.properties index 97c8713f..bae98d6e 100644 --- a/src/main/resources/i18n/strings_ca.properties +++ b/src/main/resources/i18n/strings_ca.properties @@ -425,3 +425,4 @@ s_PC1G=està escrivint… s_KRQ6=Introdueixi la contrasenya… s_2R2P=carregant… s_307W=baixant… +s_4WC0=No s'ha pogut validar el certificat del servidor. diff --git a/src/main/resources/i18n/strings_cs.properties b/src/main/resources/i18n/strings_cs.properties index f77942e2..048adbf5 100644 --- a/src/main/resources/i18n/strings_cs.properties +++ b/src/main/resources/i18n/strings_cs.properties @@ -239,3 +239,44 @@ s_XITD=Nedostupný s_NCU0=Chyba kontaktu s_NTTQ=Chyba: s_L4ZD=Server nenalezen +s_KPWG=Prázdný +s_ZX32=Smazaný +s_LBBT=Vy +s_ZUZ9=Stahování souboru selhalo +s_JRCQ=Nahrávání souboru selhalo +s_JSD2=Neobvyklá chyba: +s_BLFT=Automaticky opustíte tuto skupinu. +s_74SC=Vyberte účastníky: +s_B5NX=Upravit skupinový chat +s_U5RT=vytvořil tuto skupinu +s_5X07=opustil tuto skupinu +s_FWZZ=upravil tuto skupinu +s_OOS3=Připojování… +s_0WD8=Odpojování… +s_Z1GT=Nepodařilo se načíst všechny soubory s klíči z archivu. +s_MMBD=Hledání… +s_ZZWW=Odesílat aktivitu při chatu (píše…) ostatním uživatelům +s_PC1G=píše… +s_KRQ6=Zadejte heslo… +s_2R2P=načítání… +s_307W=stahování… +s_4WC0=Nepodařilo se ověřit certifikát serveru. +s_68LN=Požadavek +s_7MDX=Požádat o autorizaci získání stavu připojení od uživatele +s_OTEU=Automaticky autorizovat +s_MFZY=Automaticky autorizovat požadavky na stav připojení od jiných uživatelů +s_FAS6=Odesílat aktivitu v konverzaci (píše…) ostatním uživatelům +s_29CW=Upravit kontakt +s_AGYA=Upravit nastavení kontaktu +s_MNIJ=Čekám… +s_0WUC=Neověřeno +s_9S4L=Požadavek na autorizaci +s_FDTC=Pokud přijmete, tento kontakt uvidí váš stav online. +s_TAG1=Uživatelský profil +s_84VP=Upravit váš profil +s_4QXX=Váš profilový obrázek: +s_1R7I=Profil +s_O2UH=Nastavit váš uživatelský profil +s_4FTV=Stahovat obrázky avatarů +s_YL1F=Stahovat obrázky avatarů kontaktů +s_6SC9=ID klíčového uživatele: diff --git a/src/main/resources/i18n/strings_de.properties b/src/main/resources/i18n/strings_de.properties index a501bff5..a12b97ec 100644 --- a/src/main/resources/i18n/strings_de.properties +++ b/src/main/resources/i18n/strings_de.properties @@ -291,3 +291,45 @@ s_PC1G=schreibt… s_KRQ6=Passwort eingeben… s_2R2P=lade… s_307W=herunterladen… +s_68LN=Anfrage +s_29CW=Kontakt bearbeiten +s_AGYA=Kontakteinstellungen bearbeiten +s_MNIJ=Warte… +s_0WUC=Nicht überprüft +s_FDTC=Wenn Sie dies aktzeptieren, wird dieser Kontakt Ihren Onlinestatus sehen können. +s_MFZY=Onlinestatusanfragen von anderen Nutzern automatisch genehmigen +s_FAS6=Chataktivität (schreibt…) an andere Nutzer senden +s_TAG1=Nutzerprofil +s_84VP=Ihr Profil bearbeiten +s_4QXX=Dein Profilbild: +s_1R7I=Profil +s_O2UH=Ihr Nutzerprofil einstellen +s_4FTV=Avatarbilder herunterladen +s_YL1F=Herunterladen von Kontaktavatarbildern +s_6SC9=Schlüssel Nutzer ID: +s_6K2I=Profilbilder herunterladen +s_08GL=Kontaktprofilbilder herunterladen +s_T2TO=Sich selbst in den Kontakten anzeigen +s_F1X0=Sich selbst in der Kontaktliste anzeigen +s_3PFR=Entfernen +s_D7FH=Vom Server nicht unterstützt +s_RCDY=Netzwerk +s_LPE1=Netzwerkeinstellungen +s_R4F8=Original +s_K83J=Klein (0.3MP) +s_RAMV=Mittel (0.5MP) +s_7CPG=Groß (0.8MP) +s_5RTB=Bildgröße vor dem Senden anpassen +s_13FX=Bildgröße anpassen: +s_8OBK=Datei senden +s_7YWF=Maximalgröße: +s_4WC0=Das Server-Zertifikat konnte nicht verifiziert werden. +s_7MDX=Anfrage von Online-Status an Kontakt +s_OTEU=Automatisch Erlaubnis gewähren +s_9S4L=Erlaubnis-Anfrage +s_WUDV=Gruppenbesitzer +s_7NP3=? +s_VR5N=XMPP Chat ID: +s_UICM=Chat ID: +s_VEBZ=Wiederholen +s_QNW6=Versuche Nachricht erneut zu senden diff --git a/src/main/resources/i18n/strings_es.properties b/src/main/resources/i18n/strings_es.properties index 38d49d06..37780155 100644 --- a/src/main/resources/i18n/strings_es.properties +++ b/src/main/resources/i18n/strings_es.properties @@ -297,3 +297,41 @@ s_PC1G=está escribiendo… s_KRQ6=Introduzca la contraseña… s_2R2P=cargando… s_307W=bajando… +s_4WC0=No se pudo validar el certificado del servidor. +s_68LN=Solicitud +s_7MDX=Estado de la solicitud de autorización del contacto +s_OTEU=Conceder la autorización automáticamente +s_MFZY=Conceder automáticamente peticiones de autorización de estado on-line de otros usuarios +s_FAS6=Enviar actividad de conversación (escribiendo, ...) a otros usuarios +s_29CW=Editar contacto +s_AGYA=Editar la configuración del contacto +s_MNIJ=Esperando... +s_0WUC=No verificado +s_9S4L=Solicitud de autorización +s_FDTC=Al aceptar, este contacto podrá ver su estado de conexión. +s_TAG1=Perfil del usuario +s_84VP=Edite su perfil +s_4QXX=Su foto de perfil: +s_1R7I=Perfil +s_O2UH=Configurar su perfil de usuario +s_6SC9=Clave ID de usuario: +s_6K2I=Descargar fotos de perfil +s_08GL=Descargar las imágenes de perfil del contacto +s_T2TO=Mostrarse a los contactos +s_F1X0=Mostrarse en la lista de contactos +s_3PFR=Retirar +s_D7FH=No soportado por el servidor +s_WUDV=Propietario del grupo +s_RCDY=Red +s_LPE1=Configuración de la red +s_R4F8=Original +s_K83J=Pequeño (0.3MP) +s_RAMV=Medio (0.5MP) +s_7CPG=Grande (0.8MP) +s_5RTB=Reducir el tamaño de las imágenes antes de enviar +s_13FX=Cambiar el tamaño de las imágenes adjuntas: +s_8OBK=Enviar archivo +s_7YWF=max. tamaño: +s_7NP3=? +s_VR5N=Indentificación del chat XMPP +s_UICM=Indentificación del Chat: diff --git a/src/main/resources/i18n/strings_fr.properties b/src/main/resources/i18n/strings_fr.properties index 64b62091..11439744 100644 --- a/src/main/resources/i18n/strings_fr.properties +++ b/src/main/resources/i18n/strings_fr.properties @@ -223,3 +223,35 @@ s_XITD=Pas atteignable s_NCU0=Erreur de contact s_NTTQ=Erreur : s_L4ZD=Serveur introuvable +s_KPWG=Vide +s_ZX32=Effacé +s_LBBT=Vous +s_ZUZ9=Échec de téléchargement du fichier +s_JRCQ=Échec d'envoi du fichier +s_JSD2=Erreur inhabituelle : +s_BLFT=Vous allez automatiquement quitter ce groupe. +s_74SC=Sélectionnez les participants : +s_B5NX=Éditer le groupe de discussion +s_U5RT=a crée ce groupe +s_5X07=a quitté ce groupe +s_FWZZ=a changé ce groupe +s_OOS3=Connection… +s_0WD8=Déconnection… +s_Z1GT=Impossible de charger tous les fichiers de clé depuis l'archive. +s_MMBD=Recherche… +s_PC1G=écrit… +s_KRQ6=Entrez le mot de passe… +s_2R2P=chargement… +s_307W=téléchargement… +s_4WC0=Le certificat du serveur n'a pas pu être validé. +s_68LN=Requête +s_7MDX=Demande l'autorisation au contact d'accéder à son statut +s_OTEU=Donner automatiquement l'autorisation +s_MFZY=Donner automatiquement aux autres utilisateurs l'autorisation de voir le statut en ligne +s_FAS6=Envoyer l'activité de conversation (tapotage,...) aux autres utilisateurs +s_29CW=Éditer le Contact +s_AGYA=Éditer les paramètres du contact +s_MNIJ=En attente... +s_0WUC=Non vérifié +s_9S4L=Requête d'autorisation +s_FDTC=En acceptant, ce contact pourra voir votre statut de présence en ligne. diff --git a/src/main/resources/i18n/strings_ja.properties b/src/main/resources/i18n/strings_ja.properties index af80d90c..d2748676 100644 --- a/src/main/resources/i18n/strings_ja.properties +++ b/src/main/resources/i18n/strings_ja.properties @@ -281,3 +281,47 @@ s_B5NX=グループチャットを編集 s_U5RT=このグループを作成しました s_5X07=このグループを離れました s_FWZZ=グループを変更しました +s_OOS3=接続中… +s_0WD8=切断中… +s_Z1GT=アーカイブからすべてのキーファイルをロードできません。 +s_MMBD=検索… +s_ZZWW=他のユーザーにチャットのアクティビティ (入力中、…) を送信 +s_PC1G=入力中… +s_KRQ6=パスワードを入力… +s_2R2P=ロード中… +s_307W=ダウンロード中… +s_4WC0=サーバー証明書は検証されていません。 +s_68LN=リクエスト +s_7MDX=連絡先からのリクエストステータス承認 +s_OTEU=自動的に承認を許可 +s_MFZY=自動的に他のユーザからのオンラインステータス承認のリクエストを許可します +s_FAS6=他のユーザーにチャットのアクティビティを送信する (入力中,…) +s_29CW=連絡先を編集 +s_AGYA=連絡先の設定を編集 +s_MNIJ=待機中... +s_0WUC=未検証 +s_9S4L=承認リクエスト +s_FDTC=承認すると、この連絡先をあなたのオンラインステータスに表示することができます。 +s_TAG1=ユーザープロファイル +s_84VP=あなたのプロファイルを編集 +s_4QXX=プロファイル画像: +s_1R7I=プロファイル +s_O2UH=あなたのユーザープロファイルを設定 +s_6SC9=鍵ユーザーID: +s_6K2I=プロファイル画像をダウンロード +s_08GL=連絡先のプロファイル画像をダウンロード +s_T2TO=自分自身を連絡先に表示 +s_F1X0=自分自身を連絡先リストに表示 +s_3PFR=削除 +s_D7FH=サーバーでサポートされていません +s_RCDY=ネットワーク +s_LPE1=ネットワーク設定 +s_R4F8=オリジナル +s_K83J=小 (0.3MP) +s_RAMV=中 (0.5MP) +s_7CPG=大 (0.8MP) +s_5RTB=送信前に画像のサイズを縮小します +s_13FX=添付画像のサイズを変更: +s_8OBK=ファイルを送信 +s_7YWF=最大サイズ: +s_WUDV=グループオーナー diff --git a/src/main/resources/i18n/strings_sr.properties b/src/main/resources/i18n/strings_sr.properties index 850058fb..5561b418 100644 --- a/src/main/resources/i18n/strings_sr.properties +++ b/src/main/resources/i18n/strings_sr.properties @@ -1,62 +1,61 @@ - -s1 = Опције -s2 = Помоћ -s_JSQ2 = Пошаљи -s_NMPC = Пошаљи поруку -s_8WWE = Напусти -s_6P7W = Повезујем се -s_U0MG = Повезан -s_769Q = Искључујем се... -s_B56J = Неповезан -s_J1DQ = Неуспело повезивање -s_EON8 = Грешка при повезивању -s_0N2Z = Грешка -s_BTPA = Грешка шифровања -s_CEMO = Грешка дешифровања -s_HTYQ = Непозната грешка -s_PD0A = Кључ за примаоца није нађен. -s_125B = Неуобичајена грешка кодера -s_M63K = Непозната грешка\!? -s_S39E = Не могу да учитам фајл кључа из архиве. -s_VYCJ = Не могу да направим лични кључ из фајлова. -s_RQHM = Да ли је јавни кључ важећи? -s_WOYH = Да ли су сви кључеви важећи? -s_53Z5 = Да ли је лозинка исправна? -s_Q4J7 = Не могу да променим лозинку. Интерна грешка(\!?) -s_OW1R = Не могу да упишем фајлове кључа у директоријум подешавања. -s_LHST = Не могу да читам фајлове кључа из директоријума подешавања. -s_C9CO = Не могу да учитам фајлове кључа из директоријума подешавања. -s_6D91 = Поново увезите ваш кључ. -s_K94P = Не могу да направим везу -s_HZPW = Не могу да се повежем са сервером. -s_QL8R = Да ли је адреса сервера тачна? -s_J8KE = Сервер одбија кључ. -s_H4GJ = Сервер не одговара. -s_PVI0 = Не могу да се пријавим. -s_L1DT = Сервер одбија овај налог. Да ли је сервер исправно наведен и налог важећи? -s_X4A9 = Веза је затворена уз грешку. -s_0QJ9 = Инсталирана верзија Јаве је превише стара -s_N0DZ = Инсталирајте Јаву 8 -s_HLM5 = Неподржана верзија Јаве -s_8FB5 = Не могу да отворим архиву кључа. -s_VTOO = Повежи се -s_J8B9 = Повежи се на сервер -s_2939 = Прекини везу -s_XW05 = Прекини везу са сервером -s_RYMX = Постави статус -s_66AA = Поставите поруку стања коју шаљете кориснику -s_AN83 = Изађи -s_NLZ0 = Излази из апликације -s_RDGN = Подешавања -s_9UNW = Подесите апликацију -s_TKHF = О програму -s_4P6T = О програму -s_X4K8 = Ново -s_7MO0 = Нема разговора за приказ. Можете започети нов разговор из контаката +s1=Опције +s2=Помоћ +s_JSQ2=Пошаљи +s_NMPC=Пошаљи поруку +s_8WWE=Напусти +s_6P7W=Повезујем се +s_U0MG=Повезан +s_769Q=Искључујем се... +s_B56J=Неповезан +s_J1DQ=Неуспело повезивање +s_EON8=Грешка при повезивању +s_0N2Z=Грешка +s_BTPA=Грешка шифровања +s_CEMO=Грешка дешифровања +s_HTYQ=Непозната грешка +s_PD0A=Кључ за примаоца није нађен. +s_125B=Неуобичајена грешка кодера +s_M63K=Непозната грешка!? +s_S39E=Не могу да учитам фајл кључа из архиве. +s_VYCJ=Не могу да направим лични кључ из фајлова. +s_RQHM=Да ли је јавни кључ важећи? +s_WOYH=Да ли су сви кључеви важећи? +s_53Z5=Да ли је лозинка исправна? +s_Q4J7=Не могу да променим лозинку. Интерна грешка(!?) +s_OW1R=Не могу да упишем фајлове кључа у директоријум подешавања. +s_LHST=Не могу да читам фајлове кључа из директоријума подешавања. +s_C9CO=Не могу да учитам фајлове кључа из директоријума подешавања. +s_6D91=Поново увезите ваш кључ. +s_K94P=Не могу да направим везу +s_HZPW=Не могу да се повежем са сервером. +s_QL8R=Да ли је адреса сервера тачна? +s_J8KE=Сервер одбија кључ. +s_H4GJ=Сервер не одговара. +s_PVI0=Не могу да се пријавим. +s_L1DT=Сервер одбија овај налог. Да ли је сервер исправно наведен и налог важећи? +s_X4A9=Веза је затворена уз грешку. +s_0QJ9=Инсталирана верзија Јаве је превише стара +s_N0DZ=Инсталирајте Јаву 8. +s_HLM5=Неподржана верзија Јаве +s_8FB5=Не могу да отворим архиву кључа. +s_VTOO=Повежи се +s_J8B9=Повежи се на сервер +s_2939=Прекини везу +s_XW05=Прекини везу са сервером +s_RYMX=Постави статус +s_66AA=Поставите поруку стања коју шаљете кориснику +s_AN83=Изађи +s_NLZ0=Излази из апликације +s_RDGN=Подешавања +s_9UNW=Подесите апликацију +s_TKHF=О програму +s_4P6T=О програму +s_X4K8=Ново +s_7MO0=Нема разговора за приказ. Можете започети нов разговор из контаката # s_B8XU = Threads # s_012T = Add # s_VA0W = No contacts to display. You have no friends ;( -s_KBMH = Контакти +s_KBMH=Контакти # s_6COL = Visit kontalk.org # s_LPY9 = Notification sound by # s_M91R = About @@ -173,3 +172,29 @@ s_KBMH = Контакти # s_T3V1 = loading... # s_H3ZD = downloading... # s_44X2 = download failed + +s_6COL=Посетите kontalk.org +s_NMNS=Стање +s_4QK9=Ваше текуће стање: +s_ZVBN=Претходно кориштено: +s_ER26=Одустани +s_BIF7=Сачувај +s_YTBQ=Шифровање +s_QXUP=Назад +s_1GYL=Следеће +s_UK2S=Успех! +s_M4W3=Опис грешке: +s_44NP=Почнимо +s_16WW=Прикажи лозинку +s_XSQM=Налог +s_RHOQ=Главно +s_FVE4=Приватност +s_T1AI=Главне поставке +s_BGK0=Адреса сервера: +s_6OD2=Порт: +s_84MW=Поставке приватности +s_URN0=Боја: +s_X171=Слика: +s_44X2=преузимање није успело +s_WDMP=Отисак: +s_HKW8=Завршено diff --git a/src/main/resources/i18n/strings_sr@latin.properties b/src/main/resources/i18n/strings_sr@latin.properties deleted file mode 100644 index 2333761d..00000000 --- a/src/main/resources/i18n/strings_sr@latin.properties +++ /dev/null @@ -1,175 +0,0 @@ - -# s1 = Options -# s2 = Help -# s_JSQ2 = Send -# s_NMPC = Send Message -# s_8WWE = Quit -# s_6P7W = Connecting... -# s_U0MG = Connected -# s_769Q = Disconnecting... -# s_B56J = Not connected -# s_J1DQ = Connecting failed -# s_EON8 = Connection error -# s_0N2Z = Error -# s_BTPA = Encryption error -# s_CEMO = Decryption error -# s_HTYQ = Unknown error -# s_PD0A = Key for receiver not found. -# s_125B = Unusual coder error -# s_M63K = Unknown error\!? -# s_S39E = Can't load keyfile(s) from archive. -# s_VYCJ = Can't create personal key from key files. -# s_RQHM = Is the public key file valid? -# s_WOYH = Are all key files valid? -# s_53Z5 = Is the passphrase correct? -# s_Q4J7 = Can't change password. Internal error(\!?) -# s_OW1R = Can't write key files to configuration directory. -# s_LHST = Can't read key files from configuration directory. -# s_C9CO = Can't load key files from configuration directory. -# s_6D91 = Please reimport your key. -# s_K94P = Can't create connection -# s_HZPW = Can't connect to server. -# s_QL8R = Is the server address correct? -# s_J8KE = The server rejects the key. -# s_H4GJ = The server does not respond. -# s_PVI0 = Can't login to server. -# s_L1DT = The server rejects the account. Is the specified server correct and the account valid? -# s_X4A9 = Connection to server closed on error. -# s_0QJ9 = The installed Java version is too old -# s_N0DZ = Please install Java 8. -# s_HLM5 = Unsupported Java Version -# s_8FB5 = Can't open key archive. -# s_VTOO = Connect -# s_J8B9 = Connect to Server -# s_2939 = Disconnect -# s_XW05 = Disconnect from Server -# s_RYMX = Set status -# s_66AA = Set status text send to other user -# s_AN83 = Exit -# s_NLZ0 = Exit application -# s_RDGN = Preferences -# s_9UNW = Set application preferences -# s_TKHF = About -# s_4P6T = About Kontalk -# s_X4K8 = New -# s_7MO0 = No threads to display. You can create new threads from your contacts -# s_B8XU = Threads -# s_012T = Add -# s_VA0W = No contacts to display. You have no friends ;( -# s_KBMH = Contacts -# s_6COL = Visit kontalk.org -# s_LPY9 = Notification sound by -# s_M91R = About -# s_NMNS = Status -# s_4QK9 = Your current status\: -# s_ZVBN = Previously used\: -# s_ER26 = Cancel -# s_BIF7 = Save -# s_XK4W = Add New Contact -# s_MIAO = Display Name\: -# s_YTBQ = Encryption -# s_U1UW = Cancel -# s_IBU6 = Save -# s_S8GI = Search... -# s_6RRX = Import Wizard -# s_QXUP = Back -# s_1GYL = Next -# s_WW4U = Finish -# s_UK2S = Success\! -# s_E6EK = Import process finished with\: -# s_M4W3 = Error description\: -# s_44NP = Get Started -# s_VKI8 = Welcome to the import wizard. -# s_TMHY = To use the Kontalk desktop client you need an existing account. -# s_EWJ4 = Please export the key files from your Android device and select them on the next page. -# s_JFS8 = Setup -# s_EKOL = Zip archive containing personal key\: -# s_8HBO = Decryption password for key\: -# s_BWQV = Enter password... -# s_16WW = Show password -# s_GR2F = Zip archive -# s_U6NZ = Import results -# s_RHOQ = Main -# s_XSQM = Account -# s_FVE4 = Privacy -# s_T1AI = Main Settings -# s_WFDO = Connect on startup -# s_GCYS = Show tray icon -# s_V6FV = Close to tray -# s_731N = Enter key sends -# s_6G56 = Enter key sends text, Control+Enter adds new line - or vice versa -# s_4ORM = Custom background\: -# s_H4JO = Account Configuration -# s_BGK0 = Server address\: -# s_6OD2 = Port\: -# s_4WQX = Disable certificate validation -# s_VMP3 = Disable SSL certificate server validation -# s_Y7G0 = Import new Account -# s_9FKT = Save & Connect -# s_X97A = no key loaded -# s_3Q3G = Key fingerprint\: -# s_84MW = Privacy Settings -# s_UP85 = Send chatstate notification -# s_MGHZ = -# s_CYIK = No -# s_9NTE = ? -# s_1SED = Yes -# s_D0BD = YES -# s_3OK6 = Available -# s_G3C7 = Blocked -# s_956V = Last seen -# s_PDYT = New Thread -# s_052Y = Creates a new thread for this contact -# s_OUKY = Edit Contact -# s_AOG4 = Edit this contact -# s_9KDJ = Block Contact -# s_FF9R = Block all messages from this contact -# s_AEX9 = Unblock Contact -# s_QU3S = Unblock this contact -# s_J57E = Delete Contact -# s_AHPF = Delete this contact -# s_8GRW = Edit Contact -# s_45P5 = Encryption Key -# s_AW5R = Available -# s_C0EF = Not Available -# s_X9O1 = Changing the JID is only useful in very rare cases. Are you sure? -# s_HEJA = Please Confirm -# s_ELUO = Edit Thread -# s_4ZBG = Edit this thread -# s_HAK6 = Delete Thread -# s_MD21 = Delete this thread -# s_ZKM0 = Please Confirm -# s_CCNS = no messages yet -# s_FSGV = Last activity -# s_Z35B = -# s_D7B3 = -# s_O85V = Edit Thread -# s_AKL2 = Subject\: -# s_D2D8 = Participants\: -# s_UEN4 = More than one receiver not supported (yet). -# s_LQ9X = Sorry -# s_4LCO = [encrypted] -# s_92KK = ? -# s_OEGV = Attachment\: -# s_BUP5 = Decrypt -# s_7770 = Retry decrypting message -# s_5RDK = Copy -# s_0R7Z = Copy message content -# s_6AKS = unknown -# s_YNOC = not encrypted -# s_7BO5 = encrypted -# s_ZN6V = decrypted -# s_WVG1 = unknown -# s_6OKN = not signed -# s_A812 = signed -# s_CAM4 = verified -# s_4N5N = none -# s_YV5S = Security -# s_QNRV = Problems -# s_IGQC = Permanently delete all messages in this thread? -# s_V5R1 = Custom Background -# s_URN0 = Color\: -# s_X171 = Image\: -# s_T3V1 = loading... -# s_H3ZD = downloading... -# s_44X2 = download failed diff --git a/src/main/resources/i18n/strings_zh_Hans.properties b/src/main/resources/i18n/strings_zh.properties similarity index 86% rename from src/main/resources/i18n/strings_zh_Hans.properties rename to src/main/resources/i18n/strings_zh.properties index e62159a9..bc9ac2c9 100644 --- a/src/main/resources/i18n/strings_zh_Hans.properties +++ b/src/main/resources/i18n/strings_zh.properties @@ -227,3 +227,38 @@ s_PC1G=正在撰写…… s_KRQ6=输入密码…… s_2R2P=正在载入…… s_307W=正在下载…… +s_4WC0=服务器证书无法被验证。 +s_68LN=请求 +s_7MDX=从联系人请求状态验证 +s_OTEU=自动授权验证 +s_MFZY=自动从其他用户授权在线状态验证 +s_FAS6=发送聊天活动(如正在输入…)给其他用户 +s_29CW=编辑联系人 +s_AGYA=编辑联系人设置 +s_MNIJ=正在等待… +s_0WUC=未验证 +s_9S4L=验证申请 +s_FDTC=若确认,该联系人将能够看到你的在线状态。 +s_TAG1=用户资料 +s_84VP=编辑个人资料 +s_4QXX=你的头像: +s_1R7I=资料 +s_O2UH=设置用户资料 +s_6SC9=用户ID: +s_6K2I=下载资料中的头像 +s_08GL=下载联系人资料的头像 +s_T2TO=联系人中显示本人 +s_F1X0=联系人列表中显示本人 +s_3PFR=移除 +s_D7FH=不受服务器支持 +s_WUDV=组管理 +s_RCDY=网络 +s_LPE1=网络设置 +s_R4F8=原始 +s_K83J=小(300万像素) +s_RAMV=中(500万像素) +s_7CPG=大(800万像素) +s_5RTB=发送前减小图片尺寸 +s_13FX=调整图片附件尺寸: +s_8OBK=发送文件 +s_7YWF=最大文件: diff --git a/src/main/resources/ic_ui_edit.png b/src/main/resources/ic_ui_edit.png new file mode 100644 index 00000000..61949394 Binary files /dev/null and b/src/main/resources/ic_ui_edit.png differ diff --git a/src/test/java/org/kontalk/KontalkTest.java b/src/test/java/org/kontalk/KontalkTest.java new file mode 100644 index 00000000..1a7f36a8 --- /dev/null +++ b/src/test/java/org/kontalk/KontalkTest.java @@ -0,0 +1,87 @@ +/* + * Kontalk Java client + * Copyright (C) 2016 Kontalk Devteam + * + * 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 . + */ +package org.kontalk; + +import java.io.IOException; +import java.nio.file.Path; +import org.junit.After; +import org.junit.AfterClass; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.ClassRule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.fail; +import org.junit.Ignore; + +/** + * + * @author Alexander Bikadorov {@literal } + */ +public class KontalkTest { + @ClassRule + public static TemporaryFolder TEMP_FOLDER = new TemporaryFolder(); + + private static Path APP_DIR; + + public KontalkTest() { + } + + @BeforeClass + public static void setUpClass() { + APP_DIR = TEMP_FOLDER.getRoot().toPath().resolve("app_dir"); + } + + @AfterClass + public static void tearDownClass() throws IOException { + } + + @Before + public void setUp() { + } + + @After + public void tearDown() { + } + + /** + * Test of start method, of class Kontalk. + */ + @Test + public void testStart() { + System.out.println("start"); + Kontalk app = new Kontalk(APP_DIR); + int returnCode = app.start(false); + assertEquals(returnCode, 0); + // TODO stop + } + + /** + * Test of main method, of class Kontalk. + */ + @Test + @Ignore + public void testMain() { + System.out.println("main"); + String[] args = null; + Kontalk.main(args); + // TODO review the generated test code and remove the default call to fail. + fail("The test case is a prototype."); + } +} diff --git a/src/test/java/org/kontalk/util/CryptoUtilsTest.java b/src/test/java/org/kontalk/util/CryptoUtilsTest.java new file mode 100644 index 00000000..4e7bb8f6 --- /dev/null +++ b/src/test/java/org/kontalk/util/CryptoUtilsTest.java @@ -0,0 +1,64 @@ +/* + * Kontalk Java client + * Copyright (C) 2016 Kontalk Devteam + * + * 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 . + */ +package org.kontalk.util; + +import org.junit.After; +import org.junit.AfterClass; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.Test; + +/** + * + * @author Alexander Bikadorov {@literal } + */ +public class CryptoUtilsTest { + + public CryptoUtilsTest() { + } + + @BeforeClass + public static void setUpClass() { + } + + @AfterClass + public static void tearDownClass() { + } + + @Before + public void setUp() { + } + + @After + public void tearDown() { + } + + /** + * Test of removeCryptographyRestrictions method, of class CryptoUtils. + * NOTE: crypto restriction removal is platform dependend, so is the + * result of this test + */ + @Test + public void testRemoveCryptographyRestrictions() { + System.out.println("removeCryptographyRestrictions"); + boolean succ = CryptoUtils.removeCryptographyRestrictions(); + assert (succ); + // TODO better test by trying out + } + +} diff --git a/win_installer/create_installer.nsi b/win_installer/create_installer.nsi index 9e566514..b62ccb5e 100644 --- a/win_installer/create_installer.nsi +++ b/win_installer/create_installer.nsi @@ -13,7 +13,7 @@ ;Defines !define APPNAME "Kontalk Desktop Client" -!define VERSION "3.0.4" +!define VERSION "3.1" !define JARNAME "KontalkDesktopApp.jar" !define WEBSITE "kontalk.org" !define ICON "kontalk.ico"