/*
 * Copyright (C) 2014 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions an
 * limitations under the License.
 */

package com.android.server.midi;

import android.bluetooth.BluetoothDevice;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.content.ServiceConnection;
import android.content.pm.ApplicationInfo;
import android.content.pm.PackageInfo;
import android.content.pm.PackageManager;
import android.content.pm.ResolveInfo;
import android.content.pm.ServiceInfo;
import android.content.res.XmlResourceParser;
import android.media.midi.IBluetoothMidiService;
import android.media.midi.IMidiDeviceListener;
import android.media.midi.IMidiDeviceOpenCallback;
import android.media.midi.IMidiDeviceServer;
import android.media.midi.IMidiManager;
import android.media.midi.MidiDeviceInfo;
import android.media.midi.MidiDeviceService;
import android.media.midi.MidiDeviceStatus;
import android.media.midi.MidiManager;
import android.os.Binder;
import android.os.Bundle;
import android.os.IBinder;
import android.os.Process;
import android.os.RemoteException;
import android.util.Log;

import com.android.internal.content.PackageMonitor;
import com.android.internal.util.IndentingPrintWriter;
import com.android.internal.util.XmlUtils;
import com.android.server.SystemService;

import org.xmlpull.v1.XmlPullParser;

import java.io.FileDescriptor;
import java.io.PrintWriter;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;

public class MidiService extends IMidiManager.Stub {

    public static class Lifecycle extends SystemService {
        private MidiService mMidiService;

        public Lifecycle(Context context) {
            super(context);
        }

        @Override
        public void onStart() {
            mMidiService = new MidiService(getContext());
            publishBinderService(Context.MIDI_SERVICE, mMidiService);
        }
    }

    private static final String TAG = "MidiService";

    private final Context mContext;

    // list of all our clients, keyed by Binder token
    private final HashMap<IBinder, Client> mClients = new HashMap<IBinder, Client>();

    // list of all devices, keyed by MidiDeviceInfo
    private final HashMap<MidiDeviceInfo, Device> mDevicesByInfo
            = new HashMap<MidiDeviceInfo, Device>();

    // list of all Bluetooth devices, keyed by BluetoothDevice
     private final HashMap<BluetoothDevice, Device> mBluetoothDevices
            = new HashMap<BluetoothDevice, Device>();

    // list of all devices, keyed by IMidiDeviceServer
    private final HashMap<IBinder, Device> mDevicesByServer = new HashMap<IBinder, Device>();

    // used for assigning IDs to MIDI devices
    private int mNextDeviceId = 1;

    private final PackageManager mPackageManager;

    // UID of BluetoothMidiService
    private final int mBluetoothServiceUid;

    // PackageMonitor for listening to package changes
    private final PackageMonitor mPackageMonitor = new PackageMonitor() {
        @Override
        public void onPackageAdded(String packageName, int uid) {
            addPackageDeviceServers(packageName);
        }

        @Override
        public void onPackageModified(String packageName) {
            removePackageDeviceServers(packageName);
            addPackageDeviceServers(packageName);
        }

        @Override
        public void onPackageRemoved(String packageName, int uid) {
            removePackageDeviceServers(packageName);
        }
    };

    private final class Client implements IBinder.DeathRecipient {
        // Binder token for this client
        private final IBinder mToken;
        // This client's UID
        private final int mUid;
        // This client's PID
        private final int mPid;
        // List of all receivers for this client
        private final ArrayList<IMidiDeviceListener> mListeners
                = new ArrayList<IMidiDeviceListener>();
        // List of all device connections for this client
        private final HashMap<IBinder, DeviceConnection> mDeviceConnections
                = new HashMap<IBinder, DeviceConnection>();

        public Client(IBinder token) {
            mToken = token;
            mUid = Binder.getCallingUid();
            mPid = Binder.getCallingPid();
        }

        public int getUid() {
            return mUid;
        }

        public void addListener(IMidiDeviceListener listener) {
            mListeners.add(listener);
        }

