Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 01817fd5 authored by Łukasz Rymanowski's avatar Łukasz Rymanowski
Browse files

VolumeControlService: Extend with support for the volume offset

For now VCP uses only volume control offset for the balance control.

Bug: 150670922
Sponsor: @jpawlowski
Test: atest BluetoothInstrumentationTests
Change-Id: I00aefb70247830ded1154cdb8e9cc8811be44f80
parent 1cf59992
Loading
Loading
Loading
Loading
+146 −0
Original line number Diff line number Diff line
@@ -117,6 +117,84 @@ public class VolumeControlNativeInterface {
        setVolumeGroupNative(groupId, volume);
    }

    /**
     * Gets external audio output volume offset from a remote device.
     *
     * @param device the remote device
     * @param externalOutputId external audio output id
     * @return true on success, otherwise false.
     */
    @VisibleForTesting(visibility = VisibleForTesting.Visibility.PACKAGE)
    public boolean getExtAudioOutVolumeOffset(BluetoothDevice device, int externalOutputId) {
        return getExtAudioOutVolumeOffsetNative(getByteAddress(device), externalOutputId);
    }

    /**
     * Sets external audio output volume offset to a remote device.
     *
     * @param device the remote device
     * @param externalOutputId external audio output id
     * @param offset requested offset
     * @return true on success, otherwise false.
     */
    @VisibleForTesting(visibility = VisibleForTesting.Visibility.PACKAGE)
    public boolean setExtAudioOutVolumeOffset(BluetoothDevice device, int externalOutputId,
                                                    int offset) {
        return setExtAudioOutVolumeOffsetNative(getByteAddress(device), externalOutputId, offset);
    }

    /**
     * Gets external audio output location from a remote device.
     *
     * @param device the remote device
     * @param externalOutputId external audio output id
     * @return true on success, otherwise false.
     */
    @VisibleForTesting(visibility = VisibleForTesting.Visibility.PACKAGE)
    public boolean getExtAudioOutLocation(BluetoothDevice device, int externalOutputId) {
        return getExtAudioOutLocationNative(getByteAddress(device), externalOutputId);
    }

    /**
     * Sets external audio volume offset to a remote device.
     *
     * @param device the remote device
     * @param externalOutputId external audio output id
     * @param location requested location
     * @return true on success, otherwise false.
     */
    @VisibleForTesting(visibility = VisibleForTesting.Visibility.PACKAGE)
    public boolean setExtAudioOutLocation(BluetoothDevice device, int externalOutputId,
                                            int location) {
        return setExtAudioOutLocationNative(getByteAddress(device), externalOutputId, location);
    }

    /**
     * Gets external audio output description from a remote device.
     *
     * @param device the remote device
     * @param externalOutputId external audio output id
     * @return true on success, otherwise false.
     */
    @VisibleForTesting(visibility = VisibleForTesting.Visibility.PACKAGE)
    public boolean getExtAudioOutDescription(BluetoothDevice device, int externalOutputId) {
        return getExtAudioOutDescriptionNative(getByteAddress(device), externalOutputId);
    }

    /**
     * Sets external audio volume description to a remote device.
     *
     * @param device the remote device
     * @param externalOutputId external audio output id
     * @param descr requested description
     * @return true on success, otherwise false.
     */
    @VisibleForTesting(visibility = VisibleForTesting.Visibility.PACKAGE)
    public boolean setExtAudioOutDescription(BluetoothDevice device, int externalOutputId,
                                                    String descr) {
        return setExtAudioOutDescriptionNative(getByteAddress(device), externalOutputId, descr);
    }

    private BluetoothDevice getDevice(byte[] address) {
        return mAdapter.getRemoteDevice(address);
    }
