Table of contents
Lab 4
In this lab we will use:
- Broadcast receivers to receive incoming phonecalls
- Adapters, ListView & RecyclerView to show a list of calls that is kept up to date.
1. Receiving Phonecall Broadcasts
- Start a new Android Studio Project with a Blank Activity.
1.1 Creating a BroadcastReceiver
- Create a separate class that extends BroadcastReceiver ( call it CallReceiver, for example).
- Override the
onReceive()
method. Make the method body log the action of the Intent argument. Useintent.getAction()
- Override the
In MainActivity, let's add code which registers and unregisters our CallReceiver.
- Create a new instance of your CallReceiver class.
- Then, create a new IntentFilter, this specifies which kind of broadcasts you are interested in receiving. Since we want to receive call-related events, we will use the action TelephonyManager.ACTION_PHONE_STATE_CHANGED in our IntentFilter.
- You can provide the action either in the constructor of IntentFilter or use the
addAction(..)
method of IntentFilter
- You can provide the action either in the constructor of IntentFilter or use the
- Use the registerReceiver(..) and unregisterReceiver(..) methods of Context, to activate your CallReceiver, also passing the IntentFilter as the other argument.
- You should decide when to unregister the receiver based on where you registered it. The registering and unregistering should be done in a pair corresponding to lifecycle methods.
- Registering in onCreate() and unregistering in onDestroy() is one reasonable approach.
On the other hand if you register in onCreate() but unregister in onPause(), you will get unexpected behaviour,as onPause is likely to be called more often than onCreate - e.g. if the user puts your app in the background by using the "Home" button, your receiver is unregistered, but when the user returns to the app immediately afterwards, your receiver does not get re-registered since onCreate() is not called in such situation (recall HW2).
1.2 Permissions
For this lab, we need two permissions:
android.permission.READ_PHONE_STATE
- allows us to receive
ACTION_PHONE_STATE_CHANGED
broadcasts and check the state Extra attached to them.
- allows us to receive
android.permission.READ_CALL_LOG
- allows us to read the Extra containing the caller number from the same phone state changed broadcasts.
- Add them to your manifest file using the
<uses-permission>
tag. It should be placed just outside of the<application>
tag.
As discussed in lecture 4, "dangerous" permissions require the user to explicitly give permissions, e.g. via a pop-up dialogue. For now, let's manually grant these permissions from the Android device's Settings:
- In the phone, find your app in the app drawer. Long-click on the icon, "App info" should appear, click on it. There you will find "Permissions", under which you can manually grant permissions.
At the end of this lab material you can also read how to grant permissions manually using the command line.
- Try to run the application, make a phonecall to the device and verify that you can see the action in LogCat.
You will probably receive 2 events when the call is made, and another 2 when the call is declined, meaning you will see duplicate action values. This is due to the specifics of the framework for the TelephonyManager broadcast with these 2 permissions.
1.3 Handling Intent Extras in CallReceiver
Now let's implement the handling of the event (in CallReceiver.onReceive() )
- The TelephonyManager.ACTION_PHONE_STATE_CHANGED may contain two different Extras:
- phone state (ringing, idle, in call)
- incoming number.
- Read the documentation of TelephonyManager to find the exact values (keys) of these extras. Obtain their values using Intent.getExtra(.. ), Intent.hasExtra() is also useful.
Try to log the value of the state and number (if available)
2. Displaying calls in a list
We will now add some UI to our app, so that new incoming calls are immediately shown in a list. Think of it as a simplified "call log" app.
2.1 Storing call data in a list
First, we will start storing the calls in a List of Strings.
- In MainActivity, create an empty list of type
ArrayList<String>
- Make sure it is defined a class property (member variable), not as a local variable declared within the scope of a function (such as onCreate()).
- Second, create a function
addPhoneNumberToList(phoneNumber: String)
in MainActivity. It should append the number to the list.
Finally, let's make the CallReceiver invoke that function every time a call comes in with a phone number. Since we want to access a method of MainActivity from CallReceiver, let's add MainActivity as a class property to CallReceiver, and create a constructor that sets the value of this property.
- In Kotlin, this can be done with very short syntax:
class CallReceiver(var mainActivity: MainActivity) : BroadcastReceiver() { .. }
- In Java, the above code corresponds to something like this: (Show Code)
public class JavaCallReceiver extends BroadcastReceiver { MainActivity mainActivity; public CallReceiver(MainActivity mainActivity) { this.mainActivity = mainActivity; } public MainActivity getMainActivity(){ return mainActivity; } public void setMainActivity(MainActivity activity) { this.mainActivity = activity; } }
Now, you should be able to call mainActivity.addPhoneNumberToList(number)
from the receiver.
2.2 ListView & Adapter
- Add a ListView to your MainActivity XML layout file. Set its' ID as
numbers_listview
. - In MainActivity.kt, create an ArrayAdapter
- Specify as the 2nd argument
android.R.layout.simple_list_item_1
, which is a default layout from Android SDK that consists of one TextView - For the 3rd argument pass the list object we created earlier.
- Specify as the 2nd argument
- Set the ListView to use the Adapter by calling
numbers_listview.adapter = myAdapterName
- Update your addPhoneNumberToList(), to notify the adapter that the data has changed by calling
adapter.notifyDatasetChanged()
after adding the new list element.- To access the adapter from addPhoneNumberToList(), make sure you are defining it at the class-level.
The application should now be able to handle incoming calls and visually present the number in the ListView:
3. Custom Adapter with RecyclerView
Above, we used a pre-defined View and ArrayAdapter to display the text items in a ListView. ArrayAdapter displays the individual elements in layouts with exactly 1 TextView. Sometimes we need more control over our UI - what if we want to show multiple data items (e.g. number and call time) in multiple Views. What if we want to include different kinds of Views, not just TextView? To manage how data is mapped to some views, we can create our own adapter.
Secondly, a more modern and efficient replacement for ListViews is RecyclerView. Let's re-create the list using a RecyclerView and implement our own Adapter.
- First we have to add an additional library to our project. Open your
build.gradle
file (Module: app), and add this line to thedependencies { }
block:
implementation 'androidx.recyclerview:recyclerview:1.1.0'
After adding the dependency, make sure you sync the Gradle project (you should see a notification appear after changing the gradle file)!
RecyclerViews use ViewHolders to represent individual items in a dataset. The ViewHolder should contain a View object (this can be a single View like TextView or a Layout containing multiple Views). Let's create the ViewHolder for a single list item which is a single call in our case.
- Create a new class called CallViewHolder
- It's primary constructor should accept 1 argument:
val item: View
- It should extend
RecyclerView.ViewHolder
, and pass the item property to the superclass constructor- Hint:
class CallViewHolder(val item: View) : RecyclerView.ViewHolder(item)
- Hint:
- It's primary constructor should accept 1 argument:
Now let's implement the Adapter for RecyclerView
- Create a new class called CallAdapter
- Make its primary constructor have 1 parameter of type List<String> called numbers, set it as a class property.
- Hint:
CustomArrayAdapter(var numbers: List<String>)
- Hint:
CallAdapter should extend RecyclerView.Adapter. When extending RecyclerView.Adapter, we need to override the implemetation of 3 methods that RecyclerView for displaying data:
- onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder -- Creates new ViewHolder instances. Here, you may inflate some XML to initialize the View the ViewHolder contains. RecyclerView creates just enough ViewHolders to display that portion of the dataset which is currently visible (and a few extra for buffer), as RecyclerView re-uses ViewHolder objects.onBindViewHolder(holder: MyViewHolder, position: Int) -- Given the position of a single item in the dataset, this method updates the View of a ViewHolder with the correct data for that item. E.g. sets some TextViews, display an icon based on the value, etc. This gets called more frequently than onCreateViewHolder(..), as RecyclerView tries to re-use existing ViewHolders when possible.getItemCount(): Int -- total number of data elements in the dataset
- Make CallAdapter extend
RecyclerView.Adapter
.- You have to provide the type of the ViewHolder the adapter will use, we will use the one we just created (CallViewHolder).
- class CallAdapter(var numbers: ArrayList<String>): RecyclerView.Adapter<CallViewHolder>()
- You have to provide the type of the ViewHolder the adapter will use, we will use the one we just created (CallViewHolder).
Now override the above-mentioned 3 methodsif RecyclerView.Adapter:
- For getItemCount(..), return the size of the numbers list (this is our dataset).
- For onCreateViewHolder(..) return an instance of CallViewHolder.
- CallViewHolder has a View object (
listItem
) as a field, it describes the View to show in the actual List for a single dataset item. We should initialize it here. Let's inflate it from XML:
- CallViewHolder has a View object (
val view = LayoutInflater.from(parent.context).inflate(R.layout.list_row, parent, false)
- In the above code
R.layout.list_row
is a new Layout XML, you have to create it. Create a simple horizontal LinearLayout that contains 1 TextView
- In the above code
- For onBindViewHolder( ..) , you should access the View object contained in the ViewHolder and update it to display the data at the provided position.
- Find the right phone number for the given position
- Update the TextView contained within the view of CallViewHolder instance available from the method argument
Now we have finished our Adapter. Let's add a RecyclerView to our MainActivity Layout and configure it to use the adapter.
- Add a RecyclerView to your MainActivity XML layout.
In onCreate() of MainActivity:
- update the layoutmanager of your RecyclerView
myRecyclerView.layoutManager = LinearLayoutManager(this)
- Set the adapter of your RecyclerView
recyclerView.adapter = MyAdapter(numbersList)
Run the app and make some calls, you should be able to see your RecyclerView working.
Other information
Background reading: About Kotlin class fields and class constructors
- This Android Guide has some examples about how & why we use can use lateinit keyword when creating variables
- Kotlin docs about class constructors
How to grant an app permissions via ADB commandline.
Using Android Debug Bridge (ADB), you can send various commands to an Android Device (physical or emulator), it is how the app is installed to the device after compilation. Using ADB, it is also possible to manually grant an application permissions, e.g. :
adb shell pm grant ee.ut.cs.lab4app android.permission.READ_CALL_LOG
this grants the READ_CALL_LOG permission
to the app with package ee.ut.cs.lab4app
.
You can find the adb binary located in your Android SDK installation directory:
<<AndroidSDKInstallDirectory>>/Sdk/platform-tools/adb(.exe)