Interprocess communication

Android development sometimes encounter multi-process scenarios, such as the previous development with Unity, Unity takes up a lot of memory, and when Unity exits, the whole process will be killed, in order to avoid quitting Unity, the entire application quit, and to avoid Unity too much memory resulting in OOM, You need to put it in a separate process. Sometimes you need to call methods provided by other apps, which is also a multi-process scenario. Since the processes were previously memory isolated from each other, variables could not be accessed from each other, so such problems must be handled properly, which requires cross-process communication techniques.

Interprocess communication (IPC) consists of the following technologies: Intent and Bundle, AIDL, Messenger, ContentProvider, and Socket.

Intents and bundles support data transfer across processes. They are used in the same way as in the same process. ContentProvider provides a way to share data with other applications. It supports cross-processes and is used in the same way as in the same process scenario. For details, see my previous article. Socket adopts the way of local network communication to complete cross-process communication, need to declare the network permission, use ServerSocket and Socket two classes to achieve, the usage is relatively simple, specific can refer to this article. Let’s focus on AIDL and Messenger.

AIDL

Simple to use

The steps are as follows:

  1. Create an ADIL file. The ADIL file defines an interface and the methods to be run on the server.

  2. Create a Service that is the server for interprocess communication. A Service requires an inner class that implements the interface defined in the AIDL file. Stub classes are abstract classes that are subclasses of Binder and are generated by the compiler at build time. This inner class needs to implement the methods defined in the interface in AIDL. Finally, you need to return the object of the inner class in the Service’s onBind method.

  3. Bind bind bind bind bind bind bind bind bind bind bind bind bind bind bind bind Bind Bind Bind Bind Bind Bind Bind Bind Bind Bind Bind Bind Bind Bind Bind Bind Bind Bind Bind Bind Bind Bind Bind Bind bind bind bind bind bind bind bind bind bind bind bind bind bind The Stub asInterface method is required. With Binder in hand, you can invoke the methods defined in AIDL.

Here’s an example of the steps:

The first step is to create an AIDL folder. It is recommended to place the AIDL folder at the root of the main directory, the same as the Java folder. When you create an AIDL file in a folder, Android Studio automatically creates a default method called basicTypes. This method is an example method that describes the basic data types supported in AIDL. That is int, long, Boolean, float, double, char, String. Several other types are supported in AIDL, including: CharSequence, ArrayList(each element must be a type supported by AIDL), HashMap(key and value must be a type supported by AIDL), Parcelable, AIDL interfaces (that is, other AIDL interfaces can be used within an AIDL). Note that the Parcelable and AIDL interfaces used in AIDL must be imported manually, regardless of whether they are in the same package as the current AIDL file. The custom Parcelable class must also create an AIDL file with the same name and declare it as a Parcelable type in the AIDL file

// IBookManager.aidl
package com.example.testaidl;

// Declare any non-default types here with import statements
// The Book class is used here, so you must explicitly import it, even within a package.
import com.example.testaidl.Book;
interface IBookManager {
	// defines a getBookList method, where the type is List, but the actual return is ArrayList.
    // List generic types must meet AIDL's requirements for types.
    List<Book> getBookList(a);
   	// Defines an add method
    void addBook(in Book book);
}
Copy the code

The Book entity class in the Java package must implement Parcelable

public class Book implements Parcelable {
    public int bookId;
    public String bookName;

    public Book(int bookId, String bookName) {
        this.bookId = bookId;
        this.bookName = bookName;
    }
    // Omit the method required by Parcelable, which can be generated with the Android Parcelable Code Generator AS plugin
}
Copy the code

Since AIDL uses the Book class, we must create a new book. AIDL file and declare it as Parcelable in the AIDL file, as follows:

package com.example.testaidl;
parcelable Book;
Copy the code

Build/generated/aidl_source_output_dir = appbuild/generated/aidl_source_output_dir = appbuild/generated/aidl_source_output_dir

Generate Java file source as follows:

package com.example.testaidl;
public interface IBookManager extends android.os.IInterface
{
  /** Default implementation for IBookManager. */
  public static class Default implements com.example.testaidl.IBookManager
  {
      / / to omit
  }
  /** Local-side IPC implementation stub class. */
  public static abstract class Stub extends android.os.Binder implements com.example.testaidl.IBookManager
  {
      / / to omit
  }
  public java.util.List<com.example.testaidl.Book> getBookList() throws android.os.RemoteException;
  public void addBook(com.example.testaidl.Book book) throws android.os.RemoteException;
}
Copy the code

