/* * Kontalk Java client * Copyright (C) 2016 Kontalk Devteam <devteam@kontalk.org> * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see <http://www.gnu.org/licenses/>. */ package org.kontalk.model; import java.sql.ResultSet; import java.sql.SQLException; import java.util.Arrays; import java.util.Date; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Observable; import java.util.Optional; import java.util.logging.Level; import java.util.logging.Logger; import org.kontalk.misc.JID; import org.kontalk.misc.Searchable; 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"/"weak" field: contact gets deleted * when no group chat exists anymore * * @author Alexander Bikadorov {@literal <bikaejkb@mail.tu-berlin.de>} */ public final class Contact extends Observable implements Searchable { private static final Logger LOGGER = Logger.getLogger(Contact.class.getName()); /** * Online status of one contact. */ public enum Online {UNKNOWN, YES, NO, ERROR} /** * XMPP subscription status in roster. */ public enum Subscription { UNKNOWN, PENDING, SUBSCRIBED, UNSUBSCRIBED } public enum ViewChange { JID, NAME, LAST_SEEN, ONLINE_STATUS, KEY, BLOCKING, SUBSCRIPTION, AVATAR, DELETED } public static final String TABLE = "user"; public static final String COL_JID = "jid"; public static final String COL_NAME = "name"; public static final String COL_STAT = "status"; public static final String COL_LAST_SEEN = "last_seen"; 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, " + COL_NAME + " TEXT, " + COL_STAT + " TEXT, " + COL_LAST_SEEN + " INTEGER, " + // boolean, send messages encrypted? COL_ENCR + " INTEGER NOT NULL, " + COL_PUB_KEY + " TEXT, " + COL_KEY_FP + " TEXT," + COL_AVATAR_ID + " TEXT" + ")"; private final int mID; private JID mJID; private String mName; private String mStatus = ""; private Date mLastSeen = null; private Online mOnline = Online.UNKNOWN; // not in database private boolean mEncrypted = true; private String mKey = ""; private String mFingerprint = ""; private boolean mBlocked = false; private Subscription mSubStatus = Subscription.UNKNOWN; // not in database //private ItemType mType; private Avatar.DefaultAvatar mAvatar = null; private Avatar.CustomAvatar mCustomAvatar = null; private boolean mSaveOnShutdown = false; // new contact (eg from roster) Contact(JID jid, String name) { mJID = jid; mName = name; // insert List<Object> 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"); } // loading from database private Contact( int id, JID jid, String name, String status, Optional<Date> lastSeen, boolean encrypted, String publicKey, String fingerprint, String avatarID) { mID = id; mJID = jid; mName = name; mStatus = status; mLastSeen = lastSeen.orElse(null); mEncrypted = encrypted; mKey = publicKey; mFingerprint = fingerprint.toLowerCase(); mAvatar = avatarID.isEmpty() ? null : Avatar.DefaultAvatar.load(avatarID).orElse(null); mCustomAvatar = Avatar.CustomAvatar.load(mID).orElse(null); } public JID getJID() { return mJID; } void setJID(JID jid) { if (jid.equals(mJID)) return; if (!jid.isValid()) { LOGGER.warning("jid is not valid: "+jid); return; } mJID = jid; this.save(); this.changed(ViewChange.JID); } public int getID() { return mID; } public String getName() { return mName; } public void setName(String name) { if (name.equals(mName)) return; mName = name; this.save(); this.changed(ViewChange.NAME); } public String getStatus() { return mStatus; } public Optional<Date> getLastSeen() { return Optional.ofNullable(mLastSeen); } public void setLastSeen(Date lastSeen, String status) { if (!lastSeen.equals(mLastSeen)) { mLastSeen = lastSeen; mSaveOnShutdown = true; this.changed(ViewChange.LAST_SEEN); } if (!status.isEmpty() && !status.equals(mStatus)) { mStatus = status; mSaveOnShutdown = true; // notify on status change not required } } public boolean getEncrypted() { return mEncrypted; } public void setEncrypted(boolean encrypted) { if (encrypted == mEncrypted) return; mEncrypted = encrypted; this.save(); } public Online getOnline() { return this.mOnline; } public void setStatusText(String status) { if (mStatus.equals(status)) return; mStatus = status; mSaveOnShutdown = true; // notify on status change not required } public void setOnlineStatus(Online onlineStatus) { if (onlineStatus == mOnline) return; if (onlineStatus == Online.YES || (onlineStatus == Online.NO && mOnline == Online.YES)) { mLastSeen = new Date(); mSaveOnShutdown = true; // notify on last_seen change not required here } mOnline = onlineStatus; this.changed(ViewChange.ONLINE_STATUS); } public byte[] getKey() { return EncodingUtils.base64ToBytes(mKey); } public boolean hasKey() { return !mKey.isEmpty(); } public String getFingerprint() { return mFingerprint; } public void setKey(byte[] rawKey, String fingerprint) { if (!mKey.isEmpty()) LOGGER.info("overwriting public key of contact: "+this); mKey = EncodingUtils.bytesToBase64(rawKey); mFingerprint = fingerprint.toLowerCase(); this.save(); this.changed(ViewChange.KEY); } public boolean isBlocked() { return mBlocked; } public void setBlocked(boolean blocked) { mBlocked = blocked; this.changed(ViewChange.BLOCKING); } public Subscription getSubScription() { return mSubStatus; } public void setSubscriptionStatus(Subscription status) { if (status == mSubStatus) return; mSubStatus = status; this.changed(ViewChange.SUBSCRIPTION); } public Optional<Avatar> getAvatar() { return Optional.ofNullable(mAvatar); } /** Get custom or downloaded avatar. */ public Optional<Avatar> getDisplayAvatar() { return mCustomAvatar != null ? Optional.of(mCustomAvatar) : Optional.ofNullable(mAvatar); } public void setAvatar(Avatar.DefaultAvatar avatar) { // delete old if (mAvatar != null) mAvatar.delete(); // set new mAvatar = avatar; this.save(); if (mCustomAvatar == null) this.changed(ViewChange.AVATAR); } public void deleteAvatar() { if (mAvatar == null) return; mAvatar.delete(); mAvatar = null; this.save(); this.changed(ViewChange.AVATAR); } public void setCustomAvatar(Avatar.CustomAvatar avatar) { // overwrite file! mCustomAvatar = avatar; this.changed(ViewChange.AVATAR); } public boolean hasCustomAvatarSet() { return mCustomAvatar != null; } public void deleteCustomAvatar() { if (mCustomAvatar == null) return; mCustomAvatar.delete(); mCustomAvatar = null; this.changed(ViewChange.AVATAR); } public boolean isMe() { return mJID.isValid() && mJID.equals(Model.getUserJID()); } public boolean isKontalkUser(){ return XMPPUtils.isKontalkJID(mJID); } /** * 'Delete' this contact: faked by resetting all values. */ void setDeleted() { LOGGER.config("contact: "+this); mJID = JID.deleted(mID); mName = ""; mStatus = ""; mLastSeen = null; mEncrypted = false; mKey = ""; mFingerprint = ""; if (mAvatar != null) mAvatar.delete(); mAvatar = null; this.save(); this.changed(ViewChange.DELETED); } public boolean isDeleted() { return mJID.string().equals(Integer.toString(mID)); } void onShutDown() { if (!this.isDeleted() && mSaveOnShutdown) this.save(); } private void save() { Map<String, Object> set = new HashMap<>(); set.put(COL_JID, mJID); set.put(COL_NAME, mName); set.put(COL_STAT, mStatus); set.put(COL_LAST_SEEN, mLastSeen); set.put(COL_ENCR, mEncrypted); set.put(COL_PUB_KEY, Database.setString(mKey)); set.put(COL_KEY_FP, Database.setString(mFingerprint)); set.put(COL_AVATAR_ID, Database.setString(mAvatar != null ? mAvatar.getID() : "")); Model.database().execUpdate(TABLE, set, mID); mSaveOnShutdown = false; } private void changed(ViewChange change) { this.setChanged(); this.notifyObservers(change); } @Override public boolean contains(String search) { return this.getName().toLowerCase().contains(search) || this.getJID().string().toLowerCase().contains(search); } @Override public final boolean equals(Object o) { if (o == this) return true; if (!(o instanceof Contact)) return false; return mID == ((Contact) o).mID; } @Override public int hashCode() { return Objects.hash(mID); } @Override public String toString() { return "C:id="+mID+",jid="+mJID+",name="+mName+",fp="+mFingerprint +",subsc="+mSubStatus; } static Contact load(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); 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); String avatarID = Database.getString(rs, Contact.COL_AVATAR_ID); return new Contact(id, jid, name, status, Optional.ofNullable(lastSeen), encr, key, fp, avatarID); } }