/*
 * Copyright (C) 2008 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 and
 * limitations under the License.
 */

/**
 * TODO: Move this to services.jar
 * and make the contructor package private again.
 * @hide
 */

package android.server;

import android.bluetooth.BluetoothA2dp;
import android.bluetooth.BluetoothDevice;
import android.bluetooth.BluetoothError;
import android.bluetooth.BluetoothIntent;
import android.bluetooth.IBluetoothA2dp;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.content.pm.PackageManager;
import android.media.AudioManager;
import android.os.Binder;
import android.os.Handler;
import android.os.Message;
import android.provider.Settings;
import android.util.Log;

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

public class BluetoothA2dpService extends IBluetoothA2dp.Stub {
    private static final String TAG = "BluetoothA2dpService";
    private static final boolean DBG = true;

    public static final String BLUETOOTH_A2DP_SERVICE = "bluetooth_a2dp";

    private static final String BLUETOOTH_ADMIN_PERM = android.Manifest.permission.BLUETOOTH_ADMIN;
    private static final String BLUETOOTH_PERM = android.Manifest.permission.BLUETOOTH;

    private static final String A2DP_SINK_ADDRESS = "a2dp_sink_address";
    private static final String BLUETOOTH_ENABLED = "bluetooth_enabled";

    private static final int MESSAGE_CONNECT_TO = 1;
    private static final int MESSAGE_DISCONNECT = 2;

    private final Context mContext;
    private final IntentFilter mIntentFilter;
    private HashMap<String, SinkState> mAudioDevices;
    private final AudioManager mAudioManager;
    private final BluetoothDevice mBluetooth;

    // list of disconnected sinks to process after a delay
    private final ArrayList<String> mPendingDisconnects = new ArrayList<String>();
    // number of active sinks
    private int mSinkCount = 0; 

    private class SinkState {
        public String address;
        public int state;
        public SinkState(String a, int s) {address = a; state = s;}
    }

    public BluetoothA2dpService(Context context) {
        mContext = context;

        mAudioManager = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE);

        mBluetooth = (BluetoothDevice) mContext.getSystemService(Context.BLUETOOTH_SERVICE);
        if (mBluetooth == null) {
            throw new RuntimeException("Platform does not support Bluetooth");
        }

        if (!initNative()) {
            throw new RuntimeException("Could not init BluetoothA2dpService");
        }

        mIntentFilter = new IntentFilter(BluetoothIntent.BLUETOOTH_STATE_CHANGED_ACTION);
        mIntentFilter.addAction(BluetoothIntent.BOND_STATE_CHANGED_ACTION);
        mIntentFilter.addAction(BluetoothIntent.REMOTE_DEVICE_CONNECTED_ACTION);
        mContext.registerReceiver(mReceiver, mIntentFilter);

