package org.yaxim.androidclient.service; import java.io.File; import java.util.Collection; import java.util.Date; import javax.net.ssl.SSLContext; import javax.net.ssl.X509TrustManager; import de.duenndns.ssl.MemorizingTrustManager; import org.jivesoftware.smack.AccountManager; import org.jivesoftware.smack.ConnectionConfiguration; import org.jivesoftware.smack.ConnectionListener; import org.jivesoftware.smack.PacketListener; import org.jivesoftware.smack.Roster; import org.jivesoftware.smack.RosterEntry; import org.jivesoftware.smack.RosterGroup; import org.jivesoftware.smack.RosterListener; import org.jivesoftware.smack.SmackConfiguration; import org.jivesoftware.smack.XMPPConnection; import org.jivesoftware.smack.XMPPException; import org.jivesoftware.smack.filter.PacketFilter; import org.jivesoftware.smack.filter.PacketTypeFilter; import org.jivesoftware.smack.packet.IQ; import org.jivesoftware.smack.packet.IQ.Type; import org.jivesoftware.smack.packet.Message; import org.jivesoftware.smack.packet.Packet; import org.jivesoftware.smack.packet.Presence; import org.jivesoftware.smack.packet.Presence.Mode; import org.jivesoftware.smack.provider.ProviderManager; import org.jivesoftware.smack.util.DNSUtil; import org.jivesoftware.smack.util.StringUtils; import org.jivesoftware.smack.util.dns.DNSJavaResolver; import org.jivesoftware.smackx.entitycaps.EntityCapsManager; import org.jivesoftware.smackx.entitycaps.cache.SimpleDirectoryPersistentCache; import org.jivesoftware.smackx.ServiceDiscoveryManager; import org.jivesoftware.smackx.carbons.Carbon; import org.jivesoftware.smackx.carbons.CarbonManager; import org.jivesoftware.smackx.entitycaps.provider.CapsExtensionProvider; import org.jivesoftware.smackx.forward.Forwarded; import org.jivesoftware.smackx.provider.DelayInfoProvider; import org.jivesoftware.smackx.provider.DiscoverInfoProvider; import org.jivesoftware.smackx.provider.DiscoverItemsProvider; import org.jivesoftware.smackx.packet.DelayInformation; import org.jivesoftware.smackx.packet.DelayInfo; import org.jivesoftware.smackx.packet.DiscoverInfo; import org.jivesoftware.smackx.packet.Version; import org.jivesoftware.smackx.ping.PingManager; import org.jivesoftware.smackx.ping.packet.*; import org.jivesoftware.smackx.ping.provider.PingProvider; import org.jivesoftware.smackx.receipts.DeliveryReceipt; import org.jivesoftware.smackx.receipts.DeliveryReceiptManager; import org.jivesoftware.smackx.receipts.DeliveryReceiptRequest; import org.jivesoftware.smackx.receipts.ReceiptReceivedListener; import org.yaxim.androidclient.YaximApplication; import org.yaxim.androidclient.data.ChatProvider; import org.yaxim.androidclient.data.RosterProvider; import org.yaxim.androidclient.data.YaximConfiguration; import org.yaxim.androidclient.data.ChatProvider.ChatConstants; import org.yaxim.androidclient.data.RosterProvider.RosterConstants; import org.yaxim.androidclient.exceptions.YaximXMPPException; import org.yaxim.androidclient.util.ConnectionState; import org.yaxim.androidclient.util.LogConstants; import org.yaxim.androidclient.util.PreferenceConstants; import org.yaxim.androidclient.util.StatusMode; import android.app.AlarmManager; import android.app.PendingIntent; import android.app.Service; import android.content.BroadcastReceiver; import android.content.ContentResolver; import android.content.ContentValues; import android.content.Context; import android.content.Intent; import android.content.IntentFilter; import android.database.Cursor; import android.net.Uri; import android.util.Log; public class SmackableImp implements Smackable { final static private String TAG = "yaxim.SmackableImp"; final static private int PACKET_TIMEOUT = 30000; final static private String[] SEND_OFFLINE_PROJECTION = new String[] { ChatConstants._ID, ChatConstants.JID, ChatConstants.MESSAGE, ChatConstants.DATE, ChatConstants.PACKET_ID }; final static private String SEND_OFFLINE_SELECTION = ChatConstants.DIRECTION + " = " + ChatConstants.OUTGOING + " AND " + ChatConstants.DELIVERY_STATUS + " = " + ChatConstants.DS_NEW; static final DiscoverInfo.Identity YAXIM_IDENTITY = new DiscoverInfo.Identity("client", YaximApplication.XMPP_IDENTITY_NAME, YaximApplication.XMPP_IDENTITY_TYPE); static File capsCacheDir = null; ///< this is used to cache if we already initialized EntityCapsCache static { registerSmackProviders(); DNSUtil.setDNSResolver(DNSJavaResolver.getInstance()); // initialize smack defaults before any connections are created SmackConfiguration.setPacketReplyTimeout(PACKET_TIMEOUT); SmackConfiguration.setDefaultPingInterval(0); } static void registerSmackProviders() { ProviderManager pm = ProviderManager.getInstance(); // add IQ handling pm.addIQProvider("query","http://jabber.org/protocol/disco#info", new DiscoverInfoProvider()); pm.addIQProvider("query","http://jabber.org/protocol/disco#items", new DiscoverItemsProvider()); // add delayed delivery notifications pm.addExtensionProvider("delay","urn:xmpp:delay", new DelayInfoProvider()); pm.addExtensionProvider("x","jabber:x:delay", new DelayInfoProvider()); // add XEP-0092 Software Version pm.addIQProvider("query", Version.NAMESPACE, new Version.Provider()); // add carbons and forwarding pm.addExtensionProvider("forwarded", Forwarded.NAMESPACE, new Forwarded.Provider()); pm.addExtensionProvider("sent", Carbon.NAMESPACE, new Carbon.Provider()); pm.addExtensionProvider("received", Carbon.NAMESPACE, new Carbon.Provider()); // add delivery receipts pm.addExtensionProvider(DeliveryReceipt.ELEMENT, DeliveryReceipt.NAMESPACE, new DeliveryReceipt.Provider()); pm.addExtensionProvider(DeliveryReceiptRequest.ELEMENT, DeliveryReceipt.NAMESPACE, new DeliveryReceiptRequest.Provider()); // add XMPP Ping (XEP-0199) pm.addIQProvider("ping","urn:xmpp:ping", new PingProvider()); ServiceDiscoveryManager.setDefaultIdentity(YAXIM_IDENTITY); // XEP-0115 Entity Capabilities pm.addExtensionProvider("c", "http://jabber.org/protocol/caps", new CapsExtensionProvider()); XmppStreamHandler.addExtensionProviders(); } private final YaximConfiguration mConfig; private ConnectionConfiguration mXMPPConfig; private XmppStreamHandler.ExtXMPPConnection mXMPPConnection; private XmppStreamHandler mStreamHandler; private Thread mConnectingThread; private Object mConnectingThreadMutex = new Object(); private ConnectionState mRequestedState = ConnectionState.OFFLINE; private ConnectionState mState = ConnectionState.OFFLINE; private String mLastError; private XMPPServiceCallback mServiceCallBack; private Roster mRoster; private RosterListener mRosterListener; private PacketListener mPacketListener; private PacketListener mPresenceListener; private ConnectionListener mConnectionListener; private final ContentResolver mContentResolver; private AlarmManager mAlarmManager; private PacketListener mPongListener; private String mPingID; private long mPingTimestamp; private PendingIntent mPingAlarmPendIntent; private PendingIntent mPongTimeoutAlarmPendIntent; private static final String PING_ALARM = "org.yaxim.androidclient.PING_ALARM"; private static final String PONG_TIMEOUT_ALARM = "org.yaxim.androidclient.PONG_TIMEOUT_ALARM"; private Intent mPingAlarmIntent = new Intent(PING_ALARM); private Intent mPongTimeoutAlarmIntent = new Intent(PONG_TIMEOUT_ALARM); private Service mService; private PongTimeoutAlarmReceiver mPongTimeoutAlarmReceiver = new PongTimeoutAlarmReceiver(); private BroadcastReceiver mPingAlarmReceiver = new PingAlarmReceiver(); public SmackableImp(YaximConfiguration config, ContentResolver contentResolver, Service service) { this.mConfig = config; this.mContentResolver = contentResolver; this.mService = service; this.mAlarmManager = (AlarmManager)mService.getSystemService(Context.ALARM_SERVICE); } // this code runs a DNS resolver, might be blocking private synchronized void initXMPPConnection() { // allow custom server / custom port to override SRV record if (mConfig.customServer.length() > 0) mXMPPConfig = new ConnectionConfiguration(mConfig.customServer, mConfig.port, mConfig.server); else mXMPPConfig = new ConnectionConfiguration(mConfig.server); // use SRV mXMPPConfig.setReconnectionAllowed(false); mXMPPConfig.setSendPresence(false); mXMPPConfig.setCompressionEnabled(false); // disable for now mXMPPConfig.setDebuggerEnabled(mConfig.smackdebug); if (mConfig.require_ssl) this.mXMPPConfig.setSecurityMode(ConnectionConfiguration.SecurityMode.required); // register MemorizingTrustManager for HTTPS try { SSLContext sc = SSLContext.getInstance("TLS"); MemorizingTrustManager mtm = YaximApplication.getApp(mService).mMTM; sc.init(null, new X509TrustManager[] { mtm }, new java.security.SecureRandom()); this.mXMPPConfig.setCustomSSLContext(sc); this.mXMPPConfig.setHostnameVerifier(mtm.wrapHostnameVerifier( new org.apache.http.conn.ssl.StrictHostnameVerifier())); } catch (java.security.GeneralSecurityException e) { debugLog("initialize MemorizingTrustManager: " + e); } this.mXMPPConnection = new XmppStreamHandler.ExtXMPPConnection(mXMPPConfig); this.mStreamHandler = new XmppStreamHandler(mXMPPConnection, mConfig.smackdebug); mStreamHandler.addAckReceivedListener(new XmppStreamHandler.AckReceivedListener() { public void ackReceived(long handled, long total) { gotServerPong("" + handled); } }); mConfig.reconnect_required = false; initServiceDiscovery(); } // blocking, run from a thread! public boolean doConnect(boolean create_account) throws YaximXMPPException { mRequestedState = ConnectionState.ONLINE; updateConnectionState(ConnectionState.CONNECTING); if (mXMPPConnection == null || mConfig.reconnect_required) initXMPPConnection(); tryToConnect(create_account); // actually, authenticated must be true now, or an exception must have // been thrown. if (isAuthenticated()) { registerMessageListener(); registerPresenceListener(); registerPongListener(); sendOfflineMessages(); // we need to "ping" the service to let it know we are actually // connected, even when no roster entries will come in updateConnectionState(ConnectionState.ONLINE); } else throw new YaximXMPPException("SMACK connected, but authentication failed"); return true; } // BLOCKING, call on a new Thread! private void updateConnectingThread(Thread new_thread) { synchronized(mConnectingThreadMutex) { if (mConnectingThread == null) { mConnectingThread = new_thread; } else try { Log.d(TAG, "updateConnectingThread: old thread is still running, killing it."); mConnectingThread.interrupt(); mConnectingThread.join(50); } catch (InterruptedException e) { Log.d(TAG, "updateConnectingThread: failed to join(): " + e); } finally { mConnectingThread = new_thread; } } } private void finishConnectingThread() { synchronized(mConnectingThreadMutex) { mConnectingThread = null; } } /** Non-blocking, synchronized function to connect/disconnect XMPP. * This code is called from outside and returns immediately. The actual work * is done on a background thread, and notified via callback. * @param new_state The state to transition into. Possible values: * OFFLINE to properly close the connection * ONLINE to connect * DISCONNECTED when network goes down * @param create_account When going online, try to register an account. */ @Override public synchronized void requestConnectionState(ConnectionState new_state, final boolean create_account) { Log.d(TAG, "requestConnState: " + mState + " -> " + new_state + (create_account ? " create_account!" : "")); mRequestedState = new_state; if (new_state == mState) return; switch (new_state) { case ONLINE: switch (mState) { case RECONNECT_DELAYED: // TODO: cancel timer case RECONNECT_NETWORK: case OFFLINE: // update state before starting thread to prevent race conditions updateConnectionState(ConnectionState.CONNECTING); // register ping (connection) timeout handler: 2*PACKET_TIMEOUT(30s) + 3s registerPongTimeout(2*PACKET_TIMEOUT + 3000, "connection"); new Thread() { @Override public void run() { updateConnectingThread(this); try { doConnect(create_account); } catch (IllegalArgumentException e) { // this might happen when DNS resolution in ConnectionConfiguration fails onDisconnected(e); } catch (YaximXMPPException e) { onDisconnected(e); } finally { mAlarmManager.cancel(mPongTimeoutAlarmPendIntent); finishConnectingThread(); } } }.start(); break; case CONNECTING: case DISCONNECTING: // ignore all other cases break; } break; case DISCONNECTED: // spawn thread to do disconnect if (mState == ConnectionState.ONLINE) { // update state before starting thread to prevent race conditions updateConnectionState(ConnectionState.DISCONNECTING); // register ping (connection) timeout handler: PACKET_TIMEOUT(30s) registerPongTimeout(PACKET_TIMEOUT, "forced disconnect"); new Thread() { public void run() { updateConnectingThread(this); mStreamHandler.quickShutdown(); onDisconnected("forced disconnect completed"); finishConnectingThread(); //updateConnectionState(ConnectionState.OFFLINE); } }.start(); } break; case OFFLINE: switch (mState) { case CONNECTING: case ONLINE: // update state before starting thread to prevent race conditions updateConnectionState(ConnectionState.DISCONNECTING); // register ping (connection) timeout handler: PACKET_TIMEOUT(30s) registerPongTimeout(PACKET_TIMEOUT, "manual disconnect"); // spawn thread to do disconnect new Thread() { public void run() { updateConnectingThread(this); mXMPPConnection.shutdown(); mStreamHandler.close(); mAlarmManager.cancel(mPongTimeoutAlarmPendIntent); // we should reset XMPPConnection the next time mConfig.reconnect_required = true; finishConnectingThread(); // reconnect if it was requested in the meantime if (mRequestedState == ConnectionState.ONLINE) requestConnectionState(ConnectionState.ONLINE); } }.start(); break; case DISCONNECTING: break; case RECONNECT_DELAYED: // TODO: clear timer case RECONNECT_NETWORK: updateConnectionState(ConnectionState.OFFLINE); } break; case RECONNECT_NETWORK: case RECONNECT_DELAYED: switch (mState) { case DISCONNECTED: case RECONNECT_NETWORK: case RECONNECT_DELAYED: updateConnectionState(new_state); break; default: throw new IllegalArgumentException("Can not go from " + mState + " to " + new_state); } } } @Override public void requestConnectionState(ConnectionState new_state) { requestConnectionState(new_state, false); } @Override public ConnectionState getConnectionState() { return mState; } // called at the end of a state transition private synchronized void updateConnectionState(ConnectionState new_state) { if (new_state == ConnectionState.ONLINE || new_state == ConnectionState.CONNECTING) mLastError = null; Log.d(TAG, "updateConnectionState: " + mState + " -> " + new_state + " (" + mLastError + ")"); if (new_state == mState) return; mState = new_state; if (mServiceCallBack != null) mServiceCallBack.connectionStateChanged(); } private void initServiceDiscovery() { // register connection features ServiceDiscoveryManager sdm = ServiceDiscoveryManager.getInstanceFor(mXMPPConnection); // init Entity Caps manager with storage in app's cache dir try { if (capsCacheDir == null) { capsCacheDir = new File(mService.getCacheDir(), "entity-caps-cache"); capsCacheDir.mkdirs(); EntityCapsManager.setPersistentCache(new SimpleDirectoryPersistentCache(capsCacheDir)); } } catch (java.io.IOException e) { Log.e(TAG, "Could not init Entity Caps cache: " + e.getLocalizedMessage()); } // reference PingManager, set ping flood protection to 10s PingManager.getInstanceFor(mXMPPConnection).setPingMinimumInterval(10*1000); // set Version for replies String app_name = mService.getString(org.yaxim.androidclient.R.string.app_name); String build_revision = mService.getString(org.yaxim.androidclient.R.string.build_revision); Version.Manager.getInstanceFor(mXMPPConnection).setVersion( new Version(app_name, build_revision, "Android")); // reference DeliveryReceiptManager, add listener DeliveryReceiptManager dm = DeliveryReceiptManager.getInstanceFor(mXMPPConnection); dm.enableAutoReceipts(); dm.addReceiptReceivedListener(new ReceiptReceivedListener() { // DOES NOT WORK IN CARBONS public void onReceiptReceived(String fromJid, String toJid, String receiptId) { Log.d(TAG, "got delivery receipt for " + receiptId); changeMessageDeliveryStatus(receiptId, ChatConstants.DS_ACKED); }}); } public void addRosterItem(String user, String alias, String group) throws YaximXMPPException { tryToAddRosterEntry(user, alias, group); } public void removeRosterItem(String user) throws YaximXMPPException { debugLog("removeRosterItem(" + user + ")"); tryToRemoveRosterEntry(user); mServiceCallBack.rosterChanged(); } public void renameRosterItem(String user, String newName) throws YaximXMPPException { RosterEntry rosterEntry = mRoster.getEntry(user); if (!(newName.length() > 0) || (rosterEntry == null)) { throw new YaximXMPPException("JabberID to rename is invalid!"); } rosterEntry.setName(newName); } public void addRosterGroup(String group) { mRoster.createGroup(group); } public void renameRosterGroup(String group, String newGroup) { RosterGroup groupToRename = mRoster.getGroup(group); groupToRename.setName(newGroup); } public void moveRosterItemToGroup(String user, String group) throws YaximXMPPException { tryToMoveRosterEntryToGroup(user, group); } public void sendPresenceRequest(String user, String type) { // HACK: remove the fake roster entry added by handleIncomingSubscribe() if ("unsubscribed".equals(type)) deleteRosterEntryFromDB(user); Presence response = new Presence(Presence.Type.valueOf(type)); response.setTo(user); mXMPPConnection.sendPacket(response); } @Override public String changePassword(String newPassword) { try { new AccountManager(mXMPPConnection).changePassword(newPassword); return "OK"; //HACK: hard coded string to differentiate from failure modes } catch (XMPPException e) { if (e.getXMPPError() != null) return e.getXMPPError().toString(); else return e.getLocalizedMessage(); } } private void onDisconnected(String reason) { unregisterPongListener(); mLastError = reason; updateConnectionState(ConnectionState.DISCONNECTED); } private void onDisconnected(Throwable reason) { Log.e(TAG, "onDisconnected: " + reason); reason.printStackTrace(); // iterate through to the deepest exception while (reason.getCause() != null) reason = reason.getCause(); onDisconnected(reason.getLocalizedMessage()); } private void tryToConnect(boolean create_account) throws YaximXMPPException { try { if (mXMPPConnection.isConnected()) { try { mStreamHandler.quickShutdown(); // blocking shutdown prior to re-connection } catch (Exception e) { debugLog("conn.shutdown() failed: " + e); } } registerRosterListener(); boolean need_bind = !mStreamHandler.isResumePossible(); if (mConnectionListener != null) mXMPPConnection.removeConnectionListener(mConnectionListener); mConnectionListener = new ConnectionListener() { public void connectionClosedOnError(Exception e) { onDisconnected(e); } public void connectionClosed() { // TODO: fix reconnect when we got kicked by the server or SM failed! //onDisconnected(null); updateConnectionState(ConnectionState.OFFLINE); } public void reconnectingIn(int seconds) { } public void reconnectionFailed(Exception e) { } public void reconnectionSuccessful() { } }; mXMPPConnection.addConnectionListener(mConnectionListener); mXMPPConnection.connect(need_bind); // SMACK auto-logins if we were authenticated before if (!mXMPPConnection.isAuthenticated()) { if (create_account) { Log.d(TAG, "creating new server account..."); AccountManager am = new AccountManager(mXMPPConnection); am.createAccount(mConfig.userName, mConfig.password); } mXMPPConnection.login(mConfig.userName, mConfig.password, mConfig.ressource); } Log.d(TAG, "SM: can resume = " + mStreamHandler.isResumePossible() + " needbind=" + need_bind); if (need_bind) { mStreamHandler.notifyInitialLogin(); setStatusFromConfig(); } } catch (Exception e) { // actually we just care for IllegalState or NullPointer or XMPPEx. throw new YaximXMPPException("tryToConnect failed", e); } } private void tryToMoveRosterEntryToGroup(String userName, String groupName) throws YaximXMPPException { RosterGroup rosterGroup = getRosterGroup(groupName); RosterEntry rosterEntry = mRoster.getEntry(userName); removeRosterEntryFromGroups(rosterEntry); if (groupName.length() == 0) return; else { try { rosterGroup.addEntry(rosterEntry); } catch (XMPPException e) { throw new YaximXMPPException("tryToMoveRosterEntryToGroup", e); } } } private RosterGroup getRosterGroup(String groupName) { RosterGroup rosterGroup = mRoster.getGroup(groupName); // create group if unknown if ((groupName.length() > 0) && rosterGroup == null) { rosterGroup = mRoster.createGroup(groupName); } return rosterGroup; } private void removeRosterEntryFromGroups(RosterEntry rosterEntry) throws YaximXMPPException { Collection<RosterGroup> oldGroups = rosterEntry.getGroups(); for (RosterGroup group : oldGroups) { tryToRemoveUserFromGroup(group, rosterEntry); } } private void tryToRemoveUserFromGroup(RosterGroup group, RosterEntry rosterEntry) throws YaximXMPPException { try { group.removeEntry(rosterEntry); } catch (XMPPException e) { throw new YaximXMPPException("tryToRemoveUserFromGroup", e); } } private void tryToRemoveRosterEntry(String user) throws YaximXMPPException { try { RosterEntry rosterEntry = mRoster.getEntry(user); if (rosterEntry != null) { // first, unsubscribe the user Presence unsub = new Presence(Presence.Type.unsubscribed); unsub.setTo(rosterEntry.getUser()); mXMPPConnection.sendPacket(unsub); // then, remove from roster mRoster.removeEntry(rosterEntry); } } catch (XMPPException e) { throw new YaximXMPPException("tryToRemoveRosterEntry", e); } } private void tryToAddRosterEntry(String user, String alias, String group) throws YaximXMPPException { try { mRoster.createEntry(user, alias, new String[] { group }); } catch (XMPPException e) { throw new YaximXMPPException("tryToAddRosterEntry", e); } } private void removeOldRosterEntries() { Log.d(TAG, "removeOldRosterEntries()"); Collection<RosterEntry> rosterEntries = mRoster.getEntries(); StringBuilder exclusion = new StringBuilder(RosterConstants.JID + " NOT IN ("); boolean first = true; for (RosterEntry rosterEntry : rosterEntries) { updateRosterEntryInDB(rosterEntry); if (first) first = false; else exclusion.append(","); exclusion.append("'").append(rosterEntry.getUser()).append("'"); } exclusion.append(")"); int count = mContentResolver.delete(RosterProvider.CONTENT_URI, exclusion.toString(), null); Log.d(TAG, "deleted " + count + " old roster entries"); } // HACK: add an incoming subscription request as a fake roster entry private void handleIncomingSubscribe(Presence request) { final ContentValues values = new ContentValues(); values.put(RosterConstants.JID, request.getFrom()); values.put(RosterConstants.ALIAS, request.getFrom()); values.put(RosterConstants.GROUP, ""); values.put(RosterConstants.STATUS_MODE, getStatusInt(request)); values.put(RosterConstants.STATUS_MESSAGE, request.getStatus()); Uri uri = mContentResolver.insert(RosterProvider.CONTENT_URI, values); debugLog("handleIncomingSubscribe: faked " + uri); } public void setStatusFromConfig() { // TODO: only call this when carbons changed, not on every presence change CarbonManager.getInstanceFor(mXMPPConnection).sendCarbonsEnabled(mConfig.messageCarbons); Presence presence = new Presence(Presence.Type.available); Mode mode = Mode.valueOf(mConfig.statusMode); presence.setMode(mode); presence.setStatus(mConfig.statusMessage); presence.setPriority(mConfig.priority); mXMPPConnection.sendPacket(presence); mConfig.presence_required = false; } public void sendOfflineMessages() { Cursor cursor = mContentResolver.query(ChatProvider.CONTENT_URI, SEND_OFFLINE_PROJECTION, SEND_OFFLINE_SELECTION, null, null); final int _ID_COL = cursor.getColumnIndexOrThrow(ChatConstants._ID); final int JID_COL = cursor.getColumnIndexOrThrow(ChatConstants.JID); final int MSG_COL = cursor.getColumnIndexOrThrow(ChatConstants.MESSAGE); final int TS_COL = cursor.getColumnIndexOrThrow(ChatConstants.DATE); final int PACKETID_COL = cursor.getColumnIndexOrThrow(ChatConstants.PACKET_ID); ContentValues mark_sent = new ContentValues(); mark_sent.put(ChatConstants.DELIVERY_STATUS, ChatConstants.DS_SENT_OR_READ); while (cursor.moveToNext()) { int _id = cursor.getInt(_ID_COL); String toJID = cursor.getString(JID_COL); String message = cursor.getString(MSG_COL); String packetID = cursor.getString(PACKETID_COL); long ts = cursor.getLong(TS_COL); Log.d(TAG, "sendOfflineMessages: " + toJID + " > " + message); final Message newMessage = new Message(toJID, Message.Type.chat); newMessage.setBody(message); DelayInformation delay = new DelayInformation(new Date(ts)); newMessage.addExtension(delay); newMessage.addExtension(new DelayInfo(delay)); newMessage.addExtension(new DeliveryReceiptRequest()); if ((packetID != null) && (packetID.length() > 0)) { newMessage.setPacketID(packetID); } else { packetID = newMessage.getPacketID(); mark_sent.put(ChatConstants.PACKET_ID, packetID); } Uri rowuri = Uri.parse("content://" + ChatProvider.AUTHORITY + "/" + ChatProvider.TABLE_NAME + "/" + _id); mContentResolver.update(rowuri, mark_sent, null, null); mXMPPConnection.sendPacket(newMessage); // must be after marking delivered, otherwise it may override the SendFailListener } cursor.close(); } public static void sendOfflineMessage(ContentResolver cr, String toJID, String message) { ContentValues values = new ContentValues(); values.put(ChatConstants.DIRECTION, ChatConstants.OUTGOING); values.put(ChatConstants.JID, toJID); values.put(ChatConstants.MESSAGE, message); values.put(ChatConstants.DELIVERY_STATUS, ChatConstants.DS_NEW); values.put(ChatConstants.DATE, System.currentTimeMillis()); cr.insert(ChatProvider.CONTENT_URI, values); } public void sendReceipt(String toJID, String id) { Log.d(TAG, "sending XEP-0184 ack to " + toJID + " id=" + id); final Message ack = new Message(toJID, Message.Type.normal); ack.addExtension(new DeliveryReceipt(id)); mXMPPConnection.sendPacket(ack); } public void sendMessage(String toJID, String message) { final Message newMessage = new Message(toJID, Message.Type.chat); newMessage.setBody(message); newMessage.addExtension(new DeliveryReceiptRequest()); if (isAuthenticated()) { addChatMessageToDB(ChatConstants.OUTGOING, toJID, message, ChatConstants.DS_SENT_OR_READ, System.currentTimeMillis(), newMessage.getPacketID()); mXMPPConnection.sendPacket(newMessage); } else { // send offline -> store to DB addChatMessageToDB(ChatConstants.OUTGOING, toJID, message, ChatConstants.DS_NEW, System.currentTimeMillis(), newMessage.getPacketID()); } } public boolean isAuthenticated() { if (mXMPPConnection != null) { return (mXMPPConnection.isConnected() && mXMPPConnection .isAuthenticated()); } return false; } public void registerCallback(XMPPServiceCallback callBack) { this.mServiceCallBack = callBack; mService.registerReceiver(mPingAlarmReceiver, new IntentFilter(PING_ALARM)); mService.registerReceiver(mPongTimeoutAlarmReceiver, new IntentFilter(PONG_TIMEOUT_ALARM)); } public void unRegisterCallback() { debugLog("unRegisterCallback()"); // remove callbacks _before_ tossing old connection try { mXMPPConnection.getRoster().removeRosterListener(mRosterListener); mXMPPConnection.removePacketListener(mPacketListener); mXMPPConnection.removePacketListener(mPresenceListener); mXMPPConnection.removePacketListener(mPongListener); unregisterPongListener(); } catch (Exception e) { // ignore it! e.printStackTrace(); } requestConnectionState(ConnectionState.OFFLINE); setStatusOffline(); mService.unregisterReceiver(mPingAlarmReceiver); mService.unregisterReceiver(mPongTimeoutAlarmReceiver); this.mServiceCallBack = null; } public String getNameForJID(String jid) { if (null != this.mRoster.getEntry(jid) && null != this.mRoster.getEntry(jid).getName() && this.mRoster.getEntry(jid).getName().length() > 0) { return this.mRoster.getEntry(jid).getName(); } else { return jid; } } private void setStatusOffline() { ContentValues values = new ContentValues(); values.put(RosterConstants.STATUS_MODE, StatusMode.offline.ordinal()); mContentResolver.update(RosterProvider.CONTENT_URI, values, null, null); } private void registerRosterListener() { // flush roster on connecting. mRoster = mXMPPConnection.getRoster(); mRoster.setSubscriptionMode(Roster.SubscriptionMode.manual); if (mRosterListener != null) mRoster.removeRosterListener(mRosterListener); mRosterListener = new RosterListener() { private boolean first_roster = true; public void entriesAdded(Collection<String> entries) { debugLog("entriesAdded(" + entries + ")"); ContentValues[] cvs = new ContentValues[entries.size()]; int i = 0; for (String entry : entries) { RosterEntry rosterEntry = mRoster.getEntry(entry); cvs[i++] = getContentValuesForRosterEntry(rosterEntry); } mContentResolver.bulkInsert(RosterProvider.CONTENT_URI, cvs); // when getting the roster in the beginning, remove remains of old one if (first_roster) { removeOldRosterEntries(); first_roster = false; mServiceCallBack.rosterChanged(); } debugLog("entriesAdded() done"); } public void entriesDeleted(Collection<String> entries) { debugLog("entriesDeleted(" + entries + ")"); for (String entry : entries) { deleteRosterEntryFromDB(entry); } mServiceCallBack.rosterChanged(); } public void entriesUpdated(Collection<String> entries) { debugLog("entriesUpdated(" + entries + ")"); for (String entry : entries) { RosterEntry rosterEntry = mRoster.getEntry(entry); updateRosterEntryInDB(rosterEntry); } mServiceCallBack.rosterChanged(); } public void presenceChanged(Presence presence) { debugLog("presenceChanged(" + presence.getFrom() + "): " + presence); String jabberID = getBareJID(presence.getFrom()); RosterEntry rosterEntry = mRoster.getEntry(jabberID); if (rosterEntry != null) { updateRosterEntryInDB(rosterEntry); mServiceCallBack.rosterChanged(); } } }; mRoster.addRosterListener(mRosterListener); } private String getBareJID(String from) { String[] res = from.split("/"); return res[0].toLowerCase(); } public boolean changeMessageDeliveryStatus(String packetID, int new_status) { ContentValues cv = new ContentValues(); cv.put(ChatConstants.DELIVERY_STATUS, new_status); Uri rowuri = Uri.parse("content://" + ChatProvider.AUTHORITY + "/" + ChatProvider.TABLE_NAME); return mContentResolver.update(rowuri, cv, ChatConstants.PACKET_ID + " = ? AND " + ChatConstants.DELIVERY_STATUS + " != " + ChatConstants.DS_ACKED + " AND " + ChatConstants.DIRECTION + " = " + ChatConstants.OUTGOING, new String[] { packetID }) > 0; } /** Check the server connection, reconnect if needed. * * This function will try to ping the server if we are connected, and try * to reestablish a connection otherwise. */ public void sendServerPing() { if (mXMPPConnection == null || !mXMPPConnection.isAuthenticated()) { debugLog("Ping: requested, but not connected to server."); requestConnectionState(ConnectionState.ONLINE, false); return; } if (mPingID != null) { debugLog("Ping: requested, but still waiting for " + mPingID); return; // a ping is still on its way } if (mStreamHandler.isSmEnabled()) { debugLog("Ping: sending SM request"); mPingID = "" + mStreamHandler.requestAck(); } else { Ping ping = new Ping(); ping.setType(Type.GET); ping.setTo(mConfig.server); mPingID = ping.getPacketID(); debugLog("Ping: sending ping " + mPingID); mXMPPConnection.sendPacket(ping); } // register ping timeout handler: PACKET_TIMEOUT(30s) + 3s registerPongTimeout(PACKET_TIMEOUT + 3000, mPingID); } private void gotServerPong(String pongID) { long latency = System.currentTimeMillis() - mPingTimestamp; if (pongID != null && pongID.equals(mPingID)) Log.i(TAG, String.format("Ping: server latency %1.3fs", latency/1000.)); else Log.i(TAG, String.format("Ping: server latency %1.3fs (estimated)", latency/1000.)); mPingID = null; mAlarmManager.cancel(mPongTimeoutAlarmPendIntent); } /** Register a "pong" timeout on the connection. */ private void registerPongTimeout(long wait_time, String id) { mPingID = id; mPingTimestamp = System.currentTimeMillis(); debugLog(String.format("Ping: registering timeout for %s: %1.3fs", id, wait_time/1000.)); mAlarmManager.set(AlarmManager.RTC_WAKEUP, System.currentTimeMillis() + wait_time, mPongTimeoutAlarmPendIntent); } /** * BroadcastReceiver to trigger reconnect on pong timeout. */ private class PongTimeoutAlarmReceiver extends BroadcastReceiver { public void onReceive(Context ctx, Intent i) { debugLog("Ping: timeout for " + mPingID); onDisconnected("Ping timeout"); } } /** * BroadcastReceiver to trigger sending pings to the server */ private class PingAlarmReceiver extends BroadcastReceiver { public void onReceive(Context ctx, Intent i) { sendServerPing(); } } /** * Registers a smack packet listener for IQ packets, intended to recognize "pongs" with * a packet id matching the last "ping" sent to the server. * * Also sets up the AlarmManager Timer plus necessary intents. */ private void registerPongListener() { // reset ping expectation on new connection mPingID = null; if (mPongListener != null) mXMPPConnection.removePacketListener(mPongListener); mPongListener = new PacketListener() { @Override public void processPacket(Packet packet) { if (packet == null) return; gotServerPong(packet.getPacketID()); } }; mXMPPConnection.addPacketListener(mPongListener, new PacketTypeFilter(IQ.class)); mPingAlarmPendIntent = PendingIntent.getBroadcast(mService.getApplicationContext(), 0, mPingAlarmIntent, PendingIntent.FLAG_UPDATE_CURRENT); mPongTimeoutAlarmPendIntent = PendingIntent.getBroadcast(mService.getApplicationContext(), 0, mPongTimeoutAlarmIntent, PendingIntent.FLAG_UPDATE_CURRENT); mAlarmManager.setInexactRepeating(AlarmManager.RTC_WAKEUP, System.currentTimeMillis() + AlarmManager.INTERVAL_FIFTEEN_MINUTES, AlarmManager.INTERVAL_FIFTEEN_MINUTES, mPingAlarmPendIntent); } private void unregisterPongListener() { mAlarmManager.cancel(mPingAlarmPendIntent); mAlarmManager.cancel(mPongTimeoutAlarmPendIntent); } private void registerMessageListener() { // do not register multiple packet listeners if (mPacketListener != null) mXMPPConnection.removePacketListener(mPacketListener); PacketTypeFilter filter = new PacketTypeFilter(Message.class); mPacketListener = new PacketListener() { public void processPacket(Packet packet) { try { if (packet instanceof Message) { Message msg = (Message) packet; String fromJID = getBareJID(msg.getFrom()); int direction = ChatConstants.INCOMING; Carbon cc = CarbonManager.getCarbon(msg); // extract timestamp long ts; DelayInfo timestamp = (DelayInfo)msg.getExtension("delay", "urn:xmpp:delay"); if (timestamp == null) timestamp = (DelayInfo)msg.getExtension("x", "jabber:x:delay"); if (cc != null) // Carbon timestamp overrides packet timestamp timestamp = cc.getForwarded().getDelayInfo(); if (timestamp != null) ts = timestamp.getStamp().getTime(); else ts = System.currentTimeMillis(); // try to extract a carbon if (cc != null) { Log.d(TAG, "carbon: " + cc.toXML()); msg = (Message)cc.getForwarded().getForwardedPacket(); // outgoing carbon: fromJID is actually chat peer's JID if (cc.getDirection() == Carbon.Direction.sent) { fromJID = getBareJID(msg.getTo()); direction = ChatConstants.OUTGOING; } else { fromJID = getBareJID(msg.getFrom()); // hook off carbonated delivery receipts DeliveryReceipt dr = (DeliveryReceipt)msg.getExtension( DeliveryReceipt.ELEMENT, DeliveryReceipt.NAMESPACE); if (dr != null) { Log.d(TAG, "got CC'ed delivery receipt for " + dr.getId()); changeMessageDeliveryStatus(dr.getId(), ChatConstants.DS_ACKED); } } } String chatMessage = msg.getBody(); // display error inline if (msg.getType() == Message.Type.error) { if (changeMessageDeliveryStatus(msg.getPacketID(), ChatConstants.DS_FAILED)) mServiceCallBack.messageError(fromJID, msg.getError().toString(), (cc != null)); return; // we do not want to add errors as "incoming messages" } // ignore empty messages if (chatMessage == null) { Log.d(TAG, "empty message."); return; } // carbons are old. all others are new int is_new = (cc == null) ? ChatConstants.DS_NEW : ChatConstants.DS_SENT_OR_READ; if (msg.getType() == Message.Type.error) is_new = ChatConstants.DS_FAILED; addChatMessageToDB(direction, fromJID, chatMessage, is_new, ts, msg.getPacketID()); if (direction == ChatConstants.INCOMING) mServiceCallBack.newMessage(fromJID, chatMessage, (cc != null)); } } catch (Exception e) { // SMACK silently discards exceptions dropped from processPacket :( Log.e(TAG, "failed to process packet:"); e.printStackTrace(); } } }; mXMPPConnection.addPacketListener(mPacketListener, filter); } private void registerPresenceListener() { // do not register multiple packet listeners if (mPresenceListener != null) mXMPPConnection.removePacketListener(mPresenceListener); mPresenceListener = new PacketListener() { public void processPacket(Packet packet) { try { Presence p = (Presence) packet; switch (p.getType()) { case subscribe: handleIncomingSubscribe(p); break; case unsubscribe: break; } } catch (Exception e) { // SMACK silently discards exceptions dropped from processPacket :( Log.e(TAG, "failed to process presence:"); e.printStackTrace(); } } }; mXMPPConnection.addPacketListener(mPresenceListener, new PacketTypeFilter(Presence.class)); } private void addChatMessageToDB(int direction, String JID, String message, int delivery_status, long ts, String packetID) { ContentValues values = new ContentValues(); values.put(ChatConstants.DIRECTION, direction); values.put(ChatConstants.JID, JID); values.put(ChatConstants.MESSAGE, message); values.put(ChatConstants.DELIVERY_STATUS, delivery_status); values.put(ChatConstants.DATE, ts); values.put(ChatConstants.PACKET_ID, packetID); mContentResolver.insert(ChatProvider.CONTENT_URI, values); } private ContentValues getContentValuesForRosterEntry(final RosterEntry entry) { final ContentValues values = new ContentValues(); values.put(RosterConstants.JID, entry.getUser()); values.put(RosterConstants.ALIAS, getName(entry)); Presence presence = mRoster.getPresence(entry.getUser()); values.put(RosterConstants.STATUS_MODE, getStatusInt(presence)); values.put(RosterConstants.STATUS_MESSAGE, presence.getStatus()); values.put(RosterConstants.GROUP, getGroup(entry.getGroups())); return values; } private void deleteRosterEntryFromDB(final String jabberID) { int count = mContentResolver.delete(RosterProvider.CONTENT_URI, RosterConstants.JID + " = ?", new String[] { jabberID }); debugLog("deleteRosterEntryFromDB: Deleted " + count + " entries"); } private void updateRosterEntryInDB(final RosterEntry entry) { upsertRoster(getContentValuesForRosterEntry(entry), entry.getUser()); } private void upsertRoster(final ContentValues values, String jid) { if (mContentResolver.update(RosterProvider.CONTENT_URI, values, RosterConstants.JID + " = ?", new String[] { jid }) == 0) { mContentResolver.insert(RosterProvider.CONTENT_URI, values); } } private String getGroup(Collection<RosterGroup> groups) { for (RosterGroup group : groups) { return group.getName(); } return ""; } private String getName(RosterEntry rosterEntry) { String name = rosterEntry.getName(); if (name != null && name.length() > 0) { return name; } name = StringUtils.parseName(rosterEntry.getUser()); if (name.length() > 0) { return name; } return rosterEntry.getUser(); } private StatusMode getStatus(Presence presence) { if (presence.getType() == Presence.Type.subscribe) return StatusMode.subscribe; if (presence.getType() == Presence.Type.available) { if (presence.getMode() != null) { return StatusMode.valueOf(presence.getMode().name()); } return StatusMode.available; } return StatusMode.offline; } private int getStatusInt(final Presence presence) { return getStatus(presence).ordinal(); } private void debugLog(String data) { if (LogConstants.LOG_DEBUG) { Log.d(TAG, data); } } @Override public String getLastError() { return mLastError; } }