image/svg+xml $ $ ing$ ing$ ces$ ces$ Res Res ea ea Res->ea ou ou Res->ou r r ea->r ch ch ea->ch r->ces$ r->ch ch->$ ch->ing$ T T T->ea ou->r

Some tasks do not require a graphical feedback. This kind of work can be managed with Android using:

Service

Principles

A simple service displaying a toast

This simple example presents the use of a service: it displays a toast when it receives an intent.

Usually when we create a Service, we define actions (string constants) that are used in the intent to express the order given to the service. The action is set to the intent with setAction(String action).

We write the service:

public class ToastService extends Service {

    /** An action that has an unique name */
    public static final String TOAST_ACTION = ToastService.class.getName() + ".displayToast";

    @Override
    public int onStartCommand(Intent intent, int flags, int startId) {
        if (intent.getAction().equals(TOAST_ACTION)) {
            String id = intent.getStringExtra("id");
            String message = intent.getStringExtra("message");
            boolean longDuration = intent.getBooleanExtra("longDuration", false);
            Toast t = Toast.makeText(this, message, longDuration ? Toast.LENGTH_LONG : Toast.LENGTH_SHORT);
            t.show();
        }
        return START_NOT_STICKY; // if the service is killed, the intent will not be redelivered
    }

    @Override
    public IBinder onBind(Intent intent) {
        throw new UnsupportedOperationException("Not implemented (since we do not use RPC methods)");
    }
}

We write an activity that starts the service:

/** An activity submitting messages to a service displaying them as toasts */
class ToastServiceActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_toast_service)
        sendButton.setOnClickListener {
            val intent = Intent(this, ToastService::class.java)
            intent.action = ToastService.TOAST_ACTION
            intent.putExtra("message", messageView.text.toString())
            intent.putExtra("longDuration", longDurationView.isChecked)
            startService(intent)
        }
    }
}

Stopping and restarting the service

The int returned by the onStartCommand specifies what behaviour must be used when the service is killed by the system:

Foreground service

Use cases for a foreground service:

To use a foreground service, a (non sensitive) permission must be declared in the manifest:

<manifest xmlns:android="http://schemas.android.com/apk/res/android" ...>

    <uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>

    <application ...>
        ...
    </application>
</manifest>

Privacy restrictions for the services (since Android 11)

A service that uses the location API, the camera and/or the microphone must be declared as such in the manifest (and the app must own the appropriate permissions):

<manifest>
    ...
    <service ...
        android:foregroundServiceType="location|camera|microphone" />
</manifest>

When a foregound service is started when no activity of the app is visible:

About notifications

How to create or update a notification?

  1. A notification channel must be created
  2. A notification must be build using NotificationCompat.Builder.build()
  3. An instance of the notification manager must be obtained with Context.getSystemService(Context.NOTIFICATION_SERVICE)
  4. The notification must be submitted to the notification manager with nm.notify(String tag, int id, Notification notification)
  5. The notification can be updated by calling again the notify method with a new updated notification and the same id
  6. One can also use startForeground(int id, Notification notification) to set the notification for a foreground service
  7. It is possible to use several notification channels to submit several notification; each channel can be configured independentely in the system settings (to silence it for example)

Notification 1 Notification 2

Since Android 13 (API level 33), the POST_NOTIFICATIONS permission must be declared in the manifest to create notifications. It is a dangerous permission: the app must ask explicitely the authorization. For apps targeting API levels < 33, a dialog to ask the permission is automatically launched when the notification channel is created.

Example: a notifying chronometer

Example of a service displaying a chronometer with a notification of the elapsed time (the highPriority mode displays the notification in heads-up mode without having to unwrap the notification drawer).

public class ChronoService extends Service 
{
	public static final String ACTION_START = 
			NotifiedChronometer.class.getPackage().getName() + ".startChronometer";
	public static final String ACTION_STOP = 
			NotifiedChronometer.class.getPackage().getName() + ".startChronometer";
	public static final String ACTION_RESET = 
			NotifiedChronometer.class.getPackage().getName() + ".resetChronometer";

	// We don't use the RPC capability
	@Override public IBinder onBind(Intent intent) { return null; }

	private ChronoService instance = null;
	private long cumulatedTime = 0;
	private long startTime = -1;
	private static boolean running = false;

	private Thread updateThread = null;

	/** Return if the chronometer is running */
	public static boolean isRunning()
	{
		return running;
	}

	/** Identifier of the channel used for notification (required since API 26) */
	public static final String CHANNEL_ID =
			ChronoService.class.getName() + ".CHRONO_CHANNEL";