        if (mBluetooth.isEnabled()) {
            onBluetoothEnable();
        }
    }

    @Override
    protected void finalize() throws Throwable {
        try {
            cleanupNative();
        } finally {
            super.finalize();
        }
    }

    private final BroadcastReceiver mReceiver = new BroadcastReceiver() {
        @Override
        public void onReceive(Context context, Intent intent) {
            String action = intent.getAction();
            String address = intent.getStringExtra(BluetoothIntent.ADDRESS);
            if (action.equals(BluetoothIntent.BLUETOOTH_STATE_CHANGED_ACTION)) {
                int state = intent.getIntExtra(BluetoothIntent.BLUETOOTH_STATE,
                                               BluetoothError.ERROR);
                switch (state) {
                case BluetoothDevice.BLUETOOTH_STATE_ON:
                    onBluetoothEnable();
                    break;
                case BluetoothDevice.BLUETOOTH_STATE_TURNING_OFF:
                    onBluetoothDisable();
                    break;
                }
            } else if (action.equals(BluetoothIntent.BOND_STATE_CHANGED_ACTION)) {
                int bondState = intent.getIntExtra(BluetoothIntent.BOND_STATE,
                                                   BluetoothError.ERROR);
                switch(bondState) {
                case BluetoothDevice.BOND_BONDED:
                    setSinkPriority(address, BluetoothA2dp.PRIORITY_AUTO);
                    break;
                case BluetoothDevice.BOND_BONDING:
                case BluetoothDevice.BOND_NOT_BONDED:
                    setSinkPriority(address, BluetoothA2dp.PRIORITY_OFF);
                    break;
                }
            } else if (action.equals(BluetoothIntent.REMOTE_DEVICE_CONNECTED_ACTION)) {
                if (getSinkPriority(address) > BluetoothA2dp.PRIORITY_OFF) {
                    // This device is a preferred sink. Make an A2DP connection
                    // after a delay. We delay to avoid connection collisions,
                    // and to give other profiles such as HFP a chance to
                    // connect first.
                    Message msg = Message.obtain(mHandler, MESSAGE_CONNECT_TO, address);
                    mHandler.sendMessageDelayed(msg, 6000);
                }
            }
        }
    };

    private final Handler mHandler = new Handler() {
        @Override
        public void handleMessage(Message msg) {
            switch (msg.what) {
            case MESSAGE_CONNECT_TO:
                String address = (String)msg.obj;
                // check bluetooth is still on, device is still preferred, and
                // nothing is currently connected
                if (mBluetooth.isEnabled() &&
                        getSinkPriority(address) > BluetoothA2dp.PRIORITY_OFF &&
                        lookupSinksMatchingStates(new int[] {
                            BluetoothA2dp.STATE_CONNECTING,
                            BluetoothA2dp.STATE_CONNECTED,
                            BluetoothA2dp.STATE_PLAYING,
                            BluetoothA2dp.STATE_DISCONNECTING}).size() == 0) {
                    log("Auto-connecting A2DP to sink " + address);
                    connectSink(address);
                }
                break;
            case MESSAGE_DISCONNECT:
                handleDeferredDisconnect((String)msg.obj);
                break;
            }
        }
    };

    private synchronized void onBluetoothEnable() {
        mAudioDevices = new HashMap<String, SinkState>();
        String[] paths = (String[])listHeadsetsNative();
        if (paths != null) {
            for (String path : paths) {
                mAudioDevices.put(path, new SinkState(getAddressNative(path),
                        isSinkConnectedNative(path) ? BluetoothA2dp.STATE_CONNECTED :
                                                      BluetoothA2dp.STATE_DISCONNECTED));
            }
        }
        mAudioManager.setParameter(BLUETOOTH_ENABLED, "true");
    }

    private synchronized void onBluetoothDisable() {
        if (mAudioDevices != null) {
            // copy to allow modification during iteration
            String[] paths = new String[mAudioDevices.size()];
            paths = mAudioDevices.keySet().toArray(paths);
            for (String path : paths) {
                switch (mAudioDevices.get(path).state) {
                    case BluetoothA2dp.STATE_CONNECTING:
                    case BluetoothA2dp.STATE_CONNECTED:
                    case BluetoothA2dp.STATE_PLAYING:
                        disconnectSinkNative(path);
                        updateState(path, BluetoothA2dp.STATE_DISCONNECTED);
                        break;
                    case BluetoothA2dp.STATE_DISCONNECTING:
                        updateState(path, BluetoothA2dp.STATE_DISCONNECTED);
                        break;
                }
            }
            mAudioDevices = null;
        }
        mAudioManager.setBluetoothA2dpOn(false);
        mAudioManager.setParameter(BLUETOOTH_ENABLED, "false");
    }

    public synchronized int connectSink(String address) {
        mContext.enforceCallingOrSelfPermission(BLUETOOTH_ADMIN_PERM,
                                                "Need BLUETOOTH_ADMIN permission");
        if (DBG) log("connectSink(" + address + ")");
        if (!BluetoothDevice.checkBluetoothAddress(address)) {
            return BluetoothError.ERROR;
        }
        if (mAudioDevices == null) {
            return BluetoothError.ERROR;
        }
        // ignore if there are any active sinks
        if (lookupSinksMatchingStates(new int[] {
                BluetoothA2dp.STATE_CONNECTING,
                BluetoothA2dp.STATE_CONNECTED,
                BluetoothA2dp.STATE_PLAYING,
                BluetoothA2dp.STATE_DISCONNECTING}).size() != 0) {
            return BluetoothError.ERROR;
        }

        String path = lookupPath(address);
        if (path == null) {
            path = createHeadsetNative(address);
            if (DBG) log("new bluez sink: " + address + " (" + path + ")");
        }
        if (path == null) {
            return BluetoothError.ERROR;
        }

        SinkState sink = mAudioDevices.get(path);
        int state = BluetoothA2dp.STATE_DISCONNECTED;
        if (sink != null) {
            state = sink.state;
        }
        switch (state) {
        case BluetoothA2dp.STATE_CONNECTED:
        case BluetoothA2dp.STATE_PLAYING:
        case BluetoothA2dp.STATE_DISCONNECTING:
            return BluetoothError.ERROR;
        case BluetoothA2dp.STATE_CONNECTING:
            return BluetoothError.SUCCESS;
        }

        // State is DISCONNECTED
        if (!connectSinkNative(path)) {
            return BluetoothError.ERROR;
        }
        updateState(path, BluetoothA2dp.STATE_CONNECTING);
        return BluetoothError.SUCCESS;
    }

    public synchronized int disconnectSink(String address) {
        mContext.enforceCallingOrSelfPermission(BLUETOOTH_ADMIN_PERM,
                                                "Need BLUETOOTH_ADMIN permission");
        if (DBG) log("disconnectSink(" + address + ")");
        if (!BluetoothDevice.checkBluetoothAddress(address)) {
            return BluetoothError.ERROR;
        }
        if (mAudioDevices == null) {
            return BluetoothError.ERROR;
        }
        String path = lookupPath(address);
        if (path == null) {
            return BluetoothError.ERROR;
        }
        switch (mAudioDevices.get(path).state) {
        case BluetoothA2dp.STATE_DISCONNECTED:
            return BluetoothError.ERROR;
        case BluetoothA2dp.STATE_DISCONNECTING:
            return BluetoothError.SUCCESS;
        }

        // State is CONNECTING or CONNECTED or PLAYING
        if (!disconnectSinkNative(path)) {
            return BluetoothError.ERROR;
        } else {
            updateState(path, BluetoothA2dp.STATE_DISCONNECTING);
            return BluetoothError.SUCCESS;
        }
    }

    public synchronized List<String> listConnectedSinks() {
        mContext.enforceCallingOrSelfPermission(BLUETOOTH_PERM, "Need BLUETOOTH permission");
        return lookupSinksMatchingStates(new int[] {BluetoothA2dp.STATE_CONNECTED,
                                                    BluetoothA2dp.STATE_PLAYING});
    }

    public synchronized int getSinkState(String address) {
        mContext.enforceCallingOrSelfPermission(BLUETOOTH_PERM, "Need BLUETOOTH permission");
        if (!BluetoothDevice.checkBluetoothAddress(address)) {
            return BluetoothError.ERROR;
        }
        if (mAudioDevices == null) {
            return BluetoothA2dp.STATE_DISCONNECTED;
        }
        for (SinkState sink : mAudioDevices.values()) {
            if (address.equals(sink.address)) {
                return sink.state;
            }
        }
        return BluetoothA2dp.STATE_DISCONNECTED;
    }

    public synchronized int getSinkPriority(String address) {
        mContext.enforceCallingOrSelfPermission(BLUETOOTH_PERM, "Need BLUETOOTH permission");
        if (!BluetoothDevice.checkBluetoothAddress(address)) {
            return BluetoothError.ERROR;
        }
        return Settings.Secure.getInt(mContext.getContentResolver(),
                Settings.Secure.getBluetoothA2dpSinkPriorityKey(address),
                BluetoothA2dp.PRIORITY_OFF);
    }

    public synchronized int setSinkPriority(String address, int priority) {
        mContext.enforceCallingOrSelfPermission(BLUETOOTH_ADMIN_PERM,
                                                "Need BLUETOOTH_ADMIN permission");
        if (!BluetoothDevice.checkBluetoothAddress(address)) {
            return BluetoothError.ERROR;
        }
        return Settings.Secure.putInt(mContext.getContentResolver(),
                Settings.Secure.getBluetoothA2dpSinkPriorityKey(address), priority) ?
                BluetoothError.SUCCESS : BluetoothError.ERROR;
    }

    private synchronized void onHeadsetCreated(String path) {
        updateState(path, BluetoothA2dp.STATE_DISCONNECTED);
    }

    private synchronized void onHeadsetRemoved(String path) {
        if (mAudioDevices == null) return;
        mAudioDevices.remove(path);
    }

    private synchronized void onSinkConnected(String path) {
        // if we are reconnected, do not process previous disconnect event.
        mPendingDisconnects.remove(path);

        if (mAudioDevices == null) return;
        // bluez 3.36 quietly disconnects the previous sink when a new sink
        // is connected, so we need to mark all previously connected sinks as
        // disconnected

        // copy to allow modification during iteration
        String[] paths = new String[mAudioDevices.size()];
        paths = mAudioDevices.keySet().toArray(paths);
        for (String oldPath : paths) {
            if (path.equals(oldPath)) {
                continue;
            }
            int state = mAudioDevices.get(oldPath).state;
            if (state == BluetoothA2dp.STATE_CONNECTED || state == BluetoothA2dp.STATE_PLAYING) {
                updateState(path, BluetoothA2dp.STATE_DISCONNECTED);
            }
        }

        updateState(path, BluetoothA2dp.STATE_CONNECTING);
        mAudioManager.setParameter(A2DP_SINK_ADDRESS, lookupAddress(path));
        mAudioManager.setBluetoothA2dpOn(true);
        updateState(path, BluetoothA2dp.STATE_CONNECTED);
    }

    private synchronized void onSinkDisconnected(String path) {
        // This is to work around a problem in bluez that results 
        // sink disconnect events being sent, immediately followed by a reconnect.
        // To avoid unnecessary audio routing changes, we defer handling
        // sink disconnects until after a short delay.
        mPendingDisconnects.add(path);
        Message msg = Message.obtain(mHandler, MESSAGE_DISCONNECT, path);
        mHandler.sendMessageDelayed(msg, 2000);
    }

    private synchronized void handleDeferredDisconnect(String path) {
        if (mPendingDisconnects.contains(path)) {
            mPendingDisconnects.remove(path);
            if (mSinkCount == 1) {
                mAudioManager.setBluetoothA2dpOn(false);
            }
            updateState(path, BluetoothA2dp.STATE_DISCONNECTED);
        }
    }

    private synchronized void onSinkPlaying(String path) {
        updateState(path, BluetoothA2dp.STATE_PLAYING);
    }

    private synchronized void onSinkStopped(String path) {
        updateState(path, BluetoothA2dp.STATE_CONNECTED);
    }

    private synchronized final String lookupAddress(String path) {
        if (mAudioDevices == null) return null;
        SinkState sink = mAudioDevices.get(path);
        if (sink == null) {
            Log.w(TAG, "lookupAddress() called for unknown device " + path);
            updateState(path, BluetoothA2dp.STATE_DISCONNECTED);
        }
        String address = mAudioDevices.get(path).address;
        if (address == null) Log.e(TAG, "Can't find address for " + path);
        return address;
    }

    private synchronized final String lookupPath(String address) {
        if (mAudioDevices == null) return null;

        for (String path : mAudioDevices.keySet()) {
            if (address.equals(mAudioDevices.get(path).address)) {
                return path;
            }
        }
        return null;
    }

    private synchronized List<String> lookupSinksMatchingStates(int[] states) {
        List<String> sinks = new ArrayList<String>();
        if (mAudioDevices == null) {
            return sinks;
        }
        for (SinkState sink : mAudioDevices.values()) {
            for (int state : states) {
                if (sink.state == state) {
                    sinks.add(sink.address);
                    break;
                }
            }
        }
        return sinks;
    }

    private synchronized void updateState(String path, int state) {
        if (mAudioDevices == null) return;

        SinkState s = mAudioDevices.get(path);
        int prevState;
        String address;
        if (s == null) {
            address = getAddressNative(path);
            mAudioDevices.put(path, new SinkState(address, state));
            prevState = BluetoothA2dp.STATE_DISCONNECTED;
        } else {
            address = lookupAddress(path);
            prevState = s.state;
            s.state = state;
        }

        if (state != prevState) {
            if (DBG) log("state " + address + " (" + path + ") " + prevState + "->" + state);
            
            // keep track of the number of active sinks
            if (prevState == BluetoothA2dp.STATE_DISCONNECTED) {
                mSinkCount++;
            } else if (state == BluetoothA2dp.STATE_DISCONNECTED) {
                mSinkCount--;
            }

            Intent intent = new Intent(BluetoothA2dp.SINK_STATE_CHANGED_ACTION);
            intent.putExtra(BluetoothIntent.ADDRESS, address);
            intent.putExtra(BluetoothA2dp.SINK_PREVIOUS_STATE, prevState);
            intent.putExtra(BluetoothA2dp.SINK_STATE, state);
            mContext.sendBroadcast(intent, BLUETOOTH_PERM);

            if ((prevState == BluetoothA2dp.STATE_CONNECTED ||
                 prevState == BluetoothA2dp.STATE_PLAYING) &&
                    (state != BluetoothA2dp.STATE_CONNECTING &&
                     state != BluetoothA2dp.STATE_CONNECTED &&
                     state != BluetoothA2dp.STATE_PLAYING)) {
                // disconnected
                intent = new Intent(AudioManager.ACTION_AUDIO_BECOMING_NOISY);
                mContext.sendBroadcast(intent);
            }
        }
    }

    @Override
    protected synchronized void dump(FileDescriptor fd, PrintWriter pw, String[] args) {
        if (mAudioDevices == null) return;
        pw.println("Cached audio devices:");
        for (String path : mAudioDevices.keySet()) {
            SinkState sink = mAudioDevices.get(path);
            pw.println(path + " " + sink.address + " " + BluetoothA2dp.stateToString(sink.state));
        }
    }

    private static void log(String msg) {
        Log.d(TAG, msg);
    }

    private native boolean initNative();
    private native void cleanupNative();
    private synchronized native String[] listHeadsetsNative();
    private synchronized native String createHeadsetNative(String address);
    private synchronized native boolean removeHeadsetNative(String path);
    private synchronized native String getAddressNative(String path);
    private synchronized native boolean connectSinkNative(String path);
    private synchronized native boolean disconnectSinkNative(String path);
    private synchronized native boolean isSinkConnectedNative(String path);
}
