This article will guide you through building a simple call logger app for Android. It’s a great way to learn a few fundamentals of Android development.
Introduction
To begin, you’ll need the Android Studio IDE ↗. This is a free download from the official Android website. We’ll be using the Kotlin programming language ↗ and the incentivized Jetpack Compose ↗ framework to build our UI.
We’ll be building a call logger app that requests user permission, displays all call logs, and saves the information to a CSV file.
This project is an excellent introduction to the fundamentals of Android development, covering:
- Kotlin: Google’s official language for Android development.
- Jetpack Compose: The latest UI framework for building native interfaces in Kotlin.
- Permissions: We will work specifically with the
READ_CALL_LOG
permission in this article. - Activity Results: Essential for requesting permissions and handling their outcomes.
This article aims to provide a straightforward app implementation. As such, many common practices are not being taken into consideration. What we are not going to cover in this simple project:
- Compose Navigation: This application has only one screen and does not require navigation.
- ViewModels: We will not be using ViewModels for state management in this project.
- HTTP Request: The app does not interact with any external servers or APIs.
- Data Persistency: Beyond saving call logs to a CSV file, we won’t explore persistent storage solutions like Room or SharedPreferences.
New Compose App
Once installed, launch Android Studio and follow these steps:
- Create a new project: Click on “New Project” and choose the “Empty Activity” template.
- Set up your project: Name your application “Logger”, select the location where you want to save it, and set the project structure to “Android Project”.
- Configure your minimum SDK: It is safe to use the default that is being recommended. For this example, we’ll use the API 24.
After a while you should have a new Android project created and the folder structure should look like this:
app/
├── manifests/
│ ├── AndroidManifest.xml
├── kotlin+java/
│ ├── your.package.logger/
│ │ ├── ui.theme/
│ │ └── MainActivity.kt
│ └── tests
└── res
AndroidManifest: The Android Manifest is a crucial file in any Android app development journey. It defines the application’s core characteristics, including permissions, activities, services, and more. Today, we’ll delve into the
READ_CALL_LOG
permission.️your.package.logger: Our code will reside in the your.package.logger package. The most important component is MainActivity, which is responsible for initializing the app’s user interface and handling user interactions. We will explore this further later.
tests: When we create a new project, some example test classes are generated. However, we will not focus on those in this article.
res: The res folder, or resources folder, contains all the app’s resources, such as icons, fonts, XML files, etc. We will not focus on these in this article.
Device vs Emulator
In order to run your app, you’ll need an Android device. For this, you can use either an emulator or a physical device.
For the App we are developing in this article using your physical device to read call logs is recommended. It’s easier to get started and eliminates the need to set up an emulator. Additionally, it’s generally better to use a device that already has some call history.
Here’s how to run apps on a hardware device ↗ on the Android Official Documentation. Additionally, here’s how to create and manage virtual devices ↗.
Running the App
Now that your device is set up, it’s time to run the app and see what shows up. Connect your device or start your emulator, then click the “Run” button in Android Studio. Observe the app as it launches on your device.
Understanding MainActivity
The MainActivity is the entry point of your Android application. It is the first screen that users see when they launch the app, and it serves as the central hub for managing the app’s user interface and interactions.
Here’s a few of the key responsibilities of MainActivity:
Initializing the User Interface: The MainActivity sets up the layout of the app, defining how elements like buttons, text fields, and images are arranged on the screen.
Handling User Interactions: It manages user interactions, such as button clicks and text input, defining the logic for responding to these actions and updating the UI accordingly.
Managing App Lifecycle: The MainActivity handles the app’s lifecycle events, such as starting, pausing, resuming, and stopping, ensuring the app behaves correctly in different states.
Launching Other Activities: It can launch other activities and pass data between them, enabling navigation and seamless user experiences across different screens.
Now your MainActivity class should look like this:
/* MainActivity.kt */
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
LoggerTheme {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
Greeting(
name = "Android",
modifier = Modifier.padding(innerPadding)
)
}
}
}
}
}
/* ... */
- The
onCreate
method is called when the activity is first created. It initializes the activity and sets up the content view. (about activity lifecycle ↗) enableEdgeToEdge
: This function enables edge-to-edge display, allowing the app to utilize the full screen space.- The
setContent
block is where the UI is defined using Jetpack Compose. It sets the content view of the activity. LoggerTheme
: This is a placeholder for the app’s theme, ensuring that the UI components follow the app’s design guidelines. (More on Theme later)- The
Scaffold
composable provides a basic layout structure with support for material design components such as TopAppBar, BottomAppBar, FloatingActionButton, and Drawer. Themodifier = Modifier.fillMaxSize()
ensures the scaffold takes up the full screen. - The
innerPadding
parameter ensures that the content within theScaffold
is padded correctly, preventing overlap with system UI elements. TheGreeting
composable is called with the name “Android” and the padding modifier.
Greeting Function
The Greeting function is a simple composable that displays a greeting message. Here’s how it works:
/* MainActivity.kt */
@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {
Text(
text = "Hello $name!",
modifier = modifier
)
}
- @Composable Annotation: The
@Composable
annotation indicates that this function is a composable, meaning it can define part of your app’s UI. - Function Parameters:
name: String
: The name to be included in the greeting message.modifier: Modifier = Modifier
: An optional parameter that allows customization of the layout and appearance. By default, it uses an emptyModifier
.
- Text Composable: The
Text
composable displays a text string. Here, it shows the greeting message “Hello $name!” and applies the provided modifier.
Preview Greeting
The GreetingPreview
function provides a preview of the Greeting
composable within Android Studio. This is useful for seeing how the UI component will look without having to run the app on a device or emulator.
/* MainActivity.kt */
@Preview(showBackground = true)
@Composable
fun GreetingPreview() {
LoggerTheme {
Greeting("Android")
}
}
- @Preview Annotation: The
@Preview
annotation tells Android Studio to render a preview of the composable function. TheshowBackground = true
parameter ensures that the preview includes a background, making the UI components easier to see. - Greeting Composable: The
Greeting
composable is called with the name “Android” providing a sample preview of the greeting message.
First steps
Before writing any code, we need to add the READ_CALL_LOG
permission to our AndroidManifest.xml
file. This article won’t cover detailed information about the Android Manifest, but you can click here ↗ for more details.
Your AndroidManifest.xml
should look like this:
<!-- AndroidManifest.xml -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- Add the following line to request call log permission -->
<uses-permission android:name="android.permission.READ_CALL_LOG" />
<application>
<!-- ... -->
</application>
</manifest>
Model
Create a dedicated folder named model
within the my.package.logger
package to organize our app’s data models. This folder is crucial for maintaining structured data representation across the application. Define two essential model classes within this folder:
/* Call.kt */
data class Call(
val name: String?,
val number: String,
val type: CallType,
val time: Long?,
val duration: Long?,
)
/* CallType.kt */
enum class CallType(val label: String) {
UNKNOWN(label = "Unknown"), // <- custom type
INCOMING_TYPE(label = "Incoming"), // 1
OUTGOING_TYPE(label = "Outgoing"), // 2
MISSED_TYPE(label = "Missed"), // 3
VOICEMAIL_TYPE(label = "Voicemail"), // 4
REJECTED_TYPE(label = "Rejected"), // 5
BLOCKED_TYPE(label = "Blocked"), // 6
ANSWERED_EXTERNALLY_TYPE(label = "Answered externally"); // 7
/**
* Determines if this call type represents an unsuccessful outcome,
* such as a missed, rejected, or blocked call.
*
* @return `true` if the call type indicates an unsuccessful outcome, `false` otherwise.
*/
fun isUnsuccessful(): Boolean {
return when (this) {
UNKNOWN -> false
INCOMING_TYPE -> false
OUTGOING_TYPE -> false
VOICEMAIL_TYPE -> false
ANSWERED_EXTERNALLY_TYPE -> false
else -> true
}
}
}
We also wrote a helper function to determine if the current call type is unsuccessful.
Since we will later retrieve all call log data from the Android API, let’s first define the possible call types, with the exception of the UNKNOWN
type, which is a custom type. The indices commented on each call type correspond to the index from CallLog.Call.TYPE
. For more details, you can refer to the official Android documentation ↗.
Composables
In Android development with Jetpack Compose, a composable is a function that creates and manages the UI elements of an application. Unlike traditional Android views, Jetpack Compose uses composable functions to define UI components. These functions are lightweight and declarative, meaning they describe what the UI should look like based on their current state.
In this section, we introduce various composables used in our call log listing app. Each composable serves a specific purpose in displaying and managing different parts of the application’s user interface.
App Theme
This part is optional. This section covers changing the theme to match the design of the app. In the design, we support light and dark themes, but not dynamicColor ↗. Within your package, there is a folder named ui.theme
containing files named Color.kt
, Theme.kt
, and Type.kt
.
We will not be changing any typography in this article.
Color
When you start a new project, Android Studio generates default colors. Rigth now in our Color.kt
looks like this:
/* Color.kt */
val Purple80 = Color(0xFFD0BCFF)
val PurpleGrey80 = Color(0xFFCCC2DC)
val Pink80 = Color(0xFFEFB8C8)
val Purple40 = Color(0xFF6650a4)
val PurpleGrey40 = Color(0xFF625b71)
val Pink40 = Color(0xFF7D5260)
To align with our app’s specific design requirements, we can customize these colors. Here, we define new color palettes that reflect the desired look and feel. These changes ensure that our app maintains a cohesive visual identity across light and dark themes. Delete all the default colors and paste the new ones, like this:
/* Color.kt */
val blue600 = Color(0xFF366EF4)
val blue700 = Color(0xFF0052D9)
val red600 = Color(0xFFD54941)
val red700 = Color(0xFFAD352F)
val orange200 = Color(0xFFFFD9C2)
val orange500 = Color(0xFFE37318)
val orange700 = Color(0xFF954500)
val orange900 = Color(0xFF532300)
val green200 = Color(0xFFC6F3D7)
val green500 = Color(0xFF2BA471)
val green700 = Color(0xFF006C45)
val green900 = Color(0xFF003B23)
val gray100 = Color(0xFFF3F3F3)
val gray300 = Color(0xFFE7E7E7)
val gray800 = Color(0xFF777777)
val gray900 = Color(0xFF0D0D0D)
By adjusting these colors, we not only enhance the aesthetics but also create a more personalized user experience that resonates with our app’s design. Feel free to use your own colors 😊.
Theme
Now, in the Theme.kt
file we control the overall appearance of our app. Let’s customize it for both light and dark themes to match our design requirements. Our light and dark color scheme should now look like this:
/* Theme.kt */
private val DarkColorScheme = darkColorScheme(
primary = blue600,
background = gray900,
onBackground = Color.White,
surface = Color.Black,
error = red600,
)
private val LightColorScheme = lightColorScheme(
primary = blue700,
background = gray100,
onBackground = Color.Black,
surface = Color.White,
error = red700,
)
/* ... */
You may notice that not all of the colors we defined are being used. This is not a problem, we will use them in our future components.
Understanding LoggerTheme
The LoggerTheme
function is a composable function in Jetpack Compose responsible for defining the overall theme of the application. Let’s break down its implementation:
/* Theme.kt */
/* ... */
@Composable
fun LoggerTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
// Dynamic color is available on Android 12+
dynamicColor: Boolean = true,
content: @Composable () -> Unit
) {
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
darkTheme -> DarkColorScheme
else -> LightColorScheme
}
MaterialTheme(
colorScheme = colorScheme,
typography = Typography,
content = content
)
}
- Parameters:
darkTheme: Boolean = isSystemInDarkTheme()
: Specifies whether to use the dark theme. By default, it checks the system’s current theme using isSystemInDarkTheme().dynamicColor: Boolean
= true: This parameter indicates whether dynamic colors are enabled. Dynamic colors are supported on Android 12 and later versions. But, as we establish before, we are not keeping thedynamicColor
.content: @Composable () -> Unit
: The lambda parameter content represents the composable content that will be wrapped by MaterialTheme.
- Color Scheme Selection: The Dynamic Color is is enabled and the device is running Android 12 or newer (
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
) and If dynamic colors are disabled or not supported, it defaults to static color schemes. - MaterialTheme Composition: The
MaterialTheme
composable function is used to apply the selected colorScheme, Typography (presumably defined elsewhere), and the content lambda, ensuring that the entire app’s UI adheres to the specified design guidelines.
New Theme
Let’s take a look at how our updated theme will appear in the app. Since our application does not utilize dynamic colors, we’ll showcase both the light and dark theme color scheme pre-defined.
/* Theme.kt */
/* ... */
@Composable
fun LoggerTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit,
) {
val colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme
MaterialTheme(
colorScheme = colorScheme,
typography = Typography,
content = content,
)
}
CallLogItem
Now, since we have our base model defined, we can start building some components. The first component we’ll create is the CallItem
composable. This component will be used in a LazyList
to display all the call log information we retrieve.
Inside the ui
folder, create a new folder named components
where we will define all our composables. In this folder, create a new Kotlin file named CallItem.kt
. In this file, we’ll define two composable functions: one for the component itself and one for the preview. The preview function allows us to see what we are building without needing to run the app every time.
The CallItem
composable has only two parameters: Modifier
and Call
. The Modifier parameter allows customization of the layout and appearance, with an empty Modifier used by default. The Call parameter is the model that will be displayed.
/* CallItem.kt */
@Composable
fun CallItem(
modifier: Modifier = Modifier,
call: Call,
) { /* TODO */ }
@Preview
@Composable
private fun PreviewCallItem() {
LoggerTheme {
val call = Call(
name = "Dave",
number = "1234567890",
type = CallType.INCOMING_TYPE,
time = null,
duration = 60,
)
CallItem(modifier = Modifier.fillMaxWidth(), call = call)
}
}
For the Preview
function, we need to call the LoggerTheme
to ensure our colors are applied in the preview. We also need to call the CallItem
function we just created. Since this function requires a Call
object, we can mock some data. In this example, let’s assume Dave called us.
To see the preview we just declared, you’ll need to use the split view in Android Studio. On Windows, you can press Alt + Shift + Right
.
Now let’s go back to our CallItem
composable. To make the component look like this:
our code should be as follows:
/* CallItem.kt */
@Composable
fun CallItem(
modifier: Modifier = Modifier,
call: Call,
) {
/*
- The modifier is used to set the height and width to their intrinsic minimum sizes,
apply a background color from the Material theme, and add padding.
- horizontalArrangement spaces out the elements within the row
*/
Row(
modifier = modifier then Modifier
.height(IntrinsicSize.Min)
.width(IntrinsicSize.Min)
.background(MaterialTheme.colorScheme.surface)
.padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
val avatarContentColor = if (call.type.isUnsuccessful()) {
if (isSystemInDarkTheme()) orange500 else orange700
} else {
if (isSystemInDarkTheme()) green500 else green700
}
val avatarBackgroundColor = if (call.type.isUnsuccessful()) {
if (isSystemInDarkTheme()) orange900 else orange200
} else {
if (isSystemInDarkTheme()) green900 else green200
}
/*
- A Box is used to create a circular container for an icon.
- The icon is a person outline, with colors adjusted based on the call type.
*/
Box(
modifier = Modifier
.size(48.dp)
.clip(CircleShape)
.background(avatarBackgroundColor),
contentAlignment = Alignment.Center,
) {
Icon(
modifier = Modifier.size(24.dp),
imageVector = Icons.Outlined.PersonOutline,
contentDescription = "Person ícon",
tint = avatarContentColor,
)
}
/*
Responsible for displaying the details of a call
- Modifier.fillMaxSize(): Instructs the column to occupy all available space within its
parent container.
- Arrangement.SpaceAround: Distributes the child elements within the column with equal
spacing around them.
*/
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.SpaceAround,
) {
/*
Displays the name of the call if available (call.name); otherwise, it falls back to
displaying the call number (call.number).
*/
Text(
text = call.name ?: call.number,
fontSize = 16.sp,
color = MaterialTheme.colorScheme.onSurface,
)
// A Row used to arrange the call type icon, label, and time horizontally.
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
// Another nested Row is used to position the call type icon and its label.
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
/*
The icon's tint color is dynamically set based on whether the call type
indicates a problem and the current system theme (light or dark).
*/
val iconTint = if (call.type.isUnsuccessful()) {
if (isSystemInDarkTheme()) red600 else red700
} else gray800
Icon(
modifier = Modifier.size(16.dp),
imageVector = call.type.icon,
contentDescription = "${call.type.label} icon",
tint = iconTint,
)
Text(
text = call.type.label,
fontSize = 14.sp,
color = gray800,
)
}
// This block is executed only if the call.time is not null.
call.time?.let { time ->
/*
It calculates the relative time difference between the call time and the current time using DateUtils.
*/
val now = System.currentTimeMillis()
val formattedDate = DateUtils
.getRelativeTimeSpanString(time, now, DateUtils.SECOND_IN_MILLIS)
Text(
text = "·",
fontSize = 16.sp,
color = gray800,
)
Text(
text = formattedDate.toString(),
fontSize = 14.sp,
color = gray800,
)
}
}
}
}
}
If you want to see how other cases would look, go ahead and change the mocked call data in the preview function.
CallLogScreen
Now that we have defined our CallItem
, we want to display it in a list. We will do this in the CallLogScreen
composable. For this part, we need to create a screen.callLogs
folder inside our ui
folder. This will be our only screen.
But before creating our screen, we need to define its possible UI states. Create a new file named ReadCallLogPermissionState
inside the screen.callLogs
folder. Since we need to ask the user’s permission to access the call logs, the screen state will be:
/* ReadCallLogPermissionState.kt */
sealed interface ReadCallLogPermissionState {
data class Granted(val calls: List<Call>) : ReadCallLogPermissionState
data object Denied : ReadCallLogPermissionState
data object Loading : ReadCallLogPermissionState
/**
* Checks if the read call log permission is granted and there are calls available.
*
* @return `true` if the permission is granted and there's at least one call, `false` otherwise.
*/
fun isGranted(): Boolean = when (this) {
is Granted -> this.calls.isNotEmpty()
else -> false
}
}
Now we need to create a new file CallLogsScreen
in the same folder of it’s state. Just like we did with the CallItem
, we will define a composable and its preview as follows:
/* CallLogsScreen.kt */
@Composable
internal fun CallLogsScreen(
modifier: Modifier = Modifier,
callLogPermissionState: ReadCallLogPermissionState,
) { /* TODO */ }
@Preview
@Composable
private fun PreviewCallLogsScreen() {
LoggerTheme {
CallLogsScreen(
modifier = Modifier.fillMaxSize(),
callLogPermissionState = ReadCallLogPermissionState.Denied,
)
}
}
In our CallLogScreen
component, we will write the following:
/* CallLogsScreen.kt */
@Composable
internal fun CallLogsScreen(
modifier: Modifier = Modifier,
callLogPermissionState: ReadCallLogPermissionState,
) {
Column(
modifier = modifier then Modifier.background(MaterialTheme.colorScheme.background)
) {
when (callLogPermissionState) {
is ReadCallLogPermissionState.Granted -> { /* TODO */ }
ReadCallLogPermissionState.Denied -> { /* TODO */ }
ReadCallLogPermissionState.Loading -> { /* TODO */ }
}
}
}
With the when
statement, we can now declare composables for each state. Starting with the Loading
state, we will implement a simple progress indicator. It will look like this:
/* CallLogsScreen.kt */
/* ... */
when (callLogPermissionState) {
ReadCallLogPermissionState.Loading -> {
LinearProgressIndicator(
modifier = Modifier.fillMaxWidth().height(6.dp),
trackColor = MaterialTheme.colorScheme.background,
color = MaterialTheme.colorScheme.onBackground,
)
}
}
Reminder: You can change the permission state to view the Loading
states in the preview.
Next, we’ll handle the Denied
permission state. We want to show a message to the user indicating that the app needs their permission to access the call log history. Additionally, we’ll include a button that prompts the user to grant the permission again.
/* CallLogsScreen.kt */
/* ... */
when (callLogPermissionState) {
ReadCallLogPermissionState.Denied -> {
Spacer(modifier = Modifier.weight(1f))
Column(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
text = "Permission denied",
fontSize = 20.sp,
fontWeight = FontWeight.Bold
)
Text(
modifier = Modifier.widthIn(max = 320.dp),
text = "Permission to access call history has been denied, click below to allow.",
textAlign = TextAlign.Center,
)
Button(
modifier = Modifier
.widthIn(max = 320.dp)
.fillMaxWidth()
.padding(top = 18.dp),
shape = RoundedCornerShape(6.dp),
onClick = { /* TODO: ask permission */ },
colors = ButtonDefaults.buttonColors(contentColor = Color.White),
contentPadding = PaddingValues(vertical = 12.dp),
) {
Text(text = "Grant permission", fontSize = 16.sp, fontWeight = FontWeight.SemiBold)
}
}
Spacer(modifier = Modifier.weight(2f))
}
}
Notice that we need to implement the button’s onClick
functionality. To do this, we will add a new parameter to the CallLogScreen
composable and handle it when we call the function. Our function should look like this:
/* CallLogsScreen.kt */
@Composable
internal fun CallLogsScreen(
modifier: Modifier = Modifier,
callLogPermissionState: ReadCallLogPermissionState,
onAskPermission: () -> Unit, // <- add this
) {
/* ... */
when (callLogPermissionState) {
ReadCallLogPermissionState.Denied -> {
/* ... */
Button(
onClick = onAskPermission, // <- add this
) { /* ... */ }
}
}
}
@Preview
@Composable
private fun PreviewCallLogsScreen() {
LoggerTheme {
CallLogsScreen(
modifier = Modifier.fillMaxSize(),
callLogPermissionState = ReadCallLogPermissionState.Denied,
onAskPermission = { }, // <- and add this
)
}
}
Now, when we call the CallLogScreen
function, we will also need to handle the onAskPermission
parameter.
The last state we need to handle is the Granted
state. When the user grants the read call log permission, we want to display all the call logs. For this, we will use a LazyColumn
and call our CallItem
that we created previously.
/* CallLogsScreen.kt */
/* ... */
when (callLogPermissionState) {
is ReadCallLogPermissionState.Granted -> {
val calls = callLogPermissionState.calls // <- Smart cast
LazyColumn(modifier = Modifier.fillMaxWidth()) {
items(calls) { call ->
CallItem(modifier = Modifier.fillMaxWidth(), call = call)
HorizontalDivider(
modifier = Modifier.padding(start = 16.dp),
thickness = 0.5.dp,
)
}
}
}
}
If you want to, you can also handle the case when the list is empty. In this scenario, you could show an empty state message like this:
/* CallLogsScreen.kt */
/* ... */
when (callLogPermissionState) {
is ReadCallLogPermissionState.Granted -> {
val calls = callLogPermissionState.calls // <- Smart cast
if (calls.isEmpty()) {
Spacer(modifier = Modifier.weight(1f))
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
text = "No results",
fontSize = 20.sp,
fontWeight = FontWeight.Bold
)
Text(text = "No calls were found")
}
Spacer(modifier = Modifier.weight(2f))
return // <- early return
}
LazyColumn(modifier = Modifier.fillMaxWidth()) { /* ... */ }
}
}
TopBar
The last component we will create before assembling everything together is the TopBar
. This component will display the app’s name and include a button to download all the call logs as a CSV file. In the ui.components
folder, create a new file and name it TopBar.kt
. We will follow the same pattern as the previous composables we declared and create a @Composable
function and a @Preview
.
/* TopBar.kt */
@Composable
fun TopBar(
modifier: Modifier = Modifier,
downloadEnabled: Boolean,
onDownload: () -> Unit,
) {
Row(
modifier = Modifier then modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surface)
.padding(start = 20.dp, end = 12.dp, top = 8.dp, bottom = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = "Logger",
fontSize = 18.sp,
fontWeight = FontWeight.SemiBold,
)
IconButton(
onClick = onDownload,
enabled = downloadEnabled,
) {
Icon(
modifier = Modifier.size(24.dp),
imageVector = Icons.Filled.Download,
contentDescription = "Download Icon",
tint = MaterialTheme.colorScheme.onSurface,
)
}
}
}
@Preview
@Composable
private fun PreviewTopBar() {
LoggerTheme {
TopBar(
modifier = Modifier.fillMaxWidth(),
downloadEnabled = true,
onDownload = { },
)
}
}
Assemble
After declaring all the composables we need for this app, we can now assemble everything together. Since we are not using ViewModels
, all our state management will be done in the MainActivity
.
Back in our MainActivity
, we will erase all the generated composables to have a clean slate, like this:
/* MainActivity.kt */
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
LoggerTheme {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
/* ... */
}
}
}
}
}
/* ... */
Permission
First thing we are doing is declaring the variables that will be responsible for holding our state. For this we will have the following in our activity:
/* MainActivity.kt */
setContent {
/*
Stores the current state of the read call log permission and is initialized as
Loading while the permission status is being checked. The use of remember ensures
that the state is preserved across recompositions.
*/
var callLogPermissionState by remember {
mutableStateOf<ReadCallLogPermissionState>(ReadCallLogPermissionState.Loading)
}
/*
Variable that works like a function.
Fetching call logs if the permission is granted, handling potential exceptions.
Updating the callLogPermissionState based on the permission status and call log retrieval result.
*/
val updatePermissionState: (Boolean) -> Unit = { hasPermission ->
callLogPermissionState = if (hasPermission) {
// TODO: replace the empty list with the getCallLog()
val callLogs = emptyList<Call>()
ReadCallLogPermissionState.Granted(callLogs)
} else {
ReadCallLogPermissionState.Denied
}
}
}
In the code above, we have two important components for managing the call log permission state:
callLogPermissionState: This variable holds the current state of the read call log permission. It’s initialized as Loading while the permission status is being checked. The use of remember ensures that the state is preserved across recompositions.
updatePermissionState: This variable acts as a function to update the
callLogPermissionState
. It fetches the call logs if the permission is granted and handles potential exceptions. The function updatescallLogPermissionState
based on the permission status and the result of the call log retrieval.
When the state is Granted
, we need to retrieve the call logs. To achieve this, we will create a helper function that extends the Context
class. Start by creating a new folder in the root package directory named utils
. Inside this folder, create a new file named ContextExtensions.kt
. This file will contain all our extensions for the Context
class. Here’s how you can structure it:
/* ContextEctensions.kt */
/**
* Retrieves a list of call logs from the device's call log.
*
* @return A list of [Call]objects representing the call log entries,
* or an empty list if the query returns a null cursor.
*/
fun Context.getCallLogs(): List<Call> {
// Columns to retrieve from the call log
val projection = arrayOf(
CallLog.Calls.CACHED_NAME,
CallLog.Calls.NUMBER,
CallLog.Calls.TYPE,
CallLog.Calls.DATE,
CallLog.Calls.DURATION
)
// Order by date in descending order
val sortOrder = "${CallLog.Calls.DATE} DESC"
// Query the call log content provider and handle potential null cursor
contentResolver.query(
CallLog.Calls.CONTENT_URI,
projection,
null,
null,
sortOrder,
)?.let { cursor ->
val calls = mutableListOf<Call>()
while (cursor.moveToNext()) {
// Extract call log data into a list
calls.add(
Call(
// Retrieve name, handling potential null
name = cursor.getStringOrNull(0),
// Retrieve number, defaulting to empty string if null
number = cursor.getStringOrNull(1) ?: "",
// Retrieve call type, handling potential null and using default index 0 (UNKNOWN)
type = CallType.entries[(cursor.getIntOrNull(2) ?: 0)],
// Retrieve call time as Long, handling potential null
time = cursor.getLongOrNull(3),
// Retrieve call duration as Long, handling potential null
duration = cursor.getLongOrNull(4),
)
)
}
cursor.close()
return calls // Return the list of calls
}
// Return an empty list if the cursor was null
return emptyList()
}
Iteration and Extraction: The purpose of the while loop is to iterate through the cursor and extract relevant data for each call log entry.
Null Safety: The inline comments for each field (name, number, type, time, duration) highlight the use of getStringOrNull, getIntOrNull, and getLongOrNull methods. These methods handle potential null values returned by the cursor, ensuring that the application doesn’t crash due to unexpected nulls.
With this helper function created, we can now return to our MainActivity
and call this function to retrieve our list of call logs:
/* MainActivity.kt */
setContent {
/*
This variable provides access to the application context, which is essential for
various Android operations.
*/
val context = LocalContext.current
val updatePermissionState: (Boolean) -> Unit = { hasPermission ->
callLogPermissionState = if (hasPermission) {
val callLogs = context.getCallLogs() // <- Call the function here
ReadCallLogPermissionState.Granted(callLogs)
} else {
ReadCallLogPermissionState.Denied
}
}
}
Now, when users grant us permission, we have all the call logs available to display. However, the updatePermissionState
function is not being used yet. We need to create a launcher that prompts users to grant us access. We can create this launcher like this:
/* MainActivity.kt */
setContent {
/* ... */
val permissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission(),
onResult = { result : Boolean -> updatePermissionState(result) },
)
/* ... */
}
With this launcher, whenever we want to launch the permission dialog, the result will update the permission state based on the user’s response.
/* MainActivity.kt */
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { /* ... */ }
companion object {
const val READ_CALL_LOG_PERMISSION = "android.permission.READ_CALL_LOG"
}
}
The READ_CALL_LOG_PERMISSION
constant is a string that represents the permission required to read call logs. This constant will be used when requesting permission from the user and checking the permission status. By defining it in the companion object, we ensure that the permission string is easily accessible and reusable throughout our MainActivity
.
/* MainActivity.kt */
Scaffold() { innerPadding ->
Surface {
CallLogsScreen(
modifier = Modifier.padding(innerPadding).fillMaxSize(),
callLogPermissionState = callLogPermissionState,
onAskPermission = {
permissionLauncher.launch(READ_CALL_LOG_PERMISSION)
},
)
}
}
In the onAskPermission
callback, we use the launch function of the permissionLauncher
to request the READ_CALL_LOG_PERMISSION
from the user. When the permission dialog is displayed and the user responds, the result will update the callLogPermissionState
accordingly.
To ensure we handle the read call log permission efficiently, we add a LaunchedEffect
that checks the permission status and updates it accordingly. This way, we avoid asking for permission repeatedly if it’s already granted.
/* MainActivity.kt */
setContent {
/* ... */
LaunchedEffect(Unit) {
val hasPermission = context.hasPermission(READ_CALL_LOG_PERMISSION)
updatePermissionState(hasPermission)
if (!hasPermission) {
permissionLauncher.launch(READ_CALL_LOG_PERMISSION)
}
}
/* ... */
}
Within the LaunchedEffect
, we check if the app has the READ_CALL_LOG_PERMISSION
using a helper function context.hasPermission
. The result is then used to update the callLogPermissionState
using the updatePermissionState
function.
If the permission is not granted, we launch the permission request using permissionLauncher.launch(READ_CALL_LOG_PERMISSION)
. This ensures that the permission dialog is only shown when necessary, providing a better user experience.
To check if the app has the necessary permission, we create an extension function for the Context
class. This function, hasPermission
, takes a permission string as a parameter and returns a boolean indicating whether the permission is granted or not.
In the utils folder, create a new file named ContextExtensions
and add the following code:
/* ContextExtension.kt */
fun Context.hasPermission(permission: String): Boolean {
return when (ContextCompat.checkSelfPermission(this, permission)) {
PackageManager.PERMISSION_GRANTED -> true
PackageManager.PERMISSION_DENIED -> false
else -> false
}
}
The hasPermission
function uses ContextCompat.checkSelfPermission
to check the permission status. It returns true if the permission is granted (PackageManager.PERMISSION_GRANTED
), and false otherwise (PackageManager.PERMISSION_DENIED
or any other case).
Download
In our MainActivity
, we need to create a launcher that allows the user to save the call logs as a CSV file. This is done using rememberLauncherForActivityResult
with ActivityResultContracts.StartActivityForResult
. The launcher will handle the result of the document creation process and save the data to external storage.
/* MainActivity.kt */
setContent {
/* ... */
val createDocumentLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.StartActivityForResult(),
onResult = { result ->
result.data?.data?.let { uri ->
context.copyToExternalStorage(uri, callLogPermissionState)
Toast.makeText(context, "Document saved", Toast.LENGTH_SHORT).show()
}
}
)
/* ... */
}
The createDocumentLauncher
is responsible for launching the document creation intent and handling the result. When the user selects a location and saves the document, the result’s data contains the URI of the created document.
We then call context.copyToExternalStorage(uri, callLogPermissionState)
to copy the call logs to the selected location and display a toast message indicating the document has been saved successfully.
To handle saving call logs to external storage, we create an extension function for the Context
class. This function, copyToExternalStorage
, takes a Uri
and the callLogPermissionState
as parameters, and writes the call logs to the specified location if permission is granted.
In the utils folder, add the following code to ContextExtensions
:
/* ContextExtension.kt */
fun Context.copyToExternalStorage(uri: Uri, callLogPermissionState: ReadCallLogPermissionState) {
if (callLogPermissionState.isGranted()) {
val calls = (callLogPermissionState as ReadCallLogPermissionState.Granted).calls
val outputStream = contentResolver.openOutputStream(uri) ?: return
outputStream.writeCsv(calls)
outputStream.close()
}
}
To handle writing call logs to a CSV file, we create a new utility file named CallLogUtils.kt
in the utils
folder. This file will contain a function that writes the call logs to an OutputStream
in CSV format.
/* CallLogUtils.kt */
fun OutputStream.writeCsv(calls: List<Call>) {
val writer = bufferedWriter()
writer.write("""name,number,type,time,duration""")
writer.newLine()
calls.forEach { call ->
writer.write("""${call.name},${call.number},${call.type.name},${call.time},${call.duration}""")
writer.newLine()
}
writer.flush()
}
Explanation:
- writeCsv function: This function takes an
OutputStream
and a list of Call objects, and writes the call data to the stream in CSV format. - Buffered Writer: The function uses a buffered writer to efficiently write the data. It writes the header row first, followed by the call log data, each in a new line.
Next, we need to add the TopBar component to our MainActivity
. This component will display the app’s name and include a button to download all the call logs as a CSV file.
Update the MainActivity
to include the TopBar
in the Scaffold
component:
/* MainActivity.kt */
LoggerTheme {
Scaffold(
modifier = Modifier.fillMaxSize(),
topBar = { /* Add top bar here */ },
) { /* ... */ }
}
The TopBar
component is added to the Scaffold
’s topBar
slot. It includes a button that, when clicked, launches the document creation intent to save the call logs as a CSV file.
/* MainActivity.kt */
Scaffold(
modifier = Modifier.fillMaxSize(),
topBar = {
Column {
TopBar(
modifier = Modifier.windowInsetsPadding(WindowInsets.statusBars),
downloadEnabled = callLogPermissionState.isGranted(),
onDownload = {
createDocumentLauncher.launch(
Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = "text/csv"
putExtra(Intent.EXTRA_TITLE, "output.csv")
}
)
},
)
HorizontalDivider(modifier = Modifier.fillMaxWidth(), thickness = 0.5.dp)
}
},
) { /* ... */ }
The createDocumentLauncher
is used to handle the document creation and saving process. This integration ensures that the TopBar
is part of the main layout, providing the user with a consistent and accessible way to download their call logs.
Wrapping up
With all the components and logic in place, our call log listing app is complete! By following this guide, you’ve built an Android app that reads and displays call logs, handles permissions, and allows users to download their call history. This project not only demonstrates key concepts in Android development but also provides a solid foundation for more complex applications.
Improvements
To take this app even further, consider implementing the following features:
- Search and Filter: Allow users to search and filter call logs by name, number, or type.
- Enhanced UI: Improve the user interface with additional styling and animations.
- Database Integration: Save call logs in a local database for offline access and better performance.
- Testing: Add unit and UI tests to ensure the app’s functionality and reliability.