        public void removeListener(IMidiDeviceListener listener) {
            mListeners.remove(listener);
            if (mListeners.size() == 0 && mDeviceConnections.size() == 0) {
                close();
            }
        }

        public void addDeviceConnection(Device device, IMidiDeviceOpenCallback callback) {
            DeviceConnection connection = new DeviceConnection(device, this, callback);
            mDeviceConnections.put(connection.getToken(), connection);
            device.addDeviceConnection(connection);
        }

        // called from MidiService.closeDevice()
        public void removeDeviceConnection(IBinder token) {
            DeviceConnection connection = mDeviceConnections.remove(token);
            if (connection != null) {
                connection.getDevice().removeDeviceConnection(connection);
            }
            if (mListeners.size() == 0 && mDeviceConnections.size() == 0) {
                close();
            }
        }

        // called from Device.close()
        public void removeDeviceConnection(DeviceConnection connection) {
            mDeviceConnections.remove(connection.getToken());
            if (mListeners.size() == 0 && mDeviceConnections.size() == 0) {
                close();
            }
        }

        public void deviceAdded(Device device) {
            // ignore private devices that our client cannot access
            if (!device.isUidAllowed(mUid)) return;

            MidiDeviceInfo deviceInfo = device.getDeviceInfo();
            try {
                for (IMidiDeviceListener listener : mListeners) {
                    listener.onDeviceAdded(deviceInfo);
                }
            } catch (RemoteException e) {
                Log.e(TAG, "remote exception", e);
            }
        }

        public void deviceRemoved(Device device) {
            // ignore private devices that our client cannot access
            if (!device.isUidAllowed(mUid)) return;

            MidiDeviceInfo deviceInfo = device.getDeviceInfo();
            try {
                for (IMidiDeviceListener listener : mListeners) {
                    listener.onDeviceRemoved(deviceInfo);
                }
            } catch (RemoteException e) {
                Log.e(TAG, "remote exception", e);
            }
        }

        public void deviceStatusChanged(Device device, MidiDeviceStatus status) {
            // ignore private devices that our client cannot access
            if (!device.isUidAllowed(mUid)) return;

            try {
                for (IMidiDeviceListener listener : mListeners) {
                    listener.onDeviceStatusChanged(status);
                }
            } catch (RemoteException e) {
                Log.e(TAG, "remote exception", e);
            }
        }

        private void close() {
            synchronized (mClients) {
                mClients.remove(mToken);
                mToken.unlinkToDeath(this, 0);
            }

            for (DeviceConnection connection : mDeviceConnections.values()) {
                connection.getDevice().removeDeviceConnection(connection);
            }
        }

        @Override
        public void binderDied() {
            Log.d(TAG, "Client died: " + this);
            close();
        }

        @Override
        public String toString() {
            StringBuilder sb = new StringBuilder("Client: UID: ");
            sb.append(mUid);
            sb.append(" PID: ");
            sb.append(mPid);
            sb.append(" listener count: ");
            sb.append(mListeners.size());
            sb.append(" Device Connections:");
            for (DeviceConnection connection : mDeviceConnections.values()) {
                sb.append(" <device ");
                sb.append(connection.getDevice().getDeviceInfo().getId());
                sb.append(">");
            }
            return sb.toString();
        }
    }

    private Client getClient(IBinder token) {
        synchronized (mClients) {
            Client client = mClients.get(token);
            if (client == null) {
                client = new Client(token);

                try {
                    token.linkToDeath(client, 0);
                } catch (RemoteException e) {
                    return null;
                }
                mClients.put(token, client);
            }
            return client;
        }
    }

    private final class Device implements IBinder.DeathRecipient {
        private IMidiDeviceServer mServer;
        private MidiDeviceInfo mDeviceInfo;
        private final BluetoothDevice mBluetoothDevice;
        private MidiDeviceStatus mDeviceStatus;

        // ServiceInfo for the device's MidiDeviceServer implementation (virtual devices only)
        private final ServiceInfo mServiceInfo;
        // UID of device implementation
        private final int mUid;

