Implement volume control action in cec service.

Hdmi CEC's volume control is based on key event handling
but in android we can only get delta of volume change.
VolumeControlAction simulates key event action from
delta of volume change. However, it's highly dependent
on <Report Audio Status> message coming from AVR.
This implementation waits 900ms for <Report Audio Status>
message and if no message arrives it finishes action.
Instead, HdmiCecLocalDeviceTv consumes it after action
termination so that Tv can reflect system audio's
volume all the time.

Change-Id: I0442d31721365acdc009c8fa1c1e0a4361e4a1cc
diff --git a/services/core/java/com/android/server/hdmi/DeviceSelectAction.java b/services/core/java/com/android/server/hdmi/DeviceSelectAction.java
index fd3341a..b97350d 100644
--- a/services/core/java/com/android/server/hdmi/DeviceSelectAction.java
+++ b/services/core/java/com/android/server/hdmi/DeviceSelectAction.java
@@ -164,8 +164,10 @@
     }
 
     private void turnOnDevice() {
-        sendRemoteKeyCommand(HdmiConstants.UI_COMMAND_POWER);
-        sendRemoteKeyCommand(HdmiConstants.UI_COMMAND_POWER_ON_FUNCTION);
+        sendUserControlPressedAndReleased(mTarget.getLogicalAddress(),
+                HdmiConstants.UI_COMMAND_POWER);
+        sendUserControlPressedAndReleased(mTarget.getLogicalAddress(),
+                HdmiConstants.UI_COMMAND_POWER_ON_FUNCTION);
         mState = STATE_WAIT_FOR_DEVICE_POWER_ON;
         addTimer(mState, TIMEOUT_POWER_ON_MS);
     }
@@ -177,13 +179,6 @@
         addTimer(mState, TIMEOUT_ACTIVE_SOURCE_MS);
     }
 
-    private void sendRemoteKeyCommand(int keyCode) {
-        sendCommand(HdmiCecMessageBuilder.buildUserControlPressed(getSourceAddress(),
-                mTarget.getLogicalAddress(), keyCode));
-        sendCommand(HdmiCecMessageBuilder.buildUserControlReleased(getSourceAddress(),
-                mTarget.getLogicalAddress()));
-    }
-
     @Override
     public void handleTimerEvent(int timeoutState) {
         if (mState != timeoutState) {
diff --git a/services/core/java/com/android/server/hdmi/FeatureAction.java b/services/core/java/com/android/server/hdmi/FeatureAction.java
index 0ec17f6..cf28f05 100644
--- a/services/core/java/com/android/server/hdmi/FeatureAction.java
+++ b/services/core/java/com/android/server/hdmi/FeatureAction.java
@@ -248,4 +248,11 @@
     protected final int getSourcePath() {
         return mSource.getDeviceInfo().getPhysicalAddress();
     }
+
+    protected void sendUserControlPressedAndReleased(int targetAddress, int uiCommand) {
+        sendCommand(HdmiCecMessageBuilder.buildUserControlPressed(
+                getSourceAddress(), targetAddress, uiCommand));
+        sendCommand(HdmiCecMessageBuilder.buildUserControlReleased(
+                getSourceAddress(), targetAddress));
+    }
 }
diff --git a/services/core/java/com/android/server/hdmi/HdmiCecLocalDevice.java b/services/core/java/com/android/server/hdmi/HdmiCecLocalDevice.java
index f72d3f0..bf7e57b 100644
--- a/services/core/java/com/android/server/hdmi/HdmiCecLocalDevice.java
+++ b/services/core/java/com/android/server/hdmi/HdmiCecLocalDevice.java
@@ -151,6 +151,8 @@
                 return handleSetSystemAudioMode(message);
             case HdmiCec.MESSAGE_SYSTEM_AUDIO_MODE_STATUS:
                 return handleSystemAudioModeStatus(message);
+            case HdmiCec.MESSAGE_REPORT_AUDIO_STATUS:
+                return handleReportAudioStatus(message);
             default:
                 return false;
         }
@@ -263,6 +265,10 @@
         return false;
     }
 