As you can see, this Java file is also an interface, and the methods in the interface are exactly the same as those in AIDL. An abstract class Stub is also generated, which will be inherited in subsequent steps.

In the second step, create the Service that represents the Service side, create an anonymous inner class in the Service that inherits the Stub from the previous step, and return the object of this inner class in the Service’s onBind method. The code is as follows:

class AIDLServerService : Service() {
    private val mBookList = CopyOnWriteArrayList<Book>()
    override fun onBind(intent: Intent): IBinder {
        Log.i("zx"."OnBind running thread is" + Thread.currentThread().name)
        return binder
    }

    / / implementation stubs
    private val binder: Binder = object : IBookManager.Stub() {
        override fun getBookList(a): List<Book> {
            Log.i("zx"."GetBookList running thread is" + Thread.currentThread().name)
            return mBookList
        }

        override fun addBook(book: Book?). {
            Log.i("zx"."AddBook running thread is" + Thread.currentThread().name)
            Log.i("zx"."Received the book from the client,"+ book? .bookName) mBookList.add(book) } } }Copy the code

In this step, we implement the methods declared in AIDL that run on the server and are provided to the client to call the methods across processes. As you can see from the above code, you create an anonymous class that inherits from the Stub. Since the Stub is an abstract class, you must implement the Stub’s abstract methods, which are the same abstract methods declared in the AIDL interface. Stub is also a subclass of Binder, so this anonymous class is a subclass of Binder and can be returned in Service onBind so that clients can retrieve the Binder and invoke server-side methods across processes.

In the above code, addBook is used to add books, books are stored in mBookList, and getBookList is used to return all books. If the server Service and the client are the same APP, you need to specify the server Service as another process. In this way, the cross-process method invocation is exactly the same as the cross-App invocation.

The Binder returned by the server is called from the onServiceConnected method of ServiceConnection. The Stub’s asInterface method is then used to convert the Binder into an interface (IBookManager) in the Java file generated at build time, and the interface’s methods can then be called as if they were local methods.

override fun onCreate(savedInstanceState: Bundle?). {
	super.onCreate(savedInstanceState)
	setContentView(R.layout.activity_main)
    // Start the Service on the server
	val intent = Intent(this, AIDLServerService::class.java)
	bindService(intent, serviceConnection, BIND_AUTO_CREATE)
}
    
