Yet Another Dev Blog

Scheduling repetitive task on Android

There is a task that needs to be repeated every N seconds. Might be even an interview question to see what options person would consider. With no other requirements or restrictions given, here is what pops up in my mind:

As a task example I

1. Service with Handler

Expand source code
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
public class CurrencyManagerService extends Service {

  private static Logger log = LoggerFactory.getLogger(CurrencyManagerService.class);

  private static final long TIMEOUT_FETCH_CURRENCY_RATES = 1000 * 5; // seconds
  private static long ERROR_BACKOFF_TIMEOUT = TIMEOUT_FETCH_CURRENCY_RATES; // seconds
  private static final String PREFS_KEY_CURRENCY_RATES = ".currencyRates";

  private static Handler handler;

  @Override
  public void onCreate() {
      super.onCreate();
      HandlerThread fetchThread = new HandlerThread("fetchThread");
      fetchThread.setPriority(Thread.MIN_PRIORITY);
      fetchThread.start();

      handler = new Handler(fetchThread.getLooper());
  }

  @Override
  public int onStartCommand(Intent intent, int flags, int startId) {
      log.debug("Service started.");

      // firstly, check if there is sticky event available already
      // (service may be restarted by system occasionally)
      CurrencyRateResponse currencyRateResponse = EventBus.getDefault().getStickyEvent(CurrencyRateResponse.class);
      if (currencyRateResponse == null) {
          // then check if we have it stored in SharedPreferences
          currencyRateResponse = PreferencesToGson.getInstance(getApplicationContext())
                                                  .getObject(PREFS_KEY_CURRENCY_RATES, CurrencyRateResponse.class);
          if (currencyRateResponse == null) {
              // app fresh start, go fetch rates now
              handler.post(fetchCurrencyRatesRunnable);
          } else {
              // found in SharedPreferences; post event and schedule fetch meanwhile to update cache
              EventBus.getDefault().postSticky(currencyRateResponse);
              handler.post(fetchCurrencyRatesRunnable);
          }
      } else {
          // sticky event is there, schedule next fetch
          handler.postDelayed(fetchCurrencyRatesRunnable, TIMEOUT_FETCH_CURRENCY_RATES);
      }

      return START_STICKY;
  }

  private Runnable fetchCurrencyRatesRunnable = new Runnable() {
      @Override
      public void run() {
          log.debug("Fetching CurrencyRate…");
          RestAdapter restAdapter = new RestAdapter.Builder()
                                            .setEndpoint("http://yourendpoint.com:8080")
                                            .build();
          CurrencyRatesRestService restService = restAdapter.create(CurrencyRatesRestService.class);
          restService.getRate("USD", currencyRatesRequestCallback);
      }
  };

  private Callback<CurrencyRateResponse.CurrencyRate> currencyRatesRequestCallback = new Callback<CurrencyRateResponse.CurrencyRate>() {
      @Override
      public void success(CurrencyRateResponse.CurrencyRate currencyRate, Response response) {
          ERROR_BACKOFF_TIMEOUT = TIMEOUT_FETCH_CURRENCY_RATES; // set error timeout to default value
          log.info(currencyRate.toString());

          CurrencyRateResponse currencyRateResponse = new CurrencyRateResponse(System.currentTimeMillis(), currencyRate);
          PreferencesToGson.getInstance(getApplicationContext())
                           .putObject(PREFS_KEY_CURRENCY_RATES, currencyRateResponse);
          EventBus.getDefault().postSticky(currencyRateResponse);
          handler.postDelayed(fetchCurrencyRatesRunnable, TIMEOUT_FETCH_CURRENCY_RATES);
      }

      @Override
      public void failure(RetrofitError error) {
          log.error("Error while fetching currency rates.", error);
          // schedule next fetch with some backoff delay
          handler.postDelayed(fetchCurrencyRatesRunnable, ERROR_BACKOFF_TIMEOUT *= 2);
      }
  };

  @Nullable
  @Override
  public IBinder onBind(Intent intent) {
      // we don't allow binding to this service, since it doesn't make much sense
      // (i.e. other components don't need to send messages to service)
      return null;
  }
}

2. TimerTask
Android doc for TimerTask says "Prefer ScheduledThreadPoolExecutor for new code."