	/** This method creates a new notification channel (required for API 26+)
	 *  It is copied from https://developer.android.com/training/notify-user/build-notification
	 */
	private void createNotificationChannel()
	{
		// Create the NotificationChannel, but only on API 26+ because
		// the NotificationChannel class is new and not in the support library
		if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
			CharSequence name = "Chronometer channel";
			String description = "Channel for notifications of the chronometer service";
			int importance = NotificationManager.IMPORTANCE_DEFAULT;
			NotificationChannel channel = new NotificationChannel(CHANNEL_ID, name, importance);
			channel.setDescription(description);
			// Register the channel with the system; you can't change the importance
			// or other notification behaviors after this
			NotificationManager notificationManager = getSystemService(NotificationManager.class);
			notificationManager.createNotificationChannel(channel);
		}
	}


	private Notification createNotification(String text, boolean highPriority)
	{
		NotificationCompat.Builder mBuilder =
				new NotificationCompat.Builder(this, CHANNEL_ID)
					.setSmallIcon(android.R.drawable.ic_media_play)
					.setContentTitle("Chronometer")
					.setContentText(text)
					.setPriority(NotificationCompat.PRIORITY_DEFAULT)
					.setSilent(true);
		// if high priority is set, the notification is displayed in a heads-up fashion
		if (highPriority) {
			mBuilder.setPriority(NotificationCompat.PRIORITY_HIGH);
			mBuilder.setDefaults(Notification.DEFAULT_VIBRATE);
		}
		// Associate an action to the notification to start a linked Activity
		Intent resultIntent = new Intent(this, NotifiedChronometer.class)
			.putExtra("running", startTime >= 0);
		// do not start the activity again if it is already on top
		resultIntent.setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP);
		mBuilder.setContentIntent(PendingIntent.getActivity(this, 0, resultIntent, 0));
		return mBuilder.build();
	}

	@Override
	public void onCreate()
	{
		super.onCreate();
		instance = this; // set the singleton instance
		createNotificationChannel();
	}

	/** Arbitrary ID for the notification (with different IDs a service can manage several notifications) */
	public static final int NOTIFICATION_ID = 1;

	@Override
	public int onStartCommand(Intent intent, int flags, int startId) 
	{
		final NotificationManager nm = (NotificationManager)getSystemService(NOTIFICATION_SERVICE);
		if (intent == null) return Service.START_STICKY_COMPATIBILITY;
		if (intent.getAction().equals(ACTION_START) && startTime == -1)
		{
			Log.i(getClass().getName(), "Action started intercepted");
			final boolean highPriority = intent.getBooleanExtra("highPriority", false);
			startTime = System.nanoTime();
			// Put in the foreground
			running = true;
			startForeground(NOTIFICATION_ID, createNotification("Running chrono", highPriority));
			// we could post update runnables on an handler instead of using a thread
			updateThread = new Thread(() -> {
				while (! Thread.interrupted())
				{
					long time = (cumulatedTime + System.nanoTime() - startTime) / 1000000000;
					nm.notify(NOTIFICATION_ID, createNotification("Running: " + time + " s", highPriority));
					System.err.println("Notify " + time);
					try {
						Thread.sleep(1000);
					} catch (InterruptedException e)
					{
						return; // the thread was interrupted (if the service is destroyed for example)
					}
				}
			});
			updateThread.start();
		}
		else if (intent.getAction().equals(ACTION_STOP) && startTime >= 0)
		{
			cumulatedTime += System.nanoTime() - startTime;
			stopForeground(true);
			running = false;
			// remove the notification and stop the thread
			nm.cancel(NOTIFICATION_ID);
			updateThread.interrupt();
			startTime = -1;
			// stopSelf();
		}
		else if (intent.getAction().equals(ACTION_RESET))
		{
			if (startTime >= 0) startTime = System.nanoTime();
			cumulatedTime = 0;
		}
		return START_NOT_STICKY; // do not restart automatically the service if it is killed
		// however with startForeground, probability of service killing is weak
	}

	@Override
	public void onDestroy()
	{
		// do not forget to stop the thread if the service is destroyed
		if (updateThread != null) updateThread.interrupt();
	}
}

Starting a service for the first time

Starting a service at boot

Declaration of the permission and the broadcast receiver in the manifest:

<manifest ...>
	...
	<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
	...
	<application ...>
		...
		<receiver
            android:name=".receivertest.BootBroadcastReceiver"
            android:enabled="true"
            android:exported="true">
      <intent-filter>
        <action android:name="android.intent.action.BOOT_COMPLETED" />
      </intent-filter>
    </receiver>
    ...
	</application>
</manifest>

Starting the service from the receiver:

class BootBroadcastReceiver : BroadcastReceiver() {

    override fun onReceive(context: Context, intent: Intent) {
        // This method is called when the BroadcastReceiver is receiving an Intent broadcast.
        context.startService(Intent(context, ToastService::class.java).apply {
            action = ToastService.TOAST_ACTION
            putExtra("message", "Toast at boot!")
        })
    }
}

