Android App Development: A Project-Oriented Guide

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.

Logger app cover image


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:

  1. Create a new project: Click on “New Project” and choose the “Empty Activity” template.
  2. 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”.
  3. 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.

New generated app


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:

  1. 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.

  2. 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.

  3. 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.

  4. 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)
                    )
                }
            }
		}
	}
}
/* ... */
  1. The onCreate method is called when the activity is first created. It initializes the activity and sets up the content view. (about activity lifecycle ↗)
  2. enableEdgeToEdge: This function enables edge-to-edge display, allowing the app to utilize the full screen space.
  3. The setContent block is where the UI is defined using Jetpack Compose. It sets the content view of the activity.
  4. 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)
  5. The Scaffold composable provides a basic layout structure with support for material design components such as TopAppBar, BottomAppBar, FloatingActionButton, and Drawer. The modifier = Modifier.fillMaxSize() ensures the scaffold takes up the full screen.
  6. The innerPadding parameter ensures that the content within the Scaffold is padded correctly, preventing overlap with system UI elements. The Greeting 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
    )
}
  1. @Composable Annotation: The @Composable annotation indicates that this function is a composable, meaning it can define part of your app’s UI.
  2. 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 empty Modifier.
  3. 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")
    }
}
  1. @Preview Annotation: The @Preview annotation tells Android Studio to render a preview of the composable function. The showBackground = true parameter ensures that the preview includes a background, making the UI components easier to see.
  2. 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
    )
}
  1. 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 the dynamicColor.
    • content: @Composable () -> Unit: The lambda parameter content represents the composable content that will be wrapped by MaterialTheme.
  2. 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.
  3. 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:

Logger app cover image

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 updates callLogPermissionState 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.
 

Brisson 🇧🇷

An Android and Kotlin Multiplatform developer.