+    protected boolean handleReportAudioStatus(HdmiCecMessage message) {
+        return false;
+    }
+
     @ServiceThreadOnly
     final void handleAddressAllocated(int logicalAddress) {
         assertRunOnServiceThread();
diff --git a/services/core/java/com/android/server/hdmi/HdmiCecLocalDeviceTv.java b/services/core/java/com/android/server/hdmi/HdmiCecLocalDeviceTv.java
index 2431ec4..718072a 100644
--- a/services/core/java/com/android/server/hdmi/HdmiCecLocalDeviceTv.java
+++ b/services/core/java/com/android/server/hdmi/HdmiCecLocalDeviceTv.java
@@ -56,6 +56,12 @@
     @GuardedBy("mLock")
     private int mPrevPortId;
 
+    @GuardedBy("mLock")
+    private int mSystemAudioVolume = HdmiConstants.UNKNOWN_VOLUME;
+
+    @GuardedBy("mLock")
+    private boolean mSystemAudioMute = false;
+
     // Copy of mDeviceInfos to guarantee thread-safety.
     @GuardedBy("mLock")
     private List<HdmiCecDeviceInfo> mSafeAllDeviceInfos = Collections.emptyList();
@@ -353,6 +359,22 @@
         return true;
     }
 
+    @Override
+    @ServiceThreadOnly
+    protected boolean handleReportAudioStatus(HdmiCecMessage message) {
+        assertRunOnServiceThread();
+
+        byte params[] = message.getParams();
+        if (params.length < 1) {
+            Slog.w(TAG, "Invalide <Report Audio Status> message:" + message);
+            return true;
+        }
+        int mute = params[0] & 0x80;
+        int volume = params[0] & 0x7F;
+        setAudioStatus(mute == 0x80, volume);
+        return true;
+    }
+
     @ServiceThreadOnly
     private void launchDeviceDiscovery() {
         assertRunOnServiceThread();
@@ -458,9 +480,64 @@
         }
     }
 
-    @ServiceThreadOnly
     void setAudioStatus(boolean mute, int volume) {
-        mService.setAudioStatus(mute, volume);
+        synchronized (mLock) {
+            mSystemAudioMute = mute;
+            mSystemAudioVolume = volume;
+            // TODO: pass volume to service (audio service) after scale it to local volume level.
+            mService.setAudioStatus(mute, volume);
+        }
+    }
+
+    @ServiceThreadOnly
+    void changeVolume(int curVolume, int delta, int maxVolume) {
+        assertRunOnServiceThread();
+        if (delta == 0 || !isSystemAudioOn()) {
+            return;
+        }
+
+        int targetVolume = curVolume + delta;
+        int cecVolume = VolumeControlAction.scaleToCecVolume(targetVolume, maxVolume);
+        synchronized (mLock) {
+            // If new volume is the same as current system audio volume, just ignore it.
+            // Note that UNKNOWN_VOLUME is not in range of cec volume scale.
+            if (cecVolume == mSystemAudioVolume) {
+                // Update tv volume with system volume value.
+                mService.setAudioStatus(false,
+                        VolumeControlAction.scaleToCustomVolume(mSystemAudioVolume, maxVolume));
+                return;
+            }
+        }
+
+        // Remove existing volume action.
+        removeAction(VolumeControlAction.class);
+
+        HdmiCecDeviceInfo avr = getAvrDeviceInfo();
+        addAndStartAction(VolumeControlAction.ofVolumeChange(this, avr.getLogicalAddress(),
+                cecVolume, delta > 0));
+    }
+
+    @ServiceThreadOnly
+    void changeMute(boolean mute) {
+        assertRunOnServiceThread();
+        if (!isSystemAudioOn()) {
+            return;
+        }
+
+        // Remove existing volume action.
+        removeAction(VolumeControlAction.class);
+        HdmiCecDeviceInfo avr = getAvrDeviceInfo();
+        addAndStartAction(VolumeControlAction.ofMute(this, avr.getLogicalAddress(), mute));
+    }
+
+    private boolean isSystemAudioOn() {
+        if (getAvrDeviceInfo() == null) {
+            return false;
+        }
+
+        synchronized (mLock) {
+            return mSystemAudioMode;
+        }
     }
 
     @Override