        // ServiceConnection for implementing Service (virtual devices only)
        // mServiceConnection is non-null when connected or attempting to connect to the service
        private ServiceConnection mServiceConnection;

        // List of all device connections for this device
        private final ArrayList<DeviceConnection> mDeviceConnections
                = new ArrayList<DeviceConnection>();

        public Device(IMidiDeviceServer server, MidiDeviceInfo deviceInfo,
                ServiceInfo serviceInfo, int uid) {
            mDeviceInfo = deviceInfo;
            mServiceInfo = serviceInfo;
            mUid = uid;
            mBluetoothDevice = (BluetoothDevice)deviceInfo.getProperties().getParcelable(
                    MidiDeviceInfo.PROPERTY_BLUETOOTH_DEVICE);;
            setDeviceServer(server);
        }

        public Device(BluetoothDevice bluetoothDevice) {
            mBluetoothDevice = bluetoothDevice;
            mServiceInfo = null;
            mUid = mBluetoothServiceUid;
        }

        private void setDeviceServer(IMidiDeviceServer server) {
            if (server != null) {
                if (mServer != null) {
                    Log.e(TAG, "mServer already set in setDeviceServer");
                    return;
                }
                IBinder binder = server.asBinder();
                try {
                    if (mDeviceInfo == null) {
                        mDeviceInfo = server.getDeviceInfo();
                    }
                    binder.linkToDeath(this, 0);
                    mServer = server;
                } catch (RemoteException e) {
                    mServer = null;
                    return;
                }
                mDevicesByServer.put(binder, this);
            } else if (mServer != null) {
                server = mServer;
                mServer = null;

                IBinder binder = server.asBinder();
                mDevicesByServer.remove(binder);

                try {
                    server.closeDevice();
                    binder.unlinkToDeath(this, 0);
                } catch (RemoteException e) {
                    // nothing to do here
                }
            }

            if (mDeviceConnections != null) {
                for (DeviceConnection connection : mDeviceConnections) {
                    connection.notifyClient(server);
                }
            }
        }

        public MidiDeviceInfo getDeviceInfo() {
            return mDeviceInfo;
        }

        // only used for bluetooth devices, which are created before we have a MidiDeviceInfo
        public void setDeviceInfo(MidiDeviceInfo deviceInfo) {
            mDeviceInfo = deviceInfo;
        }

        public MidiDeviceStatus getDeviceStatus() {
            return mDeviceStatus;
        }

        public void setDeviceStatus(MidiDeviceStatus status) {
            mDeviceStatus = status;
        }

        public IMidiDeviceServer getDeviceServer() {
            return mServer;
        }

        public ServiceInfo getServiceInfo() {
            return mServiceInfo;
        }

        public String getPackageName() {
            return (mServiceInfo == null ? null : mServiceInfo.packageName);
        }

        public int getUid() {
            return mUid;
        }

        public boolean isUidAllowed(int uid) {
            return (!mDeviceInfo.isPrivate() || mUid == uid);
        }

