All Files in ‘SENG440 (2022-S2)’ Merged

01. Introduction

Mobility

Mobile-first software design:

Mobile is a platform:

A “platform” is a system that can be programmed and therefore customized by outside developers—users—and in that way, adapted to countless needs and niches that the platform’s original developers could not have possibly contemplated, much less had time to accommodate.

Marc Andreeson

Cultural trends:

Android vs iOS:

The course

First term (6 weeks) - basics of Android required to build any app.

Second term: location, camera, sensors etc.

Assignment 1: 30%, due 5pm 24 August. Individual assignment.

Assignment 2: 30%, due 5pm 19 October. Group assignment (3 people).

Final exam: 40%.

Not covering cross-platform languages/frameworks: will always lag behind what the native implementations can offer.

Introduction to Android

Android is a software stack for mobile devices, including:

Linux is used to provide core system services:

Android versioning:

Android Project Structure

manifests/AndroidManifest.xml:

Source code:

Resource file:

Gradle:

Android Runtime (ART, formerly Dalvik VM):

Boxing:

Packaging:

Android Debug Bridge (adb):

02. Introduction to Kotlin

Designed by IntelliJ (first released 2011) as a modern replacement to Java, with a less verbose syntax and with support for a more functional style of programming (immutability, higher-order functions, and type inference).

In 2017, Google declared Kotlin an official language for Android development.


@file:JvmName("Main")
// Name of the Java class as the function below is in global scope

fun main(args: Array<String>) {
  println("Hello, World!")
}

Variables:

Types:

String templating:

Collections:

Conditional statements

Switch:

Functions:

fun name(param1: Type, param2: Type): ReturnType {

}

fun singleLine() = Random.nextInt(100)

Classes:

class Point(x: Float, y: Float) {
  var x: Float = x
  var y: Float = y

  // Can also use `val` for constants
}

// Same variable name used in constructor argument and 
// property name, so can simply exclude property definition
class Point(var x: Float, var y: Float) {
}


class Point(var x: Float, var y: Float) {
  override fun toString = "($x, $y)"
}

Getters and setters:

// Within a class block

val computedProperty: Int
  get() {
    return ...
  }

var name: String
  set(value) {
    println("Name changed from '$field' to '$value'")
    field = value
    // if `name` is used instead of `field`, it would cause an infinite loop
  }

Lambdas:

{ param1: Type, param2: Type -> 
  body
}

// If lambda is the last parameter, Swift-like syntax to put it outside the braces:

intArrayOf(3, 1, 4, 1).forEach { x -> println(x) }

03. Activities and Layout

App Manifest

Declares several app components:

Activities:

Fragments:

Services:

Content providers:

Broadcast receivers:

Activity

Activity stack:

Lifecycle

     ┌──────────────► onCreate
     │                   │
     │                   │
     │                   ▼
     │                onStart ◄──────────onRestart
     │                   │                     ▲
     │                   │                     │
     │                   ▼                     │
     │                onResume ◄───────┐       │
     │                   │             │       │
     │                   ▼             │       │
     │            ┌────────────────┐   │       │
   System         │   Activity     │   │       │
kills process     │    running     │   │       │
     ▲            └──────┬─────────┘   │       │
     │                   │             │       │
     │           Different activity    │       │
     │           enters foreground     │       │
     │                   │             │       │
     │                   │             │       │
     │                   ▼      user returns   │
     │                onPause──────────┘       │
     │                   |      to activity    │
     │             Activity no                 │
     │            longer visible               │
     │                   │                     │
     │  low memory       ▼    user navigates   │
     └─────────────── onStop───────────────────┘
                         │      to activity
                         │
                 Finished/destroyed
                         │
                         ▼
                      onDestroy

Primary states:

Lifetimes:

Programmatically stopping an activity:

Architecture

Previously, the recommended architecture was a multiple activity architecture.

However, after the Navigation component Jetpack library was introduced in 2018, Google started to recommend a single activity-multiple fragment architecture.

This:

Architectural principles:

Views, View Groups and Layouts

XML

Tasks:

Misc:

04. Introduction to Jetpack Compose

Widgets defined in XML, but state change (and ownership), event handling etc. happens in the code, leading to poor separation of concerns.