diff --git a/services/core/java/com/android/server/hdmi/HdmiConstants.java b/services/core/java/com/android/server/hdmi/HdmiConstants.java
index 5294506..ab5b8d8 100644
--- a/services/core/java/com/android/server/hdmi/HdmiConstants.java
+++ b/services/core/java/com/android/server/hdmi/HdmiConstants.java
@@ -97,5 +97,12 @@
 
     static final int UNKNOWN_VOLUME = -1;
 
+    // IRT(Initiator Repetition Time) in millisecond as recommended in the standard.
+    // Outgoing UCP commands, when in 'Press and Hold' mode, should be this much apart
+    // from the adjacent one so as not to place unnecessarily heavy load on the CEC line.
+    // TODO: This value might need tweaking per product basis. Consider putting it
+    //       in config.xml to allow customization.
+    static final int IRT_MS = 300;
+
     private HdmiConstants() { /* cannot be instantiated */ }
 }
diff --git a/services/core/java/com/android/server/hdmi/SendKeyAction.java b/services/core/java/com/android/server/hdmi/SendKeyAction.java
index c3078a2..5d81251 100644
--- a/services/core/java/com/android/server/hdmi/SendKeyAction.java
+++ b/services/core/java/com/android/server/hdmi/SendKeyAction.java
@@ -15,6 +15,8 @@
  */
 package com.android.server.hdmi;
 
+import static com.android.server.hdmi.HdmiConstants.IRT_MS;
+
 import android.hardware.hdmi.HdmiCecMessage;
 import android.util.Slog;
 import android.view.KeyEvent;
@@ -38,13 +40,6 @@
     // persists throughout the process till it is set back to {@code STATE_NONE} at the end.
     private static final int STATE_PROCESSING_KEYCODE = 1;
 
-    // IRT(Initiator Repetition Time) in millisecond as recommended in the standard.
-    // Outgoing UCP commands, when in 'Press and Hold' mode, should be this much apart
-    // from the adjacent one so as not to place unnecessarily heavy load on the CEC line.
-    // TODO: This value might need tweaking per product basis. Consider putting it
-    //       in config.xml to allow customization.
-    private static final int IRT_MS = 450;
-
     // Logical address of the device to which the UCP/UCP commands are sent.
     private final int mTargetAddress;
 