Communicating with a service

Example: a location logger service

<service android:name=".gpslogger.LocationLoggerService"
            android:foregroundServiceType="location" />

We write the service that logs the location and emits a broadcast with the location:

/** A simple service that logs locations into a file.
 * It must be activated by a companion activity that must check that the location permissions are enabled.
 *
 * @author chilowi at u-pem.fr
 */
public class LocationLoggerService extends Service
{
	public static final String LOCATION_FILE = "locations.log";

	public static final String LOCATION_BROADCAST_ACTION = LocationLoggerService.class.getName() + ".LOCATION_INFO";
	
	LocationManager locationManager = null;
	LocationListener locationListener = null;
	Writer writer = null;

	private static boolean running = false;

	public static boolean isRunning()
	{
		return running;
	}
	
	@Override
	public IBinder onBind(Intent arg0) { return null; }

	public static final String CHANNEL_ID = "locationLogger";

	/** This method creates a new notification channel (required for API 26+)
	 *  It is copied from https://developer.android.com/training/notify-user/build-notification
	 */
	private void createNotificationChannel()
	{
		// Create the NotificationChannel, but only on API 26+ because
		// the NotificationChannel class is new and not in the support library
		if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
			CharSequence name = "Location logger channel";
			String description = "Channel for the location logger";
			int importance = NotificationManager.IMPORTANCE_DEFAULT;
			NotificationChannel channel = new NotificationChannel(CHANNEL_ID, name, importance);
			channel.setDescription(description);
			// Register the channel with the system; you can't change the importance
			// or other notification behaviors after this
			NotificationManager notificationManager = getSystemService(NotificationManager.class);
			notificationManager.createNotificationChannel(channel);
		}
	}

	private Notification createNotification()
	{
		NotificationCompat.Builder mBuilder =
				new NotificationCompat.Builder(this, CHANNEL_ID)
						.setSmallIcon(android.R.drawable.ic_media_play)
						.setContentTitle("Location logger")
						.setContentText("The logger is active")
						.setPriority(NotificationCompat.PRIORITY_DEFAULT)
						.setSilent(true);
		return mBuilder.build();
	}
	
	private void log(String message)
	{
		try {
			if (writer == null)
			{
				writer = new OutputStreamWriter(openFileOutput(LOCATION_FILE, MODE_APPEND));
			}
			writer.write(new Date() + ": " + message);
			writer.flush();
		} catch (IOException e)
		{
			Log.e(getClass().getName(), "Cannot log message " + message + " due to an exception", e);
		}
	}
	
	@Override
	public void onCreate()
	{
		createNotificationChannel();
		locationManager = (LocationManager)getSystemService(LOCATION_SERVICE);
		locationListener = new LocationListener() {
			
			@Override public void onStatusChanged(String provider, int status, Bundle extras) 
			{
				log(String.format("Change of status of provider %s: %d", provider, status));
			}
			
			@Override public void onProviderEnabled(String provider) 
			{
				log(String.format("Provider %s is enabled", provider));
			}
			
			@Override public void onProviderDisabled(String provider) 
			{
				log(String.format("Provider %s is disabled", provider));
			}
			
			@Override public void onLocationChanged(Location location) 
			{
				log(String.format("latitude=%f, longitude=%f, altitude=%f", 
					location.getLatitude(), location.getLongitude(), location.getAltitude()));
				Intent i = new Intent(LOCATION_BROADCAST_ACTION);
				i.putExtra("latitude", location.getLatitude());
				i.putExtra("longitude", location.getLongitude());
				sendBroadcast(i);
			}
		};
	}

	@Override
	public int onStartCommand(Intent intent, int flags, int startId) 
	{
		startForeground(1, createNotification());
		try {
			String provider = intent.getStringExtra("provider");
			if (provider == null) provider = LocationManager.GPS_PROVIDER;
			locationManager.requestLocationUpdates(provider,
					intent.getLongExtra("minTime", 10000),
					intent.getFloatExtra("minDistance", 100.0f),
					locationListener);
			running = true;
		} catch (SecurityException e)
		{
			Toast.makeText(this, "Cannot start the location logger service", Toast.LENGTH_LONG).show();
			stopSelf();
		}
		return Service.START_REDELIVER_INTENT; // Restart the service with the intent if it is destroyed
	}
	
	@Override
	public void onDestroy()
	{
		try { if (writer != null) writer.close(); } catch (IOException ignored) {}
		try {
			locationManager.removeUpdates(locationListener);
		} catch (SecurityException e)
		{
			// this case should not be encountered
		}
		running = false;
	}

}

And we write the activity that starts the service and listen for the broadcasted locations with a receiver:

class LocationLoggerActivity : RequiringPermissionActivity() {

    var serviceIntent: Intent? = null