var serviceConnection: ServiceConnection = object : ServiceConnection {
    override fun onServiceConnected(name: ComponentName, service: IBinder) {
        Log.i("zx"."OnServiceConnected running thread is + Thread.currentThread().name)
        iBookManager = IBookManager.Stub.asInterface(service)
        try {
            iBookManager.addBook(Book(1."PHP from beginner to Master"))
            val list: List<Book> = iBookManager.getBookList()
            Log.i("zx", list[0].bookName)
        } catch (e: RemoteException) {
            e.printStackTrace()
        }
    }

    override fun onServiceDisconnected(name: ComponentName){}}Copy the code

Run it and the final output is:

As can be seen from the log, addBook and getBookList on the server are not run in the main thread, but actually run in the Binder thread pool. Therefore, if there are multiple clients calling the server methods at the same time, these two methods will have a thread safety problem. So we use CopyOnWriteArrayList, which is a thread-safe version of ArrayList that supports concurrent reads and writes, to avoid thread-safe issues.

This is the end of the simple use of AIDL. If you don’t consider some theories and principles, just consider the use of AIDL, it is quite simple, only need three steps on the line.

Two-way communication

At present, only the client remotely calls the method of the server, and there is no two-way communication, that is, the server calls the method of the client. If the server wants to actively notify the client of data changes, the server needs to communicate with the client, which is then implemented.

In the same way that the client calls the server, you need to define an AIDL file first. In the AIDL, you declare that the server Service needs methods to remotely call the client Activity. In this case, the Activity is more like the server (remote method executor) and the Service is more like the client (remote method caller).

// IOnNewBookArrivedListener.aidl
package com.example.testaidl;
import com.example.testaidl.Book;
// Declare any non-default types here with import statements

interface IOnNewBookArrivedListener {
void onNewBookArrived(in Book newBook);
}
Copy the code

Then inherited in the Activity of IOnNewBookArrivedListener stub, and implementation method.

val listener = object : IOnNewBookArrivedListener.Stub() {
    override fun onNewBookArrived(newBook: Book?). {
        Log.i("zx"."The client received the data,"+ newBook? .bookId) } }Copy the code

Create IOnNewBookArrivedListener client Activity. A stub implementation class of the object, the object is of the type of Binder, now only need to pass this Binder object to the server, the server can remote calls when data changes of the Binder method, In this way, the server actively notifies the client. How do you pass this Binder object to the server? AIDL is going to be used again. Just as the Activity used AIDL to pass the Book object to the server, define an AIDL method and pass the Listener (Binder) to the server Service.

// IBookManager.aidl
package com.example.testaidl;

// Declare any non-default types here with import statements
import com.example.testaidl.Book;
import com.example.testaidl.IOnNewBookArrivedListener;
interface IBookManager {
    List<Book> getBookList(a);
    void addBook(in Book book);
    void registerListener(IOnNewBookArrivedListener listener);
    void unregisterListener(IOnNewBookArrivedListener listener);
}
Copy the code

AIDL adds two methods: registerListener and unregisterListener. RegisterListener is used to send the listener to the server, and unregisterListener is used to tell the server to unlisten. That is, the server does not need to notify the client of data changes. The two new methods need to be implemented in the Service.

class AIDLServerService : Service() {
    private val mBookList = CopyOnWriteArrayList<Book>()

    RemoteCallbackList is used here. If you use ArrayList, you will not be able to remove the listener.
    // Because the client calls the same object passed by Binder twice (registerListener and unregisterListener), Binder's underlying conversion
    // It is not the same object on the server, so if you use ArrayList, you cannot use the listener corresponding to arrayList.remove.
    private val mListenerList = RemoteCallbackList<IOnNewBookArrivedListener>()
    private val mIsServiceDestroyed = AtomicBoolean(false)

    override fun onCreate(a) {
        super.onCreate()
        // Start a thread that changes data every 2 seconds and notifies the client
        thread {
            while(! mIsServiceDestroyed.get()) {
                try {
                    Thread.sleep(2000)}catch (e: InterruptedException) {
                    e.printStackTrace()
                }
                val bookId: Int = mBookList.size + 1
                val newBook = Book(bookId, "new book#$bookId")
                try {
                    notifyClientDataChanged(newBook)
                } catch (e: RemoteException) {
                    e.printStackTrace()
                }
            }
        }
    }

    override fun onBind(intent: Intent): IBinder {
        Log.i("zx"."OnBind running thread is" + Thread.currentThread().name)
        return binder
    }

    / / implementation stubs
    private val binder: Binder = object : IBookManager.Stub() {
        override fun getBookList(a): List<Book> {
            Log.i("zx"."GetBookList running thread is" + Thread.currentThread().name)
            return mBookList
        }

        override fun addBook(book: Book?). {
            Log.i("zx"."AddBook running thread is" + Thread.currentThread().name)
            Log.i("zx"."Received the book from the client,"+ book? .bookName) mBookList.add(book) }override fun registerListener(listener: IOnNewBookArrivedListener?). {
            mListenerList.register(listener)
        }

        override fun unregisterListener(listener: IOnNewBookArrivedListener?). {
            val success = mListenerList.unregister(listener)
            Log.i("zx"."Whether unregister succeeded$success")
            val size = mListenerList.beginBroadcast()
            mListenerList.finishBroadcast()
            Log.i("zx".Size = "unregisterListener$size")}}private fun notifyClientDataChanged(book: Book) {
        mBookList.add(book)
        //beginBroadcast and finishBroadcast should be used together
        val mListenerListSize = mListenerList.beginBroadcast()
        for (i in 0 until mListenerListSize) {
            val listener = mListenerList.getBroadcastItem(i)
            if(listener ! =null) {
                try {
                    // Call the client method to notify the client.
                    listener.onNewBookArrived(book)
                } catch (e: RemoteException) {
                    e.printStackTrace()
                }
            }
        }
        mListenerList.finishBroadcast()
    }

    override fun onDestroy(a) {
        mIsServiceDestroyed.set(true)
        super.onDestroy()
    }
}
Copy the code

A RemoteCallbackList has been added to the Service to save listeners sent by the client. You can’t use ArrayList here because you can’t unlisten if you use ArrayList. The causes of this problem are as follows: ** Client registerListener and unregisterListener pass the same listener, but there is a process of serialization and deserialization in cross-process transfer, the same object passed by the client, server deserialization creates two different objects. You can’t use arrayList.remove () to remove the listener. ** While transferring the same object across multiple processes can generate different objects on the server, these newly generated objects have one thing in common: their underlying binders and girings are the same. Using this feature, you can achieve functions that cannot be achieved above. RemoteCallbackList is based on this principle. RemoteCallbackList stores data using an ArrayMap, with Binder keys and values as newly generated objects on the server. When unregisterListener is sent to the client, we simply iterate through all the server listeners, find the server listener with the same Binder object as the client listener and delete it. That’s what RemoteCallbackList’s unregister() method does for us. When the client process terminates, it automatically removes the listener registered by the client. Thread synchronization is automatically implemented internally in RemoteCallbackList. So we’re using RemoteCallbackList instead of ArrayList. Note that when RemoteCallbackList is used, beginBroadcast and finishBroadcast should be used together, even if only to get the number of elements in RemoteCallbackList.

In the code above, a thread is created in the Service onCreate, adding data every 2 seconds, and notifies the client. To notify the client, obtain the listener using getBroadcastItem(index), and then call the listener.

The client Activity needs to call the registerListener above to pass the listener to the server (register the listener), and call unregisterListener when the Activity is destroyed to tell the server that it does not need to be notified when the data changes (unregister the listener). The complete code is as follows:

class MainActivity : AppCompatActivity() {
    private lateinit var iBookManager: IBookManager
    override fun onCreate(savedInstanceState: Bundle?). {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        val intent = Intent(this, AIDLServerService::class.java)
        bindService(intent, serviceConnection, BIND_AUTO_CREATE)
    }

    var serviceConnection: ServiceConnection = object : ServiceConnection {
        override fun onServiceConnected(name: ComponentName, service: IBinder) {
            Log.i("zx"."OnServiceConnected running thread is + Thread.currentThread().name)
            iBookManager = IBookManager.Stub.asInterface(service)
            try {
                iBookManager.addBook(Book(1."PHP from beginner to Master"))
                // Pass the listener to the server (register the listener)
                iBookManager.registerListener(listener)
                val list: List<Book> = iBookManager.getBookList()
                Log.i("zx", list[0].bookName)
            } catch (e: RemoteException) {
                e.printStackTrace()
            }
        }

        override fun onServiceDisconnected(name: ComponentName){}}val listener = object : IOnNewBookArrivedListener.Stub() {
        override fun onNewBookArrived(newBook: Book?). {
            Log.i("zx"."OnNewBookArrived running thread is" + Thread.currentThread().name)
            Log.i("zx"."The client received the data,"+ newBook? .bookId) } }override fun onDestroy(a) {
        // Tell the server that data changes without notification (unlisten)
        if (iBookManager.asBinder().isBinderAlive) {
            try {
                iBookManager.unregisterListener(listener)
            } catch (e: RemoteException) {
                Log.e("zx", e.toString())
                e.printStackTrace()
            }
        }
        unbindService(serviceConnection)
        super.onDestroy()
    }
}
Copy the code

The final output

After the Activity returns, the following log is printed:

The logs show that listening can indeed be removed.

The logs show that onNewBookArrived is running in the Binder thread pool. Combined with the previous logs of the addBook and getBookList methods, it can be concluded that remote methods (the actual implementation of abstract methods in the AIDL interface) are always executed in the Binder thread pool. As with network requests, the caller blocks until the result of a remote method is retrieved, so it is best for the caller to be on a non-UI thread to avoid blocking the interface.

Permission to verify

Currently, any Activity in an APP can bind and call remote methods in a server Service. Sometimes we need to limit that only clients that meet our requirements can call methods in a server Service. Permissions are generally verified in the following two locations:

  1. If onBind() returns null, the client will not be able to access the Binder on the server and will not be able to call the server methods remotely. Using this rule, you can verify permissions in onBind().

  2. In the Stub implementation onTransact(), the onTransact() method of the Stub class generated by AIDL files looks for the remote method on the server based on the code that the client sends to represent the method. If no remote method is found, it returns false and the server does nothing. So if we return false in onTransact(), we will reject all remote calls from the client, and we can also achieve the purpose of verifying permissions.

Permission verification can be performed in the following two ways:

  1. Verify the client permission, through checkCallingOrSelfPermission () can determine whether the calling process of IPC has been granted special privileges. This approach requires us to define permissions in the manifest file of the server, and then verify that the client has the permissions declared in the manifest file.

  2. To verify the package name of the client, run getCallingUid() to obtain the Linux UID of the client process. Then run getPackageManager().getPackagesForUID () to obtain the package name of the client based on the UID. Once you have the package name, you can verify that the package name conforms to our rules and verify permissions.

The following is an example: first define permissions in the manifest of the server, and specify that the client must have permissions to invoke the service. Where protectionLevel is signature, the client must have the same signature as the server to pass the permission verification. This situation is common in multiple products of the same company. If you do not need to verify the signature of the APP, You can set Android :protectionLevel=”normal” when defining permission

<permission
    android:name="com.example.testaidl.permission.SERVICE"
    android:protectionLevel="signature" />

<service
         android:name=".AIDLServerService"
         android:enabled="true"
         android:exported="true"
         android:permission="com.example.testaidl.permission.SERVICE"
         android:process=":remote" />

Copy the code

Then verify permissions in the Service’s onBind() and Stub implementation’s onTransact() methods.

override fun onBind(intent: Intent): IBinder? {
    Log.i("zx"."OnBind running thread is" + Thread.currentThread().name)
    return if (checkPermission()) {
        binder
    } else null
}

/ / implementation stubs
private val binder: Binder = object : IBookManager.Stub() {
	// omit the code
    
    // The onTransact() method looks for the remote method on the server side according to the code representing the method passed by the client. If no remote method is found,
    // Will return false and the server will do nothing. So if we return false in onTransact(),
    // It is equivalent to rejecting all remote calls from the client, which can achieve the purpose of verifying permissions
    override fun onTransact(code: Int.data: Parcel, reply: Parcel? , flags:Int): Boolean {
        return if (checkPackage()) {
            super.onTransact(code, data, reply, flags)
        } else false}}private fun checkPermission(a): Boolean {
    / / the client APP manifest without announcement com. Example. Testaidl. Permission. SERVICE access, not through authentication
    val check = checkCallingOrSelfPermission("com.example.testaidl.permission.SERVICE")
    returncheck ! = PackageManager.PERMISSION_DENIED }private fun checkPackage(a): Boolean {
    val packageName: String
    val packages = packageManager.getPackagesForUid(
        Binder.getCallingUid()
    )
    return if(packages ! =null && packages.isNotEmpty()) {
        packageName = packages[0]
        Log.d("zx"."Caller package name:$packageName")
        // If the package name does not match the rule, reject it
        packageName.startsWith("com.example")}else false

}
Copy the code

Finally, when used by the client, permissions need to be declared in the manifest.

<uses-permission android:name="com.example.testaidl.permission.SERVICE" />
Copy the code

In addition, the package name must comply with the specified rules, and the signature of the client APP must be consistent with that of the server APP. Only when the preceding conditions are met, the permission can be authenticated.

Understand the Binder

Binder is an Android interprocess communication mechanism that uses a C/S architecture. The Binder framework defines four roles: Client, Server, ServiceManager, and Binder drivers. The relationship between the four roles is similar to that of the Internet: Server is a Server, Client is a Client, ServiceManager is a Domain name Server (DNS), and driver is a router.

Client, needless to say, is the caller of a cross-process call, such as the Activity in the example above. Server is the caller of a cross-process call, such as the Service in the example above.

Just as DNS manages the mapping between domain names and IP addresses, ServiceManager manages references to Binder. After a server registers Binder with ServiceManger, clients request ServiceManager by name. The ServiceManager translates the character Binder name into a reference to the Binder in the Client. After obtaining the reference, the Client can call remote methods.

Binder drivers run at the kernel level and act like routers, converting and sending data. The client gets a reference from the Binder, which is not the same as the server’s reference, because they are two processes and do not share resources. Therefore, they must not be the same object, which is not the same object. Binder drives solve this problem. The Binder driver maps our reference and finds the remote process based on our reference object. To call a remote object function, a Client writes data to a Parcel and calls a Transact () function with its Binder reference. The Transact function executes arguments, identifiers that mark the remote object and its functions into the Client’s shared memory. Binder drivers read data from the Client’s shared memory, find the remote process’s shared memory, copy the data to the remote process’s shared memory, and notify the remote process to execute onTransact(). The Binder driver copies the remote process’s shared memory data to the client’s shared memory and wakes up the client thread. Client Binder references map to server binders, data Parcel and parse, shared memory copy, wake up threads, and so on, all done at the bottom by Binder drivers. We don’t need to pay attention to these low-level details when we use them at the application level, just define AIDL interface methods and implement them on the server side, then get Binder references on the client side and call them remotely.

Messenger

Messenger can pass Message objects between different processes. By putting the data we need to pass in the Message, we can easily pass data between processes. Messenger is a lightweight IPC scheme whose underlying implementation is AIDL.

Similar to AIDL, a Service is created to handle client connection requests, and a Handler is created to create a Messenger object. The Binder underlying the Messenger object is then returned in the Service’s on Bind. The code is as follows:

public class MessengerService extends Service {
    public MessengerService(a) {}@Override
    public IBinder onBind(Intent intent) {
        // Returns Binder at the bottom of the Messenger object
        return messenger.getBinder();
    }


    @SuppressLint("HandlerLeak")
    Handler messengerHandler = new Handler() {
        @Override
        public void handleMessage(Message msg) {
            super.handleMessage(msg);
            Log.i("MessengerService"."Messages received by the server" + msg.getData().get("info").toString());
            // Respond to the client with the replyTo parameter
            Messenger messenger = msg.replyTo;
            Message replyMessage = new Message();
            Bundle bundle = new Bundle();
            bundle.putString("response"."This is a message returned from the server.");
            replyMessage.setData(bundle);
            try {
                // Send a return message to the client
                messenger.send(replyMessage);
            } catch(RemoteException e) { e.printStackTrace(); }}};// Create Messenger according to Handler
    Messenger messenger = new Messenger(messengerHandler);
}
Copy the code

The client starts the server’s Service as a bindService and creates a Messenger object with the Binder object returned from the server in ServiceConnection’s onServiceConnected(). You can use this Messenger to send messages to the server. If the server needs to be able to respond to the client, create a Handler and use it to create a new Messenger, just as the server does, and pass the Messenger object to the server via the replyTo parameter of Message. The server responds to the client with the replyTo parameter. The code is as follows:

public class MessengerActivity extends AppCompatActivity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_messenger);
        bindService(new Intent(this, MessengerService.class), serviceConnection, Service.BIND_AUTO_CREATE);
    }

    ServiceConnection serviceConnection = new ServiceConnection() {
        @Override
        public void onServiceConnected(ComponentName name, IBinder service) {
            Messenger messenger = new Messenger(service);
            Message message = new Message();
            Bundle bundle = new Bundle();
            bundle.putString("info"."This is a message from the client.");
            message.setData(bundle);
            // The Messenger object is passed to the server via Message's replyTo parameter, which the server can use to respond to the client
            message.replyTo = responseMessenger;
            try {
                messenger.send(message);
            } catch(RemoteException e) { e.printStackTrace(); }}@Override
        public void onServiceDisconnected(ComponentName name) {}};@SuppressLint("HandlerLeak")
    Handler handler = new Handler() {
        @Override
        public void handleMessage(Message msg) {
            super.handleMessage(msg);
            Log.i("MessengerActivity"."The client received the response from the server," + msg.getData().get("response").toString()); }};// Used to receive messages returned by the server
    Messenger responseMessenger = new Messenger(handler);

    @Override
    protected void onDestroy(a) {
        super.onDestroy(); unbindService(serviceConnection); }}Copy the code

Post run output

As you can see from the example, Messenger can only pass data in the form of Message and does not support RPC(remote method calls). Because the data is transferred in the Bundle, only the data types supported by the Bundle can be transferred. It has limitations compared to AIDL, but it’s easy to use.