Jetpack Compose:

‘Components’ are functions the @Composable annotations:

05. Intents and Navigation

The three core components of applications: activates, services, and broadcast receivers, are all started through intents.

Intents can be used as a messaging system to activate components in the same application, or to start other applications.

Intents can be implicit or explicit:

Intents can return values to the activity which started it.

Some common intents: alarm clock, calendar, camera, contacts, app, email, file storage, maps, music, phone, search, settings.

TODO

startActivity startActivityForResult startService bindService

Android manifest:

Context.startActivity()
Activity.startActivityForResult() // expect a return value
Activity.setResult() // Set the return value to give to activtity that started the activity


Context.startService()
Context.bindService()
TODO

Intent object properties:

If your multi-screen app is a multiple-activity app, navigation can be achieved by using explicit intents TODO.

Navigation Architecture:

Recycler List Views

Generating views is expensive: list views only render the items that will be visible on the screen.

A recycler view is used to manage some backing data set and to re-use existing views as rows become hidden and visible.

data DataItem(val id: Int, val text: String)

class MainActivity: AppCompatActivity(), OnDataItemListener {
  private val data = arrayOf<DataItem>(
    DataItem(1, "Hello"),
    DataItem(2, "World"),
    DataItem(3, "Cat")
  )

  override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.activity_main)
    val dataAdapter = DataAdapter(data)
    val recyclerView: RecyclerView = findViewById(R.id.recycler_view)
    recyclerView.adapter = dataAdapter
  }

  fun onItemClick(position: Int) {
    val intent = Intent(Intent.ACTION_WEB_SEARCH).apply {
      putExtra(SearchManager.QUERY, data[position])
    }
    if (intent.resolveActivity(packageManager) != null) {
      startActivity(intent)
    }
  }
}

class DataAdapter(private val data: Array<DataItem>,
                  private val listener: OnDataItemListener) :
      RecyclerView.Adapter<DataAdapter.DataItemViewHolder>() {



  // Make the class a subclass of RecyclerView, and call the superclass's
  // initializer with the `itemView` as the argument.
  // Also pass through the listener to each list item view holder
  private class DataItemViewHolder(itemView: View,
                                   private val listener: OnDataItemListener) :
                RecyclerView.ViewHolder(itemView) {
    val textView: TextView

    init {
      textView = itemView.findViewById("data_text")
    }

    override fun onClick(view: View?) {
      listener.onItemClick(adapterPosition)
    }
  }

  override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DataItemViewHolder {
    // parent context required as views need an owner
    val view = LayoutInflater.from(parent.context)
     .inflate(R.layout.list_item, parent, attachToRoot: false)
    // list_item.xml is a XML layout resource file for the list item

    return DataItemViewHolder(view, listener);
  }

  // Set view item content depending on element index
  override fun onBindViewHolder(viewHolder: DataItemViewHolder, position: Int) {
    viewHolder.textView = data[position].text;
  }

  override fun getItemCount() = data.size

  interface OnDataItemListener {
    fun onItemClick(position: Int)
  }
}

<!-- activity_main.xml -->
...
  <androidx.recyclerview.widget.RecyclerView
    android:id="@+id/recyclerView"
    app:layoutManager=:LinearLayoutManager"
  />
  <!-- LinearLayoutManager = list view. grid view managers also available -->
...

<!-- list_item.xml -->
...
  <TextView
    android:id="@+id/data_text"
    android:text="Default Text"
  />
...

06. Persisting Data

Options:

Bundles

On rotation, an activity is recreated and hence, we must ensure that state is restored. We use Bundles to transfer information between activities, or between instances of the same activity.

/// Called after `onStart` if a bundle exists
/// Same parameter passed in `onCreate`, but null checking is required, and it
/// may be more convenient to do the state restoration after all initialization
/// has been done or to allow subclasses to override it
override fun onRestoreInstanceState(savedInstanceState: Bundle?) {
  savedInstanceState?.run {
    someStateValue = getString(SOME_KEY)
  }
}

override fun onSaveInstanceState(outState: Bundle?) {
  savedInstanceState?.run {
    putString(SOME_KEY, someStateValue)
  }
}

Internal Storage