Expand source code
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
public class CurrencyManagerTimer {
  private static Logger log = LoggerFactory.getLogger(CurrencyManagerTimer.class);

  private static final long TIMEOUT_FETCH_CURRENCY_RATES = 1000 * 5; // seconds
  private static long ERROR_BACKOFF_TIMEOUT = TIMEOUT_FETCH_CURRENCY_RATES;
  private static final String PREFS_KEY_CURRENCY_RATES = ".currencyRates";

  private final Context context;

  private static Timer timer;
  private TimerTask timerTask;

  public CurrencyManagerTimer(@NonNull Context context) {
      this.context = context;
      timer = new Timer("fetchTimer");
      timerTask = createTimerTask();
      start();
  }

  private TimerTask createTimerTask() {
      return new TimerTask() {
          @Override
          public void run() {
              log.debug("Fetching CurrencyRate…");
              RestAdapter restAdapter = new RestAdapter.Builder()
                                                .setEndpoint("http://localhost:8080")
                                                .build();
              CurrencyRatesRestService restService = restAdapter.create(CurrencyRatesRestService.class);
              restService.getRate("USD", currencyRatesRequestCallback);
          }
      };
  }

  private Callback<CurrencyRateResponse.CurrencyRate> currencyRatesRequestCallback = new Callback<CurrencyRateResponse.CurrencyRate>() {
      @Override
      public void success(CurrencyRateResponse.CurrencyRate currencyRate, Response response) {
          ERROR_BACKOFF_TIMEOUT = TIMEOUT_FETCH_CURRENCY_RATES; // set error timeout to default
          log.info(currencyRate.toString());

          CurrencyRateResponse currencyRateResponse = new CurrencyRateResponse(System.currentTimeMillis(), currencyRate);
          PreferencesToGson.getInstance(context)
                           .putObject(PREFS_KEY_CURRENCY_RATES, currencyRateResponse);
          EventBus.getDefault().postSticky(currencyRateResponse);
      }

      @Override
      public void failure(RetrofitError error) {
          log.error("Error while fetching currency rates.", error);
          // schedule next fetch with some backoff delay
          timerTask.cancel();
          timerTask = createTimerTask();
          timer.scheduleAtFixedRate(timerTask, ERROR_BACKOFF_TIMEOUT *= 2, TIMEOUT_FETCH_CURRENCY_RATES);
      }
  };


  private void start() {
      log.debug("Manager started.");

      // firstly, check if there is sticky event available already
      CurrencyRateResponse currencyRateResponse = EventBus.getDefault().getStickyEvent(CurrencyRateResponse.class);
      if (currencyRateResponse == null) {
          // then check if we have it stored in SharedPreferences
          currencyRateResponse = PreferencesToGson.getInstance(context)
                                                  .getObject(PREFS_KEY_CURRENCY_RATES, CurrencyRateResponse.class);
          if (currencyRateResponse == null) {
              // app fresh start, go fetch rates now
              timer.scheduleAtFixedRate(timerTask, 0, TIMEOUT_FETCH_CURRENCY_RATES);
          } else {
              // found in SharedPreferences; post event and schedule fetch meanwhile to update cache
              EventBus.getDefault().postSticky(currencyRateResponse);
              timer.scheduleAtFixedRate(timerTask, 0, TIMEOUT_FETCH_CURRENCY_RATES);
          }
      } else {
          // sticky event is there, schedule next fetch
          timer.scheduleAtFixedRate(timerTask, TIMEOUT_FETCH_CURRENCY_RATES, TIMEOUT_FETCH_CURRENCY_RATES);
      }

  }
}

3. ScheduledThreadPoolExecutor

Expand source code
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
public class CurrencyManagerScheduledExecutor {
  private static Logger log = LoggerFactory.getLogger(CurrencyManagerScheduledExecutor.class);

  private static final long TIMEOUT_FETCH_CURRENCY_RATES = 1000 * 5; // seconds
  private static long ERROR_BACKOFF_TIMEOUT = TIMEOUT_FETCH_CURRENCY_RATES;
  private static final String PREFS_KEY_CURRENCY_RATES = ".currencyRates";

  private final Context context;
  private ScheduledFuture<?> fetchFuture;

