The most important building blocks of an Android app:
Activity: one screen of your app. With Jetpack Compose the Activity is a thin host: it calls
setContent { } and owns the lifecycle, the UI itself lives in composables (and, from lecture 11 on,
the UI state lives in a ViewModel)Fragment (legacy): modularisation of a screen in the View system. Compose does not need Fragments,
we use Navigation Compose instead (lecture 08)Service: runs code without a UI, e.g. a long-running download (lecture 09)BroadcastReceiver: receives messages (events) from the system, other apps or your own app (lecture 09)ContentProvider: exposes data of your app to other apps, e.g. the contacts providerIntent: message object for inter-component communication, used e.g. to start an Activity and
to transfer information to itAll components except Intent must be declared in AndroidManifest.xml.
ComponentActivity is the base class for Compose apps (AppCompatActivity is for XML layouts)setContent { } defines the UI as a tree of composablesAn activity has essentially four states:
Context and provides the lifecycle callbackssuperclass MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) // called once, set up the UI here
}
override fun onStart() { super.onStart() } // becoming visible
override fun onResume() { super.onResume() } // in the foreground, interactive
override fun onPause() { super.onPause() } // losing focus, still visible
override fun onStop() { super.onStop() } // no longer visible
override fun onRestart() { super.onRestart() } // stopped -> started again
override fun onDestroy() { super.onDestroy() } // finishing or being recreated
}
This is the complete MainActivity of assignment 1:
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
MovieTrackerTheme {
ClickCounter()
}
}
}
}
setContent (from androidx.activity:activity-compose) attaches the Compose UI to the ActivityClickCounter composablerememberSaveable (lecture 05)Composables do not override lifecycle callbacks. They use effects that follow the composition and the lifecycle of the host:
@Composable
fun MovieScreen(onRefresh: () -> Unit) {
// Runs once when MovieScreen enters the composition (coroutine, may call suspend functions)
LaunchedEffect(Unit) { onRefresh() }
// Runs when entering, onDispose runs when MovieScreen leaves the composition
DisposableEffect(Unit) {
Log.d("MovieScreen", "shown")
onDispose { Log.d("MovieScreen", "hidden") }
}
// Follows the Activity lifecycle: ON_RESUME / ON_PAUSE (androidx.lifecycle:lifecycle-runtime-compose)
LifecycleResumeEffect(Unit) {
Log.d("MovieScreen", "resumed")
onPauseOrDispose { Log.d("MovieScreen", "paused") }
}
}
LifecycleEventEffect(Lifecycle.Event.ON_START) { ... } and LifecycleStartEffect exist as wellLaunchedEffect are covered in detail in lecture 10Activity,
Service and Application are Contexts// launches a new activity
abstract fun startActivity(intent: Intent)
// launches a new service
abstract fun startService(service: Intent): ComponentName?
// registers a BroadcastReceiver
abstract fun registerReceiver(receiver: BroadcastReceiver?, filter: IntentFilter): Intent?
val context = LocalContext.currentputExtra()Int, String, Boolean, ...MainActivity.kt): start ResultActivity after the 5th click@Composable
fun ClickCounter() {
var counter by rememberSaveable { mutableIntStateOf(0) }
val context = LocalContext.current // the Context is needed to start another Activity
Button(
onClick = {
counter++
if (counter == 5) {
val intent = Intent(context, ResultActivity::class.java)
intent.putExtra(ResultActivity.EXTRA_CLICKS, counter)
context.startActivity(intent)
}
},
modifier = Modifier.testTag("button")
) {
Text(text = "Button")
}
}
intent and reads the extras with the typed
getters (getIntExtra, getStringExtra, ...) and a default valueclass ResultActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Read the value that MainActivity put into the Intent
val clicks = intent.getIntExtra(EXTRA_CLICKS, 0)
setContent {
MovieTrackerTheme {
Text(text = "Clicks: $clicks")
}
}
}
companion object {
const val EXTRA_CLICKS = "de.hdmstuttgart.movietracker.EXTRA_CLICKS"
}
}
Note: An explicit intent is always delivered to its target, regardless of any intent filters the component declares.
AndroidManifest.xml, otherwise startActivity() throws an
ActivityNotFoundException<application ...>
<activity
android:name=".ResultActivity"
android:exported="false" />
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
android:name=".ResultActivity" is relative to the namespace declared in build.gradle.ktsandroid:exported="false": only your own app can start this Activity (mandatory attribute since
Android 12 for components with an intent filter)Uri)@Composable
fun ContactButtons(phoneNumber: String, website: String) {
val context = LocalContext.current
Row {
Button(onClick = {
val dialIntent = Intent(Intent.ACTION_DIAL, Uri.parse("tel:$phoneNumber"))
context.startActivity(dialIntent)
}) { Text(text = "Call") }
Button(onClick = {
val viewIntent = Intent(Intent.ACTION_VIEW, Uri.parse(website))
context.startActivity(viewIntent)
}) { Text(text = "Website") }
}
}
startActivity()onCreate() method and passing it the Intent<intent-filter> element in your manifest file<intent-filter>, you can specify the type of intents to accept using one or more of
these three elements:<action> - Declares the intent action accepted, in the name attribute. The value must be the
literal string value of an action, not the class constant<data> - Declares the type of data accepted, using one or more attributes that specify
various aspects of the data URI (scheme, host, port, path) and MIME type<category> - Declares the intent category accepted, in the name attributehttps:// links<activity android:name=".MainActivity" android:exported="true">
<!-- This activity is the main entry, should appear in app launcher -->
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- This activity can also handle ACTION_VIEW for https links -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" android:host="www.hdm-stuttgart.de" />
</intent-filter>
</activity>
Note: To receive implicit intents, you must include the CATEGORY_DEFAULT category in the intent filter.
Assignment 1: Introduction to Android Studio and your first app
What you need from this lecture for it:
ComponentActivity + setContent { } as the host of your Compose UI (two Activities)Intent with putExtra() to start ResultActivity after the 5th click, and
intent.getIntExtra() to read the value in the second ActivityLocalContext.current to get the Context for startActivity() inside a composable<activity> entry in AndroidManifest.xml for the second ActivityINTERNET)AndroidManifest.xmlINTERNET, CAMERA, ACCESS_FINE_LOCATION, ...SecurityException<uses-permission> elements to the manifest<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.CAMERA" />
...
</manifest>
INTERNET, FOREGROUND_SERVICE)CAMERA, ACCESS_FINE_LOCATION,
POST_NOTIFICATIONS)To request a dangerous permission in Compose:
@Composable
fun CameraPermissionButton(onGranted: () -> Unit) {
val context = LocalContext.current
var message by remember { mutableStateOf("") }
// 1. Register the launcher unconditionally at the top of the composable
val permissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission()
) { isGranted ->
if (isGranted) onGranted() else message = "Camera permission denied"
}
Column {
Button(onClick = {
// 2. Check first, the user may have revoked the permission in the meantime
val granted = ContextCompat.checkSelfPermission(
context, Manifest.permission.CAMERA
) == PackageManager.PERMISSION_GRANTED
// 3. Use the feature or ask the user (system dialog)
if (granted) onGranted() else permissionLauncher.launch(Manifest.permission.CAMERA)
}) {
Text(text = "Take photo")
}
Text(text = message)
}
}
rememberLauncherForActivityResult must be called on every composition (never inside an if or a
click handler), the result callback replaces the old onRequestPermissionsResult()RequestPermission() asks for one permission, RequestMultiplePermissions() for several at onceActivityCompat.shouldShowRequestPermissionRationale(activity, Manifest.permission.CAMERA)
tells you if that is the case. It is an Activity method, in Compose get the Activity with
LocalActivity.current (activity-compose)false immediately: offer
a way into the app settings or disable the feature gracefullyandroid.webkit.WebView renders web pages inside your appval context = LocalContext.current
...
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("https://www.hdm-stuttgart.de")))
WebView is useful for content that belongs to your app (help pages, OAuth logins,
hybrid apps)INTERNET permission in AndroidManifest.xml (a normal permission, granted at
install time):<uses-permission android:name="android.permission.INTERNET" />
WebView. Views that have no Compose equivalent are embedded with
AndroidViewfactory lambda, which runs once. The update lambda runs on every
recomposition, so never call loadUrl() there@SuppressLint("SetJavaScriptEnabled")
@Composable
fun WebViewScreen(url: String, modifier: Modifier = Modifier) {
AndroidView(
modifier = modifier.fillMaxSize(),
factory = { context ->
WebView(context).apply {
// Keep clicked links inside the WebView instead of opening the browser
webViewClient = WebViewClient()
// JavaScript is disabled by default for security reasons
settings.javaScriptEnabled = true
loadUrl(url)
}
}
)
}
WebViewClient, clicking a link launches the app that handles URLs (usually the browser)@SuppressLint("SetJavaScriptEnabled") annotation (lint warning)WebView loads pages asynchronously. Register a WebChromeClient to get the loading progress
and put it into Compose state:@SuppressLint("SetJavaScriptEnabled")
@Composable
fun WebViewScreen(url: String, modifier: Modifier = Modifier) {
var progress by remember { mutableFloatStateOf(0f) }
Column(modifier = modifier) {
if (progress < 1f) {
LinearProgressIndicator(progress = { progress }, modifier = Modifier.fillMaxWidth())
}
AndroidView(
modifier = Modifier.fillMaxSize(),
factory = { context ->
WebView(context).apply {
webViewClient = WebViewClient()
settings.javaScriptEnabled = true
webChromeClient = object : WebChromeClient() {
override fun onProgressChanged(view: WebView?, newProgress: Int) {
progress = newProgress / 100f
}
}
loadUrl(url)
}
}
)
}
}
Note: LinearProgressIndicator and CircularProgressIndicator without a progress lambda show an
indeterminate animation, useful whenever something loads in the background (e.g. a network call).
Activity is one screen; with Compose it is a thin host that calls setContent { } and owns
the lifecycle. The system recreates it on rotationLaunchedEffect, DisposableEffect,
LifecycleResumeEffectintent.getIntExtra() etc. Every Activity must be declared in the manifestrememberLauncherForActivityResult and can be
revoked by the user at any timeWebView is embedded with AndroidView and configured in the factory lambda; a progress
indicator shows background loadingrememberLauncherForActivityResult be called unconditionally in a composable?WebView configured in the factory lambda of AndroidView and not in update?WebView from opening clicked links in the system browser?