        public void addDeviceConnection(DeviceConnection connection) {
            synchronized (mDeviceConnections) {
                if (mServer != null) {
                    mDeviceConnections.add(connection);
                    connection.notifyClient(mServer);
                } else if (mServiceConnection == null &&
                    (mServiceInfo != null || mBluetoothDevice != null)) {
                    mDeviceConnections.add(connection);

                    mServiceConnection = new ServiceConnection() {
                        @Override
                        public void onServiceConnected(ComponentName name, IBinder service) {
                            IMidiDeviceServer server = null;
                            if (mBluetoothDevice != null) {
                                IBluetoothMidiService mBluetoothMidiService = IBluetoothMidiService.Stub.asInterface(service);
                                try {
                                    // We need to explicitly add the device in a separate method
                                    // because onBind() is only called once.
                                    IBinder deviceBinder = mBluetoothMidiService.addBluetoothDevice(mBluetoothDevice);
                                    server = IMidiDeviceServer.Stub.asInterface(deviceBinder);
                                } catch(RemoteException e) {
                                    Log.e(TAG, "Could not call addBluetoothDevice()", e);
                                }
                            } else {
                                server = IMidiDeviceServer.Stub.asInterface(service);
                            }
                            setDeviceServer(server);
                        }

                        @Override
                        public void onServiceDisconnected(ComponentName name) {
                            setDeviceServer(null);
                            mServiceConnection = null;
                        }
                    };

                    Intent intent;
                    if (mBluetoothDevice != null) {
                        intent = new Intent(MidiManager.BLUETOOTH_MIDI_SERVICE_INTENT);
                        intent.setComponent(new ComponentName(
                                MidiManager.BLUETOOTH_MIDI_SERVICE_PACKAGE,
                                MidiManager.BLUETOOTH_MIDI_SERVICE_CLASS));
                    } else {
                        intent = new Intent(MidiDeviceService.SERVICE_INTERFACE);
                        intent.setComponent(
                                new ComponentName(mServiceInfo.packageName, mServiceInfo.name));
                    }

                    if (!mContext.bindService(intent, mServiceConnection,
                            Context.BIND_AUTO_CREATE)) {
                        Log.e(TAG, "Unable to bind service: " + intent);
                        setDeviceServer(null);
                        mServiceConnection = null;
                    }
                } else {
                    Log.e(TAG, "No way to connect to device in addDeviceConnection");
                    connection.notifyClient(null);
                }
            }
        }

        public void removeDeviceConnection(DeviceConnection connection) {
            synchronized (mDeviceConnections) {
                mDeviceConnections.remove(connection);

                if (mDeviceConnections.size() == 0 && mServiceConnection != null) {
                    mContext.unbindService(mServiceConnection);
                    mServiceConnection = null;
                    if (mBluetoothDevice != null) {
                        // Bluetooth devices are ephemeral - remove when no clients exist
                        synchronized (mDevicesByInfo) {
                            closeLocked();
                        }
                    } else {
                        setDeviceServer(null);
                    }
                }
            }
        }

        // synchronize on mDevicesByInfo
        public void closeLocked() {
            synchronized (mDeviceConnections) {
                for (DeviceConnection connection : mDeviceConnections) {
                    connection.getClient().removeDeviceConnection(connection);
                }
                mDeviceConnections.clear();
            }
            setDeviceServer(null);

            // closed virtual devices should not be removed from mDevicesByInfo
            // since they can be restarted on demand
            if (mServiceInfo == null) {
                removeDeviceLocked(this);
            } else {
                mDeviceStatus = new MidiDeviceStatus(mDeviceInfo);
            }

            if (mBluetoothDevice != null) {
                mBluetoothDevices.remove(mBluetoothDevice);
            }
        }

        @Override
        public void binderDied() {
            Log.d(TAG, "Device died: " + this);
            synchronized (mDevicesByInfo) {
                closeLocked();
            }
        }

        @Override
        public String toString() {
            StringBuilder sb = new StringBuilder("Device Info: ");
            sb.append(mDeviceInfo);
            sb.append(" Status: ");
            sb.append(mDeviceStatus);
            sb.append(" UID: ");
            sb.append(mUid);
            sb.append(" DeviceConnection count: ");
            sb.append(mDeviceConnections.size());
            sb.append(" mServiceConnection: ");
            sb.append(mServiceConnection);
            return sb.toString();
        }
    }

    // Represents a connection between a client and a device
    private final class DeviceConnection {
        private final IBinder mToken = new Binder();
        private final Device mDevice;
        private final Client mClient;
        private IMidiDeviceOpenCallback mCallback;

        public DeviceConnection(Device device, Client client, IMidiDeviceOpenCallback callback) {
            mDevice = device;
            mClient = client;
            mCallback = callback;
        }

        public Device getDevice() {
            return mDevice;
        }

        public Client getClient() {
            return mClient;
        }

        public IBinder getToken() {
            return mToken;
        }

        public void notifyClient(IMidiDeviceServer deviceServer) {
            if (mCallback != null) {
                try {
                    mCallback.onDeviceOpened(deviceServer, (deviceServer == null ? null : mToken));
                } catch (RemoteException e) {
                    // Client binderDied() method will do necessary cleanup, so nothing to do here
                }
                mCallback = null;
            }
        }