  private static ScheduledThreadPoolExecutor executor;

  public CurrencyManagerScheduledExecutor(@NonNull Context context) {
      this.context = context;
      executor = new ScheduledThreadPoolExecutor(1);

      start();
  }

  private Runnable fetchRunnable = new Runnable() {
      @Override
      public void run() {
          log.debug("Fetching CurrencyRate…");
          RestAdapter restAdapter = new RestAdapter.Builder()
                                            .setEndpoint("http://localhost:8080")
                                            .build();
          CurrencyRatesRestService restService = restAdapter.create(CurrencyRatesRestService.class);
          restService.getRate("USD", currencyRatesRequestCallback);
      }
  };

  private Callback<CurrencyRateResponse.CurrencyRate> currencyRatesRequestCallback = new Callback<CurrencyRateResponse.CurrencyRate>() {
      @Override
      public void success(CurrencyRateResponse.CurrencyRate currencyRate, Response response) {
          ERROR_BACKOFF_TIMEOUT = TIMEOUT_FETCH_CURRENCY_RATES; // default error timeout
          log.info(currencyRate.toString());

          CurrencyRateResponse currencyRateResponse = new CurrencyRateResponse(System.currentTimeMillis(), currencyRate);
          PreferencesToGson.getInstance(context)
                           .putObject(PREFS_KEY_CURRENCY_RATES, currencyRateResponse);
          EventBus.getDefault().postSticky(currencyRateResponse);
      }

      @Override
      public void failure(RetrofitError error) {
          log.error("Error while fetching currency rates.", error);

          // cancel all subsequent fixed-rate tasks because of an error
          fetchFuture.cancel(true);
          // schedule next fetch with some backoff delay.
          // if error disappears, tasks will be fixed-rate again with "period" parameter
          fetchFuture = executor.scheduleAtFixedRate(fetchRunnable,
                                                     ERROR_BACKOFF_TIMEOUT *= 2,    // backoff delay
                                                     TIMEOUT_FETCH_CURRENCY_RATES,  // period
                                                     TimeUnit.MILLISECONDS);
      }
  };


  private void start() {
      log.debug("Manager started.");

      // First, check if there is sticky event available already
      CurrencyRateResponse currencyRateResponse = EventBus.getDefault().getStickyEvent(CurrencyRateResponse.class);
      if (currencyRateResponse == null) {
          // then check if we have it stored in SharedPreferences
          currencyRateResponse = PreferencesToGson.getInstance(context)
                                                  .getObject(PREFS_KEY_CURRENCY_RATES, CurrencyRateResponse.class);
          if (currencyRateResponse == null) {
              // app fresh start, go fetch rates now
              fetchFuture = executor.scheduleAtFixedRate(fetchRunnable, 0, TIMEOUT_FETCH_CURRENCY_RATES, TimeUnit.MILLISECONDS);
          } else {
              // found in SharedPreferences; post event and schedule fetch meanwhile to update cache
              EventBus.getDefault().postSticky(currencyRateResponse);
              fetchFuture = executor.scheduleAtFixedRate(fetchRunnable, 0, TIMEOUT_FETCH_CURRENCY_RATES, TimeUnit.MILLISECONDS);
          }
      } else {
          // sticky event is there, schedule next fetch
          fetchFuture = executor.scheduleAtFixedRate(fetchRunnable, TIMEOUT_FETCH_CURRENCY_RATES, TIMEOUT_FETCH_CURRENCY_RATES,
                                                     TimeUnit.MILLISECONDS);
      }

  }
}

4. AlarmManager From the AlarmManager doc:
Note: The Alarm Manager is intended for cases where you want to have your application code run at a specific time, even if your application is not currently running. For normal timing operations (ticks, timeouts, etc) it is easier and much more efficient to use Handler.

Expand source code
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
public class CurrencyManagerAlarm {
  private static Logger log = LoggerFactory.getLogger(CurrencyManagerAlarm.class);

  private static final long TIMEOUT_FETCH_CURRENCY_RATES = 1000 * 5; // seconds
  private static long ERROR_BACKOFF_TIMEOUT = TIMEOUT_FETCH_CURRENCY_RATES;
  private static final String PREFS_KEY_CURRENCY_RATES = ".currencyRates";