    var receiver = object: BroadcastReceiver() {
        override fun onReceive(context: Context, intent: Intent) {
            val infoTextView = findViewById<TextView>(R.id.infoTextView)
            val latitude = intent.getDoubleExtra("latitude", Double.NaN)
            val longitude = intent.getDoubleExtra("longitude", Double.NaN)
            infoTextView.text = "Update received on ${Date()}: $latitude/$longitude"
        }
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        serviceIntent = Intent(this, LocationLoggerService::class.java)
        setContentView(R.layout.activity_location_logger)
    }

    override fun onStart() {
        super.onStart()
        val cb = findViewById<CheckBox>(R.id.locationLoggerServiceCheckBox)
        runWithPermission(Manifest.permission.ACCESS_FINE_LOCATION, "You must grant the GPS permission",
                {Toast.makeText(this, "Location permission is not granted", Toast.LENGTH_SHORT).show()
                    cb.isEnabled = false},
                {cb.isEnabled = true})
        cb.isChecked = LocationLoggerService.isRunning()
        registerReceiver(receiver, IntentFilter(LocationLoggerService.LOCATION_BROADCAST_ACTION))
    }

    override fun onStop() {
        super.onStop()
        unregisterReceiver(receiver)
    }


    fun onCheckBoxClick(v: View) {
        val action = if ((v as CheckBox).isChecked) { x: Intent? -> startService(x) } else { x -> stopService(x) }
        action(serviceIntent)
    }
}

WorkManager

Principles

How to create a work that can be submitted?

Example of a submitted task

We want to retrieve periodically (every 15 minutes) the conversion rates of an API in the background.

We write the Worker (using a Listenable<Worker>) to do the retrieval task (a cache file is stored with the result) :

/** Worker that retrieves currency rates */
class CurrencyFetchingWorker(context: Context, params: WorkerParameters): ListenableWorker(context, params) {
    companion object {
        const val REQUEST_URL = "https://api.ratesapi.io/api/latest"
        const val RATES_FILENAME = "currencyRates.json"
    }

    override fun startWork(): ListenableFuture<Result> {
        return CallbackToFutureAdapter.getFuture { completer ->

            val stringRequest = StringRequest(Request.Method.GET, REQUEST_URL,
                { response -> // write the result to the RATES file
                    applicationContext.cacheDir.resolve(RATES_FILENAME).writeText(response)
                },
                    object : Response.ErrorListener {
                        override fun onErrorResponse(error: VolleyError) {
                            Toast.makeText(applicationContext, "Cannot fetch rates due to " + error, Toast.LENGTH_SHORT).show()
                            Log.e(this.javaClass.name, "Error while retrieving rates", error)
                            completer.set(Result.failure())
                        }
                    }
            )

            //Instantiate the RequestQueue and add the request to the queue
            val queue = Volley.newRequestQueue(applicationContext)
            queue.add(stringRequest)

            "currencyJob"
        }
    }
}

We write an activity to start or stop the retrieving tasks:

class CurrencyActivity : AppCompatActivity() {

    companion object {
        const val JOB_NAME = "fetchingCurrencyRates"
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_currency)

        WorkManager.getInstance(this).apply {
            val data = getWorkInfosForUniqueWorkLiveData(JOB_NAME)
            // create an observer to be informed about the status of the job
            val observer = Observer { list: List<WorkInfo> ->
                currencyJobStatus.text = if (list.isNotEmpty()) list[0].state.toString() else "unknown status"
                currencyRatesTextView.text = try {
                    val f = cacheDir.resolve(CurrencyFetchingWorker.RATES_FILENAME)
                    "Modification date: ${Date(f.lastModified())}\n\n${f.readText()}"
                } catch (e: IOException) {
                    "data not available yet"
                }
            }
            data.observe(this@CurrencyActivity, observer)
        }
    }

    fun startJob(v: View) {
        val constraints = Constraints.Builder()
                .setRequiredNetworkType(NetworkType.CONNECTED)
                .setRequiresBatteryNotLow(true)
                .build()

        // ask to fetch the currency rates every 15 minutes if network is available and battery not low
        val request = PeriodicWorkRequestBuilder<CurrencyFetchingWorker>(15L, TimeUnit.MINUTES)
                .setConstraints(constraints)
                .setBackoffCriteria(BackoffPolicy.LINEAR, 60L, TimeUnit.SECONDS)
                .addTag(JOB_NAME)
                .build()
        WorkManager.getInstance(this).apply {
            enqueueUniquePeriodicWork(JOB_NAME, ExistingPeriodicWorkPolicy.REPLACE, request)
        }
    }

    fun stopJob(v: View) {
        WorkManager.getInstance(this).apply {
            cancelUniqueWork(JOB_NAME)
        }
    }
}

Priority Buckets

The applications are put in 5 priority buckets according to their usage: