From bf3c0cf948d3fc0134d44ba5d8986e070d3d6439 Mon Sep 17 00:00:00 2001 From: Steve Kondik Date: Sat, 23 Apr 2016 00:57:07 -0700 Subject: [PATCH] cmsdk: Add CMAudioManager * This is a rework of the session callback API which previously lived in the framework due to JNI usage. This has been split out and cleaned up for CMSDK. * The JNI library lives on the server side, and the app-level callback has been changed to a protected broadcast. This allows us to wake up registered services when these events occur. * Additionally, we support listing all active audio sessions. * Also brings some JNI love/hate over to CMSDK. Change-Id: I31c293943474419e3db088bb7ffab75f7440ac0f --- cm/jni/Android.mk | 40 +++ cm/jni/src/onload.cpp | 45 +++ ...enmod_platform_internal_CMAudioService.cpp | 170 ++++++++++++ .../platform/internal/CMAudioService.java | 174 ++++++++++++ cm/res/AndroidManifest.xml | 9 + cm/res/res/values/config.xml | 1 + cm/res/res/values/strings.xml | 4 + .../cyanogenmod/app/CMContextConstants.java | 16 ++ .../cyanogenmod/media/AudioSessionInfo.aidl | 19 ++ .../cyanogenmod/media/AudioSessionInfo.java | 176 ++++++++++++ .../cyanogenmod/media/CMAudioManager.java | 172 ++++++++++++ .../cyanogenmod/media/ICMAudioService.aidl | 26 ++ tests/AndroidManifest.xml | 1 + .../tests/media/unit/CMAudioManagerTest.java | 260 ++++++++++++++++++ 14 files changed, 1113 insertions(+) create mode 100644 cm/jni/Android.mk create mode 100644 cm/jni/src/onload.cpp create mode 100644 cm/jni/src/org_cyanogenmod_platform_internal_CMAudioService.cpp create mode 100644 cm/lib/main/java/org/cyanogenmod/platform/internal/CMAudioService.java create mode 100644 sdk/src/java/cyanogenmod/media/AudioSessionInfo.aidl create mode 100644 sdk/src/java/cyanogenmod/media/AudioSessionInfo.java create mode 100644 sdk/src/java/cyanogenmod/media/CMAudioManager.java create mode 100644 sdk/src/java/cyanogenmod/media/ICMAudioService.aidl create mode 100644 tests/src/org/cyanogenmod/tests/media/unit/CMAudioManagerTest.java diff --git a/cm/jni/Android.mk b/cm/jni/Android.mk new file mode 100644 index 0000000..5e1da9f --- /dev/null +++ b/cm/jni/Android.mk @@ -0,0 +1,40 @@ +# Copyright (C) 2016 The CyanogenMod 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. + +LOCAL_PATH:= $(call my-dir) + +include $(CLEAR_VARS) + +LOCAL_SRC_FILES := \ + src/org_cyanogenmod_platform_internal_CMAudioService.cpp \ + src/onload.cpp + +LOCAL_C_INCLUDES := \ + $(JNI_H_INCLUDE) \ + $(TOP)/frameworks/base/core/jni \ + $(TOP)/frameworks/av/include + +LOCAL_SHARED_LIBRARIES := \ + libandroid_runtime \ + libmedia \ + liblog \ + libcutils \ + libutils \ + +LOCAL_MODULE := libcmsdk_platform_jni +LOCAL_MODULE_TAGS := optional +LOCAL_CFLAGS := -Wall -Werror -Wno-unused-parameter + +include $(BUILD_SHARED_LIBRARY) + diff --git a/cm/jni/src/onload.cpp b/cm/jni/src/onload.cpp new file mode 100644 index 0000000..d9892ba --- /dev/null +++ b/cm/jni/src/onload.cpp @@ -0,0 +1,45 @@ +/* + * Copyright (C) 2016 The CyanogenMod 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. + */ + +#include "JNIHelp.h" +#include "jni.h" +#include "utils/Log.h" +#include "utils/misc.h" + +namespace android { + +int register_org_cyanogenmod_platform_internal_CMAudioService(JNIEnv* env); + +}; + +using namespace android; + +extern "C" jint JNI_OnLoad(JavaVM* vm, void* /* reserved */) +{ + JNIEnv* env = NULL; + jint result = -1; + + if (vm->GetEnv((void**) &env, JNI_VERSION_1_4) != JNI_OK) { + ALOGE("GetEnv failed!"); + return result; + } + ALOG_ASSERT(env, "Could not retrieve the env!"); + + register_org_cyanogenmod_platform_internal_CMAudioService(env); + + return JNI_VERSION_1_4; +} + diff --git a/cm/jni/src/org_cyanogenmod_platform_internal_CMAudioService.cpp b/cm/jni/src/org_cyanogenmod_platform_internal_CMAudioService.cpp new file mode 100644 index 0000000..3d717cf --- /dev/null +++ b/cm/jni/src/org_cyanogenmod_platform_internal_CMAudioService.cpp @@ -0,0 +1,170 @@ +/* +** +** Copyright 2016, The CyanogenMod 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. +*/ + +//#define LOG_NDEBUG 0 + +#define LOG_TAG "CMAudioService-JNI" + +#include + +#include +#include +#include "core_jni_helpers.h" +#include "android_media_AudioErrors.h" + +#include +#include + +#include +#include + +// ---------------------------------------------------------------------------- + +namespace android { + +static const char* const kClassPathName = "org/cyanogenmod/platform/internal/CMAudioService"; + +static jclass gArrayListClass; +static struct { + jmethodID add; + jmethodID toArray; +} gArrayListMethods; + +static struct { + jmethodID postAudioSessionEventFromNative; +} gAudioSessionEventHandlerMethods; + +static jclass gAudioSessionInfoClass; +static jmethodID gAudioSessionInfoCstor; + +static jobject gThiz; + +static Mutex gCallbackLock; + +// ---------------------------------------------------------------------------- + +static void +org_cyanogenmod_platform_internal_CMAudioService_session_info_callback(int event, + sp& info, bool added) +{ + AutoMutex _l(gCallbackLock); + + JNIEnv *env = AndroidRuntime::getJNIEnv(); + if (env == NULL) { + return; + } + + jobject jSession = env->NewObject(gAudioSessionInfoClass, gAudioSessionInfoCstor, + info->mSessionId, info->mStream, info->mFlags, info->mChannelMask, info->mUid); + + env->CallVoidMethod(gThiz, + gAudioSessionEventHandlerMethods.postAudioSessionEventFromNative, + event, jSession, added); + + env->DeleteLocalRef(jSession); +} + +static void +org_cyanogenmod_platform_internal_CMAudioService_registerAudioSessionCallback( + JNIEnv *env, jobject thiz, jboolean enabled) +{ + if (gThiz == NULL) { + gThiz = env->NewGlobalRef(thiz); + } + + AudioSystem::setAudioSessionCallback( enabled ? + org_cyanogenmod_platform_internal_CMAudioService_session_info_callback : NULL); +} + +static jint +org_cyanogenmod_platform_internal_CMAudioService_listAudioSessions(JNIEnv *env, jobject thiz, + jint streams, jobject jSessions) +{ + ALOGV("listAudioSessions"); + + if (jSessions == NULL) { + ALOGE("listAudioSessions NULL arraylist"); + return (jint)AUDIO_JAVA_BAD_VALUE; + } + if (!env->IsInstanceOf(jSessions, gArrayListClass)) { + ALOGE("listAudioSessions not an arraylist"); + return (jint)AUDIO_JAVA_BAD_VALUE; + } + + status_t status; + Vector< sp> sessions; + + status = AudioSystem::listAudioSessions((audio_stream_type_t)streams, sessions); + if (status != NO_ERROR) { + ALOGE("AudioSystem::listAudioSessions error %d", status); + } else { + ALOGV("AudioSystem::listAudioSessions count=%d", sessions.size()); + } + + jint jStatus = nativeToJavaStatus(status); + if (jStatus != AUDIO_JAVA_SUCCESS) { + goto exit; + } + + for (size_t i = 0; i < sessions.size(); i++) { + const sp& s = sessions.itemAt(i); + + jobject jSession = env->NewObject(gAudioSessionInfoClass, gAudioSessionInfoCstor, + s->mSessionId, s->mStream, s->mFlags, s->mChannelMask, s->mUid); + + if (jSession == NULL) { + jStatus = (jint)AUDIO_JAVA_ERROR; + goto exit; + } + + env->CallBooleanMethod(jSessions, gArrayListMethods.add, jSession); + env->DeleteLocalRef(jSession); + } + +exit: + return jStatus; +} + + +// ---------------------------------------------------------------------------- + +static JNINativeMethod gMethods[] = { + {"native_listAudioSessions", "(ILjava/util/ArrayList;)I", + (void *)org_cyanogenmod_platform_internal_CMAudioService_listAudioSessions}, + {"native_registerAudioSessionCallback", "(Z)V", + (void *)org_cyanogenmod_platform_internal_CMAudioService_registerAudioSessionCallback}, +}; + +int register_org_cyanogenmod_platform_internal_CMAudioService(JNIEnv *env) +{ + jclass arrayListClass = FindClassOrDie(env, "java/util/ArrayList"); + gArrayListClass = MakeGlobalRefOrDie(env, arrayListClass); + gArrayListMethods.add = GetMethodIDOrDie(env, arrayListClass, "add", "(Ljava/lang/Object;)Z"); + gArrayListMethods.toArray = GetMethodIDOrDie(env, arrayListClass, "toArray", "()[Ljava/lang/Object;"); + + jclass audioSessionInfoClass = FindClassOrDie(env, "cyanogenmod/media/AudioSessionInfo"); + gAudioSessionInfoClass = MakeGlobalRefOrDie(env, audioSessionInfoClass); + gAudioSessionInfoCstor = GetMethodIDOrDie(env, audioSessionInfoClass, "", "(IIIII)V"); + + gAudioSessionEventHandlerMethods.postAudioSessionEventFromNative = + GetMethodIDOrDie(env, env->FindClass(kClassPathName), + "audioSessionCallbackFromNative", "(ILcyanogenmod/media/AudioSessionInfo;Z)V"); + + return RegisterMethodsOrDie(env, kClassPathName, gMethods, NELEM(gMethods)); +} + +} /* namespace android */ diff --git a/cm/lib/main/java/org/cyanogenmod/platform/internal/CMAudioService.java b/cm/lib/main/java/org/cyanogenmod/platform/internal/CMAudioService.java new file mode 100644 index 0000000..99f3f6f --- /dev/null +++ b/cm/lib/main/java/org/cyanogenmod/platform/internal/CMAudioService.java @@ -0,0 +1,174 @@ +/* + * Copyright (C) 2016 The CyanogenMod 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 org.cyanogenmod.platform.internal; + +import android.content.Context; +import android.content.Intent; +import android.os.Binder; +import android.os.IBinder; +import android.os.RemoteException; +import android.os.UserHandle; +import android.util.Log; + +import com.android.server.SystemService; + +import java.io.FileDescriptor; +import java.io.PrintWriter; +import java.util.ArrayList; +import java.util.List; + +import cyanogenmod.app.CMContextConstants; +import cyanogenmod.media.AudioSessionInfo; +import cyanogenmod.media.CMAudioManager; +import cyanogenmod.media.ICMAudioService; +import cyanogenmod.platform.Manifest; + +public class CMAudioService extends SystemService { + + private static final String TAG = "CMAudioService"; + private static final boolean DEBUG = Log.isLoggable(TAG, Log.DEBUG); + + private final Context mContext; + + private static final int AUDIO_STATUS_OK = 0; + + //keep in sync with include/media/AudioPolicy.h + private final static int AUDIO_OUTPUT_SESSION_EFFECTS_UPDATE = 10; + + private static boolean sNativeLibraryLoaded; + + static { + try { + System.loadLibrary("cmsdk_platform_jni"); + sNativeLibraryLoaded = true; + + } catch (Throwable t) { + sNativeLibraryLoaded = false; + Log.w(TAG, "CMSDK native platform unavailable"); + } + } + + public CMAudioService(Context context) { + super(context); + + mContext = context; + } + + @Override + public void onStart() { + if (!mContext.getPackageManager().hasSystemFeature( + CMContextConstants.Features.AUDIO)) { + Log.wtf(TAG, "CM Audio service started by system server but feature xml not" + + " declared. Not publishing binder service!"); + return; + } + + if (!sNativeLibraryLoaded) { + Log.wtf(TAG, "CM Audio service started by system server by native library is" + + "unavailable. Service will be unavailable."); + return; + } + publishBinderService(CMContextConstants.CM_AUDIO_SERVICE, mBinder); + } + + @Override + public void onBootPhase(int phase) { + if (phase == PHASE_BOOT_COMPLETED) { + if (sNativeLibraryLoaded) { + native_registerAudioSessionCallback(true); + } + } + } + + private final IBinder mBinder = new ICMAudioService.Stub() { + + @Override + public List listAudioSessions(int streamType) throws RemoteException { + final ArrayList sessions = new ArrayList(); + if (!sNativeLibraryLoaded) { + // no sessions for u + return sessions; + } + + int status = native_listAudioSessions(streamType, sessions); + if (status != AUDIO_STATUS_OK) { + Log.e(TAG, "Error retrieving audio sessions! status=" + status); + } + + return sessions; + } + + @Override + public void dump(FileDescriptor fd, PrintWriter pw, String[] args) { + pw.println(); + pw.println("CMAudio Service State:"); + try { + List sessions = listAudioSessions(-1); + if (sessions.size() > 0) { + pw.println(" Audio sessions:"); + for (AudioSessionInfo info : sessions) { + pw.println(" " + info.toString()); + } + } else { + pw.println(" No active audio sessions"); + } + } catch (RemoteException e) { + // nothing + } + } + }; + + private void broadcastSessionChanged(boolean added, AudioSessionInfo sessionInfo) { + Intent i = new Intent(CMAudioManager.ACTION_AUDIO_SESSIONS_CHANGED); + i.putExtra(CMAudioManager.EXTRA_SESSION_INFO, sessionInfo); + i.putExtra(CMAudioManager.EXTRA_SESSION_ADDED, added); + + sendBroadcastToAll(i, Manifest.permission.OBSERVE_AUDIO_SESSIONS); + } + + private void sendBroadcastToAll(Intent intent, String receiverPermission) { + intent.addFlags(Intent.FLAG_RECEIVER_FOREGROUND); + + final long ident = Binder.clearCallingIdentity(); + try { + if (DEBUG) Log.d(TAG, "Sending broadcast: " + intent.toString()); + + mContext.sendBroadcastAsUser(intent, UserHandle.ALL, receiverPermission); + } finally { + Binder.restoreCallingIdentity(ident); + } + } + + /* + * Handles events from JNI + */ + private synchronized void audioSessionCallbackFromNative(int event, + AudioSessionInfo sessionInfo, boolean added) { + + switch (event) { + case AUDIO_OUTPUT_SESSION_EFFECTS_UPDATE: + broadcastSessionChanged(added, sessionInfo); + break; + default: + Log.e(TAG, "Unknown event " + event); + } + } + + private native final void native_registerAudioSessionCallback(boolean enabled); + + private native final int native_listAudioSessions( + int stream, ArrayList sessions); +} diff --git a/cm/res/AndroidManifest.xml b/cm/res/AndroidManifest.xml index c2be8dc..4c2d228 100644 --- a/cm/res/AndroidManifest.xml +++ b/cm/res/AndroidManifest.xml @@ -24,6 +24,7 @@ + @@ -211,6 +212,14 @@ android:description="@string/permdesc_weather_bind" android:protectionLevel="signature"/> + + + + diff --git a/cm/res/res/values/config.xml b/cm/res/res/values/config.xml index 3755e4b..db621b1 100644 --- a/cm/res/res/values/config.xml +++ b/cm/res/res/values/config.xml @@ -98,5 +98,6 @@ org.cyanogenmod.platform.internal.LiveLockScreenServiceBroker org.cyanogenmod.platform.internal.CMWeatherManagerService org.cyanogenmod.platform.internal.display.LiveDisplayService + org.cyanogenmod.platform.internal.CMAudioService diff --git a/cm/res/res/values/strings.xml b/cm/res/res/values/strings.xml index 18bfd6a..2c57e00 100644 --- a/cm/res/res/values/strings.xml +++ b/cm/res/res/values/strings.xml @@ -214,4 +214,8 @@ manage LiveDisplay settings Allows an app to configure advanced display settings. + + observe audio session changes + Allows an app to observe audio streams being created and destroyed. + diff --git a/sdk/src/java/cyanogenmod/app/CMContextConstants.java b/sdk/src/java/cyanogenmod/app/CMContextConstants.java index 07a2980..43c700b 100644 --- a/sdk/src/java/cyanogenmod/app/CMContextConstants.java +++ b/sdk/src/java/cyanogenmod/app/CMContextConstants.java @@ -138,6 +138,14 @@ public final class CMContextConstants { */ public static final String CM_LIVEDISPLAY_SERVICE = "cmlivedisplay"; + + /** + * Manages enhanced audio functionality + * + * @hide + */ + public static final String CM_AUDIO_SERVICE = "cmaudio"; + /** * Features supported by the CMSDK. */ @@ -229,5 +237,13 @@ public final class CMContextConstants { */ @SdkConstant(SdkConstant.SdkConstantType.FEATURE) public static final String LIVEDISPLAY = "org.cyanogenmod.livedisplay"; + + /** + * Feature for {@link PackageManager#getSystemAvailableFeatures} and + * {@link PackageManager#hasSystemFeature}: The device includes the CM audio extensions + * utilized by the cmsdk. + */ + @SdkConstant(SdkConstant.SdkConstantType.FEATURE) + public static final String AUDIO = "org.cyanogenmod.audio"; } } diff --git a/sdk/src/java/cyanogenmod/media/AudioSessionInfo.aidl b/sdk/src/java/cyanogenmod/media/AudioSessionInfo.aidl new file mode 100644 index 0000000..4a44239 --- /dev/null +++ b/sdk/src/java/cyanogenmod/media/AudioSessionInfo.aidl @@ -0,0 +1,19 @@ +/* + * Copyright (C) 2016 The CyanogenMod 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 cyanogenmod.media; + +parcelable AudioSessionInfo; diff --git a/sdk/src/java/cyanogenmod/media/AudioSessionInfo.java b/sdk/src/java/cyanogenmod/media/AudioSessionInfo.java new file mode 100644 index 0000000..5f688cc --- /dev/null +++ b/sdk/src/java/cyanogenmod/media/AudioSessionInfo.java @@ -0,0 +1,176 @@ +/* + * Copyright (C) 2016 The CyanogenMod 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 cyanogenmod.media; + +import android.os.Parcel; +import android.os.Parcelable; + +import java.util.Objects; + +import cyanogenmod.os.Build; +import cyanogenmod.os.Concierge; +import cyanogenmod.os.Concierge.ParcelInfo; + +/** + * AudioSessionInfo represents a single instance of an audio session. + * Audio sessions are created when a new AudioTrack is started, and + * removed when the track is released. Audio session identifiers + * are unique (but may be reused) and are used as a handle by which + * an application may create audio effects on the output stream. + * + * A list of global audio sessions can be obtained by calling + * listAudioSessions() via {@link CMAudioManager}, or a component can listen + * for the {@link CMAudioManager#ACTION_AUDIO_SESSIONS_CHANGED} broadcast. + * + * @hide + */ +public final class AudioSessionInfo implements Parcelable { + + /** + * Unique session id + */ + private final int mSessionId; + /** + * Stream type - see audio_stream_type_t + */ + private final int mStream; + /** + * Output flags - see audio_output_flags_t + */ + private final int mFlags; + /** + * Channel mask - see audio_channel_mask_t + */ + private final int mChannelMask; + /** + * UID of the source application + */ + private final int mUid; + + public AudioSessionInfo(int sessionId, int stream, int flags, int channelMask, int uid) { + mSessionId = sessionId; + mStream = stream; + mFlags = flags; + mChannelMask = channelMask; + mUid = uid; + } + + private AudioSessionInfo(Parcel in) { + // Read parcelable version via the Concierge + ParcelInfo parcelInfo = Concierge.receiveParcel(in); + int parcelableVersion = parcelInfo.getParcelVersion(); + + /* -- FIG -- */ + mSessionId = in.readInt(); + mStream = in.readInt(); + mFlags = in.readInt(); + mChannelMask = in.readInt(); + mUid = in.readInt(); + + if (parcelableVersion > Build.CM_VERSION_CODES.FIG) { + // next-gen mind-altering shit goes here + } + + // Complete parcel info for the concierge + parcelInfo.complete(); + } + + public int getSessionId() { + return mSessionId; + } + + public int getStream() { + return mStream; + } + + public int getFlags() { + return mFlags; + } + + public int getChannelMask() { + return mChannelMask; + } + + public int getUid() { + return mUid; + } + + @Override + public String toString() { + return String.format( + "audioSessionInfo[sessionId=%d, stream=%d, flags=%d, channelMask=%d, uid=%d", + mSessionId, mStream, mFlags, mChannelMask, mUid); + } + + @Override + public int hashCode() { + return Objects.hash(mSessionId, mStream, mFlags, mChannelMask, mUid); + } + + @Override + public boolean equals(Object obj) { + if (obj == null || !(obj instanceof AudioSessionInfo)) { + return false; + } + + final AudioSessionInfo other = (AudioSessionInfo)obj; + return this == other || + (mSessionId == other.mSessionId && + mStream == other.mStream && + mFlags == other.mFlags && + mChannelMask == other.mChannelMask && + mUid == other.mUid); + } + + /** @hide */ + @Override + public int describeContents() { + return 0; + } + + /** @hide */ + @Override + public void writeToParcel(Parcel dest, int flags) { + // Tell the concierge to prepare the parcel + ParcelInfo parcelInfo = Concierge.prepareParcel(dest); + + /* -- FIG -- */ + dest.writeInt(mSessionId); + dest.writeInt(mStream); + dest.writeInt(mFlags); + dest.writeInt(mChannelMask); + dest.writeInt(mUid); + + parcelInfo.complete(); + } + + /** @hide */ + public static final Creator CREATOR = new Creator() { + + @Override + public AudioSessionInfo createFromParcel(Parcel source) { + return new AudioSessionInfo(source); + } + + @Override + public AudioSessionInfo[] newArray(int size) { + return new AudioSessionInfo[size]; + } + }; +} + + diff --git a/sdk/src/java/cyanogenmod/media/CMAudioManager.java b/sdk/src/java/cyanogenmod/media/CMAudioManager.java new file mode 100644 index 0000000..801cab4 --- /dev/null +++ b/sdk/src/java/cyanogenmod/media/CMAudioManager.java @@ -0,0 +1,172 @@ +/* + * Copyright (C) 2016 The CyanogenMod 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 cyanogenmod.media; + +import android.content.Context; +import android.os.IBinder; +import android.os.RemoteException; +import android.os.ServiceManager; +import android.util.Log; + +import java.util.Collections; +import java.util.List; + +import cyanogenmod.app.CMContextConstants; + +/** + * Manager for extended audio system capabilities provided by + * CyanogenMod. + * + * Currently, this API provides an application the ability to + * query active global audio sessions, and receive broadcasts + * when new audio sessions are created and destroyed. + * + * Applications wishing to receive audio session information + * should register for the {@link ACTION_AUDIO_SESSIONS_CHANGED} + * broadcast. This broadcast requires an application to hold the + * {@link cyanogenmod.permission.OBSERVE_AUDIO_SESSIONS} + * permission. When receiving the broadcast, {@link EXTRA_SESSION_INFO} + * will hold the {@link AudioSessionInfo} object associated + * with the session. {@link EXTRA_SESSION_ADDED} will hold + * a boolean value, true if the session is added, false if it + * is being removed. + * + * It is important for applications to be cautious about which + * audio streams effects are attached to when using this API as + * it may interfere with their normal operation. An equalizer + * application for example would only want to react to streams + * with the type of {@link android.media.AudioManager.STREAM_MUSIC}. + * + * @see android.media.AudioManager + * + * @hide + */ +public final class CMAudioManager { + + private static final String TAG = "CMAudioManager"; + + /** + * Broadcast sent when audio session are added and removed. + */ + public static final String ACTION_AUDIO_SESSIONS_CHANGED = + "cyanogenmod.intent.action.ACTION_AUDIO_SESSIONS_CHANGED"; + + /** + * Extra containing {@link AudioSessionInfo} + */ + public static final String EXTRA_SESSION_INFO = "session_info"; + + /** + * Boolean extra, true if session is being added. + */ + public static final String EXTRA_SESSION_ADDED = "added"; + + private Context mContext; + + private static CMAudioManager sInstance; + private static ICMAudioService sService; + + /** + * @hide to prevent subclassing from outside of the framework + */ + private CMAudioManager(Context context) { + Context appContext = context.getApplicationContext(); + if (appContext != null) { + mContext = appContext; + } else { + mContext = context; + + } + sService = getService(); + + if (!context.getPackageManager().hasSystemFeature( + CMContextConstants.Features.AUDIO) || !checkService()) { + throw new RuntimeException("Unable to get CMAudioService. The service either" + + " crashed, was not started, or the interface has been called to early in" + + " SystemServer init"); + } + } + + /** + * Get or create an instance of the {@link cyanogenmod.media.CMAudioManager} + * @param context + * @return {@link CMAudioManager} + */ + public synchronized static CMAudioManager getInstance(Context context) { + if (sInstance == null) { + sInstance = new CMAudioManager(context); + } + return sInstance; + } + + /** @hide */ + public static ICMAudioService getService() { + if (sService != null) { + return sService; + } + IBinder b = ServiceManager.getService(CMContextConstants.CM_AUDIO_SERVICE); + if (b != null) { + sService = ICMAudioService.Stub.asInterface(b); + return sService; + } + return null; + } + + /** + * @return true if service is valid + */ + private boolean checkService() { + if (sService == null) { + Log.w(TAG, "not connected to CMAudioService"); + return false; + } + return true; + } + + /** + * List audio sessions for the given stream type defined in + * {@link android.media.AudioManager}, for example, + * {@link android.media.AudioManager#STREAM_MUSIC}. + * + * @param streamType from {@link android.media.AudioManager} + * @return list of {@link AudioSessionInfo}, or empty list if none found + * @see android.media.AudioManager + */ + public List listAudioSessions(int streamType) { + if (checkService()) { + try { + final List sessions = sService.listAudioSessions(streamType); + if (sessions != null) { + return Collections.unmodifiableList(sessions); + } + } catch (RemoteException e) { + Log.e(TAG, "Failed to list audio sessions!", e); + } + } + return Collections.emptyList(); + } + + /** + * List all audio sessions. + * + * @return list of {@link AudioSessionInfo}, or empty list if none found + * @see android.media.AudioManager + */ + public List listAudioSessions() { + return listAudioSessions(-1); + } +} diff --git a/sdk/src/java/cyanogenmod/media/ICMAudioService.aidl b/sdk/src/java/cyanogenmod/media/ICMAudioService.aidl new file mode 100644 index 0000000..7edcb16 --- /dev/null +++ b/sdk/src/java/cyanogenmod/media/ICMAudioService.aidl @@ -0,0 +1,26 @@ +/* + * Copyright (C) 2016 The CyanogenMod 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 cyanogenmod.media; + +import cyanogenmod.media.AudioSessionInfo; + +/** @hide */ +interface ICMAudioService { + + List listAudioSessions(int streamType); + +} diff --git a/tests/AndroidManifest.xml b/tests/AndroidManifest.xml index c5a499c..52e3565 100644 --- a/tests/AndroidManifest.xml +++ b/tests/AndroidManifest.xml @@ -26,6 +26,7 @@ + diff --git a/tests/src/org/cyanogenmod/tests/media/unit/CMAudioManagerTest.java b/tests/src/org/cyanogenmod/tests/media/unit/CMAudioManagerTest.java new file mode 100644 index 0000000..807a5bd --- /dev/null +++ b/tests/src/org/cyanogenmod/tests/media/unit/CMAudioManagerTest.java @@ -0,0 +1,260 @@ +/* + * Copyright (C) 2016 The CyanogenMod 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 org.cyanogenmod.tests.media.unit; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.media.AudioFormat; +import android.media.AudioManager; +import android.media.AudioTrack; +import android.test.AndroidTestCase; +import android.test.suitebuilder.annotation.SmallTest; +import android.util.Log; + +import org.junit.Assume; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; + +import cyanogenmod.app.CMContextConstants; +import cyanogenmod.media.AudioSessionInfo; +import cyanogenmod.media.CMAudioManager; +import cyanogenmod.media.ICMAudioService; + +public class CMAudioManagerTest extends AndroidTestCase { + + private static final String TAG = "CMAudioManagerTest"; + + private CMAudioManager mCMAudioManager; + + @Override + protected void setUp() throws Exception { + super.setUp(); + + Assume.assumeTrue(mContext.getPackageManager().hasSystemFeature( + CMContextConstants.Features.AUDIO)); + + mCMAudioManager = CMAudioManager.getInstance(mContext); + } + + /** + * EXPECT: The platform should return a valid manager instance. + */ + @SmallTest + public void testManagerExists() { + assertNotNull(mCMAudioManager); + } + + /** + * EXPECT: The service in the manager should also be valid. + */ + @SmallTest + public void testManagerServiceIsAvailable() { + ICMAudioService service = CMAudioManager.getService(); + assertNotNull(service); + } + + /** + * EXPECT: listAudioSessions should be populated when a new stream is + * created, and it's session ID should match what AudioTrack says it is. + * + * We simply create an audio track, and query the policy for sessions. + */ + @SmallTest + public void testSessionList() { + + AudioTrack track = createTestTrack(); + int session = track.getAudioSessionId(); + + AudioSessionInfo info = findAudioSessionInfo(session); + assertNotNull(info); + assertEquals(session, info.getSessionId()); + assertEquals(3, info.getChannelMask()); + + track.release(); + + info = findAudioSessionInfo(session); + assertNull(info); + } + + /** + * EXPECT: CMAudioManager.ACTION_AUDIO_SESSIONS_CHANGED should be broadcast when + * audio sessions are opened and closed. + * + * We register a receiver for the broadcast, create an AudioTrack, and wait to + * observe both the open and close session events. The info in the returned + * AudioSessionInfo should match our expectations. + */ + @SmallTest + public void testSessionInfoBroadcast() throws Exception { + + IntentFilter filter = new IntentFilter(CMAudioManager.ACTION_AUDIO_SESSIONS_CHANGED); + AudioSessionReceiver receiver = new AudioSessionReceiver(2); + mContext.registerReceiver(receiver, filter); + + AudioTrack track = createTestTrack(); + track.play(); + track.release(); + + receiver.waitForSessions(); + + mContext.unregisterReceiver(receiver); + + assertEquals(1, receiver.getNumAdded()); + assertEquals(1, receiver.getNumRemoved()); + + assertEquals(1, receiver.getSessions().size()); + + AudioSessionInfo info = receiver.getSessions().get(0); + assertNotNull(info); + assertNotNull(info.toString()); + assertEquals(track.getAudioSessionId(), info.getSessionId()); + assertEquals(3, info.getChannelMask()); + assertEquals(AudioManager.STREAM_MUSIC, info.getStream()); + + } + + private static final int SESSIONS = 50; + + /** + * EXPECT: The ACTION_AUDIO_SESSIONS_CHANGED broadcast and associated backend should + * be resilent to multithreaded and/or aggressive/destructive usage. A single add + * and a single remove event should be broadcast for the lifecycle of a stream. + * + * We register a receiver for session events, spawn a small thread pool, and create + * up to SESSIONS AudioTracks and play + release them on the thread. A small delay + * is inserted to prevent AudioFlinger from running out of tracks. Once the expected + * number of sessions arrives, we verify our expectation regarding event counts, + * and additionally verify that all the session ids we saw when creating our + * AudioTracks were returned in the AudioSessionInfo broadcasts. + */ + @SmallTest + public void testSymphonyOfDestruction() throws Exception { + IntentFilter filter = new IntentFilter(CMAudioManager.ACTION_AUDIO_SESSIONS_CHANGED); + AudioSessionReceiver receiver = new AudioSessionReceiver(SESSIONS * 2); + mContext.registerReceiver(receiver, filter); + + final List sessions = new ArrayList(); + + ExecutorService sexecutioner = Executors.newFixedThreadPool(5); + for (int i = 0; i < SESSIONS; i++) { + sexecutioner.submit(new Runnable() { + @Override + public void run() { + AudioTrack track = createTestTrack(); + synchronized (sessions) { + sessions.add(track.getAudioSessionId()); + } + track.play(); + track.release(); + } + }); + if ((i % 2) == 0) { + Thread.sleep(100); + } + } + + receiver.waitForSessions(); + sexecutioner.shutdown(); + + assertEquals(SESSIONS, sessions.size()); + assertEquals(SESSIONS, receiver.getNumAdded()); + assertEquals(SESSIONS, receiver.getNumRemoved()); + + for (AudioSessionInfo info : receiver.getSessions()) { + assertTrue(sessions.contains(info.getSessionId())); + } + } + + private static class AudioSessionReceiver extends BroadcastReceiver { + + private int mAdded = 0; + private int mRemoved = 0; + + private final CountDownLatch mLatch; + + private List mSessions = new ArrayList(); + + public AudioSessionReceiver(int count) { + mLatch = new CountDownLatch(count); + } + + @Override + public void onReceive(Context context, Intent intent) { + assertNotNull(intent); + + boolean added = intent.getBooleanExtra(CMAudioManager.EXTRA_SESSION_ADDED, false); + + AudioSessionInfo info = intent.getParcelableExtra(CMAudioManager.EXTRA_SESSION_INFO); + Log.d(TAG, "onReceive: " + info); + + assertNotNull(info); + + synchronized (mSessions) { + if (added) { + mAdded++; + mSessions.add(info); + } else { + mRemoved++; + } + } + + mLatch.countDown(); + } + + public int getNumAdded() { + return mAdded; + } + + public int getNumRemoved() { + return mRemoved; + } + + public List getSessions() { + return mSessions; + } + + public void waitForSessions() throws InterruptedException { + mLatch.await(60, TimeUnit.SECONDS); + } + }; + + private AudioSessionInfo findAudioSessionInfo(int sessionId) { + List infos = mCMAudioManager.listAudioSessions(AudioManager.STREAM_MUSIC); + for (AudioSessionInfo info : infos) { + if (info.getSessionId() == sessionId) { + return info; + } + } + return null; + } + + private AudioTrack createTestTrack() { + int bytes = 2 * 44100 / 1000; + AudioTrack track = new AudioTrack(AudioManager.STREAM_MUSIC, 44100, + AudioFormat.CHANNEL_OUT_STEREO, + AudioFormat.ENCODING_PCM_16BIT, + bytes, + AudioTrack.STATE_INITIALIZED); + return track; + } +}