/* * VoIP.ms SMS * Copyright (C) 2015-2016 Michael Kourlas * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package net.kourlas.voipms_sms.db; import android.app.Activity; import android.content.ContentValues; import android.content.Context; import android.database.Cursor; import android.database.sqlite.SQLiteDatabase; import android.database.sqlite.SQLiteOpenHelper; import android.os.AsyncTask; import android.support.annotation.NonNull; import android.util.Log; import android.widget.Toast; import net.kourlas.voipms_sms.R; import net.kourlas.voipms_sms.activities.ActivityMonitor; import net.kourlas.voipms_sms.activities.ConversationActivity; import net.kourlas.voipms_sms.activities.ConversationQuickReplyActivity; import net.kourlas.voipms_sms.activities.ConversationsActivity; import net.kourlas.voipms_sms.model.Message; import net.kourlas.voipms_sms.notifications.Notifications; import net.kourlas.voipms_sms.preferences.Preferences; import net.kourlas.voipms_sms.receivers.SynchronizationIntervalReceiver; import net.kourlas.voipms_sms.utils.Utils; import org.json.JSONArray; import org.json.JSONException; import org.json.JSONObject; import java.io.UnsupportedEncodingException; import java.net.URLEncoder; import java.text.ParseException; import java.text.SimpleDateFormat; import java.util.*; /** * Provides access to the application's database. */ public class Database { public static final String COLUMN_DATABASE_ID = "DatabaseId"; public static final String COLUMN_VOIP_ID = "VoipId"; public static final String COLUMN_DATE = "Date"; public static final String COLUMN_TYPE = "Type"; public static final String COLUMN_DID = "Did"; public static final String COLUMN_CONTACT = "Contact"; public static final String COLUMN_MESSAGE = "Text"; public static final String COLUMN_UNREAD = "Unread"; public static final String COLUMN_DELETED = "Deleted"; public static final String COLUMN_DELIVERED = "Delivered"; public static final String COLUMN_DELIVERY_IN_PROGRESS = "DeliveryInProgress"; public static final String COLUMN_DRAFT = "Draft"; private static final String TAG = "Database"; private static final String TABLE_MESSAGE = "sms"; private static final String[] columns = { COLUMN_DATABASE_ID, COLUMN_VOIP_ID, COLUMN_DATE, COLUMN_TYPE, COLUMN_DID, COLUMN_CONTACT, COLUMN_MESSAGE, COLUMN_UNREAD, COLUMN_DELETED, COLUMN_DELIVERED, COLUMN_DELIVERY_IN_PROGRESS, COLUMN_DRAFT}; private static Database instance = null; private final Context applicationContext; private final Preferences preferences; private final SQLiteDatabase database; /** * Initializes an instance of the Database class. * * @param applicationContext The application context. */ private Database(Context applicationContext) { this.applicationContext = applicationContext; preferences = Preferences.getInstance(applicationContext); DatabaseHelper helper = new DatabaseHelper(applicationContext); database = helper.getWritableDatabase(); } /** * Gets the sole instance of the Database class. Initializes the instance * if it does not already exist. * * @param applicationContext The application context. * @return The single instance of the Database class. */ public synchronized static Database getInstance( Context applicationContext) { if (instance == null) { instance = new Database(applicationContext); } return instance; } /** * Adds a message to the database. If a record with the message's database * ID or VoIP.ms ID already exists, that record is replaced. Otherwise, a * new record is created. * * @param message The message to be added to the database. * @return The database ID of the newly added message. */ public synchronized long insertMessage(Message message) { ContentValues values = new ContentValues(); // Replace records with a defined database ID or VoIP.ms ID instead of // inserting new ones if (message.getDatabaseId() != null) { values.put(COLUMN_DATABASE_ID, message.getDatabaseId()); } else if (message.getVoipId() != null) { Long databaseId = getDatabaseIdForVoipId(message.getDid(), message.getVoipId()); if (databaseId != null) { values.put(COLUMN_DATABASE_ID, databaseId); } } values.put(COLUMN_VOIP_ID, message.getVoipId()); values.put(COLUMN_DATE, message.getDateInDatabaseFormat()); values.put(COLUMN_TYPE, message.getTypeInDatabaseFormat()); values.put(COLUMN_DID, message.getDid()); values.put(COLUMN_CONTACT, message.getContact()); values.put(COLUMN_MESSAGE, message.getText()); values.put(COLUMN_UNREAD, message.isUnreadInDatabaseFormat()); values.put(COLUMN_DELETED, message.isDeletedInDatabaseFormat()); values.put(COLUMN_DELIVERED, message.isDeliveredInDatabaseFormat()); values.put(COLUMN_DELIVERY_IN_PROGRESS, message.isDeliveryInProgressInDatabaseFormat()); values.put(COLUMN_DRAFT, message.isDraftInDatabaseFormat()); if (values.getAsLong(COLUMN_DATABASE_ID) != null) { return database.replace(TABLE_MESSAGE, null, values); } else { return database.insert(TABLE_MESSAGE, null, values); } } /** * Gets the database ID for the row in the database with the specified * VoIP.ms ID. * * @param did The currently selected DID. * @param voipId The VoIP.ms ID. * @return The database ID. */ private synchronized Long getDatabaseIdForVoipId(String did, long voipId) { Cursor cursor = database.query( TABLE_MESSAGE, columns, COLUMN_DID + "=" + did + " AND " + COLUMN_VOIP_ID + "=" + voipId, null, null, null, null); if (cursor.moveToFirst()) { long databaseId = cursor.getLong(cursor.getColumnIndexOrThrow( COLUMN_DATABASE_ID)); cursor.close(); return databaseId; } cursor.close(); return null; } /** * Gets the message with the specified VoIP.ms ID from the database. * * @param did The currently selected DID. * @param voipId The VoIP.ms ID. * @return The message with the specified VoIP.ms ID. */ private synchronized Message getMessageWithVoipId(String did, long voipId) { Cursor cursor = database.query( TABLE_MESSAGE, columns, COLUMN_DID + "=" + did + " AND " + COLUMN_VOIP_ID + "=" + voipId, null, null, null, null); List<Message> messages = getMessageListFromCursor(cursor); if (messages.size() > 0) { return messages.get(0); } else { return null; } } /** * Retrieves all of the messages that can be accessed by the specified * cursor. This function consumes the cursor. * * @param cursor The cursor from which to retrieve the messages. * @return The messages that could be accessed by the specified cursor. */ @NonNull private List<Message> getMessageListFromCursor(Cursor cursor) { List<Message> messages = new ArrayList<>(); cursor.moveToFirst(); while (!cursor.isAfterLast()) { Message message = new Message( cursor.getLong(cursor.getColumnIndexOrThrow( COLUMN_DATABASE_ID)), cursor.isNull(cursor.getColumnIndexOrThrow( COLUMN_VOIP_ID)) ? null : cursor.getLong( cursor.getColumnIndex(COLUMN_VOIP_ID)), cursor.getLong(cursor.getColumnIndexOrThrow( COLUMN_DATE)), cursor.getLong(cursor.getColumnIndexOrThrow( COLUMN_TYPE)), cursor.getString(cursor.getColumnIndexOrThrow( COLUMN_DID)), cursor.getString(cursor.getColumnIndexOrThrow( COLUMN_CONTACT)), cursor.getString(cursor.getColumnIndexOrThrow( COLUMN_MESSAGE)), cursor.getLong(cursor.getColumnIndexOrThrow( COLUMN_UNREAD)), cursor.getLong(cursor.getColumnIndexOrThrow( COLUMN_DELETED)), cursor.getLong(cursor.getColumnIndexOrThrow( COLUMN_DELIVERED)), cursor.getLong(cursor.getColumnIndexOrThrow( COLUMN_DELIVERY_IN_PROGRESS)), cursor.getLong(cursor.getColumnIndexOrThrow( COLUMN_DRAFT))); messages.add(message); cursor.moveToNext(); } cursor.close(); return messages; } /** * Gets the most recent non-deleted message in each conversation that * matches a specified filter constraint. The resulting list is sorted * by date, from most recent to least recent. * * @param did The currently selected DID. * @param filterConstraint The filter constraint. * @return The most recent non-deleted message in each conversation that * matches a specified filter constraint. The resulting list is * sorted by date, from most recent to least recent. */ public synchronized List<Message> getMostRecentFilteredMessageForAllConversations( String did, String filterConstraint) { // Process filter constraints for use in SQL query String filterString = "%" + filterConstraint + "%"; String[] params = new String[] { filterString }; String numberFilterConstraint = Utils.getDigitsOfString( filterConstraint); String numericFilterStringQuery = ""; if (!numberFilterConstraint.equals("")) { String numericFilterString = "%" + numberFilterConstraint + "%"; params = new String[] { filterString, numericFilterString }; numericFilterStringQuery = " OR " + COLUMN_CONTACT + " LIKE ? "; } // First, retrieve the most recent message for each conversation, // filtering only on the contact phone number and message text String query = "SELECT * FROM " + TABLE_MESSAGE + " a " + "INNER JOIN (SELECT " + COLUMN_DATABASE_ID + ", " + COLUMN_CONTACT + ", MAX(" + COLUMN_DATE + ") AS " + COLUMN_DATE + " FROM " + TABLE_MESSAGE + " WHERE (" + COLUMN_MESSAGE + " LIKE ? COLLATE NOCASE" + numericFilterStringQuery + ") AND " + COLUMN_DID + "=" + did + " AND " + COLUMN_DELETED + "=0 " + "GROUP BY " + COLUMN_CONTACT + ") b on a." + COLUMN_DATABASE_ID + " = b." + COLUMN_DATABASE_ID + " AND a." + COLUMN_DATE + " = b." + COLUMN_DATE + " ORDER BY " + COLUMN_DATE + " DESC, " + COLUMN_DATABASE_ID + " DESC"; Cursor cursor = database.rawQuery(query, params); List<Message> messages = getMessageListFromCursor(cursor); // Then, retrieve the most recent message for each conversation without // filtering; if any conversation present in the second list is not // present in the first list, filter the message in the second list on // contact name and add it to the first list if there is a match query = "SELECT * FROM " + TABLE_MESSAGE + " a " + "INNER JOIN (SELECT " + COLUMN_DATABASE_ID + ", " + COLUMN_CONTACT + ", MAX(" + COLUMN_DATE + ") AS " + COLUMN_DATE + " FROM " + TABLE_MESSAGE + " WHERE " + COLUMN_DID + "=" + did + " AND " + COLUMN_DELETED + "=0" + " GROUP BY" + " " + COLUMN_CONTACT + ") b on a." + COLUMN_DATABASE_ID + " = b." + COLUMN_DATABASE_ID + " AND a." + COLUMN_DATE + " = b." + COLUMN_DATE + " ORDER BY " + COLUMN_DATE + " DESC, " + COLUMN_DATABASE_ID + " DESC"; cursor = database.rawQuery(query, null); List<Message> contactNameMessages = getMessageListFromCursor(cursor); cursor.close(); loop: for (Message contactNameMessage : contactNameMessages) { for (Message message : messages) { if (message.getContact() .equals(contactNameMessage.getContact())) { continue loop; } } String contactName = Utils.getContactName( applicationContext, contactNameMessage.getContact()); if (contactName != null && contactName.toLowerCase() .contains(filterConstraint)) { messages.add(contactNameMessage); } } // If any message has a draft message that meets the filter constraint, // use that message instead for (int i = 0; i < messages.size(); i++) { Message message = messages.get(i); if (!message.isDraft()) { Message draftMessage = getDraftMessageForConversation( message.getDid(), message.getContact()); if (draftMessage != null && draftMessage.getText().toLowerCase() .contains(filterConstraint)) { messages.set(i, draftMessage); } } } return messages; } /** * Gets the draft message associated with the specified conversation. * * @param did The currently selected DID. * @param contact The contact associated with the conversation. * @return The draft message associated with the specified conversation. */ public synchronized Message getDraftMessageForConversation( String did, String contact) { Cursor cursor = database.query( TABLE_MESSAGE, columns, COLUMN_DID + "=" + did + " AND " + COLUMN_CONTACT + "=" + contact + " AND " + COLUMN_DRAFT + "=1", null, null, null, null); List<Message> messages = getMessageListFromCursor(cursor); if (messages.size() > 0) { return messages.get(0); } else { return null; } } /** * Gets all non-deleted, non-draft messages in a specified conversation * that match a specified filter constraint. The resulting list is sorted * by date, from least recent to most recent. * * @param did The currently selected DID. * @param contact The contact associated with the conversation. * @param filterConstraint The filter constraint. * @return All non-deleted, non-draft messages in a specified conversation * that match a specified filter constraint. The resulting list is * sorted by date, from least recent to most recent. */ public synchronized List<Message> getFilteredMessagesForConversation( String did, String contact, String filterConstraint) { // Process filter constraint for use in SQL query String filterString = "%" + filterConstraint + "%"; String[] params = new String[] { filterString }; Cursor cursor = database.query( TABLE_MESSAGE, columns, COLUMN_DID + "=" + did + " AND " + COLUMN_CONTACT + "=" + contact + " AND " + COLUMN_DELETED + "=0 AND " + COLUMN_DRAFT + "=0 AND " + COLUMN_MESSAGE + " LIKE ?", params, null, null, COLUMN_DATE + " ASC, " + COLUMN_DATABASE_ID + " ASC"); return getMessageListFromCursor(cursor); } /** * Gets all non-deleted, non-draft unread messages that chronologically * follow the most recent outgoing message for a particular conversation. * The resulting list is sorted by date, from most recent to least recent. * * @param did The currently selected DID. * @param contact The contact associated with the conversation. * @return All non-deleted, non-draft unread messages that chronologically * follow the most recent outgoing message for a particular * conversation. The resulting list is sorted by date, from most * recent to least recent. */ public synchronized List<Message> getUnreadMessages(String did, String contact) { // Retrieve the most recent outgoing message Cursor cursor = database.query( TABLE_MESSAGE, new String[] { "COALESCE(MAX(" + COLUMN_DATE + "), 0) AS " + COLUMN_DATE, }, COLUMN_DID + "=" + did + " AND " + COLUMN_CONTACT + "=" + contact + " AND " + COLUMN_DELETED + "=0 AND " + COLUMN_DRAFT + "=0 AND " + COLUMN_TYPE + "=0", null, null, null, null); cursor.moveToFirst(); long date = 0; if (!cursor.isAfterLast()) { date = cursor.getLong(cursor.getColumnIndexOrThrow(COLUMN_DATE)); } cursor.close(); // Retrieve all non-deleted, non-draft unread messages with a date // equal to or after the most recent outgoing message cursor = database.query( TABLE_MESSAGE, columns, COLUMN_DID + "=" + did + " AND " + COLUMN_CONTACT + "=" + contact + " AND " + COLUMN_DELETED + "=0" + " AND " + COLUMN_DRAFT + "=0 AND " + COLUMN_TYPE + "=1 AND " + COLUMN_DATE + ">=" + date + " AND " + COLUMN_UNREAD + "=1", null, null, null, COLUMN_DATE + " DESC, " + COLUMN_DATABASE_ID + " DESC"); return getMessageListFromCursor(cursor); } /** * Gets all of the messages in the database. The resulting list is sorted * by database ID in descending order. * * @return All of the messages in the database. The resulting list is * sorted by database ID in descending order. */ public synchronized List<Message> getAllMessages() { Cursor cursor = database.query( TABLE_MESSAGE, columns, null, null, null, null, COLUMN_DATABASE_ID + " DESC"); return getMessageListFromCursor(cursor); } /** * Marks the message with the specified database ID as in the process of * being sent. * * @param databaseId The database ID. */ public synchronized void markMessageAsSending(long databaseId) { ContentValues contentValues = new ContentValues(); contentValues.put(COLUMN_DELIVERED, "0"); contentValues.put(COLUMN_DELIVERY_IN_PROGRESS, "1"); database.update( TABLE_MESSAGE, contentValues, COLUMN_DATABASE_ID + "=" + databaseId, null); } /** * Marks the message with the specified database ID as having failed to * be sent. * * @param databaseId The database ID. */ public synchronized void markMessageAsFailedToSend(long databaseId) { ContentValues contentValues = new ContentValues(); contentValues.put(COLUMN_DELIVERED, "0"); contentValues.put(COLUMN_DELIVERY_IN_PROGRESS, "0"); database.update( TABLE_MESSAGE, contentValues, COLUMN_DATABASE_ID + "=" + databaseId, null); } /** * Marks the specified conversation as read. * * @param did The currently selected DID. * @param contact The contact associated with the conversation. */ public synchronized void markConversationAsRead(String did, String contact) { ContentValues contentValues = new ContentValues(); contentValues.put(COLUMN_UNREAD, "0"); database.update( TABLE_MESSAGE, contentValues, COLUMN_CONTACT + "=" + contact + " AND " + COLUMN_DID + "=" + did, null); } /** * Marks the specified conversation as unread. Note that only incoming * messages are marked as unread. * * @param did The currently selected DID. * @param contact The contact associated with the conversation. */ public synchronized void markConversationAsUnread(String did, String contact) { ContentValues contentValues = new ContentValues(); contentValues.put(COLUMN_UNREAD, "1"); database.update( TABLE_MESSAGE, contentValues, COLUMN_CONTACT + "=" + contact + " AND " + COLUMN_DID + "=" + did + " AND " + COLUMN_TYPE + "=1", null); } /** * Deletes the specified message from the database by marking the message * as deleted if it has a VoIP.ms ID and removing it if it does not. */ public synchronized void deleteMessage(long databaseId) { // First, mark message as deleted ContentValues contentValues = new ContentValues(); contentValues.put(COLUMN_DELETED, "1"); database.update( TABLE_MESSAGE, contentValues, COLUMN_DATABASE_ID + "=" + databaseId + " AND " + COLUMN_DELETED + "=0", null); // Next, remove message from database if it does not have a VoIP.ms ID database.delete(TABLE_MESSAGE, COLUMN_DATABASE_ID + "=" + databaseId + " AND " + COLUMN_DELETED + "=1 AND " + COLUMN_VOIP_ID + " IS NULL", null); } /** * Deletes the specified conversation from the database by marking entries * with VoIP.ms IDs as deleted and removing entries without such IDs. * This method does not delete draft messages. * * @param did The currently selected DID. * @param contact The contact associated with the conversation. */ public synchronized void deleteMessages(String did, String contact) { // First, mark all messages in conversation as deleted ContentValues contentValues = new ContentValues(); contentValues.put(COLUMN_DELETED, "1"); database.update( TABLE_MESSAGE, contentValues, COLUMN_CONTACT + "=" + contact + " AND " + COLUMN_DID + "=" + did + " AND " + COLUMN_DELETED + "=0 AND " + COLUMN_DRAFT + "=0", null); // Next, remove all deleted messages in conversation without a // VoIP.ms ID from the database database.delete( TABLE_MESSAGE, COLUMN_CONTACT + "=" + contact + " AND " + COLUMN_DID + "=" + did + " AND " + COLUMN_DELETED + "=1 AND " + COLUMN_DRAFT + "=0 AND " + COLUMN_VOIP_ID + " IS NULL", null); } /** * Deletes the message with the specified database ID from the database. * * @param databaseId The database ID. */ public synchronized void removeMessage(long databaseId) { database.delete( TABLE_MESSAGE, COLUMN_DATABASE_ID + "=" + databaseId, null); } /** * Deletes all messages from the database. */ public synchronized void removeAllMessages() { database.delete( TABLE_MESSAGE, null, null); } /** * Returns true if the specified conversation has any non-deleted messages. * * @param did The currently selected DID. * @param contact The contact associated with the conversation. * @return True if the specified conversation has any non-deleted messages. */ public synchronized boolean conversationHasMessages(String did, String contact) { Cursor cursor = database.query( TABLE_MESSAGE, columns, COLUMN_DID + "=" + did + " AND " + COLUMN_CONTACT + "=" + contact + " AND " + COLUMN_DELETED + "=0", null, null, null, null); cursor.moveToFirst(); boolean hasMessages = !cursor.isAfterLast(); cursor.close(); return hasMessages; } /** * Sends the SMS message with the specified database ID using the VoIP.ms * API. * * @param sourceActivity The source activity of the send request. * @param databaseId The database ID of the message to send. */ public synchronized void sendMessage(Activity sourceActivity, long databaseId) { Message message = getMessageWithDatabaseId(databaseId); SendMessageTask task = new SendMessageTask( sourceActivity.getApplicationContext(), message, sourceActivity); if (message == null) { throw new IllegalArgumentException("Database ID is invalid"); } if (preferences.getEmail().equals("") || preferences.getPassword().equals("") || preferences.getDid().equals("")) { // Do not show an error; this method should never be called // unless the email, password and DID are set task.cleanup(false); return; } // Do not send message if a network connection is not available if (!Utils.isNetworkConnectionAvailable(applicationContext)) { Toast.makeText(applicationContext, applicationContext.getString( R.string.conversation_send_error_network), Toast.LENGTH_SHORT).show(); task.cleanup(false); return; } try { String voipUrl = "https://www.voip.ms/api/v1/rest.php?" + "api_username=" + URLEncoder.encode( preferences.getEmail(), "UTF-8") + "&" + "api_password=" + URLEncoder.encode( preferences.getPassword(), "UTF-8") + "&" + "method=sendSMS" + "&" + "did=" + URLEncoder.encode( preferences.getDid(), "UTF-8") + "&" + "dst=" + URLEncoder.encode( message.getContact(), "UTF-8") + "&" + "message=" + URLEncoder.encode( message.getText(), "UTF-8"); task.start(voipUrl); } catch (UnsupportedEncodingException ex) { // This should never happen since the encoding (UTF-8) is hardcoded throw new Error(ex); } } /** * Gets the message with the specified database ID from the database. * * @return The message with the specified database ID. */ private synchronized Message getMessageWithDatabaseId(long databaseId) { Cursor cursor = database.query( TABLE_MESSAGE, columns, COLUMN_DATABASE_ID + "=" + databaseId, null, null, null, null); List<Message> messages = getMessageListFromCursor(cursor); if (messages.size() > 0) { return messages.get(0); } else { return null; } } /** * Synchronize database with VoIP.ms. This may include any of the * following, depending on synchronization settings: * <li> retrieving all messages from VoIP.ms, or only those messages * dated after the most recent message stored * locally; * <li> retrieving messages from VoIP.ms that were deleted locally; * <li> deleting messages from VoIP.ms that were deleted locally; and * <li> deleting messages stored locally that were deleted from VoIP.ms. * * @param forceRecent Retrieve only recent messages and do nothing * else if true, regardless of synchronization * settings. * @param showErrors Shows error messages if true. * @param sourceActivity The source activity of the send request. */ public synchronized void synchronize(boolean forceRecent, boolean showErrors, Activity sourceActivity) { boolean retrieveOnlyRecentMessages = forceRecent || preferences.getRetrieveOnlyRecentMessages(); boolean retrieveDeletedMessages = !forceRecent && preferences.getRetrieveDeletedMessages(); boolean propagateLocalDeletions = !forceRecent && preferences.getPropagateLocalDeletions(); boolean propagateRemoteDeletions = !forceRecent && preferences.getPropagateRemoteDeletions(); SynchronizeDatabaseTask task = new SynchronizeDatabaseTask(applicationContext, forceRecent, retrieveDeletedMessages, propagateRemoteDeletions, showErrors, sourceActivity); if (preferences.getEmail().equals("") || preferences.getPassword() .equals("") || preferences.getDid().equals("")) { // Do not show an error; this method should never be called // unless the email, password and DID are set task.cleanup(forceRecent); return; } // Do not synchronize if a network connection is not available if (!Utils.isNetworkConnectionAvailable(applicationContext)) { if (showErrors) { Toast.makeText(applicationContext, applicationContext.getString( R.string.database_sync_error_network), Toast.LENGTH_SHORT).show(); } task.cleanup(forceRecent); return; } try { String did = preferences.getDid(); List<Message> messages = getNonDeletedAndNonDraftMessages(did); List<SynchronizeDatabaseTask.RequestObject> requests = new LinkedList<>(); // Propagate local deletions if applicable if (propagateLocalDeletions) { for (Message message : getDeletedAndNonDraftMessages( preferences.getDid())) { if (message.getVoipId() != null) { String url = "https://www.voip.ms/api/v1/rest.php?" + "api_username=" + URLEncoder.encode( preferences.getEmail(), "UTF-8") + "&" + "api_password=" + URLEncoder.encode( preferences.getPassword(), "UTF-8") + "&" + "method=deleteSMS" + "&" + "id=" + message.getVoipId(); requests.add( new SynchronizeDatabaseTask.RequestObject( url, SynchronizeDatabaseTask.RequestObject .RequestType.DELETION)); } } } // Get number of days between now and the message retrieval start // date or when the most recent message was received, as appropriate Date then = (messages.size() == 0 || !retrieveOnlyRecentMessages) ? preferences.getStartDate() : messages.get(0).getDate(); // Use EDT because the VoIP.ms API only works with EDT Calendar thenCalendar = Calendar.getInstance( TimeZone.getTimeZone("America/New_York"), Locale.US); thenCalendar.setTime(then); thenCalendar.set(Calendar.HOUR_OF_DAY, 0); thenCalendar.set(Calendar.MINUTE, 0); thenCalendar.set(Calendar.SECOND, 0); thenCalendar.set(Calendar.MILLISECOND, 0); then = thenCalendar.getTime(); Date now = new Date(); // Use EDT because the VoIP.ms API only works with EDT Calendar nowCalendar = Calendar.getInstance( TimeZone.getTimeZone("America/New_York"), Locale.US); nowCalendar.setTime(now); nowCalendar.set(Calendar.HOUR_OF_DAY, 0); nowCalendar.set(Calendar.MINUTE, 0); nowCalendar.set(Calendar.SECOND, 0); nowCalendar.set(Calendar.MILLISECOND, 0); now = nowCalendar.getTime(); long millisecondsDifference = now.getTime() - then.getTime(); long daysDifference = (long) Math.ceil(millisecondsDifference / (1000f * 60f * 60f * 24f)); // Split this number into 90 day periods (approximately the // maximum supported by the VoIP.ms API) int periods = (int) Math.ceil(daysDifference / 90f); if (periods == 0) { periods = 1; } Date[] dates = new Date[periods + 1]; dates[0] = then; for (int i = 1; i < dates.length - 1; i++) { Calendar calendar = Calendar.getInstance( TimeZone.getTimeZone("America/New_York"), Locale.US); calendar.setTime(dates[i - 1]); calendar.add(Calendar.DAY_OF_YEAR, 90); dates[i] = calendar.getTime(); } dates[dates.length - 1] = now; // Create VoIP.ms API urls for each of these periods SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd", Locale.US); sdf.setTimeZone(TimeZone.getTimeZone("America/New_York")); for (int i = 0; i < dates.length - 1; i++) { String url = "https://www.voip.ms/api/v1/rest.php?" + "api_username=" + URLEncoder.encode( preferences.getEmail(), "UTF-8") + "&" + "api_password=" + URLEncoder.encode( preferences.getPassword(), "UTF-8") + "&" + "method=getSMS" + "&" + "did=" + URLEncoder.encode(preferences.getDid(), "UTF-8") + "&" + "limit=" + URLEncoder.encode("1000000", "UTF-8") + "&" + "from=" + URLEncoder.encode(sdf.format(dates[i]), "UTF-8") + "&" + "to=" + URLEncoder.encode(sdf.format(dates[i + 1]), "UTF-8") + "&" + "timezone=-5"; // -5 corresponds to EDT requests.add(new SynchronizeDatabaseTask.RequestObject( url, SynchronizeDatabaseTask.RequestObject.RequestType .MESSAGE_RETRIEVAL, dates[i], dates[i + 1])); } task.start(requests); } catch (UnsupportedEncodingException ex) { // This should never happen since the encoding (UTF-8) is hardcoded throw new Error(ex); } } /** * Gets all of the messages in the database except for deleted and draft * messages. * * @param did The currently selected DID. * @return All of the messages in the database except for deleted and draft * messages. */ private synchronized List<Message> getNonDeletedAndNonDraftMessages( String did) { Cursor cursor = database.query( TABLE_MESSAGE, columns, COLUMN_DID + "=" + did + " AND " + COLUMN_DELETED + "=0 AND " + COLUMN_DRAFT + " = 0", null, null, null, COLUMN_DATE + " DESC"); return getMessageListFromCursor(cursor); } /** * Gets all of the deleted and non-draft messages in the database. * * @param did The currently selected DID. * @return All of the deleted and non-draft messages in the database. */ private synchronized List<Message> getDeletedAndNonDraftMessages( String did) { Cursor cursor = database.query( TABLE_MESSAGE, columns, COLUMN_DID + "=" + did + " AND " + COLUMN_DELETED + "=1 AND " + COLUMN_DRAFT + "=0", null, null, null, null); return getMessageListFromCursor(cursor); } /** * Wrapper class for sending a message using the VoIP.ms API. */ private static class SendMessageTask { private final Context applicationContext; private final Message message; private final Activity sourceActivity; /** * Initializes a new instance of the SendMessageTask class. * * @param applicationContext The application context. * @param message The message to send. * @param sourceActivity The activity that originated the send * request. */ SendMessageTask(Context applicationContext, Message message, Activity sourceActivity) { this.applicationContext = applicationContext; this.message = message; this.sourceActivity = sourceActivity; } /** * Sends the message using the specified VoIP.ms API URL. * * @param voipUrl The VoIP.ms API URL. */ void start(String voipUrl) { new SendMessageAsyncTask().execute(voipUrl); } /** * Cleans up after the message was sent. * * @param success Whether the message was sucessfully sent. */ void cleanup(boolean success) { if (sourceActivity instanceof ConversationActivity) { ((ConversationActivity) sourceActivity) .postSendMessage(success, message.getDatabaseId()); } else if (sourceActivity instanceof ConversationQuickReplyActivity) { ((ConversationQuickReplyActivity) sourceActivity) .postSendMessage(success, message.getDatabaseId()); } } /** * Custom AsyncTask used to send a message using the VoIP.ms API. */ private class SendMessageAsyncTask extends AsyncTask<String, String, Boolean> { @Override protected Boolean doInBackground(String... params) { JSONObject resultJson; try { resultJson = Utils.getJson(params[0]); } catch (JSONException ex) { Log.w(TAG, Log.getStackTraceString(ex)); publishProgress(applicationContext.getString( R.string.conversation_send_error_api_parse)); return false; } catch (Exception ex) { Log.w(TAG, Log.getStackTraceString(ex)); publishProgress(applicationContext.getString( R.string.conversation_send_error_api_request)); return false; } String status = resultJson.optString("status"); if (status == null) { publishProgress(applicationContext.getString( R.string.conversation_send_error_api_parse)); return false; } if (!status.equals("success")) { publishProgress(applicationContext.getString( R.string.conversation_send_error_api_error) .replace("{error}", status)); return false; } return true; } @Override protected void onPostExecute(Boolean success) { cleanup(success); } /** * Shows a toast to the user. * * @param message The message to show. This must be a String * array with a single element containing the * message. */ @Override protected void onProgressUpdate(String... message) { Toast.makeText(applicationContext, message[0], Toast.LENGTH_SHORT).show(); } } } /** * Wrapper class for handling database synchronization. */ private static class SynchronizeDatabaseTask { private final Context applicationContext; private final Database database; private final Preferences preferences; private final boolean forceRecent; private final boolean retrieveDeletedMessages; private final boolean propagateRemoteDeletions; private final boolean showErrors; private final Activity sourceActivity; private List<RequestObject> requests; /** * Initializes a new instance of the SynchronizeDatabaseTask class. * * @param forceRecent Retrieve only recent messages (and * do nothing else) if true, * regardless of * synchronization settings. This * value isn't actually used; it's * merely stored * to be used during the cleanup * routine. * @param retrieveDeletedMessages Retrieves messages that were * deleted locally from the VoIP.ms * servers if * true. * @param propagateRemoteDeletions Deletes local copies of messages * if they were deleted from the VoIP.ms * servers if true. * @param showErrors Shows error messages if true. * @param sourceActivity The calling activity. */ SynchronizeDatabaseTask(Context applicationContext, boolean forceRecent, boolean retrieveDeletedMessages, boolean propagateRemoteDeletions, boolean showErrors, Activity sourceActivity) { this.applicationContext = applicationContext; this.database = Database.getInstance(applicationContext); this.preferences = Preferences.getInstance(applicationContext); this.requests = null; this.forceRecent = forceRecent; this.retrieveDeletedMessages = retrieveDeletedMessages; this.propagateRemoteDeletions = propagateRemoteDeletions; this.showErrors = showErrors; this.sourceActivity = sourceActivity; } /** * Starts the database update. * * @param requests The VoIP.ms API request objects to use to * facilitate the database update. */ public void start(List<RequestObject> requests) { this.requests = requests; start(0); } /** * Continues the database update. * * @param i The index of the VoIP.ms API request object to use for * the next part of the update. */ private void start(int i) { new CustomAsyncTask().execute(i); } /** * Cleans up after the database update. */ void cleanup(boolean forceRecent) { if (sourceActivity instanceof ConversationsActivity) { ((ConversationsActivity) sourceActivity).postUpdate(); } else if (sourceActivity instanceof ConversationActivity) { ((ConversationActivity) sourceActivity).postUpdate(); } else if (sourceActivity == null) { Activity currentActivity = ActivityMonitor.getInstance() .getCurrentActivity(); if (currentActivity instanceof ConversationsActivity) { ((ConversationsActivity) currentActivity).postUpdate(); } else if (currentActivity instanceof ConversationActivity) { ((ConversationActivity) currentActivity).postUpdate(); } } if (!forceRecent) { preferences.setLastCompleteSyncTime(System.currentTimeMillis()); SynchronizationIntervalReceiver.setupSynchronizationInterval( applicationContext); } } /** * Represents a single VoIP.ms API request in the context of database * synchronization. */ private static class RequestObject { private String url; private RequestType requestType; private Date startDate; private Date endDate; RequestObject(String url, RequestType requestType) { this.url = url; this.requestType = requestType; this.startDate = null; this.endDate = null; } RequestObject(String url, RequestType requestType, Date startDate, Date endDate) { this.url = url; this.requestType = requestType; this.startDate = startDate; this.endDate = endDate; } String getUrl() { return url; } RequestType getRequestType() { return requestType; } Date getStartDate() { return startDate; } Date getEndDate() { return endDate; } enum RequestType { MESSAGE_RETRIEVAL, DELETION } } /** * Custom AsyncTask for use with database updating. */ private class CustomAsyncTask extends AsyncTask<Integer, String, Boolean> { private RequestObject request; @Override protected Boolean doInBackground(Integer... params) { request = requests.get(params[0]); JSONObject resultJson; try { resultJson = Utils.getJson(request.getUrl()); } catch (JSONException ex) { Log.w(TAG, Log.getStackTraceString(ex)); if (showErrors) { publishProgress(applicationContext.getString( R.string.database_sync_error_api_parse)); } return false; } catch (Exception ex) { Log.w(TAG, Log.getStackTraceString(ex)); if (showErrors) { publishProgress(applicationContext.getString( R.string.database_sync_error_api_request)); } return false; } // Parse the VoIP.ms API response String status = resultJson.optString("status"); if (status == null) { if (showErrors) { publishProgress(applicationContext.getString( R.string.database_sync_error_api_parse)); } return false; } if (!status.equals("success")) { if (!status.equals("no_sms")) { if (showErrors) { publishProgress(applicationContext.getString( R.string.database_sync_error_api_error).replace( "{error}", status)); } return false; } // Continue the database update by calling the next URL; // otherwise, if the database update is // complete, clean up int current = requests.indexOf(request); if (current != requests.size() - 1) { start(current + 1); return null; } return true; } if (request.getRequestType() == RequestObject.RequestType.DELETION) { // Continue the database update by calling the next URL; // otherwise, if the database update is // complete, clean up int current = requests.indexOf(request); if (current != requests.size() - 1) { start(current + 1); return null; } return true; } // Extract messages from the VoIP.ms API response List<Message> serverMessages = new ArrayList<>(); JSONArray rawMessages = resultJson.optJSONArray("sms"); if (rawMessages == null) { if (showErrors) { publishProgress(applicationContext.getString( R.string.database_sync_error_api_parse)); } return false; } for (int i = 0; i < rawMessages.length(); i++) { JSONObject rawSms = rawMessages.optJSONObject(i); if (rawSms == null || rawSms.optString("id") == null || rawSms.optString("date") == null || rawSms.optString("type") == null || rawSms.optString("did") == null || rawSms.optString("contact") == null || rawSms.optString("message") == null) { if (showErrors) { publishProgress(applicationContext.getString( R.string.database_sync_error_api_parse)); } return false; } String id = rawSms.optString("id"); String date = rawSms.optString("date"); String type = rawSms.optString("type"); String did = rawSms.optString("did"); String contact = rawSms.optString("contact"); String message = rawSms.optString("message"); try { Message sms = new Message(id, date, type, did, contact, message); serverMessages.add(sms); } catch (ParseException ex) { Log.w(TAG, Log.getStackTraceString(ex)); if (showErrors) { publishProgress(applicationContext.getString( R.string.database_sync_error_api_parse)); } return false; } } // Add new messages from the server List<Message> newMessages = new ArrayList<>(); for (Message serverMessage : serverMessages) { Message localMessage = database.getMessageWithVoipId(preferences.getDid(), serverMessage .getVoipId()); if (localMessage != null) { if (localMessage.isDeleted()) { if (retrieveDeletedMessages) { serverMessage.setUnread( localMessage.isUnread()); database.insertMessage(serverMessage); } } else { serverMessage.setUnread(localMessage.isUnread()); database.insertMessage(serverMessage); } } else { database.insertMessage(serverMessage); newMessages.add(serverMessage); } } // Delete old messages stored locally, if applicable if (propagateRemoteDeletions) { List<Message> localMessages = database.getNonDeletedAndNonDraftMessages( preferences.getDid()); for (Message localMessage : localMessages) { if (localMessage.getVoipId() == null) { continue; } boolean match = false; for (Message serverMessage : serverMessages) { if (serverMessage.getVoipId() != null && localMessage.getVoipId().equals( serverMessage.getVoipId())) { match = true; break; } } if (!match) { Date startDate = request.getStartDate(); Date endDate = request.getEndDate(); endDate.setTime( endDate.getTime() + (1000L * 60L * 60L * 24L)); if ((localMessage.getDate().getTime() == startDate .getTime() || localMessage.getDate().after(startDate)) && (localMessage.getDate().getTime() == endDate .getTime() || localMessage.getDate().before(endDate))) { if (localMessage.getDatabaseId() != null) { database.removeMessage( localMessage.getDatabaseId()); } } } } } // Show notifications for new messages Set<String> newContacts = new HashSet<>(); for (Message newMessage : newMessages) { newContacts.add(newMessage.getContact()); } Notifications.getInstance(applicationContext) .showNotifications(new LinkedList<>(newContacts)); int current = requests.indexOf(request); if (current != requests.size() - 1) { start(current + 1); return null; } return true; } @Override protected void onPostExecute(Boolean success) { if (success != null) { cleanup(forceRecent); } } /** * Shows a toast to the user. * * @param message The message to show. This must be a String * array with a single element containing the * message. */ @Override protected void onProgressUpdate(String... message) { Toast.makeText(applicationContext, message[0], Toast.LENGTH_SHORT).show(); } } } /** * Subclass of the SQLiteOpenHelper class for use with the Database class. */ private class DatabaseHelper extends SQLiteOpenHelper { private static final String DATABASE_NAME = "sms.db"; private static final int DATABASE_VERSION = 8; private static final String DATABASE_CREATE = "CREATE TABLE " + TABLE_MESSAGE + "(" + COLUMN_DATABASE_ID + " INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL," + COLUMN_VOIP_ID + " INTEGER," + COLUMN_DATE + " INTEGER NOT NULL," + COLUMN_TYPE + " INTEGER NOT NULL," + COLUMN_DID + " TEXT NOT NULL," + COLUMN_CONTACT + " TEXT NOT NULL, " + COLUMN_MESSAGE + " TEXT NOT NULL," + COLUMN_UNREAD + " INTEGER NOT NULL," + COLUMN_DELETED + " INTEGER NOT NULL," + COLUMN_DELIVERED + " INTEGER NOT NULL," + COLUMN_DELIVERY_IN_PROGRESS + " INTEGER NOT NULL," + COLUMN_DRAFT + " INTEGER NOT NULL)"; /** * Initializes a new instance of the DatabaseHelper class. * * @param context The context to be used by SQLiteOpenHelper. */ DatabaseHelper(Context context) { super(context, DATABASE_NAME, null, DATABASE_VERSION); } /** * Creates the messages table within an SQLite database. * * @param db The SQLite database. */ @Override public void onCreate(SQLiteDatabase db) { db.execSQL(DATABASE_CREATE); } /** * Upgrades the messages table within an SQLite database upon a * version change. * * @param db The SQLite database. * @param oldVersion The old version of the database. * @param newVersion The new version of the database. */ @Override public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { if (oldVersion <= 5) { // For version 5 and below, the database was nothing more // than a cache so it can simply be dropped db.execSQL("DROP TABLE IF EXISTS sms"); onCreate(db); } else { // After version 5, the database must be converted; it cannot // be simply dropped if (oldVersion <= 6) { // In version 6, dates from VoIP.ms were parsed as if // they did not have daylight savings time when // they actually did; the code below re-parses the dates // properly try { String table = "sms"; String[] columns = {"DatabaseId", "VoipId", "Date", "Type", "Did", "Contact", "Text", "Unread", "Deleted", "Delivered", "DeliveryInProgress"}; Cursor cursor = db.query(table, columns, null, null, null, null, null); cursor.moveToFirst(); while (!cursor.isAfterLast()) { Message message = new Message( cursor.getLong( cursor.getColumnIndexOrThrow(columns[0])), cursor.isNull(cursor.getColumnIndexOrThrow( columns[1])) ? null : cursor.getLong( cursor.getColumnIndex( columns[1])), cursor.getLong(cursor.getColumnIndexOrThrow( columns[2])), cursor.getLong(cursor.getColumnIndexOrThrow( columns[3])), cursor.getString( cursor.getColumnIndexOrThrow( columns[4])), cursor.getString( cursor.getColumnIndexOrThrow( columns[5])), cursor.getString( cursor.getColumnIndexOrThrow( columns[6])), cursor.getLong(cursor.getColumnIndexOrThrow( columns[7])), cursor.getLong(cursor.getColumnIndexOrThrow( columns[8])), cursor.getLong(cursor.getColumnIndexOrThrow( columns[9])), cursor.getLong(cursor.getColumnIndexOrThrow( columns[10])), 0); // Incorrect date has an hour removed outside of // daylight savings time Date date = message.getDate(); SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US); sdf.setTimeZone(TimeZone.getTimeZone("UTC")); // Incorrect date converted to UTC with an hour // removed outside of daylight savings time String dateString = sdf.format(date); // Incorrect date string is parsed as if it were // EST/EDT; it is now four hours ahead of EST/EDT // at all times sdf.setTimeZone( TimeZone.getTimeZone("America/New_York")); date = sdf.parse(dateString); Calendar calendar = Calendar.getInstance( TimeZone.getTimeZone("America/New_York"), Locale.US); calendar.setTime(date); calendar.add(Calendar.HOUR_OF_DAY, -4); // Date is now stored correctly message.setDate(calendar.getTime()); ContentValues values = new ContentValues(); values.put(columns[0], message.getDatabaseId()); values.put(columns[1], message.getVoipId()); values.put(columns[2], message.getDateInDatabaseFormat()); values.put(columns[3], message.getTypeInDatabaseFormat()); values.put(columns[4], message.getDid()); values.put(columns[5], message.getContact()); values.put(columns[6], message.getText()); values.put(columns[7], message.isUnreadInDatabaseFormat()); values.put(columns[8], message.isDeletedInDatabaseFormat()); values.put(columns[9], message.isDeliveredInDatabaseFormat()); values.put(columns[10], message .isDeliveryInProgressInDatabaseFormat()); db.replace(table, null, values); cursor.moveToNext(); } cursor.close(); } catch (ParseException ex) { // This should never happen since the same // SimpleDateFormat that formats the date parses it throw new Error(ex); } } if (oldVersion <= 7) { db.execSQL("ALTER TABLE sms ADD Draft INTEGER NOT NULL" + " DEFAULT(0)"); } } } } }