@@ -188,6 +266,65 @@ public class VolumeControlNativeInterface {
        sendMessageToService(event);
    }

    private void onDeviceAvailable(int numOfExternalOutputs,
                                   byte[] address) {
        VolumeControlStackEvent event =
                new VolumeControlStackEvent(
                        VolumeControlStackEvent.EVENT_TYPE_DEVICE_AVAILABLE);
        event.device = getDevice(address);
        event.valueInt1 = numOfExternalOutputs;

        if (DBG) {
            Log.d(TAG, "onDeviceAvailable: " + event);
        }
        sendMessageToService(event);
    }

    private void onExtAudioOutVolumeOffsetChanged(int externalOutputId, int offset,
                                               byte[] address) {
        VolumeControlStackEvent event =
                new VolumeControlStackEvent(
                    VolumeControlStackEvent.EVENT_TYPE_EXT_AUDIO_OUT_VOL_OFFSET_CHANGED);
        event.device = getDevice(address);
        event.valueInt1 = externalOutputId;
        event.valueInt2 = offset;

        if (DBG) {
            Log.d(TAG, "onExtAudioOutVolumeOffsetChanged: " + event);
        }
        sendMessageToService(event);
    }

    private void onExtAudioOutLocationChanged(int externalOutputId, int location,
                                               byte[] address) {
        VolumeControlStackEvent event =
                new VolumeControlStackEvent(
                    VolumeControlStackEvent.EVENT_TYPE_EXT_AUDIO_OUT_LOCATION_CHANGED);
        event.device = getDevice(address);
        event.valueInt1 = externalOutputId;
        event.valueInt2 = location;

        if (DBG) {
            Log.d(TAG, "onExtAudioOutLocationChanged: " + event);
        }
        sendMessageToService(event);
    }

    private void onExtAudioOutDescriptionChanged(int externalOutputId, String descr,
                                               byte[] address) {
        VolumeControlStackEvent event =
                new VolumeControlStackEvent(
                    VolumeControlStackEvent.EVENT_TYPE_EXT_AUDIO_OUT_DESCRIPTION_CHANGED);
        event.device = getDevice(address);
        event.valueInt1 = externalOutputId;
        event.valueString1 = descr;

        if (DBG) {
            Log.d(TAG, "onExtAudioOutLocationChanged: " + event);
        }
        sendMessageToService(event);
    }

    // Native methods that call into the JNI interface
    private static native void classInitNative();
    private native void initNative();
@@ -196,4 +333,13 @@ public class VolumeControlNativeInterface {
    private native boolean disconnectVolumeControlNative(byte[] address);
    private native void setVolumeNative(byte[] address, int volume);
    private native void setVolumeGroupNative(int groupId, int volume);
    private native boolean getExtAudioOutVolumeOffsetNative(byte[] address, int externalOutputId);
    private native boolean setExtAudioOutVolumeOffsetNative(byte[] address, int externalOutputId,
                                                                int offset);
    private native boolean getExtAudioOutLocationNative(byte[] address, int externalOutputId);
    private native boolean setExtAudioOutLocationNative(byte[] address, int externalOutputId,
                                                            int location);
    private native boolean getExtAudioOutDescriptionNative(byte[] address, int externalOutputId);
    private native boolean setExtAudioOutDescriptionNative(byte[] address, int externalOutputId,
                                                                String descr);
}
+301 −7
Original line number Diff line number Diff line
@@ -27,6 +27,7 @@ import android.bluetooth.BluetoothProfile;
import android.bluetooth.BluetoothUuid;
import android.bluetooth.BluetoothVolumeControl;
import android.bluetooth.IBluetoothVolumeControl;
import android.bluetooth.IBluetoothVolumeControlCallback;
import android.content.AttributionSource;
import android.content.BroadcastReceiver;
import android.content.Context;
@@ -37,6 +38,8 @@ import android.media.AudioDeviceInfo;
import android.media.AudioManager;
import android.os.HandlerThread;
import android.os.ParcelUuid;
import android.os.RemoteCallbackList;
import android.os.RemoteException;
import android.util.Log;