We can write to a private (not accessible to other apps) internal storage directory using Context.MODE_PRIVATE.

In Android 10 and higher, the directory location is encrypted. The security API can be used to encrypt files in older versions.

context.openFileOutput(filename, Context.MODE_PRIVATE).use {
  it.write(fileContents.toByteArray())
}

To get the absolute path to the directory, use context.getFilesDir().

context.getDir can be used to get (and if necessary, create) a directory.

context.fileList() returns an array of strings with files associated with the context.

Static Files

Read-only static data which is generated at or before compile time:

Cache files

Use context.getCacheDir() to get a File object which is a reference to a directory where you can create and save temporary files.

The Android system may delete them later if space is needed. However, you should still clean up cache files on yourself (context.deleteFile(name: String)).

SharedPreferences

A key-value store backed by an XML file in the internal storage directory.

Jetpack DataStore

Co-routines used to store data asynchronously. While the Preference DataStore is similar to SharedPreferences, the Proto DataStore can be used to store data as custom data types (and hence, it can perform type-checking).

ExternalStorage

getExternalFilesDir(type: String?) returns the primary shared/external data storage device. There is no security - any app with the WRITE_EXTERNAL_STORAGE permission can write and overwrite.

The type can be null to get the root directory, or Environment.DIRECTORY_$type where type is something such as DCIM, DOWNLOADS, or SCREENSHOTS.

Shareable Storage Directories

For media content not owned by your app which you wish to be accessible through the MediaStore API.

ORM

Object-Relational Mapping (ORM):

Automatic mapping of models in code to relational database tables. This is found in various platforms:

Platform Main ORM implementation
Android Room
Ruby on Rails ActiveRecord
Java Hibernate
iOS CoreData

Room

Process:

def room_version = "2.2.6"
implementation "androidx.room:room-runtime:$room_version"
kapt "androidx.room:room-compiler:$room_version"

// For Kotlin extension and coroutine support
implementation "androidx.room:room-ktx:$room_version"

Example:

@Entity
data class User(
  @PrimaryKey val id: Int,
  @ColumnInfo(name = "first_name") val firstName: String?,
  @ColumnInfo(name = "last_name") val lastName: String?,
  @Ignore val profileImage: Bitmap?,
)


@Dao
interface Dao {
  @Query("SELECT * FROM user")
  fun getAll(): List<User>

  @Query("SELECT * FROM user WHERE id IN (:userIds)")
  fun getAllWithId(userIds: IntArray): List<User>

  @Query("SELECT * FROM user WHERE first_name LIKE :firstName AND " +
         "last_name LIKE :lastName LIMIT 1")
  fun findByName(first: String, last: String): User

  @Insert
  fun insertAll(vararg users: User)

  @Delete
  fun delete(user: User)
}


@Database(entities = arrayOf(User::class), version = 1)
abstract class AppDatabase: RoomDatabase() {
  abstract fun userDao(): UserDao
}


val db = Room.databaseBuilder(applicationContext,
                              AppDatabase::class.java, "database-name").build()

val userDao = db.userDao()
val users: List<User> = userDao.getAll()

View Models

A helper class which prepares data for the UI. It is lifecycle aware - it is retained during configuration changes (e.g. orientation change), and hence can replace the use of bundles in activities.

implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.2.0'

An activity can have a ViewModel which it uses to share data between fragments:

private val viewModel: MyAppViewModel by viewModels()

The ViewModel can contain LiveData - observable data which can trigger UI updates. LiveData observables can be mapped to Compose state using androidx.compose.runtime:runtime-livedata and the LiveData.observeAsState() method:

07. Async Tasks and Coroutines

Asynchronous Tasks in Android

The Main/UI thread dispatches events to UI element (widgets). Hence, code running on that threat should always be non-blocking: if it is blocked for more than 5 seconds, it will result in the ‘application not responding’ dialog.

The Android UI toolkit is not thread-safe; that is, it should never be accessed from outside the UI thread.

In Kotlin, Thread and Runnable can be used to create worker threads. AsyncTask simplifies the creation of workers that interact with the UI.

AsyncTask

Usage:

You can optionally implement the onProgressUpdate method to provide progress updates (e.g. progress bars) in the UI.

