summaryrefslogtreecommitdiffstats
path: root/java/src/com/android/inputmethod/dictionarypack/DictionaryService.java
blob: c678f081d89e2a754dcaadb54e5cd1375bfc044a (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
/**
 * Copyright (C) 2011 The Android Open Source Project
 *
 * 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 com.android.inputmethod.dictionarypack;

import android.app.AlarmManager;
import android.app.PendingIntent;
import android.app.Service;
import android.content.Context;
import android.content.Intent;
import android.os.IBinder;
import android.util.Log;
import android.widget.Toast;

import com.android.inputmethod.latin.R;
import com.android.inputmethod.latin.common.LocaleUtils;

import java.util.Locale;
import java.util.Random;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;

import javax.annotation.Nonnull;

/**
 * Service that handles background tasks for the dictionary provider.
 *
 * This service provides the context for the long-running operations done by the
 * dictionary provider. Those include:
 * - Checking for the last update date and scheduling the next update. This runs every
 *   day around midnight, upon reception of the DATE_CHANGED_INTENT_ACTION broadcast.
 *   Every four days, it schedules an update of the metadata with the alarm manager.
 * - Issuing the order to update the metadata. This runs every four days, between 0 and
 *   6, upon reception of the UPDATE_NOW_INTENT_ACTION broadcast sent by the alarm manager
 *   as a result of the above action.
 * - Handling a download that just ended. These come in two flavors:
 *   - Metadata is finished downloading. We should check whether there are new dictionaries
 *     available, and download those that we need that have new versions.
 *   - A dictionary file finished downloading. We should put the file ready for a client IME
 *     to access, and mark the current state as such.
 */
public final class DictionaryService extends Service {
    private static final String TAG = DictionaryService.class.getSimpleName();

    /**
     * The package name, to use in the intent actions.
     */
    private static final String PACKAGE_NAME = "com.android.inputmethod.latin";

    /**
     * The action of the date changing, used to schedule a periodic freshness check
     */
    private static final String DATE_CHANGED_INTENT_ACTION =
            Intent.ACTION_DATE_CHANGED;

    /**
     * The action of displaying a toast to warn the user an automatic download is starting.
     */
    /* package */ static final String SHOW_DOWNLOAD_TOAST_INTENT_ACTION =
            PACKAGE_NAME + ".SHOW_DOWNLOAD_TOAST_INTENT_ACTION";

    /**
     * A locale argument, as a String.
     */
    /* package */ static final String LOCALE_INTENT_ARGUMENT = "locale";

    /**
     * How often, in milliseconds, we want to update the metadata. This is a
     * floor value; actually, it may happen several hours later, or even more.
     */
    private static final long UPDATE_FREQUENCY_MILLIS = TimeUnit.DAYS.toMillis(4);

    /**
     * We are waked around midnight, local time. We want to wake between midnight and 6 am,
     * roughly. So use a random time between 0 and this delay.
     */
    private static final int MAX_ALARM_DELAY_MILLIS = (int)TimeUnit.HOURS.toMillis(6);

    /**
     * How long we consider a "very long time". If no update took place in this time,
     * the content provider will trigger an update in the background.
     */
    private static final long VERY_LONG_TIME_MILLIS = TimeUnit.DAYS.toMillis(14);

    /**
     * After starting a download, how long we wait before considering it may be stuck. After this
     * period is elapsed, if the keyboard tries to download again, then we cancel and re-register
     * the request; if it's within this time, we just leave it be.
     * It's important to note that we do not re-submit the request merely because the time is up.
     * This is only to decide whether to cancel the old one and re-requesting when the keyboard
     * fires a new request for the same data.
     */
    public static final long NO_CANCEL_DOWNLOAD_PERIOD_MILLIS = TimeUnit.SECONDS.toMillis(30);

    /**
     * An executor that serializes tasks given to it.
     */
    private ThreadPoolExecutor mExecutor;
    private static final int WORKER_THREAD_TIMEOUT_SECONDS = 15;

    @Override
    public void onCreate() {
        // By default, a thread pool executor does not timeout its core threads, so it will
        // never kill them when there isn't any work to do any more. That would mean the service
        // can never die! By creating it this way and calling allowCoreThreadTimeOut, we allow
        // the single thread to time out after WORKER_THREAD_TIMEOUT_SECONDS = 15 seconds, allowing
        // the process to be reclaimed by the system any time after that if it's not doing
        // anything else.
        // Executors#newSingleThreadExecutor creates a ThreadPoolExecutor but it returns the
        // superclass ExecutorService which does not have the #allowCoreThreadTimeOut method,
        // so we can't use that.
        mExecutor = new ThreadPoolExecutor(1 /* corePoolSize */, 1 /* maximumPoolSize */,
                WORKER_THREAD_TIMEOUT_SECONDS /* keepAliveTime */,
                TimeUnit.SECONDS /* unit for keepAliveTime */,
                new LinkedBlockingQueue<Runnable>() /* workQueue */);
        mExecutor.allowCoreThreadTimeOut(true);
    }

    @Override
    public void onDestroy() {
    }

    @Override
    public IBinder onBind(Intent intent) {
        // This service cannot be bound
        return null;
    }

    /**
     * Executes an explicit command.
     *
     * This is the entry point for arbitrary commands that are executed upon reception of certain
     * events that should be executed on the context of this service. The supported commands are:
     * - Check last update time and possibly schedule an update of the data for later.
     *     This is triggered every day, upon reception of the DATE_CHANGED_INTENT_ACTION broadcast.
     * - Update data NOW.
     *     This is normally received upon trigger of the scheduled update.
     * - Handle a finished download.
     *     This executes the actions that must be taken after a file (metadata or dictionary data
     *     has been downloaded (or failed to download).
     * The commands that can be spun an another thread will be executed serially, in order, on
     * a worker thread that is created on demand and terminates after a short while if there isn't
     * any work left to do.
     */
    @Override
    public synchronized int onStartCommand(final Intent intent, final int flags,
            final int startId) {
        final DictionaryService self = this;
        if (SHOW_DOWNLOAD_TOAST_INTENT_ACTION.equals(intent.getAction())) {
            final String localeString = intent.getStringExtra(LOCALE_INTENT_ARGUMENT);
            if (localeString == null) {
                Log.e(TAG, "Received " + intent.getAction() + " without locale; skipped");
            } else {
                // This is a UI action, it can't be run in another thread
                showStartDownloadingToast(
                        this, LocaleUtils.constructLocaleFromString(localeString));
            }
        } else {
            // If it's a command that does not require UI, arrange for the work to be done on a
            // separate thread, so that we can return right away. The executor will spawn a thread
            // if necessary, or reuse a thread that has become idle as appropriate.
            // DATE_CHANGED or UPDATE_NOW are examples of commands that can be done on another
            // thread.
            mExecutor.submit(new Runnable() {
                @Override
                public void run() {
                    dispatchBroadcast(self, intent);
                    // Since calls to onStartCommand are serialized, the submissions to the executor
                    // are serialized. That means we are guaranteed to call the stopSelfResult()
                    // in the same order that we got them, so we don't need to take care of the
                    // order.
                    stopSelfResult(startId);
                }
            });
        }
        return Service.START_REDELIVER_INTENT;
    }

    static void dispatchBroadcast(final Context context, final Intent intent) {
        if (DATE_CHANGED_INTENT_ACTION.equals(intent.getAction())) {
            // This happens when the date of the device changes. This normally happens
            // at midnight local time, but it may happen if the user changes the date
            // by hand or something similar happens.
            checkTimeAndMaybeSetupUpdateAlarm(context);
        } else if (DictionaryPackConstants.UPDATE_NOW_INTENT_ACTION.equals(intent.getAction())) {
            // Intent to trigger an update now.
            UpdateHandler.tryUpdate(context, false);
        } else {
            UpdateHandler.downloadFinished(context, intent);
        }
    }

    /**
     * Setups an alarm to check for updates if an update is due.
     */
    private static void checkTimeAndMaybeSetupUpdateAlarm(final Context context) {
        // Of all clients, if the one that hasn't been updated for the longest
        // is still more recent than UPDATE_FREQUENCY_MILLIS, do nothing.
        if (!isLastUpdateAtLeastThisOld(context, UPDATE_FREQUENCY_MILLIS)) return;

        PrivateLog.log("Date changed - registering alarm");
        AlarmManager alarmManager = (AlarmManager)context.getSystemService(Context.ALARM_SERVICE);

        // Best effort to wake between midnight and MAX_ALARM_DELAY_MILLIS in the morning.
        // It doesn't matter too much if this is very inexact.
        final long now = System.currentTimeMillis();
        final long alarmTime = now + new Random().nextInt(MAX_ALARM_DELAY_MILLIS);
        final Intent updateIntent = new Intent(DictionaryPackConstants.UPDATE_NOW_INTENT_ACTION);
        final PendingIntent pendingIntent = PendingIntent.getBroadcast(context, 0,
                updateIntent, PendingIntent.FLAG_CANCEL_CURRENT);

        // We set the alarm in the type that doesn't forcefully wake the device
        // from sleep, but fires the next time the device actually wakes for any
        // other reason.
        if (null != alarmManager) alarmManager.set(AlarmManager.RTC, alarmTime, pendingIntent);
    }

    /**
     * Utility method to decide whether the last update is older than a certain time.
     *
     * @return true if at least `time' milliseconds have elapsed since last update, false otherwise.
     */
    private static boolean isLastUpdateAtLeastThisOld(final Context context, final long time) {
        final long now = System.currentTimeMillis();
        final long lastUpdate = MetadataDbHelper.getOldestUpdateTime(context);
        PrivateLog.log("Last update was " + lastUpdate);
        return lastUpdate + time < now;
    }

    /**
     * Refreshes data if it hasn't been refreshed in a very long time.
     *
     * This will check the last update time, and if it's been more than VERY_LONG_TIME_MILLIS,
     * update metadata now - and possibly take subsequent update actions.
     */
    public static void updateNowIfNotUpdatedInAVeryLongTime(final Context context) {
        if (!isLastUpdateAtLeastThisOld(context, VERY_LONG_TIME_MILLIS)) return;
        UpdateHandler.tryUpdate(context, false);
    }

    /**
     * Shows a toast informing the user that an automatic dictionary download is starting.
     */
    private static void showStartDownloadingToast(final Context context,
            @Nonnull final Locale locale) {
        final String toastText = String.format(
                context.getString(R.string.toast_downloading_suggestions),
                locale.getDisplayName());
        Toast.makeText(context, toastText, Toast.LENGTH_LONG).show();
    }
}