import com.android.bluetooth.Utils;
@@ -70,6 +73,98 @@ public class VolumeControlService extends ProfileService {
    private HandlerThread mStateMachinesThread;
    private BluetoothDevice mPreviousAudioDevice;

    @VisibleForTesting
    RemoteCallbackList<IBluetoothVolumeControlCallback> mCallbacks;

    private class VolumeControlOffsetDescriptor {
        private class Descriptor {
            Descriptor() {
                mValue = 0;
                mLocation = 0;
                mDescription = null;
            }
            int mValue;
            int mLocation;
            String mDescription;
        };

        VolumeControlOffsetDescriptor() {
            mVolumeOffsets = new HashMap<>();
        }
        int size() {
            return mVolumeOffsets.size();
        }
        void add(int id) {
            Descriptor d = mVolumeOffsets.get(id);
            if (d == null) {
                mVolumeOffsets.put(id, new Descriptor());
            }
        }
        boolean setValue(int id, int value) {
            Descriptor d = mVolumeOffsets.get(id);
            if (d == null) {
                return false;
            }
            d.mValue = value;
            return true;
        }
        int getValue(int id) {
            Descriptor d = mVolumeOffsets.get(id);
            if (d == null) {
                return 0;
            }
            return d.mValue;
        }
        boolean setDescription(int id, String desc) {
            Descriptor d = mVolumeOffsets.get(id);
            if (d == null) {
                return false;
            }
            d.mDescription = desc;
            return true;
        }
        String getDescription(int id) {
            Descriptor d = mVolumeOffsets.get(id);
            if (d == null) {
                return null;
            }
            return d.mDescription;
        }
        boolean setLocation(int id, int location) {
            Descriptor d = mVolumeOffsets.get(id);
            if (d == null) {
                return false;
            }
            d.mLocation = location;
            return true;
        }
        int getLocation(int id) {
            Descriptor d = mVolumeOffsets.get(id);
            if (d == null) {
                return 0;
            }
            return d.mLocation;
        }
        void remove(int id) {
            mVolumeOffsets.remove(id);
        }
        void clear() {
            mVolumeOffsets.clear();
        }
        void dump(StringBuilder sb) {
            for (Map.Entry<Integer, Descriptor> entry : mVolumeOffsets.entrySet()) {
                Descriptor descriptor = entry.getValue();
                Integer id = entry.getKey();
                ProfileService.println(sb, "        Id: " + id);
                ProfileService.println(sb, "        value: " + descriptor.mValue);
                ProfileService.println(sb, "        location: " + descriptor.mLocation);
                ProfileService.println(sb, "        description: " + descriptor.mDescription);
            }
        }

        Map<Integer, Descriptor> mVolumeOffsets;
    }

    private int mMusicMaxVolume = 0;
    private int mMusicMinVolume = 0;
    private int mVoiceCallMaxVolume = 0;
@@ -81,6 +176,8 @@ public class VolumeControlService extends ProfileService {
    AudioManager mAudioManager;

    private final Map<BluetoothDevice, VolumeControlStateMachine> mStateMachines = new HashMap<>();
    private final Map<BluetoothDevice, VolumeControlOffsetDescriptor> mAudioOffsets =
                                                                            new HashMap<>();

    private BroadcastReceiver mBondStateChangedReceiver;
    private BroadcastReceiver mConnectionStateChangedReceiver;
@@ -139,6 +236,9 @@ public class VolumeControlService extends ProfileService {
        mConnectionStateChangedReceiver = new ConnectionStateChangedReceiver();
        registerReceiver(mConnectionStateChangedReceiver, filter);

        mAudioOffsets.clear();
        mCallbacks = new RemoteCallbackList<IBluetoothVolumeControlCallback>();

        // Mark service as started
        setVolumeControlService(this);

@@ -158,10 +258,6 @@ public class VolumeControlService extends ProfileService {
            return true;
        }

        // Cleanup native interface
        mVolumeControlNativeInterface.cleanup();
        mVolumeControlNativeInterface = null;

        // Mark service as stopped
        setVolumeControlService(null);

@@ -180,7 +276,6 @@ public class VolumeControlService extends ProfileService {
            mStateMachines.clear();
        }


        if (mStateMachinesThread != null) {
            try {
                mStateMachinesThread.quitSafely();
@@ -191,11 +286,21 @@ public class VolumeControlService extends ProfileService {
            }
        }

        // Cleanup native interface
        mVolumeControlNativeInterface.cleanup();
        mVolumeControlNativeInterface = null;

        mAudioOffsets.clear();

        // Clear AdapterService, VolumeControlNativeInterface
        mAudioManager = null;
        mVolumeControlNativeInterface = null;
        mAdapterService = null;

        if (mCallbacks != null) {
            mCallbacks.kill();
        }

        return true;
    }

@@ -433,8 +538,31 @@ public class VolumeControlService extends ProfileService {
                .getProfileConnectionPolicy(device, BluetoothProfile.VOLUME_CONTROL);
    }

    boolean isVolumeOffsetAvailable(BluetoothDevice device) {
        VolumeControlOffsetDescriptor offsets = mAudioOffsets.get(device);
        if (offsets == null) {
            Log.i(TAG, " There is no offset service for device: " + device);
            return false;
        }
        Log.i(TAG, " Offset service available for device: " + device);
        return true;
    }

    void setVolumeOffset(BluetoothDevice device, int volumeOffset) {
        // TODO Implement
        VolumeControlOffsetDescriptor offsets = mAudioOffsets.get(device);
        if (offsets == null) {
            Log.e(TAG, " There is no offset service for device: " + device);
            return;
        }

        /* Use first offset always */
        int value = offsets.getValue(1);
        if (value == volumeOffset) {
            /* Nothing to do - offset already applied */
            return;
        }

        mVolumeControlNativeInterface.setExtAudioOutVolumeOffset(device, 1, volumeOffset);
    }

    /**
@@ -484,6 +612,85 @@ public class VolumeControlService extends ProfileService {
        return AudioManager.STREAM_MUSIC;
    }

    void handleDeviceAvailable(BluetoothDevice device, int numberOfExternalOutputs) {
        if (numberOfExternalOutputs == 0) {
            Log.i(TAG, "Volume offset not available");
            return;
        }

        VolumeControlOffsetDescriptor offsets = mAudioOffsets.get(device);
        if (offsets == null) {
            offsets = new VolumeControlOffsetDescriptor();
            mAudioOffsets.put(device, offsets);
        } else if (offsets.size() != numberOfExternalOutputs) {
            Log.i(TAG, "Number of offset changed: ");
            offsets.clear();
        }

        /* Stack delivers us number of audio outputs.
         * Offset ids a countinous from 1 to number_of_ext_outputs*/
        for (int i = 1; i <= numberOfExternalOutputs; i++) {
            offsets.add(i);
            mVolumeControlNativeInterface.getExtAudioOutVolumeOffset(device, i);
            mVolumeControlNativeInterface.getExtAudioOutDescription(device, i);
        }
    }

    void handleDeviceExtAudioOffsetChanged(BluetoothDevice device, int id, int value) {
        if (DBG) {
            Log.d(TAG, " device: " + device + " offset_id: " +  id + " value: " + value);
        }
        VolumeControlOffsetDescriptor offsets = mAudioOffsets.get(device);
        if (offsets == null) {
            Log.e(TAG, " Offsets not found for device: " + device);
            return;
        }
        offsets.setValue(id, value);

        if (mCallbacks == null) {
            return;
        }

        int n = mCallbacks.beginBroadcast();
        for (int i = 0; i < n; i++) {
            try {
                mCallbacks.getBroadcastItem(i).onVolumeOffsetChanged(device, value);
            } catch (RemoteException e) {
                continue;
            }
        }
        mCallbacks.finishBroadcast();
    }

    void handleDeviceExtAudioLocationChanged(BluetoothDevice device, int id, int location) {
        if (DBG) {
            Log.d(TAG, " device: " + device + " offset_id: "
                    + id + " location: " + location);
        }

        VolumeControlOffsetDescriptor offsets = mAudioOffsets.get(device);
        if (offsets == null) {
            Log.e(TAG, " Offsets not found for device: " + device);
            return;
        }
        offsets.setLocation(id, location);
    }

    void handleDeviceExtAudioDescriptionChanged(BluetoothDevice device, int id,
                                                String description) {
        if (DBG) {
            Log.d(TAG, " device: " + device + " offset_id: "
                    + id + " description: " + description);
        }

        VolumeControlOffsetDescriptor offsets = mAudioOffsets.get(device);
        if (offsets == null) {
            Log.e(TAG, " Offsets not found for device: " + device);
            return;
        }
        offsets.setDescription(id, description);
    }

    void messageFromNative(VolumeControlStackEvent stackEvent) {

        if (stackEvent.type == VolumeControlStackEvent.EVENT_TYPE_VOLUME_STATE_CHANGED) {
@@ -505,8 +712,33 @@ public class VolumeControlService extends ProfileService {
            return;
        }

        synchronized (mStateMachines) {
        BluetoothDevice device = stackEvent.device;
        if (stackEvent.type == VolumeControlStackEvent.EVENT_TYPE_DEVICE_AVAILABLE) {
            handleDeviceAvailable(device, stackEvent.valueInt1);
            return;
        }

        if (stackEvent.type
                == VolumeControlStackEvent.EVENT_TYPE_EXT_AUDIO_OUT_VOL_OFFSET_CHANGED) {
            handleDeviceExtAudioOffsetChanged(device, stackEvent.valueInt1, stackEvent.valueInt2);
            return;
        }

        if (stackEvent.type
                == VolumeControlStackEvent.EVENT_TYPE_EXT_AUDIO_OUT_LOCATION_CHANGED) {
            handleDeviceExtAudioLocationChanged(device, stackEvent.valueInt1,
                                                    stackEvent.valueInt2);
            return;
        }

        if (stackEvent.type
                == VolumeControlStackEvent.EVENT_TYPE_EXT_AUDIO_OUT_DESCRIPTION_CHANGED) {
            handleDeviceExtAudioDescriptionChanged(device, stackEvent.valueInt1,
                                                    stackEvent.valueString1);
            return;
        }

        synchronized (mStateMachines) {
            VolumeControlStateMachine sm = mStateMachines.get(device);
            if (sm == null) {
                if (stackEvent.type
@@ -791,6 +1023,21 @@ public class VolumeControlService extends ProfileService {
            }
        }

        @Override
        public void isVolumeOffsetAvailable(BluetoothDevice device,
                AttributionSource source, SynchronousResultReceiver receiver) {
            try {
                boolean defaultValue = false;
                VolumeControlService service = getService(source);
                if (service != null) {
                    defaultValue = service.isVolumeOffsetAvailable(device);
                }
                receiver.send(defaultValue);
            } catch (RuntimeException e) {
                receiver.propagateException(e);
            }
        }

        @Override
        public void setVolumeOffset(BluetoothDevice device, int volumeOffset,
                AttributionSource source, SynchronousResultReceiver receiver) {
@@ -818,6 +1065,44 @@ public class VolumeControlService extends ProfileService {
                receiver.propagateException(e);
            }
        }

        @Override
        public void registerCallback(IBluetoothVolumeControlCallback callback,
                AttributionSource source, SynchronousResultReceiver receiver) {
            VolumeControlService service = getService(source);
            if (service == null) {
                throw new IllegalStateException("Service is unavailable");
            }

            enforceBluetoothPrivilegedPermission(service);

            try {
                service.mCallbacks.register(callback);
                receiver.send(null);
            } catch (RuntimeException e) {
                Log.e(TAG, e.toString() + "\n" + Log.getStackTraceString(new Throwable()));
                throw new IllegalArgumentException(" Invalid callback");
            }
        }

        @Override
        public void unregisterCallback(IBluetoothVolumeControlCallback callback,
                AttributionSource source, SynchronousResultReceiver receiver) {
            VolumeControlService service = getService(source);
            if (service == null) {
                throw new IllegalStateException("Service is unavailable");
            }

            enforceBluetoothPrivilegedPermission(service);

            try {
                service.mCallbacks.unregister(callback);
                receiver.send(null);
            } catch (RuntimeException e) {
                Log.e(TAG, e.toString() + "\n" + Log.getStackTraceString(new Throwable()));
                throw new IllegalArgumentException(" Invalid callback ");
            }
        }
    }

    @Override
@@ -826,5 +1111,14 @@ public class VolumeControlService extends ProfileService {
        for (VolumeControlStateMachine sm : mStateMachines.values()) {
            sm.dump(sb);
        }

        for (Map.Entry<BluetoothDevice, VolumeControlOffsetDescriptor> entry :
                                                            mAudioOffsets.entrySet()) {
            VolumeControlOffsetDescriptor descriptor = entry.getValue();
            BluetoothDevice device = entry.getKey();
            ProfileService.println(sb, "    Device: " + device);
            ProfileService.println(sb, "    Volume offset cnt: " + descriptor.size());
            descriptor.dump(sb);
        }
    }
}
+31 −0
Original line number Diff line number Diff line
@@ -24,6 +24,10 @@ public class VolumeControlStackEvent {
    private static final int EVENT_TYPE_NONE = 0;
    public static final int EVENT_TYPE_CONNECTION_STATE_CHANGED = 1;
    public static final int EVENT_TYPE_VOLUME_STATE_CHANGED = 2;
    public static final int EVENT_TYPE_DEVICE_AVAILABLE = 3;
    public static final int EVENT_TYPE_EXT_AUDIO_OUT_VOL_OFFSET_CHANGED = 4;
    public static final int EVENT_TYPE_EXT_AUDIO_OUT_LOCATION_CHANGED = 5;
    public static final int EVENT_TYPE_EXT_AUDIO_OUT_DESCRIPTION_CHANGED = 6;

    // Do not modify without updating the HAL bt_vc_aid.h files.
    // Match up with enum class ConnectionState of bt_vc_aid.h.
@@ -38,6 +42,8 @@ public class VolumeControlStackEvent {
    public int valueInt2;
    public boolean valueBool1;
    public boolean valueBool2;
    public String valueString1;

    /* Might need more for other callbacks*/

    VolumeControlStackEvent(int type) {
@@ -54,6 +60,7 @@ public class VolumeControlStackEvent {
        result.append(", valueInt2:" + eventTypeValue2ToString(type, valueInt2));
        result.append(", valueBool1:" + eventTypeValueBool1ToString(type, valueBool1));
        result.append(", valueBool2:" + eventTypeValueBool2ToString(type, valueBool2));
        result.append(", valueString1:" + eventTypeString1ToString(type, valueString1));
        result.append("}");
        return result.toString();
    }
@@ -66,6 +73,14 @@ public class VolumeControlStackEvent {
                return "EVENT_TYPE_CONNECTION_STATE_CHANGED";
            case EVENT_TYPE_VOLUME_STATE_CHANGED:
                return "EVENT_TYPE_VOLUME_STATE_CHANGED";
            case EVENT_TYPE_DEVICE_AVAILABLE:
                return "EVENT_TYPE_DEVICE_AVAILABLE";
            case EVENT_TYPE_EXT_AUDIO_OUT_VOL_OFFSET_CHANGED:
                return "EVENT_TYPE_EXT_AUDIO_OUT_VOL_OFFSET_CHANGED";
            case EVENT_TYPE_EXT_AUDIO_OUT_LOCATION_CHANGED:
                return "EVENT_TYPE_EXT_AUDIO_OUT_LOCATION_CHANGED";
            case EVENT_TYPE_EXT_AUDIO_OUT_DESCRIPTION_CHANGED:
                return "EVENT_TYPE_EXT_AUDIO_OUT_DESCRIPTION_CHANGED";
            default:
                return "EVENT_TYPE_UNKNOWN:" + type;
        }
@@ -88,6 +103,12 @@ public class VolumeControlStackEvent {
                }
            case EVENT_TYPE_VOLUME_STATE_CHANGED:
                return "{group_id:" + value + "}";
            case EVENT_TYPE_DEVICE_AVAILABLE:
                return "{num_ext_outputs:"  + value + "}";
            case EVENT_TYPE_EXT_AUDIO_OUT_VOL_OFFSET_CHANGED:
            case EVENT_TYPE_EXT_AUDIO_OUT_LOCATION_CHANGED:
            case EVENT_TYPE_EXT_AUDIO_OUT_DESCRIPTION_CHANGED:
                return "{ext output id:" + value + "}";
            default:
                break;
        }
@@ -136,4 +157,14 @@ public class VolumeControlStackEvent {
        }
        return Boolean.toString(value);
    }

    private static String eventTypeString1ToString(int type, String value) {
        switch (type) {
            case EVENT_TYPE_EXT_AUDIO_OUT_DESCRIPTION_CHANGED:
                return "{descrition:" + value + "}";
            default:
                break;
        }
        return value;
    }
}
+1 −0
Original line number Diff line number Diff line
@@ -27,6 +27,7 @@ filegroup {
        "android/bluetooth/IBluetoothHapClient.aidl",
        "android/bluetooth/IBluetoothHapClientCallback.aidl",
        "android/bluetooth/IBluetoothVolumeControl.aidl",
        "android/bluetooth/IBluetoothVolumeControlCallback.aidl",
        "android/bluetooth/IBluetoothHidHost.aidl",
        "android/bluetooth/IBluetoothLeAudio.aidl",
        "android/bluetooth/IBluetoothLeBroadcastCallback.aidl",
+8 −0

File changed.

Preview size limit exceeded, changes collapsed.

Loading