        @Override
        public String toString() {
            return "DeviceConnection Device ID: " + mDevice.getDeviceInfo().getId();
        }
    }

    public MidiService(Context context) {
        mContext = context;
        mPackageManager = context.getPackageManager();
        mPackageMonitor.register(context, null, true);

        Intent intent = new Intent(MidiDeviceService.SERVICE_INTERFACE);
        List<ResolveInfo> resolveInfos = mPackageManager.queryIntentServices(intent,
                PackageManager.GET_META_DATA);
        if (resolveInfos != null) {
            int count = resolveInfos.size();
            for (int i = 0; i < count; i++) {
                ServiceInfo serviceInfo = resolveInfos.get(i).serviceInfo;
                if (serviceInfo != null) {
                    addPackageDeviceServer(serviceInfo);
                }
            }
        }

        PackageInfo info;
        try {
            info = mPackageManager.getPackageInfo(MidiManager.BLUETOOTH_MIDI_SERVICE_PACKAGE, 0);
        } catch (PackageManager.NameNotFoundException e) {
            info = null;
        }
        if (info != null && info.applicationInfo != null) {
            mBluetoothServiceUid = info.applicationInfo.uid;
        } else {
            mBluetoothServiceUid = -1;
        }
   }

    @Override
    public void registerListener(IBinder token, IMidiDeviceListener listener) {
        Client client = getClient(token);
        if (client == null) return;
        client.addListener(listener);
        // Let listener know whether any ports are already busy.
        updateStickyDeviceStatus(client.mUid, listener);
    }

    @Override
    public void unregisterListener(IBinder token, IMidiDeviceListener listener) {
        Client client = getClient(token);
        if (client == null) return;
        client.removeListener(listener);
    }

    // Inform listener of the status of all known devices.
    private void updateStickyDeviceStatus(int uid, IMidiDeviceListener listener) {
        synchronized (mDevicesByInfo) {
            for (Device device : mDevicesByInfo.values()) {
                // ignore private devices that our client cannot access
                if (device.isUidAllowed(uid)) {
                    try {
                        MidiDeviceStatus status = device.getDeviceStatus();
                        if (status != null) {
                            listener.onDeviceStatusChanged(status);
                        }
                    } catch (RemoteException e) {
                        Log.e(TAG, "remote exception", e);
                    }
                }
            }
        }
    }

    private static final MidiDeviceInfo[] EMPTY_DEVICE_INFO_ARRAY = new MidiDeviceInfo[0];

    public MidiDeviceInfo[] getDevices() {
        ArrayList<MidiDeviceInfo> deviceInfos = new ArrayList<MidiDeviceInfo>();
        int uid = Binder.getCallingUid();

        synchronized (mDevicesByInfo) {
            for (Device device : mDevicesByInfo.values()) {
                if (device.isUidAllowed(uid)) {
                    deviceInfos.add(device.getDeviceInfo());
                }
            }
        }

        return deviceInfos.toArray(EMPTY_DEVICE_INFO_ARRAY);
    }

    @Override
    public void openDevice(IBinder token, MidiDeviceInfo deviceInfo,
            IMidiDeviceOpenCallback callback) {
        Client client = getClient(token);
        if (client == null) return;

        Device device;
        synchronized (mDevicesByInfo) {
            device = mDevicesByInfo.get(deviceInfo);
            if (device == null) {
                throw new IllegalArgumentException("device does not exist: " + deviceInfo);
            }
            if (!device.isUidAllowed(Binder.getCallingUid())) {
                throw new SecurityException("Attempt to open private device with wrong UID");
            }
        }

        // clear calling identity so bindService does not fail
        long identity = Binder.clearCallingIdentity();
        try {
            client.addDeviceConnection(device, callback);
        } finally {
            Binder.restoreCallingIdentity(identity);
        }
    }