  private static final String intentFilter = ".managers.sendSchedule";
  private final Context context;
  private final PendingIntent pendingIntent;

  private static AlarmManager alarmManager;

  public CurrencyManagerAlarm(@NonNull Context context) {
      this.context = context;
      alarmManager = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE);

      Intent alarmIntent = new Intent(intentFilter);
      pendingIntent = PendingIntent.getBroadcast(context, 0, alarmIntent, PendingIntent.FLAG_UPDATE_CURRENT);

      FetchCommandReceiver fetchCommandReceiver = new FetchCommandReceiver();
      context.registerReceiver(fetchCommandReceiver, new IntentFilter(intentFilter));

      start();
  }

  private Runnable fetchRunnable = new Runnable() {
      @Override
      public void run() {
          log.debug("Fetching CurrencyRate…");
          RestAdapter restAdapter = new RestAdapter.Builder()
                                            .setEndpoint("http://localhost:8080")
                                            .build();
          CurrencyRatesRestService restService = restAdapter.create(CurrencyRatesRestService.class);
          restService.getRate("USD", currencyRatesRequestCallback);
      }
  };

  private Callback<CurrencyRateResponse.CurrencyRate> currencyRatesRequestCallback = new Callback<CurrencyRateResponse.CurrencyRate>() {
      @Override
      public void success(CurrencyRateResponse.CurrencyRate currencyRate, Response response) {
          ERROR_BACKOFF_TIMEOUT = TIMEOUT_FETCH_CURRENCY_RATES;
          log.info(currencyRate.toString());

          CurrencyRateResponse currencyRateResponse = new CurrencyRateResponse(System.currentTimeMillis(), currencyRate);
          PreferencesToGson.getInstance(context)
                           .putObject(PREFS_KEY_CURRENCY_RATES, currencyRateResponse);
          EventBus.getDefault().postSticky(currencyRateResponse);
      }

      @Override
      public void failure(RetrofitError error) {
          log.error("Error while fetching currency rates.", error);

          // Cancel all subsequent fixed-rate tasks because of an error.
          // Schedule next fetch with some backoff delay.
          // If error disappears, tasks will be fixed-rate again with "period" parameter
          alarmManager.cancel(pendingIntent);

          long now = Calendar.getInstance().getTimeInMillis();
          alarmManager.setInexactRepeating(AlarmManager.RTC,
                                           now + (ERROR_BACKOFF_TIMEOUT *= 2),
                                           TIMEOUT_FETCH_CURRENCY_RATES,
                                           pendingIntent);
      }
  };


  private void start() {
      log.debug("Manager started.");

      long now = Calendar.getInstance().getTimeInMillis();

      // firstly, check if there is sticky event available already
      // (service may be restarted by system occasionally)
      CurrencyRateResponse currencyRateResponse = EventBus.getDefault().getStickyEvent(CurrencyRateResponse.class);
      if (currencyRateResponse == null) {
          // then check if we have it stored in SharedPreferences
          currencyRateResponse = PreferencesToGson.getInstance(context)
                                                  .getObject(PREFS_KEY_CURRENCY_RATES, CurrencyRateResponse.class);
          if (currencyRateResponse == null) {
              // app fresh start, go fetch rates now
              alarmManager.setRepeating(AlarmManager.RTC,
                                        now,
                                        TIMEOUT_FETCH_CURRENCY_RATES,
                                        pendingIntent);

          } else {
              // found in SharedPreferences; post event and schedule fetch meanwhile to update cache
              EventBus.getDefault().postSticky(currencyRateResponse);
              alarmManager.setRepeating(AlarmManager.RTC,
                                        now,
                                        TIMEOUT_FETCH_CURRENCY_RATES,
                                        pendingIntent);
          }
      } else {
          // sticky event is there, schedule next fetch
          alarmManager.setRepeating(AlarmManager.RTC,
                                    now + TIMEOUT_FETCH_CURRENCY_RATES,
                                    TIMEOUT_FETCH_CURRENCY_RATES,
                                    pendingIntent);
      }

  }

  public class FetchCommandReceiver extends BroadcastReceiver {

      @Override
      public void onReceive(Context context, Intent intent) {
          new Thread(fetchRunnable).start();
      }
  }
}