1/*
2 * Copyright (C) 2015 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *      http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17package com.android.messaging.datamodel.action;
18
19import android.content.ContentValues;
20import android.content.Context;
21import android.content.Intent;
22import android.database.Cursor;
23import android.net.ConnectivityManager;
24import android.os.Parcel;
25import android.os.Parcelable;
26import android.telephony.ServiceState;
27
28import com.android.messaging.Factory;
29import com.android.messaging.datamodel.BugleDatabaseOperations;
30import com.android.messaging.datamodel.DataModel;
31import com.android.messaging.datamodel.DatabaseHelper;
32import com.android.messaging.datamodel.DatabaseHelper.MessageColumns;
33import com.android.messaging.datamodel.DatabaseWrapper;
34import com.android.messaging.datamodel.MessagingContentProvider;
35import com.android.messaging.datamodel.data.MessageData;
36import com.android.messaging.datamodel.data.ParticipantData;
37import com.android.messaging.sms.MmsUtils;
38import com.android.messaging.util.BugleGservices;
39import com.android.messaging.util.BugleGservicesKeys;
40import com.android.messaging.util.BuglePrefs;
41import com.android.messaging.util.BuglePrefsKeys;
42import com.android.messaging.util.ConnectivityUtil.ConnectivityListener;
43import com.android.messaging.util.LogUtil;
44import com.android.messaging.util.OsUtil;
45import com.android.messaging.util.PhoneUtils;
46
47import java.util.HashSet;
48import java.util.Set;
49
50/**
51 * Action used to lookup any messages in the pending send/download state and either fail them or
52 * retry their action. This action only initiates one retry at a time - further retries should be
53 * triggered by successful sending of a message, network status change or exponential backoff timer.
54 */
55public class ProcessPendingMessagesAction extends Action implements Parcelable {
56    private static final String TAG = LogUtil.BUGLE_DATAMODEL_TAG;
57    private static final int PENDING_INTENT_REQUEST_CODE = 101;
58
59    public static void processFirstPendingMessage() {
60        // Clear any pending alarms or connectivity events
61        unregister();
62        // Clear retry count
63        setRetry(0);
64
65        // Start action
66        final ProcessPendingMessagesAction action = new ProcessPendingMessagesAction();
67        action.start();
68    }
69
70    public static void scheduleProcessPendingMessagesAction(final boolean failed,
71            final Action processingAction) {
72        LogUtil.i(TAG, "ProcessPendingMessagesAction: Scheduling pending messages"
73                + (failed ? "(message failed)" : ""));
74        // Can safely clear any pending alarms or connectivity events as either an action
75        // is currently running or we will run now or register if pending actions possible.
76        unregister();
77
78        final boolean isDefaultSmsApp = PhoneUtils.getDefault().isDefaultSmsApp();
79        boolean scheduleAlarm = false;
80        // If message succeeded and if Bugle is default SMS app just carry on with next message
81        if (!failed && isDefaultSmsApp) {
82            // Clear retry attempt count as something just succeeded
83            setRetry(0);
84
85            // Lookup and queue next message for immediate processing by background worker
86            //  iff there are no pending messages this will do nothing and return true.
87            final ProcessPendingMessagesAction action = new ProcessPendingMessagesAction();
88            if (action.queueActions(processingAction)) {
89                if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
90                    if (processingAction.hasBackgroundActions()) {
91                        LogUtil.v(TAG, "ProcessPendingMessagesAction: Action queued");
92                    } else {
93                        LogUtil.v(TAG, "ProcessPendingMessagesAction: No actions to queue");
94                    }
95                }
96                // Have queued next action if needed, nothing more to do
97                return;
98            }
99            // In case of error queuing schedule a retry
100            scheduleAlarm = true;
101            LogUtil.w(TAG, "ProcessPendingMessagesAction: Action failed to queue; retrying");
102        }
103        if (getHavePendingMessages() || scheduleAlarm) {
104            // Still have a pending message that needs to be queued for processing
105            final ConnectivityListener listener = new ConnectivityListener() {
106                @Override
107                public void onConnectivityStateChanged(final Context context, final Intent intent) {
108                    final int networkType =
109                            MmsUtils.getConnectivityEventNetworkType(context, intent);
110                    if (networkType != ConnectivityManager.TYPE_MOBILE) {
111                        return;
112                    }
113                    final boolean isConnected = !intent.getBooleanExtra(
114                            ConnectivityManager.EXTRA_NO_CONNECTIVITY, false);
115                    // TODO: Should we check in more detail?
116                    if (isConnected) {
117                        onConnected();
118                    }
119                }
120
121                @Override
122                public void onPhoneStateChanged(final Context context, final int serviceState) {
123                    if (serviceState == ServiceState.STATE_IN_SERVICE) {
124                        onConnected();
125                    }
126                }
127
128                private void onConnected() {
129                    LogUtil.i(TAG, "ProcessPendingMessagesAction: Now connected; starting action");
130
131                    // Clear any pending alarms or connectivity events but leave attempt count alone
132                    unregister();
133
134                    // Start action
135                    final ProcessPendingMessagesAction action = new ProcessPendingMessagesAction();
136                    action.start();
137                }
138            };
139            // Read and increment attempt number from shared prefs
140            final int retryAttempt = getNextRetry();
141            register(listener, retryAttempt);
142        } else {
143            // No more pending messages (presumably the message that failed has expired) or it
144            // may be possible that a send and a download are already in process.
145            // Clear retry attempt count.
146            // TODO Might be premature if send and download in process...
147            //  but worst case means we try to send a bit more often.
148            setRetry(0);
149
150            if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
151                LogUtil.v(TAG, "ProcessPendingMessagesAction: No more pending messages");
152            }
153        }
154    }
155
156    private static void register(final ConnectivityListener listener, final int retryAttempt) {
157        int retryNumber = retryAttempt;
158
159        // Register to be notified about connectivity changes
160        DataModel.get().getConnectivityUtil().register(listener);
161
162        final ProcessPendingMessagesAction action = new ProcessPendingMessagesAction();
163        final long initialBackoffMs = BugleGservices.get().getLong(
164                BugleGservicesKeys.INITIAL_MESSAGE_RESEND_DELAY_MS,
165                BugleGservicesKeys.INITIAL_MESSAGE_RESEND_DELAY_MS_DEFAULT);
166        final long maxDelayMs = BugleGservices.get().getLong(
167                BugleGservicesKeys.MAX_MESSAGE_RESEND_DELAY_MS,
168                BugleGservicesKeys.MAX_MESSAGE_RESEND_DELAY_MS_DEFAULT);
169        long delayMs;
170        long nextDelayMs = initialBackoffMs;
171        do {
172            delayMs = nextDelayMs;
173            retryNumber--;
174            nextDelayMs = delayMs * 2;
175        }
176        while (retryNumber > 0 && nextDelayMs < maxDelayMs);
177
178        LogUtil.i(TAG, "ProcessPendingMessagesAction: Registering for retry #" + retryAttempt
179                + " in " + delayMs + " ms");
180
181        action.schedule(PENDING_INTENT_REQUEST_CODE, delayMs);
182    }
183
184    private static void unregister() {
185        // Clear any pending alarms or connectivity events
186        DataModel.get().getConnectivityUtil().unregister();
187
188        final ProcessPendingMessagesAction action = new ProcessPendingMessagesAction();
189        action.schedule(PENDING_INTENT_REQUEST_CODE, Long.MAX_VALUE);
190
191        if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
192            LogUtil.v(TAG, "ProcessPendingMessagesAction: Unregistering for connectivity changed "
193                    + "events and clearing scheduled alarm");
194        }
195    }
196
197    private static void setRetry(final int retryAttempt) {
198        final BuglePrefs prefs = Factory.get().getApplicationPrefs();
199        prefs.putInt(BuglePrefsKeys.PROCESS_PENDING_MESSAGES_RETRY_COUNT, retryAttempt);
200    }
201
202    private static int getNextRetry() {
203        final BuglePrefs prefs = Factory.get().getApplicationPrefs();
204        final int retryAttempt =
205                prefs.getInt(BuglePrefsKeys.PROCESS_PENDING_MESSAGES_RETRY_COUNT, 0) + 1;
206        prefs.putInt(BuglePrefsKeys.PROCESS_PENDING_MESSAGES_RETRY_COUNT, retryAttempt);
207        return retryAttempt;
208    }
209
210    private ProcessPendingMessagesAction() {
211    }
212
213    /**
214     * Read from the DB and determine if there are any messages we should process
215     * @return true if we have pending messages
216     */
217    private static boolean getHavePendingMessages() {
218        final DatabaseWrapper db = DataModel.get().getDatabase();
219        final long now = System.currentTimeMillis();
220
221        final String toSendMessageId = findNextMessageToSend(db, now);
222        if (toSendMessageId != null) {
223            return true;
224        } else {
225            final String toDownloadMessageId = findNextMessageToDownload(db, now);
226            if (toDownloadMessageId != null) {
227                return true;
228            }
229        }
230        // Messages may be in the process of sending/downloading even when there are no pending
231        // messages...
232        return false;
233    }
234
235    /**
236     * Queue any pending actions
237     * @param actionState
238     * @return true if action queued (or no actions to queue) else false
239     */
240    private boolean queueActions(final Action processingAction) {
241        final DatabaseWrapper db = DataModel.get().getDatabase();
242        final long now = System.currentTimeMillis();
243        boolean succeeded = true;
244
245        // Will queue no more than one message to send plus one message to download
246        // This keeps outgoing messages "in order" but allow downloads to happen even if sending
247        //  gets blocked until messages time out.  Manual resend bumps messages to head of queue.
248        final String toSendMessageId = findNextMessageToSend(db, now);
249        final String toDownloadMessageId = findNextMessageToDownload(db, now);
250        if (toSendMessageId != null) {
251            LogUtil.i(TAG, "ProcessPendingMessagesAction: Queueing message " + toSendMessageId
252                    + " for sending");
253            // This could queue nothing
254            if (!SendMessageAction.queueForSendInBackground(toSendMessageId, processingAction)) {
255                LogUtil.w(TAG, "ProcessPendingMessagesAction: Failed to queue message "
256                        + toSendMessageId + " for sending");
257                succeeded = false;
258            }
259        }
260        if (toDownloadMessageId != null) {
261            LogUtil.i(TAG, "ProcessPendingMessagesAction: Queueing message " + toDownloadMessageId
262                    + " for download");
263            // This could queue nothing
264            if (!DownloadMmsAction.queueMmsForDownloadInBackground(toDownloadMessageId,
265                    processingAction)) {
266                LogUtil.w(TAG, "ProcessPendingMessagesAction: Failed to queue message "
267                        + toDownloadMessageId + " for download");
268                succeeded = false;
269            }
270        }
271        if (toSendMessageId == null && toDownloadMessageId == null) {
272            if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
273                LogUtil.d(TAG, "ProcessPendingMessagesAction: No messages to send or download");
274            }
275        }
276        return succeeded;
277    }
278
279    @Override
280    protected Object executeAction() {
281        // If triggered by alarm will not have unregistered yet
282        unregister();
283
284        if (PhoneUtils.getDefault().isDefaultSmsApp()) {
285            queueActions(this);
286        } else {
287            if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
288                LogUtil.v(TAG, "ProcessPendingMessagesAction: Not default SMS app; rescheduling");
289            }
290            scheduleProcessPendingMessagesAction(true, this);
291        }
292
293        return null;
294    }
295
296    private static String findNextMessageToSend(final DatabaseWrapper db, final long now) {
297        String toSendMessageId = null;
298        db.beginTransaction();
299        Cursor sending = null;
300        Cursor cursor = null;
301        int sendingCnt = 0;
302        int pendingCnt = 0;
303        int failedCnt = 0;
304        try {
305            // First check to see if we have any messages already sending
306            sending = db.query(DatabaseHelper.MESSAGES_TABLE,
307                    MessageData.getProjection(),
308                    DatabaseHelper.MessageColumns.STATUS + " IN (?, ?)",
309                    new String[]{Integer.toString(MessageData.BUGLE_STATUS_OUTGOING_SENDING),
310                           Integer.toString(MessageData.BUGLE_STATUS_OUTGOING_RESENDING)},
311                    null,
312                    null,
313                    DatabaseHelper.MessageColumns.RECEIVED_TIMESTAMP + " ASC");
314            final boolean messageCurrentlySending = sending.moveToNext();
315            sendingCnt = sending.getCount();
316            // Look for messages we could send
317            final ContentValues values = new ContentValues();
318            values.put(DatabaseHelper.MessageColumns.STATUS,
319                    MessageData.BUGLE_STATUS_OUTGOING_FAILED);
320            cursor = db.query(DatabaseHelper.MESSAGES_TABLE,
321                    MessageData.getProjection(),
322                    DatabaseHelper.MessageColumns.STATUS + " IN ("
323                            + MessageData.BUGLE_STATUS_OUTGOING_YET_TO_SEND + ","
324                            + MessageData.BUGLE_STATUS_OUTGOING_AWAITING_RETRY + ")",
325                    null,
326                    null,
327                    null,
328                    DatabaseHelper.MessageColumns.RECEIVED_TIMESTAMP + " ASC");
329            pendingCnt = cursor.getCount();
330
331            while (cursor.moveToNext()) {
332                final MessageData message = new MessageData();
333                message.bind(cursor);
334                if (message.getInResendWindow(now)) {
335                    // If no messages currently sending
336                    if (!messageCurrentlySending) {
337                        // Resend this message
338                        toSendMessageId = message.getMessageId();
339                        // Before queuing the message for resending, check if the message's self is
340                        // active. If not, switch back to the system's default subscription.
341                        if (OsUtil.isAtLeastL_MR1()) {
342                            final ParticipantData messageSelf = BugleDatabaseOperations
343                                    .getExistingParticipant(db, message.getSelfId());
344                            if (messageSelf == null || !messageSelf.isActiveSubscription()) {
345                                final ParticipantData defaultSelf = BugleDatabaseOperations
346                                        .getOrCreateSelf(db, PhoneUtils.getDefault()
347                                                .getDefaultSmsSubscriptionId());
348                                if (defaultSelf != null) {
349                                    message.bindSelfId(defaultSelf.getId());
350                                    final ContentValues selfValues = new ContentValues();
351                                    selfValues.put(MessageColumns.SELF_PARTICIPANT_ID,
352                                            defaultSelf.getId());
353                                    BugleDatabaseOperations.updateMessageRow(db,
354                                            message.getMessageId(), selfValues);
355                                    MessagingContentProvider.notifyMessagesChanged(
356                                            message.getConversationId());
357                                }
358                            }
359                        }
360                    }
361                    break;
362                } else {
363                    failedCnt++;
364
365                    // Mark message as failed
366                    BugleDatabaseOperations.updateMessageRow(db, message.getMessageId(), values);
367                    MessagingContentProvider.notifyMessagesChanged(message.getConversationId());
368                }
369            }
370            db.setTransactionSuccessful();
371        } finally {
372            db.endTransaction();
373            if (cursor != null) {
374                cursor.close();
375            }
376            if (sending != null) {
377                sending.close();
378            }
379        }
380
381        if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
382            LogUtil.d(TAG, "ProcessPendingMessagesAction: "
383                    + sendingCnt + " messages already sending, "
384                    + pendingCnt + " messages to send, "
385                    + failedCnt + " failed messages");
386        }
387
388        return toSendMessageId;
389    }
390
391    private static String findNextMessageToDownload(final DatabaseWrapper db, final long now) {
392        String toDownloadMessageId = null;
393        db.beginTransaction();
394        Cursor cursor = null;
395        int downloadingCnt = 0;
396        int pendingCnt = 0;
397        try {
398            // First check if we have any messages already downloading
399            downloadingCnt = (int) db.queryNumEntries(DatabaseHelper.MESSAGES_TABLE,
400                    DatabaseHelper.MessageColumns.STATUS + " IN (?, ?)",
401                    new String[] {
402                        Integer.toString(MessageData.BUGLE_STATUS_INCOMING_AUTO_DOWNLOADING),
403                        Integer.toString(MessageData.BUGLE_STATUS_INCOMING_MANUAL_DOWNLOADING)
404                    });
405
406            // TODO: This query is not actually needed if downloadingCnt == 0.
407            cursor = db.query(DatabaseHelper.MESSAGES_TABLE,
408                    MessageData.getProjection(),
409                    DatabaseHelper.MessageColumns.STATUS + " =? OR "
410                            + DatabaseHelper.MessageColumns.STATUS + " =?",
411                    new String[]{
412                            Integer.toString(
413                                    MessageData.BUGLE_STATUS_INCOMING_RETRYING_AUTO_DOWNLOAD),
414                            Integer.toString(
415                                    MessageData.BUGLE_STATUS_INCOMING_RETRYING_MANUAL_DOWNLOAD)
416                    },
417                    null,
418                    null,
419                    DatabaseHelper.MessageColumns.RECEIVED_TIMESTAMP + " ASC");
420
421            pendingCnt = cursor.getCount();
422
423            // If no messages are currently downloading and there is a download pending,
424            // queue the download of the oldest pending message.
425            if (downloadingCnt == 0 && cursor.moveToNext()) {
426                // Always start the next pending message. We will check if a download has
427                // expired in DownloadMmsAction and mark message failed there.
428                final MessageData message = new MessageData();
429                message.bind(cursor);
430                toDownloadMessageId = message.getMessageId();
431            }
432            db.setTransactionSuccessful();
433        } finally {
434            db.endTransaction();
435            if (cursor != null) {
436                cursor.close();
437            }
438        }
439
440        if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
441            LogUtil.d(TAG, "ProcessPendingMessagesAction: "
442                    + downloadingCnt + " messages already downloading, "
443                    + pendingCnt + " messages to download");
444        }
445
446        return toDownloadMessageId;
447    }
448
449    private ProcessPendingMessagesAction(final Parcel in) {
450        super(in);
451    }
452
453    public static final Parcelable.Creator<ProcessPendingMessagesAction> CREATOR
454            = new Parcelable.Creator<ProcessPendingMessagesAction>() {
455        @Override
456        public ProcessPendingMessagesAction createFromParcel(final Parcel in) {
457            return new ProcessPendingMessagesAction(in);
458        }
459
460        @Override
461        public ProcessPendingMessagesAction[] newArray(final int size) {
462            return new ProcessPendingMessagesAction[size];
463        }
464    };
465
466    @Override
467    public void writeToParcel(final Parcel parcel, final int flags) {
468        writeActionToParcel(parcel, flags);
469    }
470}
471