    @Override
    public void openBluetoothDevice(IBinder token, BluetoothDevice bluetoothDevice,
            IMidiDeviceOpenCallback callback) {
        Client client = getClient(token);
        if (client == null) return;

        // Bluetooth devices are created on demand
        Device device;
        synchronized (mDevicesByInfo) {
            device = mBluetoothDevices.get(bluetoothDevice);
            if (device == null) {
                device = new Device(bluetoothDevice);
                mBluetoothDevices.put(bluetoothDevice, device);
            }
        }

        // clear calling identity so bindService does not fail
        long identity = Binder.clearCallingIdentity();
        try {
            client.addDeviceConnection(device, callback);
        } finally {
            Binder.restoreCallingIdentity(identity);
        }
    }

    @Override
    public void closeDevice(IBinder clientToken, IBinder deviceToken) {
        Client client = getClient(clientToken);
        if (client == null) return;
        client.removeDeviceConnection(deviceToken);
    }

    @Override
    public MidiDeviceInfo registerDeviceServer(IMidiDeviceServer server, int numInputPorts,
            int numOutputPorts, String[] inputPortNames, String[] outputPortNames,
            Bundle properties, int type) {
        int uid = Binder.getCallingUid();
        if (type == MidiDeviceInfo.TYPE_USB && uid != Process.SYSTEM_UID) {
            throw new SecurityException("only system can create USB devices");
        } else if (type == MidiDeviceInfo.TYPE_BLUETOOTH && uid != mBluetoothServiceUid) {
            throw new SecurityException("only MidiBluetoothService can create Bluetooth devices");
        }

        synchronized (mDevicesByInfo) {
            return addDeviceLocked(type, numInputPorts, numOutputPorts, inputPortNames,
                    outputPortNames, properties, server, null, false, uid);
        }
    }

    @Override
    public void unregisterDeviceServer(IMidiDeviceServer server) {
        synchronized (mDevicesByInfo) {
            Device device = mDevicesByServer.get(server.asBinder());
            if (device != null) {
                device.closeLocked();
            }
        }
    }

    @Override
    public MidiDeviceInfo getServiceDeviceInfo(String packageName, String className) {
        synchronized (mDevicesByInfo) {
            for (Device device : mDevicesByInfo.values()) {
                 ServiceInfo serviceInfo = device.getServiceInfo();
                 if (serviceInfo != null &&
                        packageName.equals(serviceInfo.packageName) &&
                        className.equals(serviceInfo.name)) {
                    return device.getDeviceInfo();
                }
            }
            return null;
        }
    }

    @Override
    public MidiDeviceStatus getDeviceStatus(MidiDeviceInfo deviceInfo) {
        Device device = mDevicesByInfo.get(deviceInfo);
        if (device == null) {
            throw new IllegalArgumentException("no such device for " + deviceInfo);
        }
        return device.getDeviceStatus();
    }

    @Override
    public void setDeviceStatus(IMidiDeviceServer server, MidiDeviceStatus status) {
        Device device = mDevicesByServer.get(server.asBinder());
        if (device != null) {
            if (Binder.getCallingUid() != device.getUid()) {
                throw new SecurityException("setDeviceStatus() caller UID " + Binder.getCallingUid()
                        + " does not match device's UID " + device.getUid());
            }
            device.setDeviceStatus(status);
            notifyDeviceStatusChanged(device, status);
        }
    }

    private void notifyDeviceStatusChanged(Device device, MidiDeviceStatus status) {
        synchronized (mClients) {
            for (Client c : mClients.values()) {
                c.deviceStatusChanged(device, status);
            }
        }
    }