@@ -77,7 +72,6 @@
      *
      * @param keyCode key code of {@link KeyEvent} object
      * @param isPressed true if the key event is of {@link KeyEvent#ACTION_DOWN}
-     * @param param additional parameter that comes with the key event
      */
     void processKeyEvent(int keyCode, boolean isPressed) {
         if (mState != STATE_PROCESSING_KEYCODE) {
diff --git a/services/core/java/com/android/server/hdmi/SystemAudioStatusAction.java b/services/core/java/com/android/server/hdmi/SystemAudioStatusAction.java
index 89206a7..ecb158b 100644
--- a/services/core/java/com/android/server/hdmi/SystemAudioStatusAction.java
+++ b/services/core/java/com/android/server/hdmi/SystemAudioStatusAction.java
@@ -72,19 +72,12 @@
         int uiCommand = tv().getSystemAudioMode()
                 ? HdmiConstants.UI_COMMAND_RESTORE_VOLUME_FUNCTION  // SystemAudioMode: ON
                 : HdmiConstants.UI_COMMAND_MUTE_FUNCTION;           // SystemAudioMode: OFF
-        sendUserControlPressedAndReleased(uiCommand);
+        sendUserControlPressedAndReleased(mAvrAddress, uiCommand);
 
         // Still return SUCCESS to callback.
         finishWithCallback(HdmiCec.RESULT_SUCCESS);
     }
 
-    private void sendUserControlPressedAndReleased(int uiCommand) {
-        sendCommand(HdmiCecMessageBuilder.buildUserControlPressed(
-                getSourceAddress(), mAvrAddress, uiCommand));
-        sendCommand(HdmiCecMessageBuilder.buildUserControlReleased(
-                getSourceAddress(), mAvrAddress));
-    }
-
     @Override
     boolean processCommand(HdmiCecMessage cmd) {
         if (mState != STATE_WAIT_FOR_REPORT_AUDIO_STATUS) {
@@ -109,7 +102,7 @@
 
             if ((tv().getSystemAudioMode() && mute) || (!tv().getSystemAudioMode() && !mute)) {
                 // Toggle AVR's mute status to match with the system audio status.
-                sendUserControlPressedAndReleased(HdmiConstants.UI_COMMAND_MUTE);
+                sendUserControlPressedAndReleased(mAvrAddress, HdmiConstants.UI_COMMAND_MUTE);
             }
             finishWithCallback(HdmiCec.RESULT_SUCCESS);
         } else {
diff --git a/services/core/java/com/android/server/hdmi/VolumeControlAction.java b/services/core/java/com/android/server/hdmi/VolumeControlAction.java
new file mode 100644
index 0000000..07c72f7
--- /dev/null
+++ b/services/core/java/com/android/server/hdmi/VolumeControlAction.java
@@ -0,0 +1,222 @@
+/*
+ * 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 and
+ * limitations under the License.
+ */
+
+package com.android.server.hdmi;
+
+import static com.android.server.hdmi.HdmiConstants.IRT_MS;
+
+import android.hardware.hdmi.HdmiCec;
+import android.hardware.hdmi.HdmiCecMessage;
+import android.util.Slog;
+
+import com.android.internal.util.Preconditions;
+
+/**
+ * Feature action that transmits volume change to Audio Receiver.
+ * <p>
+ * This action is created when a user pressed volume up/down. However, Since Android only provides a
+ * listener for delta of some volume change, we will set a target volume, and check reported volume
+ * from Audio Receiver(AVR). If TV receives no &lt;Report Audio Status&gt; from AVR, this action
+ * will be finished in {@link #IRT_MS} * {@link #VOLUME_CHANGE_TIMEOUT_MAX_COUNT} (ms).
+ */
+final class VolumeControlAction extends FeatureAction {
+    private static final String TAG = "VolumeControlAction";
+
+    private static final int VOLUME_MUTE = 101;
+    private static final int VOLUME_RESTORE = 102;
+    private static final int MAX_VOLUME = 100;
+    private static final int MIN_VOLUME = 0;
+
+    // State where to wait for <Report Audio Status>
+    private static final int STATE_WAIT_FOR_REPORT_VOLUME_STATUS = 1;
+
+    // Maximum count of time out used to finish volume action.
+    private static final int VOLUME_CHANGE_TIMEOUT_MAX_COUNT = 2;
+
+    private final int mAvrAddress;
+    private final int mTargetVolume;
+    private final boolean mIsVolumeUp;
+    private int mTimeoutCount;
+
+    /**
+     * Create a {@link VolumeControlAction} for mute/restore change
+     *
+     * @param source source device sending volume change
+     * @param avrAddress address of audio receiver
+     * @param mute whether to mute sound or not. {@code true} for mute on; {@code false} for mute
+     *            off, i.e restore volume
+     * @return newly created {@link VolumeControlAction}
+     */
+    public static VolumeControlAction ofMute(HdmiCecLocalDevice source, int avrAddress,
+            boolean mute) {
+        return new VolumeControlAction(source, avrAddress, mute ? VOLUME_MUTE : VOLUME_RESTORE,
+                false);
+    }
+
+    /**
+     * Create a {@link VolumeControlAction} for volume up/down change
+     *
+     * @param source source device sending volume change
+     * @param avrAddress address of audio receiver
+     * @param targetVolume target volume to be set to AVR. It should be in range of [0-100]
+     * @param isVolumeUp whether to volume up or not. {@code true} for volume up; {@code false} for
+     *            volume down
+     * @return newly created {@link VolumeControlAction}
+     */
+    public static VolumeControlAction ofVolumeChange(HdmiCecLocalDevice source, int avrAddress,
+            int targetVolume, boolean isVolumeUp) {
+        Preconditions.checkArgumentInRange(targetVolume, MIN_VOLUME, MAX_VOLUME, "volume");
+        return new VolumeControlAction(source, avrAddress, targetVolume, isVolumeUp);
+    }
+
+    /**
+     * Scale a custom volume value to cec volume scale.
+     *
+     * @param volume volume value in custom scale
+     * @param scale scale of volume (max volume)
+     * @return a volume scaled to cec volume range
+     */
+    public static int scaleToCecVolume(int volume, int scale) {
+        return (volume * MAX_VOLUME) / scale;
+    }
+
+    /**
+     * Scale a cec volume which is in range of 0 to 100 to custom volume level.
+     *
+     * @param cecVolume volume value in cec volume scale. It should be in a range of [0-100]
+     * @param scale scale of custom volume (max volume)
+     * @return a volume value scaled to custom volume range
+     */
+    public static int scaleToCustomVolume(int cecVolume, int scale) {
+        return (cecVolume * scale) / MAX_VOLUME;
+    }
+
+    private VolumeControlAction(HdmiCecLocalDevice source, int avrAddress, int targetVolume,
+            boolean isVolumeUp) {
+        super(source);
+
+        mAvrAddress = avrAddress;
+        mTargetVolume = targetVolume;
+        mIsVolumeUp = isVolumeUp;
+    }
+
+    @Override
+    boolean start() {
+        if (isForMute()) {
+            sendMuteChange(mTargetVolume == VOLUME_MUTE);
+            finish();
+            return true;
+        }
+
+        startVolumeChange();
+        return true;
+    }
+
+
+    private boolean isForMute() {
+        return mTargetVolume == VOLUME_MUTE || mTargetVolume == VOLUME_RESTORE;
+    }
+
+
+    private void startVolumeChange() {
+        mTimeoutCount = 0;
+        sendVolumeChange(mIsVolumeUp);
+        mState = STATE_WAIT_FOR_REPORT_VOLUME_STATUS;
+        addTimer(mState, IRT_MS);
+    }
+
+    private void sendVolumeChange(boolean up) {
+        sendCommand(HdmiCecMessageBuilder.buildUserControlPressed(getSourceAddress(), mAvrAddress,
+                up ? HdmiCecKeycode.CEC_KEYCODE_VOLUME_UP
+                        : HdmiCecKeycode.CEC_KEYCODE_VOLUME_DOWN));
+    }
+
+    private void sendMuteChange(boolean mute) {
+        sendUserControlPressedAndReleased(mAvrAddress,
+                mute ? HdmiConstants.UI_COMMAND_MUTE_FUNCTION :
+                        HdmiConstants.UI_COMMAND_RESTORE_VOLUME_FUNCTION);
+    }
+
+    @Override
+    boolean processCommand(HdmiCecMessage cmd) {
+        if (mState != STATE_WAIT_FOR_REPORT_VOLUME_STATUS) {
+            return false;
+        }
+
+        switch (cmd.getOpcode()) {
+            case HdmiCec.MESSAGE_REPORT_AUDIO_STATUS:
+                handleReportAudioStatus(cmd);
+                return true;
+            case HdmiCec.MESSAGE_FEATURE_ABORT:
+                // TODO: handle feature abort.
+                finish();
+                return true;
+            default:
+                return false;
+        }
+    }
+
+    private void handleReportAudioStatus(HdmiCecMessage cmd) {
+        byte[] params = cmd.getParams();
+        if (params.length != 1) {
+            Slog.e(TAG, "Invalid <Report Audio Status> message:" + cmd);
+            return;
+        }
+
+        int volume = params[0] & 0x7F;
+        // Update volume with new value.
+        // Note that it will affect system volume change.
+        tv().setAudioStatus(false, volume);
+        if (mIsVolumeUp) {
+            if (mTargetVolume <= volume) {
+                finishWithVolumeChangeRelease();
+                return;
+            }
+        } else {
+            if (mTargetVolume >= volume) {
+                finishWithVolumeChangeRelease();
+                return;
+            }
+        }
+
+        // Clear action status and send another volume change command.
+        clear();
+        startVolumeChange();
+    }
+
+    private void finishWithVolumeChangeRelease() {
+        sendCommand(HdmiCecMessageBuilder.buildUserControlReleased(
+                getSourceAddress(), mAvrAddress));
+        finish();
+    }
+
+    @Override
+    void handleTimerEvent(int state) {
+        if (mState != STATE_WAIT_FOR_REPORT_VOLUME_STATUS) {
+            return;
+        }
+
+        // If no report volume action after IRT * VOLUME_CHANGE_TIMEOUT_MAX_COUNT just stop volume
+        // action.
+        if (++mTimeoutCount == VOLUME_CHANGE_TIMEOUT_MAX_COUNT) {
+            finishWithVolumeChangeRelease();
+            return;
+        }
+
+        sendVolumeChange(mIsVolumeUp);
+        addTimer(mState, IRT_MS);
+    }
+}