Kotlin coroutines

Starting with version 1.3, the Kotlin language provides coroutines which will replace AsyncTask in future Android versions.

Coroutines manage long-running tasks and can safely call network or disk operations.

Coroutines are lighter than threads (use less memory).

Dispatchers:

suspend functions:

Calling suspend functions:

suspend func suspendMethodCall() -> Int {
  return withContext(Dispatchers.IO) {
    // some kind of synchronous work
    10 // Last statement is the return value
  }
}

// async
coroutineScope {
  val deferred = async { suspendMethodCall() }
  val result = deferred.await()
}

// launch
viewModelScope.launch {
  suspendMethodCall()
}
// no return value when using launch

Built-in coroutine scopes:

Flows

Flows emit multiple values sequentially, compared to suspend functions which can only return a single value.

Actors:

Flow builder notation:

fun counter(): Flow<Int> = flow {
  // flow builder

  for (i in 1..3) {
    delay(100); // doing useful work here
    emit(i)
  }
}

counter().collect { value -> println(value) }

Room + Flow + LiveData + ViewModel:

                                               Suspend          Room
                                           ┌─────> Remote Data Source
View -----> ViewModel -----> Repository ---|
        LiveData         Flow              └─────> Local Data Source
                                               Suspend          Room

08. Services and Broadcast Receivers

Services

Services:

Examples:

Basic:

Starting services:

Stopping services:

Responsiveness:

Service types:

Broadcast Receivers

A component that response to system-wide broadcast announcements.

Some broadcasts built-in to Android: screen turned off, battery low, storage low, picture captured, SMS received, SMS sent.

Permissions:

Receiving broadcasts:

During development, broadcasts can be spoofed with adb e.g. adb shell 'am broadcast -a android.intent.action.BOOT_COMPLETED'

Initiating broadcasts:

Live Demo

Daily notification to take a picture.

<manifest ...>
  <application ...>
    <receiver
      android:name=".AlarmReceiver"
      android:enable="true"
      android:exported="true" /* Broadcast scope is global, not within the application */
    />
  </application>
</manifest>

fun Bundle.toParamsString() = keySet().map { "$it -> ${get(it)}" }.joinToString("\n")

// When the scheduled alarm event occurs, show a notification to the user
class AlarmReceiver: BroadcastReceiver() {
  override fun onReceive(context: Context, intent: Intent) {
    Log.d(TAG, "${intent.action} with extras ${intent.extras.toBundleString()}")

    val intent: PendingIndent = Intent(context, MainActivity::class.java).run {
      // Open the main activity
       PendingIntent.getActivity(
         context,
         requestCode = 0,
         intent = this,
         flags = 0
      )
    }

    val notification = Notification.Builder(
      context,
      Notification.CATEGORY_REMINDER
    ).run {
      setSmallIcon(R.drawable.camera)
      setContentTitle("Notification title")
      setContentText("Notification text")
      // Run intent on click
      setContentIntent(intent)
      // Remove the notification on click
      setAutoCancel(true)
      build()
    }

    // May need to add `@SuppressLint("ServiceCast")` annotation to the method
    val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
    manager.notify(0, notification);
  }
}


class MainActivity: AppCompatActivity() {
  override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.activity_main)

    createNotificationChannel();
  }

  // Used so that users can disable notifications or set preferences on
  // how they are delivered (e.g. deliver silently)
  private fun createNotificationChannel()  {
    val channel = NotificationChannel(
      Notification.CATEGORY_ALARM,
      "Daily reminders",
      NotificationManager.IMPORTANCE_DEFAULT
    ) {
      description = "Send daily reminders"
    }
    val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
    notificationManager.createNotificationChannel(channel)
  }
}

// Also need to schedule the reminder on boot
object Utilities {
  // Notifications fire immediately: use AlarmManager to schedule them
  fun scheduleReminder(context: Context, hour: Int, minute: Int) {
    val today = Calendar.getInstance().apply {
      set(Calendar.HOUR_OF_DAY, hour)
      set(Calendar.MINUTE, minute)
    }

    val intent = Intent(context, AlarmReceiver::class.java).let {
      PendingIntent.getBroadcast(context, 0, it, FLAG_IMMUTABLE)
    }

    val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
    alarmManager.setInexactRepeating(
      AlarmManager.RTC,
      today.timeInMillis,
      AlarmManager.INTERVAL_DAY,
      intent)
  }
}