    // synchronize on mDevicesByInfo
    private MidiDeviceInfo addDeviceLocked(int type, int numInputPorts, int numOutputPorts,
            String[] inputPortNames, String[] outputPortNames, Bundle properties,
            IMidiDeviceServer server, ServiceInfo serviceInfo,
            boolean isPrivate, int uid) {

        int id = mNextDeviceId++;
        MidiDeviceInfo deviceInfo = new MidiDeviceInfo(type, id, numInputPorts, numOutputPorts,
                inputPortNames, outputPortNames, properties, isPrivate);

        if (server != null) {
            try {
                server.setDeviceInfo(deviceInfo);
            } catch (RemoteException e) {
                Log.e(TAG, "RemoteException in setDeviceInfo()");
                return null;
            }
        }

        Device device = null;
        BluetoothDevice bluetoothDevice = null;
        if (type == MidiDeviceInfo.TYPE_BLUETOOTH) {
            bluetoothDevice = (BluetoothDevice)properties.getParcelable(
                    MidiDeviceInfo.PROPERTY_BLUETOOTH_DEVICE);
            device = mBluetoothDevices.get(bluetoothDevice);
            if (device != null) {
                device.setDeviceInfo(deviceInfo);
            }
        }
        if (device == null) {
            device = new Device(server, deviceInfo, serviceInfo, uid);
        }
        mDevicesByInfo.put(deviceInfo, device);
        if (bluetoothDevice != null) {
            mBluetoothDevices.put(bluetoothDevice, device);
        }

        synchronized (mClients) {
            for (Client c : mClients.values()) {
                c.deviceAdded(device);
            }
        }

        return deviceInfo;
    }

    // synchronize on mDevicesByInfo
    private void removeDeviceLocked(Device device) {
        IMidiDeviceServer server = device.getDeviceServer();
        if (server != null) {
            mDevicesByServer.remove(server.asBinder());
        }
        mDevicesByInfo.remove(device.getDeviceInfo());

        synchronized (mClients) {
            for (Client c : mClients.values()) {
                c.deviceRemoved(device);
            }
        }
    }

    private void addPackageDeviceServers(String packageName) {
        PackageInfo info;

        try {
            info = mPackageManager.getPackageInfo(packageName,
                    PackageManager.GET_SERVICES | PackageManager.GET_META_DATA);
        } catch (PackageManager.NameNotFoundException e) {
            Log.e(TAG, "handlePackageUpdate could not find package " + packageName, e);
            return;
        }

        ServiceInfo[] services = info.services;
        if (services == null) return;
        for (int i = 0; i < services.length; i++) {
            addPackageDeviceServer(services[i]);
        }
    }

    private static final String[] EMPTY_STRING_ARRAY = new String[0];

