{@link State#UNKNOWN}: We cannot do anything. We're waiting for the GoogleApiClient to * connect. * *
{@link State#DISCOVERING}: Our default state (after we've connected). We constantly listen for * a device to advertise near us. * *
{@link State#ADVERTISING}: If a user shakes their device, they enter this state. We advertise * our device so that others nearby can discover us. * *
{@link State#CONNECTED}: We've connected to another device. We can now talk to them by holding * down the volume keys and speaking into the phone. We'll continue to advertise (if we were already * advertising) so that more people can connect to us. */ public class MainActivity extends ConnectionsActivity implements SensorEventListener { /** If true, debug logs are shown on the device. */ private static final boolean DEBUG = true; /** Acceleration required to detect a shake. In multiples of Earth's gravity. */ private static final float SHAKE_THRESHOLD_GRAVITY = 2; /** * Advertise for 30 seconds before going back to discovering. If a client connects, we'll continue * to advertise indefinitely so others can still connect. */ private static final long ADVERTISING_DURATION = 30000; /** How long to vibrate the phone when we change states. */ private static final long VIBRATION_STRENGTH = 500; /** Length of state change animations. */ private static final long ANIMATION_DURATION = 600; /** * This service id lets us find other nearby devices that are interested in the same thing. Our * sample does exactly one thing, so we hardcode the ID. */ private static final String SERVICE_ID = "com.google.location.nearby.apps.walkietalkie.manual.SERVICE_ID"; /** * The state of the app. As the app changes states, the UI will update and advertising/discovery * will start/stop. */ private State mState = State.UNKNOWN; /** A random UID used as this device's endpoint name. */ private String mName; /** Displays the previous state during animation transitions. */ private TextView mPreviousStateView; /** Displays the current state. */ private TextView mCurrentStateView; /** An animator that controls the animation from previous state to current state. */ @Nullable private Animator mCurrentAnimator; /** A running log of debug messages. Only visible when DEBUG=true. */ private TextView mDebugLogView; /** The SensorManager gives us access to sensors on the device. */ private SensorManager mSensorManager; /** The accelerometer sensor allows us to detect device movement for shake-to-advertise. */ private Sensor mAccelerometer; /** Listens to holding/releasing the volume rocker. */ private final GestureDetector mGestureDetector = new GestureDetector(KeyEvent.KEYCODE_VOLUME_DOWN, KeyEvent.KEYCODE_VOLUME_UP) { @Override protected void onHold() { logV("onHold"); startRecording(); } @Override protected void onRelease() { logV("onRelease"); stopRecording(); } }; /** For recording audio as the user speaks. */ @Nullable private AudioRecorder mRecorder; /** For playing audio from other users nearby. */ private final Set mAudioPlayers = new HashSet<>(); /** The phone's original media volume. */ private int mOriginalVolume; /** * A Handler that allows us to post back on to the UI thread. We use this to resume discovery * after an uneventful bout of advertising. */ private final Handler mUiHandler = new Handler(Looper.getMainLooper()); /** Starts discovery. Used in a postDelayed manor with {@link #mUiHandler}. */ private final Runnable mDiscoverRunnable = new Runnable() { @Override public void run() { setState(State.DISCOVERING); } }; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); getSupportActionBar() .setBackgroundDrawable(ContextCompat.getDrawable(this, R.drawable.actionBar)); mSensorManager = (SensorManager) getSystemService(SENSOR_SERVICE); mAccelerometer = mSensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER); mPreviousStateView = (TextView) findViewById(R.id.previous_state); mCurrentStateView = (TextView) findViewById(R.id.current_state); mDebugLogView = (TextView) findViewById(R.id.debug_log); mDebugLogView.setVisibility(DEBUG ? View.VISIBLE : View.GONE); mDebugLogView.setMovementMethod(new ScrollingMovementMethod()); mName = generateRandomName(); ((TextView) findViewById(R.id.name)).setText(mName); } @Override public boolean dispatchKeyEvent(KeyEvent event) { if (mState == State.CONNECTED && mGestureDetector.onKeyEvent(event)) { return true; } return super.dispatchKeyEvent(event); } @Override protected void onStart() { super.onStart(); mSensorManager.registerListener(this, mAccelerometer, SensorManager.SENSOR_DELAY_UI); // Set the media volume to max. setVolumeControlStream(AudioManager.STREAM_MUSIC); AudioManager audioManager = (AudioManager) getSystemService(Context.AUDIO_SERVICE); mOriginalVolume = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC); audioManager.setStreamVolume( AudioManager.STREAM_MUSIC, audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC), 0); } @Override protected void onStop() { mSensorManager.unregisterListener(this); // Restore the original volume. AudioManager audioManager = (AudioManager) getSystemService(Context.AUDIO_SERVICE); audioManager.setStreamVolume(AudioManager.STREAM_MUSIC, mOriginalVolume, 0); setVolumeControlStream(AudioManager.USE_DEFAULT_STREAM_TYPE); if (isRecording()) { stopRecording(); } if (isPlaying()) { stopPlaying(); } setState(State.UNKNOWN); mUiHandler.removeCallbacksAndMessages(null); if (mCurrentAnimator != null && mCurrentAnimator.isRunning()) { mCurrentAnimator.cancel(); } super.onStop(); } @Override public void onBackPressed() { if (getState() == State.CONNECTED || getState() == State.ADVERTISING) { setState(State.DISCOVERING); return; } super.onBackPressed(); } /** * We've connected to Nearby Connections. We can now start calling {@link #startDiscovering()} and * {@link #startAdvertising()}. */ @Override public void onConnected(@Nullable Bundle bundle) { super.onConnected(bundle); setState(State.DISCOVERING); } /** We were disconnected! Halt everything! */ @Override public void onConnectionSuspended(int reason) { super.onConnectionSuspended(reason); setState(State.UNKNOWN); } @Override protected void onEndpointDiscovered(Endpoint endpoint) { // We found an advertiser! connectToEndpoint(endpoint); } @Override protected void onConnectionInitiated(Endpoint endpoint, ConnectionInfo connectionInfo) { // A connection to another device has been initiated! We'll accept the connection immediately. acceptConnection(endpoint); } @Override protected void onEndpointConnected(Endpoint endpoint) { Toast.makeText( this, getString(R.string.toast_connected, endpoint.getName()), Toast.LENGTH_SHORT) .show(); setState(State.CONNECTED); } @Override protected void onEndpointDisconnected(Endpoint endpoint) { Toast.makeText( this, getString(R.string.toast_disconnected, endpoint.getName()), Toast.LENGTH_SHORT) .show(); // If we lost all our endpoints, then we should reset the state of our app and go back // to our initial state (discovering). if (getConnectedEndpoints().isEmpty()) { setState(State.DISCOVERING); } } @Override protected void onConnectionFailed(Endpoint endpoint) { // Let's try someone else. if (getState() == State.DISCOVERING && !getDiscoveredEndpoints().isEmpty()) { connectToEndpoint(pickRandomElem(getDiscoveredEndpoints())); } } /** * The state has changed. I wonder what we'll be doing now. * * @param state The new state. */ private void setState(State state) { if (mState == state) { logW("State set to " + state + " but already in that state"); return; } logD("State set to " + state); State oldState = mState; mState = state; onStateChanged(oldState, state); } /** @return The current state. */ private State getState() { return mState; } /** * State has changed. * * @param oldState The previous state we were in. Clean up anything related to this state. * @param newState The new state we're now in. Prepare the UI for this state. */ private void onStateChanged(State oldState, State newState) { if (mCurrentAnimator != null && mCurrentAnimator.isRunning()) { mCurrentAnimator.cancel(); } // Update Nearby Connections to the new state. switch (newState) { case DISCOVERING: if (isAdvertising()) { stopAdvertising(); } disconnectFromAllEndpoints(); startDiscovering(); break; case ADVERTISING: if (isDiscovering()) { stopDiscovering(); } disconnectFromAllEndpoints(); startAdvertising(); break; case CONNECTED: if (isDiscovering()) { stopDiscovering(); } else if (isAdvertising()) { // Continue to advertise, so others can still connect, // but clear the discover runnable. removeCallbacks(mDiscoverRunnable); } break; default: // no-op break; } // Update the UI. switch (oldState) { case UNKNOWN: // Unknown is our initial state. Whatever state we move to, // we're transitioning forwards. transitionForward(oldState, newState); break; case DISCOVERING: switch (newState) { case UNKNOWN: transitionBackward(oldState, newState); break; case ADVERTISING: case CONNECTED: transitionForward(oldState, newState); break; default: // no-op break; } break; case ADVERTISING: switch (newState) { case UNKNOWN: case DISCOVERING: transitionBackward(oldState, newState); break; case CONNECTED: transitionForward(oldState, newState); break; default: // no-op break; } break; case CONNECTED: // Connected is our final state. Whatever new state we move to, // we're transitioning backwards. transitionBackward(oldState, newState); break; default: // no-op break; } } /** Transitions from the old state to the new state with an animation implying moving forward. */ @UiThread private void transitionForward(State oldState, final State newState) { mPreviousStateView.setVisibility(View.VISIBLE); mCurrentStateView.setVisibility(View.VISIBLE); updateTextView(mPreviousStateView, oldState); updateTextView(mCurrentStateView, newState); mCurrentAnimator = createAnimator(false /* reverse */); mCurrentAnimator.addListener( new AnimatorListener() { @Override public void onAnimationEnd(Animator animator) { updateTextView(mCurrentStateView, newState); } }); mCurrentAnimator.start(); } /** Transitions from the old state to the new state with an animation implying moving backward. */ @UiThread private void transitionBackward(State oldState, final State newState) { mPreviousStateView.setVisibility(View.VISIBLE); mCurrentStateView.setVisibility(View.VISIBLE); updateTextView(mCurrentStateView, oldState); updateTextView(mPreviousStateView, newState); mCurrentAnimator = createAnimator(true /* reverse */); mCurrentAnimator.addListener( new AnimatorListener() { @Override public void onAnimationEnd(Animator animator) { updateTextView(mCurrentStateView, newState); } }); mCurrentAnimator.start(); } @NonNull private Animator createAnimator(boolean reverse) { Animator animator; if (Build.VERSION.SDK_INT >= 21) { int cx = mCurrentStateView.getMeasuredWidth() / 2; int cy = mCurrentStateView.getMeasuredHeight() / 2; int initialRadius = 0; int finalRadius = Math.max(mCurrentStateView.getWidth(), mCurrentStateView.getHeight()); if (reverse) { int temp = initialRadius; initialRadius = finalRadius; finalRadius = temp; } animator = ViewAnimationUtils.createCircularReveal( mCurrentStateView, cx, cy, initialRadius, finalRadius); } else { float initialAlpha = 0f; float finalAlpha = 1f; if (reverse) { float temp = initialAlpha; initialAlpha = finalAlpha; finalAlpha = temp; } mCurrentStateView.setAlpha(initialAlpha); animator = ObjectAnimator.ofFloat(mCurrentStateView, "alpha", finalAlpha); } animator.addListener( new AnimatorListener() { @Override public void onAnimationCancel(Animator animator) { mPreviousStateView.setVisibility(View.GONE); mCurrentStateView.setAlpha(1); } @Override public void onAnimationEnd(Animator animator) { mPreviousStateView.setVisibility(View.GONE); mCurrentStateView.setAlpha(1); } }); animator.setDuration(ANIMATION_DURATION); return animator; } /** Updates the {@link TextView} with the correct color/text for the given {@link State}. */ @UiThread private void updateTextView(TextView textView, State state) { switch (state) { case DISCOVERING: textView.setBackgroundResource(R.color.state_discovering); textView.setText(R.string.status_discovering); break; case ADVERTISING: textView.setBackgroundResource(R.color.state_advertising); textView.setText(R.string.status_advertising); break; case CONNECTED: textView.setBackgroundResource(R.color.state_connected); textView.setText(R.string.status_connected); break; default: textView.setBackgroundResource(R.color.state_unknown); textView.setText(R.string.status_unknown); break; } } /** The device has moved. We need to decide if it was intentional or not. */ @Override public void onSensorChanged(SensorEvent sensorEvent) { float x = sensorEvent.values[0]; float y = sensorEvent.values[1]; float z = sensorEvent.values[2]; float gX = x / SensorManager.GRAVITY_EARTH; float gY = y / SensorManager.GRAVITY_EARTH; float gZ = z / SensorManager.GRAVITY_EARTH; double gForce = Math.sqrt(gX * gX + gY * gY + gZ * gZ); if (gForce > SHAKE_THRESHOLD_GRAVITY && getState() == State.DISCOVERING) { logD("Device shaken"); vibrate(); setState(State.ADVERTISING); postDelayed(mDiscoverRunnable, ADVERTISING_DURATION); } } @Override public void onAccuracyChanged(Sensor sensor, int accuracy) {} /** Vibrates the phone. */ private void vibrate() { Vibrator vibrator = (Vibrator) getSystemService(Context.VIBRATOR_SERVICE); if (hasPermissions(this, Manifest.permission.VIBRATE) && vibrator.hasVibrator()) { vibrator.vibrate(VIBRATION_STRENGTH); } } /** {@see ConnectionsActivity#onReceive(Endpoint, Payload)} */ @Override protected void onReceive(Endpoint endpoint, Payload payload) { if (payload.getType() == Payload.Type.STREAM) { AudioPlayer player = new AudioPlayer(payload.asStream().asInputStream()) { @WorkerThread @Override protected void onFinish() { final AudioPlayer audioPlayer = this; post( new Runnable() { @UiThread @Override public void run() { mAudioPlayers.remove(audioPlayer); } }); } }; mAudioPlayers.add(player); player.start(); } } /** Stops all currently streaming audio tracks. */ private void stopPlaying() { logV("stopPlaying()"); for (AudioPlayer player : mAudioPlayers) { player.stop(); } mAudioPlayers.clear(); } /** @return True if currently playing. */ private boolean isPlaying() { return !mAudioPlayers.isEmpty(); } /** Starts recording sound from the microphone and streaming it to all connected devices. */ private void startRecording() { logV("startRecording()"); try { ParcelFileDescriptor[] payloadPipe = ParcelFileDescriptor.createPipe(); // Send the first half of the payload (the read side) to Nearby Connections. send(Payload.fromStream(payloadPipe[0])); // Use the second half of the payload (the write side) in AudioRecorder. mRecorder = new AudioRecorder(payloadPipe[1]); mRecorder.start(); } catch (IOException e) { logE("startRecording() failed", e); } } /** Stops streaming sound from the microphone. */ private void stopRecording() { logV("stopRecording()"); if (mRecorder != null) { mRecorder.stop(); mRecorder = null; } } /** @return True if currently streaming from the microphone. */ private boolean isRecording() { return mRecorder != null && mRecorder.isRecording(); } /** {@see ConnectionsActivity#getRequiredPermissions()} */ @Override protected String[] getRequiredPermissions() { return join( super.getRequiredPermissions(), Manifest.permission.RECORD_AUDIO); } /** Joins 2 arrays together. */ private static String[] join(String[] a, String... b) { String[] join = new String[a.length + b.length]; System.arraycopy(a, 0, join, 0, a.length); System.arraycopy(b, 0, join, a.length, b.length); return join; } /** * Queries the phone's contacts for their own profile, and returns their name. Used when * connecting to another device. */ @Override protected String getName() { return mName; } /** {@see ConnectionsActivity#getServiceId()} */ @Override public String getServiceId() { return SERVICE_ID; } /** {@see Handler#post()} */ protected void post(Runnable r) { mUiHandler.post(r); } /** {@see Handler#postDelayed(Runnable, long)} */ protected void postDelayed(Runnable r, long duration) { mUiHandler.postDelayed(r, duration); } /** {@see Handler#removeCallbacks(Runnable)} */ protected void removeCallbacks(Runnable r) { mUiHandler.removeCallbacks(r); } @Override protected void logV(String msg) { super.logV(msg); appendToLogs(toColor(msg, getResources().getColor(R.color.log_verbose))); } @Override protected void logD(String msg) { super.logD(msg); appendToLogs(toColor(msg, getResources().getColor(R.color.log_debug))); } @Override protected void logW(String msg) { super.logW(msg); appendToLogs(toColor(msg, getResources().getColor(R.color.log_warning))); } @Override protected void logE(String msg, Throwable e) { super.logE(msg, e); appendToLogs(toColor(msg, getResources().getColor(R.color.log_error))); } private void appendToLogs(CharSequence msg) { mDebugLogView.append("\n"); mDebugLogView.append(DateFormat.format("hh:mm", System.currentTimeMillis()) + ": "); mDebugLogView.append(msg); } private static CharSequence toColor(String msg, int color) { SpannableString spannable = new SpannableString(msg); spannable.setSpan(new ForegroundColorSpan(color), 0, msg.length(), 0); return spannable; } private static String generateRandomName() { String name = ""; Random random = new Random(); for (int i = 0; i < 5; i++) { name += random.nextInt(10); } return name; } @SuppressWarnings("unchecked") private static T pickRandomElem(Collection collection) { return (T) collection.toArray()[new Random().nextInt(collection.size())]; } /** * Provides an implementation of Animator.AnimatorListener so that we only have to override the * method(s) we're interested in. */ private abstract static class AnimatorListener implements Animator.AnimatorListener { @Override public void onAnimationStart(Animator animator) {} @Override public void onAnimationEnd(Animator animator) {} @Override public void onAnimationCancel(Animator animator) {} @Override public void onAnimationRepeat(Animator animator) {} } /** States that the UI goes through. */ public enum State { UNKNOWN, DISCOVERING, ADVERTISING, CONNECTED } }