09. Location

Location sources:

Android has three methods to obtain location:

Permissions:

<manifest>
  // Foreground: one-off access to location
  <uses-permission
    android:name="android.permission.ACCESS_COARSE_LOCATION"
  />
  // If you want fine access, you must also request coarse
  // Gives you accses to GPS location
  <uses-permission
    android:name="android.permission.ACCESS_FINE_LOCATION"
  />
  // Background: requires permission on API level 29 and higher
  <uses-permission
    android:name="android.permission.ACCESS_BACKGROUND_LOCATION"
  />
</manifest>

Older approach was easy to get wrong: forgetting to disable it and leading to large battery drain:

Newer approach uses Google Play Services and provides a higher-level API:

10. Camera

SENG440 exam:

Camera API

Essentially an intent to the camera app which takes a photo and returns it.

Requires the camera permission in the app manifest.

// Get thumbnail
Intent(MediaStore.ACTION_IMAGE_CAPTURE).also { takePictureIntent ->
  takePictureIntent.resolveActivity(packageManager)?.also {

    // OPTIONAL
    // Requres full-size image requires read/write access to external storage.
    var photoFile: File = try {
      // Probably use `getExternalFilesDir(Environment.DIRECTORY_PICTURES)
      createImageFile()
    } catch (e: IOException) {
      null
    }
    photoFile?.also {
      // File provider: probably `androidx.core.content.FileProvider`
      // Must be declared in manifest in `<provider>`
      uri = FileProvider.getUriForFile(this, "com.example.android.fileprovider", it)
      takePictureIntent.putExtra(MediaStore.EXTRA_OUTPUT, uri)
    }
    // END OPTIONAL

    startActivityForResult(takePictureIntent, 1)
  }
}

...

override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
  if (requestCode == REQUEST_IMAGE_CAPTURE && resultCode == RESULT_OK) {
    // Only returns a small thumbnail
    val thumbnailBitmap = data.extras.get("data") as Bitmap
    imageView.setImageBitmap(thumbnailBitmap)
  }
}

Video is similar: ACTION_VIDEO_CAPTURE, but intent.data is a URI to the file, not a thumbnail.

CameraX

For direct control of the camera:

Two versions available: camera and camera2.

However, you should prefer to use the CameraX support library, which is backwards compatible with the Camera API and makes it easier to deal with device-specific features.

It supports the ImageAnalysis class which makes it easy to perform computer vision and machine learning.

// Call when view is created

val cameraProviderFuture = ProcessCameraProvider.getInstance(this)
cameraFutureProvider.addListener(Runnable {
  // Camera lifecycle now bound to the lifecycle owner
  val cameraProvider: ProcessCameraProvider = cameraProviderFuture.get()

  // Surface which is passed the video stream
  val preview = Preview.Builder().build().also {
    it.setSurfaceProvider(viewFinder.createSurfaceProvider())
  }

  val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA
  try {
    // Unbind existing use cases - it can only be bound to one view at a time
    cameraProvider.unbindAll()

    // Can add an an analyzer here
    cameraProvider.bindToLifecycle(this, cameraSelector, preview)
  } catch(exc: Exception) {
    Log.e(TAG, "Use case binding failed", exc)
  }
}, ContextCompat.getMainExecutor(this)) // Running on main, not UI, thread

MLKit Integration

val imageAnalysis = ImageAnalysis.Builder()
  // runs on the previews, not the full resolution frames
  .setTargetResolution(Size(1280, 720))
  .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
  .build()

imageAnalysis.setAnalyzer(executor, ImageAnalysis.Analyzer { image ->
  val rotationDegrees = image.imageInfo.rotationDegrees
  // ... analysis code
})

cameraProvider.bindToLifecycle(this as LifecycleOwner, cameraSelector, imageAnalysis, preview)

MLKit contains:

Lecture Demo

Face detection: keypoints view (drawn on a canvas) overlaid on top of full screen camera stream.

Using constraint layout with camera preview and dots view both matching parent size.