    private void addPackageDeviceServer(ServiceInfo serviceInfo) {
        XmlResourceParser parser = null;

        try {
            parser = serviceInfo.loadXmlMetaData(mPackageManager,
                    MidiDeviceService.SERVICE_INTERFACE);
            if (parser == null) return;

            // ignore virtual device servers that do not require the correct permission
            if (!android.Manifest.permission.BIND_MIDI_DEVICE_SERVICE.equals(
                    serviceInfo.permission)) {
                Log.w(TAG, "Skipping MIDI device service " + serviceInfo.packageName
                        + ": it does not require the permission "
                        + android.Manifest.permission.BIND_MIDI_DEVICE_SERVICE);
                return;
            }

            Bundle properties = null;
            int numInputPorts = 0;
            int numOutputPorts = 0;
            boolean isPrivate = false;
            ArrayList<String> inputPortNames = new ArrayList<String>();
            ArrayList<String> outputPortNames = new ArrayList<String>();

            while (true) {
                int eventType = parser.next();
                if (eventType == XmlPullParser.END_DOCUMENT) {
                    break;
                } else if (eventType == XmlPullParser.START_TAG) {
                    String tagName = parser.getName();
                    if ("device".equals(tagName)) {
                        if (properties != null) {
                            Log.w(TAG, "nested <device> elements in metadata for "
                                + serviceInfo.packageName);
                            continue;
                        }
                        properties = new Bundle();
                        properties.putParcelable(MidiDeviceInfo.PROPERTY_SERVICE_INFO, serviceInfo);
                        numInputPorts = 0;
                        numOutputPorts = 0;
                        isPrivate = false;

                        int count = parser.getAttributeCount();
                        for (int i = 0; i < count; i++) {
                            String name = parser.getAttributeName(i);
                            String value = parser.getAttributeValue(i);
                            if ("private".equals(name)) {
                                isPrivate = "true".equals(value);
                            } else {
                                properties.putString(name, value);
                            }
                        }
                    } else if ("input-port".equals(tagName)) {
                        if (properties == null) {
                            Log.w(TAG, "<input-port> outside of <device> in metadata for "
                                + serviceInfo.packageName);
                            continue;
                        }
                        numInputPorts++;

                        String portName = null;
                        int count = parser.getAttributeCount();
                        for (int i = 0; i < count; i++) {
                            String name = parser.getAttributeName(i);
                            String value = parser.getAttributeValue(i);
                            if ("name".equals(name)) {
                                portName = value;
                                break;
                            }
                        }
                        inputPortNames.add(portName);
                    } else if ("output-port".equals(tagName)) {
                        if (properties == null) {
                            Log.w(TAG, "<output-port> outside of <device> in metadata for "
                                + serviceInfo.packageName);
                            continue;
                        }
                        numOutputPorts++;

                        String portName = null;
                        int count = parser.getAttributeCount();
                        for (int i = 0; i < count; i++) {
                            String name = parser.getAttributeName(i);
                            String value = parser.getAttributeValue(i);
                            if ("name".equals(name)) {
                                portName = value;
                                break;
                            }
                        }
                        outputPortNames.add(portName);
                    }
                } else if (eventType == XmlPullParser.END_TAG) {
                    String tagName = parser.getName();
                    if ("device".equals(tagName)) {
                        if (properties != null) {
                            if (numInputPorts == 0 && numOutputPorts == 0) {
                                Log.w(TAG, "<device> with no ports in metadata for "
                                    + serviceInfo.packageName);
                                continue;
                            }

                            int uid;
                            try {
                                ApplicationInfo appInfo = mPackageManager.getApplicationInfo(
                                        serviceInfo.packageName, 0);
                                uid = appInfo.uid;
                            } catch (PackageManager.NameNotFoundException e) {
                                Log.e(TAG, "could not fetch ApplicationInfo for "
                                        + serviceInfo.packageName);
                                continue;
                            }

                            synchronized (mDevicesByInfo) {
                                addDeviceLocked(MidiDeviceInfo.TYPE_VIRTUAL,
                                    numInputPorts, numOutputPorts,
                                    inputPortNames.toArray(EMPTY_STRING_ARRAY),
                                    outputPortNames.toArray(EMPTY_STRING_ARRAY),
                                    properties, null, serviceInfo, isPrivate, uid);
                            }
                            // setting properties to null signals that we are no longer
                            // processing a <device>
                            properties = null;
                            inputPortNames.clear();
                            outputPortNames.clear();
                        }
                    }
                }
            }
        } catch (Exception e) {
            Log.w(TAG, "Unable to load component info " + serviceInfo.toString(), e);
        } finally {
            if (parser != null) parser.close();
        }
    }

    private void removePackageDeviceServers(String packageName) {
        synchronized (mDevicesByInfo) {
            Iterator<Device> iterator = mDevicesByInfo.values().iterator();
            while (iterator.hasNext()) {
                Device device = iterator.next();
                if (packageName.equals(device.getPackageName())) {
                    iterator.remove();
                    removeDeviceLocked(device);
                }
            }
        }
    }

    @Override
    public void dump(FileDescriptor fd, PrintWriter writer, String[] args) {
        mContext.enforceCallingOrSelfPermission(android.Manifest.permission.DUMP, TAG);
        final IndentingPrintWriter pw = new IndentingPrintWriter(writer, "  ");

        pw.println("MIDI Manager State:");
        pw.increaseIndent();

        pw.println("Devices:");
        pw.increaseIndent();
        synchronized (mDevicesByInfo) {
            for (Device device : mDevicesByInfo.values()) {
                pw.println(device.toString());
            }
        }
        pw.decreaseIndent();

        pw.println("Clients:");
        pw.increaseIndent();
        synchronized (mClients) {
            for (Client client : mClients.values()) {
                pw.println(client.toString());
            }
        }
        pw.decreaseIndent();
    }
}
