/* * Copyright 2011 Harleen Sahni * * 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.harleensahni.android.mbr; import static com.harleensahni.android.mbr.Constants.TAG; import java.util.List; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; import android.app.AlertDialog; import android.app.ListActivity; import android.content.BroadcastReceiver; import android.content.ComponentName; import android.content.Context; import android.content.Intent; import android.content.IntentFilter; import android.content.SharedPreferences; import android.content.pm.ResolveInfo; import android.media.AudioManager; import android.media.MediaPlayer; import android.media.MediaPlayer.OnCompletionListener; import android.os.Bundle; import android.os.PowerManager; import android.os.PowerManager.WakeLock; import android.preference.PreferenceManager; import android.speech.tts.TextToSpeech; import android.speech.tts.TextToSpeech.OnInitListener; import android.util.Log; import android.view.KeyEvent; import android.view.LayoutInflater; import android.view.Menu; import android.view.MenuInflater; import android.view.MenuItem; import android.view.View; import android.view.View.OnClickListener; import android.view.ViewGroup; import android.view.WindowManager; import android.widget.BaseAdapter; import android.widget.ImageView; import android.widget.ListView; import android.widget.TextView; import com.harleensahni.android.mbr.receivers.MediaButtonReceiver; /** * Allows the user to choose which media receiver will handle a media button * press. Can be navigated via touch screen or media button keys. Provides voice * feedback. * * @author Harleen Sahni */ public class ReceiverSelector extends ListActivity implements OnInitListener, AudioManager.OnAudioFocusChangeListener { private class SweepBroadcastReceiver extends BroadcastReceiver { String name; public SweepBroadcastReceiver(String name) { this.name = name; } @Override public void onReceive(Context context, Intent intent) { /* COMMENTED OUT FOR MARKET RELEASE Log.i(TAG, "Media Button Selector: After running broadcast receiver " + name + "have resultcode: " + getResultCode() + " result Data: " + getResultData()); */ } } /** * Key used to store and retrieve last selected receiver. */ private static final String SELECTION_KEY = "btButtonSelection"; /** * Key used to store and retrieve last selected receiver that actually was * forwarded a media button by the user. */ private static final String SELECTION_ACTED_KEY = "btButtonSelectionActed"; /** * Number of seconds to wait before timing out and just cancelling. */ private int timeoutTime; /** * The media button event that {@link MediaButtonReceiver} captured, and * that we will be forwarding to a music player's {@code BroadcastReceiver} * on selection. */ private KeyEvent trappedKeyEvent; /** * The {@code BroadcastReceiver}'s registered in the system for * * {@link Intent.ACTION_MEDIA_BUTTON}. */ private List<ResolveInfo> receivers; /** The intent filter for registering our local {@code BroadcastReceiver}. */ private IntentFilter uiIntentFilter; /** The text to speech engine used to announce navigation to the user. */ private TextToSpeech textToSpeech; /** * The receiver currently selected by bluetooth next/prev navigation. We * track this ourselves because there isn't persisted selection w/ touch * screen interfaces. */ private int btButtonSelection; /** * Whether we've done the start up announcement to the user using the text * to speech. Tracked so we don't repeat ourselves on orientation change. */ private boolean announced; /** * The power manager used to wake the device with a wake lock so that we can * handle input. Allows us to have a regular activity life cycle when media * buttons are pressed when and the screen is off. */ private PowerManager powerManager; /** * Used to wake up screen so that we can navigate our UI and select an app * to handle media button presses when display is off. */ private WakeLock wakeLock; /** * Whether we've requested audio focus. */ private boolean audioFocus; /** * ScheduledExecutorService used to time out and close activity if the user * doesn't make a selection within certain amount of time. Resets on user * interaction. */ private ScheduledExecutorService timeoutExecutor; /** * ScheduledFuture of timeout. */ private ScheduledFuture<?> timeoutScheduledFuture; /** The cancel button. */ private View cancelButton; private ImageView mediaImage; /** The header */ private TextView header; /** The intro dialog. May be null if no dialog is being shown. */ private AlertDialog introDialog; private boolean eulaAcceptedAlready; /** * Local broadcast receiver that allows us to handle media button events for * navigation inside the activity. */ private BroadcastReceiver uiMediaReceiver = new BroadcastReceiver() { @Override public void onReceive(Context context, Intent intent) { if (Intent.ACTION_MEDIA_BUTTON.equals(intent.getAction()) || Constants.INTENT_ACTION_VIEW_MEDIA_LIST_KEYPRESS.equals(intent.getAction())) { KeyEvent navigationKeyEvent = (KeyEvent) intent.getExtras().get(Intent.EXTRA_KEY_EVENT); int keyCode = navigationKeyEvent.getKeyCode(); if (Utils.isMediaButton(keyCode)) { /* COMMENTED OUT FOR MARKET RELEASE Log.i(TAG, "Media Button Selector: UI is directly handling key: " + navigationKeyEvent); */ if (navigationKeyEvent.getAction() == KeyEvent.ACTION_UP) { switch (Utils.getAdjustedKeyCode(navigationKeyEvent)) { case KeyEvent.KEYCODE_MEDIA_NEXT: moveSelection(1); break; case KeyEvent.KEYCODE_MEDIA_PREVIOUS: moveSelection(-1); break; case KeyEvent.KEYCODE_HEADSETHOOK: case KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE: select(); break; case KeyEvent.KEYCODE_MEDIA_STOP: // just cancel finish(); break; default: break; } } if (isOrderedBroadcast()) { abortBroadcast(); } } } } }; /** Used to figure out if music is playing and handle audio focus. */ private AudioManager audioManager; /** Preferences. */ private SharedPreferences preferences; /** * {@inheritDoc} */ @Override public void onInit(int status) { // text to speech initialized // XXX This is where we announce to the user what we're handling. It's // not clear that this will always get called. I don't know how else to // query if the text to speech is started though. // Only announce if we haven't before if (!announced && trappedKeyEvent != null) { requestAudioFocus(); String actionText = ""; switch (Utils.getAdjustedKeyCode(trappedKeyEvent)) { case KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE: // This is just play even though the keycode is both play/pause, // the app shouldn't handle // pause if music is already playing, it should go to whoever is // playing the music. actionText = getString(audioManager.isMusicActive() ? R.string.pause_play_speak_text : R.string.play_speak_text); break; case KeyEvent.KEYCODE_MEDIA_NEXT: actionText = getString(R.string.next_speak_text); break; case KeyEvent.KEYCODE_MEDIA_PREVIOUS: actionText = getString(R.string.previous_speak_text); break; case KeyEvent.KEYCODE_MEDIA_STOP: actionText = getString(R.string.stop_speak_text); break; } String textToSpeak = null; if (btButtonSelection >= 0 && btButtonSelection < receivers.size()) { textToSpeak = String.format(getString(R.string.application_announce_speak_text), actionText, Utils.getAppName(receivers.get(btButtonSelection), getPackageManager())); } else { textToSpeak = String.format(getString(R.string.announce_speak_text), actionText); } textToSpeech.speak(textToSpeak, TextToSpeech.QUEUE_FLUSH, null); announced = true; } } /** * {@inheritDoc} */ @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); Log.d(TAG, "Media Button Selector: On Create Called"); getWindow().addFlags(WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED); setContentView(R.layout.media_button_list); // Show eula eulaAcceptedAlready = Eula.show(this); uiIntentFilter = new IntentFilter(Intent.ACTION_MEDIA_BUTTON); uiIntentFilter.addAction(Constants.INTENT_ACTION_VIEW_MEDIA_LIST_KEYPRESS); uiIntentFilter.setPriority(Integer.MAX_VALUE); preferences = PreferenceManager.getDefaultSharedPreferences(this); // TODO Handle text engine not installed, etc. Documented on android // developer guide boolean ttsDisabled = preferences.getBoolean(Constants.DISABLE_TTS, false); textToSpeech = ttsDisabled ? null : new TextToSpeech(this, this); audioManager = (AudioManager) this.getSystemService(AUDIO_SERVICE); powerManager = (PowerManager) getSystemService(Context.POWER_SERVICE); // XXX can't use integer array, argh: // http://code.google.com/p/android/issues/detail?id=2096 timeoutTime = Integer.valueOf(preferences.getString(Constants.TIMEOUT_KEY, "5")); btButtonSelection = preferences.getInt(SELECTION_KEY, -1); receivers = Utils.getMediaReceivers(getPackageManager(), true, getApplicationContext()); Boolean lastAnnounced = (Boolean) getLastNonConfigurationInstance(); if (lastAnnounced != null) { announced = lastAnnounced; } // Remove our app's receiver from the list so users can't select it. // NOTE: Our local receiver isn't registered at this point so we don't // have to remove it. if (receivers != null) { for (int i = 0; i < receivers.size(); i++) { if (MediaButtonReceiver.class.getName().equals(receivers.get(i).activityInfo.name)) { receivers.remove(i); break; } } } // TODO MAYBE sort receivers by MRU so user doesn't have to skip as many // apps, // right now apps are sorted by priority (not set by the user, set by // the app authors.. ) setListAdapter(new BaseAdapter() { @Override public int getCount() { return receivers.size(); } @Override public Object getItem(int position) { return receivers.get(position); } @Override public long getItemId(int position) { return position; } @Override public View getView(int position, View convertView, ViewGroup parent) { View view = convertView; if (view == null) { LayoutInflater vi = (LayoutInflater) getSystemService(Context.LAYOUT_INFLATER_SERVICE); view = vi.inflate(R.layout.media_receiver_view, null); } ResolveInfo resolveInfo = receivers.get(position); view.findViewById(R.id.receiverSelectionIndicator).setVisibility( btButtonSelection == position ? View.VISIBLE : view.INVISIBLE); ImageView imageView = (ImageView) view.findViewById(R.id.receiverAppImage); imageView.setImageDrawable(resolveInfo.loadIcon(getPackageManager())); TextView textView = (TextView) view.findViewById(R.id.receiverAppName); textView.setText(Utils.getAppName(resolveInfo, getPackageManager())); return view; } }); header = (TextView) findViewById(R.id.dialogHeader); cancelButton = findViewById(R.id.cancelButton); cancelButton.setOnClickListener(new OnClickListener() { @Override public void onClick(View v) { finish(); } }); mediaImage = (ImageView) findViewById(R.id.mediaImage); /* COMMENTED OUT FOR MARKET RELEASE Log.i(TAG, "Media Button Selector: created."); */ } /** * {@inheritDoc} */ @Override protected void onDestroy() { super.onDestroy(); if (textToSpeech != null) { textToSpeech.shutdown(); } Log.d(TAG, "Media Button Selector: destroyed."); } /** * {@inheritDoc} */ @Override protected void onListItemClick(ListView l, View v, int position, long id) { btButtonSelection = position; getListView().invalidateViews(); forwardToMediaReceiver(position); } /** * {@inheritDoc} */ @Override protected void onPause() { super.onPause(); Log.d(TAG, "Media Button Selector: onPause"); Log.d(TAG, "Media Button Selector: unegistered UI receiver"); unregisterReceiver(uiMediaReceiver); if (wakeLock.isHeld()) { wakeLock.release(); } if (textToSpeech != null) { textToSpeech.stop(); } timeoutExecutor.shutdownNow(); audioManager.abandonAudioFocus(this); preferences.edit().putInt(SELECTION_KEY, btButtonSelection).commit(); } @Override protected void onStart() { super.onStart(); Log.d(TAG, "Media Button Selector: On Start called"); // TODO Originally thought most work should happen onResume and onPause. // I don't know if the onResume part is // right since you can't actually ever get back to this view, single // instance, and not shown in recents. Maybe it's possible if ANOTHER // dialog opens in front of ours? } /** * {@inheritDoc} */ @Override protected void onResume() { super.onResume(); Log.d(TAG, "Media Button Selector: onResume"); // Check to see if intro has been displayed before if (introDialog == null || !introDialog.isShowing()) { introDialog = Utils.showIntroifNeccessary(this); } requestAudioFocus(); // TODO Clean this up, figure out which things need to be set on the // list view and which don't. if (getIntent().getExtras() != null && getIntent().getExtras().get(Intent.EXTRA_KEY_EVENT) != null) { trappedKeyEvent = (KeyEvent) getIntent().getExtras().get(Intent.EXTRA_KEY_EVENT); /* COMMENTED OUT FOR MARKET RELEASE Log.i(TAG, "Media Button Selector: handling event: " + trappedKeyEvent + " from intent:" + getIntent()); */ getListView().setChoiceMode(ListView.CHOICE_MODE_SINGLE); getListView().setClickable(true); getListView().setFocusable(true); getListView().setFocusableInTouchMode(true); String action = ""; switch (Utils.getAdjustedKeyCode(trappedKeyEvent)) { case KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE: action = getString(audioManager.isMusicActive() ? R.string.pausePlay : R.string.play); break; case KeyEvent.KEYCODE_MEDIA_NEXT: action = getString(R.string.next); break; case KeyEvent.KEYCODE_MEDIA_PREVIOUS: action = getString(R.string.prev); break; case KeyEvent.KEYCODE_MEDIA_STOP: action = getString(R.string.stop); break; } header.setText(String.format(getString(R.string.dialog_header_with_action), action)); if (btButtonSelection >= 0 && btButtonSelection < receivers.size()) { // scroll to last selected item getListView().setSelection(btButtonSelection); } } else { /* COMMENTED OUT FOR MARKET RELEASE Log.i(TAG, "Media Button Selector: launched without key event, started with intent: " + getIntent()); */ trappedKeyEvent = null; getListView().setClickable(false); getListView().setChoiceMode(ListView.CHOICE_MODE_NONE); getListView().setFocusable(false); getListView().setFocusableInTouchMode(false); } Log.d(TAG, "Media Button Selector: Registered UI receiver"); registerReceiver(uiMediaReceiver, uiIntentFilter); // power on device's screen so we can interact with it, otherwise on // pause gets called immediately. // alternative would be to change all of the selection logic to happen // in a service?? don't know if that process life cycle would fit well // -- look into // added On after release so screen stays on a little longer instead of // immediately to try and stop resume pause cycle that sometimes // happens. wakeLock = powerManager.newWakeLock(PowerManager.ACQUIRE_CAUSES_WAKEUP | PowerManager.SCREEN_DIM_WAKE_LOCK | PowerManager.ON_AFTER_RELEASE, TAG); wakeLock.setReferenceCounted(false); wakeLock.acquire(); timeoutExecutor = Executors.newSingleThreadScheduledExecutor(); if (introDialog == null && eulaAcceptedAlready) { // Don't time out in the middle of showing the dialog, that's rude. // We could reset timeout here, but this is the first time the user // is seeing the selection screen, so just let it stay till they // dismiss. resetTimeout(); } } /** * {@inheritDoc} */ @Override public Object onRetainNonConfigurationInstance() { return announced; } /** * {@inheritDoc} */ @Override public boolean onCreateOptionsMenu(Menu menu) { // We're not supposed to show a menu since we show as a dialog, // according to google's ui guidelines. No other sane place to put this, // except maybe // a small configure button in the dialog header, but don't want users // to hit it by accident when selecting music app. MenuInflater inflater = getMenuInflater(); inflater.inflate(R.menu.selector_menu, menu); return true; } @Override public boolean onOptionsItemSelected(MenuItem item) { if (item.getItemId() == R.id.menu_settings) { startActivity(new Intent(this, MediaButtonConfigure.class)); return true; } return super.onOptionsItemSelected(item); } /** * Resets the timeout before the application is automatically dismissed. */ private void resetTimeout() { if (timeoutScheduledFuture != null) { timeoutScheduledFuture.cancel(false); } timeoutScheduledFuture = timeoutExecutor.schedule(new Runnable() { @Override public void run() { runOnUiThread(new Runnable() { @Override public void run() { onTimeout(); } }); } }, timeoutTime, TimeUnit.SECONDS); } /** * {@inheritDoc} */ @Override public void onUserInteraction() { super.onUserInteraction(); // Reset timeout before we finish if (!timeoutExecutor.isShutdown()) { resetTimeout(); } } /** * Forwards the {@code #trappedKeyEvent} to the receiver at specified * position. * * @param position * The index of the receiver to select. Must be in bounds. */ private void forwardToMediaReceiver(int position) { ResolveInfo resolveInfo = receivers.get(position); if (resolveInfo != null) { if (trappedKeyEvent != null) { ComponentName selectedReceiver = new ComponentName(resolveInfo.activityInfo.packageName, resolveInfo.activityInfo.name); Utils.forwardKeyCodeToComponent(this, selectedReceiver, true, Utils.getAdjustedKeyCode(trappedKeyEvent), new SweepBroadcastReceiver(selectedReceiver.toString())); // save the last acted on app in case we have no idea who is // playing music so we can make a guess preferences.edit().putString(SELECTION_ACTED_KEY, resolveInfo.activityInfo.name).commit(); finish(); } } } /** * Moves selection by the amount specified in the list. If we're already at * the last item and we're moving forward, wraps to the first item. If we're * already at the first item, and we're moving backwards, wraps to the last * item. * * @param amount * The amount to move, may be positive or negative. */ private void moveSelection(int amount) { resetTimeout(); btButtonSelection += amount; if (btButtonSelection >= receivers.size()) { // wrap btButtonSelection = 0; } else if (btButtonSelection < 0) { // wrap btButtonSelection = receivers.size() - 1; } // May not highlight, but will scroll to item getListView().invalidateViews(); getListView().setSelection(btButtonSelection); if (textToSpeech != null) { textToSpeech.speak(Utils.getAppName(receivers.get(btButtonSelection), getPackageManager()), TextToSpeech.QUEUE_FLUSH, null); } } /** * Select the currently selected receiver. */ private void select() { if (btButtonSelection == -1 || btButtonSelection >= receivers.size()) { finish(); } else { forwardToMediaReceiver(btButtonSelection); } } /** * Takes appropriate action to notify user and dismiss activity on timeout. */ private void onTimeout() { Log.d(TAG, "Media Button Selector: Timed out waiting for user interaction, finishing activity"); final MediaPlayer timeoutPlayer = MediaPlayer.create(this, R.raw.dismiss); timeoutPlayer.start(); // not having an on error listener results in on completion listener // being called anyway timeoutPlayer.setOnCompletionListener(new OnCompletionListener() { public void onCompletion(MediaPlayer mp) { timeoutPlayer.release(); } }); // If the user has set their preference not to confirm actions, we'll // just forward automatically to whoever was last selected. If no one is // selected, it just acts like finish anyway. if (preferences.getBoolean(Constants.CONFIRM_ACTION_PREF_KEY, true)) { finish(); } else { select(); } } /** * Requests audio focus if necessary. */ private void requestAudioFocus() { if (!audioFocus) { audioFocus = audioManager.requestAudioFocus(this, AudioManager.STREAM_NOTIFICATION, AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK) == AudioManager.AUDIOFOCUS_REQUEST_GRANTED; } } @Override public void onAudioFocusChange(int focusChange) { // TODO Auto-generated method stub } }