Camera:

override fun onViewCreated() {
  ...
  cameraExecutor = Executors.newSingleThreadExecutor()

  // Update points transform when view size changes

  viewFinder.post {
    setUpCamera();
  }

}

fun setUpCamera() {
  val cameraProviderFuture = ProcessCameraProvider.getInstance(requireContext())
  // runnable so that it can run on a different thread
  cameraProviderFuture.addListener(Runnable {
      cameraProvider = cameraProviderFuture.get()
      lensFacing = CameraSelector.LENS_FACING_FRONT
      bindCameraUseCases()
    }, ContextCompat.getMainExecutor(requireContext())
  )
}

fun bindCameraUseCases() {
  // Get screen metrics used to setup camera for full screen resolution
  val metrics: WindovMetrics = viewFinder.context.getSystemService(WindowManager::class.jova).currentWindowMetrics
  val screenAspectRatio = aspectRatio(metrics.bounds.width(), metrics.bounds.height())
  val rotation = vievFinder.display.rotation
  val cameraProvider = cameraProvider
  ?: throw IllegalStateException("Camera initialization failed.")
  val cameraSelector = CameraSelector.Builder().requireLensFacing(lensFacing).build()

  preview = Preview.Builder()
    // Set aspect ratio but auto resolution
    .setTargetAspectRatio(screenAspectRatio)
    // Set initial target rotation
    .setTargetRotation(rotation)
    .build()

  // So that the user can take a photo
  imageCapture = ImageCapture.Builder()
    .setCaptureMode(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY)
    // Once again, set aspect ratio but allow CameraX to set resolution
    .setTargetAspectRatio(screenAspectRatio)
    // Set initial rotation - will need to call if this changes
    .setTargetRotation(rotation)
    .build()

  imageAnalayzer = ImageAnalysis.Builder()
    .setTargetAspectRatio(screenAspectRatio)
    .setTargetRotation(rotation)
    .build()
    .also {
      it.setAnalyzer(cameraExecutor, FaceAnalyzer({ pointsList ->
        facePointsView.points = pointsList
      }, { size ->
        // Update transform matrix
        // Aspect ratio, mirroring
        updateOverlayTransform(facePointsView, size)
      }))
    }

  // Unbind existing use cases
  cameraProvider.unbindAll()

  try {
    preview?.setSurfaceProvider(viewFinder.surfaceProvider)
    camera = camearProvider.bindToLifecycle(
      this,
      cameraSelector,
      preview,
      imageCapture,
      imageAnalyzer
    )
  } catch(e: Exception) {
    Log.e(TAG, "Failed to bind camera", e)
  }
}

class FaceAnalyzer(private val pointsListListener: PointsListListener,
                   private val sizeListener: SizeListener): ImageAnalysis.Analyzer {
  private var isAnalyzing = AtomicBoolean(false)

  private val faceDetector: FaceDetector by Lazy {
    val options = FaceDetectorOptions.Builder()
      .setContourMode (FaceDetectorOptions.CONTOUR_MODE_ALL)
      •build()
    FaceDetection.getClient(options)
  }

  private val successListener = OnSuccessListener<List<Face>> { faces ->
    isAnalyzing.set(false)
    val points = mutableListOf<PointF>()
    for (face in faces) {
      val contours = face.getAllContours()
      for (contour in contours) {
        points += contour.points.map { PointF(it.x, it.y) }
      }
    }
    pointsListListener(points)
  }

  private val failureListener = OnFailureListener { e ->
    isAnalyzing.set(false)
  }

  override fun analyze(image: ImageProxy) {
    if (!isAnalyzing.get()) {
      // Analyzer may be to slow to process every frame
      isAnalyzing.set(true)
      if (image != null && image.image != null) {
        val mlKitImage = InputImage.fromMediaImage(
          cameraImage,
          image.imageInfo.rotationDegrees
        )

        faceDetector.process(mlKitImage)
          .addOnSuccessListener(successListener)
          .addOnFailureListener(failureListener)
          .addOnCompleteListener { image.close() }
      }
    }
  }
}

11. Sensors

Sensor framework:

Types:

Can Sensor capabilities:

Using sensor:

Sensor coordinate system: