diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index ad65da3..1d1c324 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -18,12 +18,19 @@ + + android:theme="@android:style/Theme.Holo"> + + . - */ -package de.rochefort.childmonitor; - -public interface AudioListener { - void onAudio(short[] audioBytes); -} diff --git a/app/src/main/java/de/rochefort/childmonitor/ListenActivity.java b/app/src/main/java/de/rochefort/childmonitor/ListenActivity.java index 9232e70..0f38f64 100644 --- a/app/src/main/java/de/rochefort/childmonitor/ListenActivity.java +++ b/app/src/main/java/de/rochefort/childmonitor/ListenActivity.java @@ -16,169 +16,132 @@ */ package de.rochefort.childmonitor; -import static de.rochefort.childmonitor.AudioCodecDefines.CODEC; - import android.app.Activity; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.ServiceConnection; import android.media.AudioManager; -import android.media.AudioTrack; -import android.media.MediaPlayer; import android.os.Bundle; -import android.support.v4.app.NotificationCompat; -import android.support.v4.app.NotificationManagerCompat; +import android.os.IBinder; +import android.support.v4.content.ContextCompat; import android.util.Log; import android.widget.TextView; - -import java.io.IOException; -import java.io.InputStream; -import java.net.Socket; -import java.net.UnknownHostException; +import android.widget.Toast; public class ListenActivity extends Activity { - final String TAG = "ChildMonitor"; - // Sets an ID for the notification - final static int mNotificationId = 1; + final String TAG = "ListenActivity"; - String _address; - int _port; - String _name; - NotificationManagerCompat _mNotifyMgr; + String address; + int port; + String name; - Thread _listenThread; - private final int frequency = AudioCodecDefines.FREQUENCY; - private final int channelConfiguration = AudioCodecDefines.CHANNEL_CONFIGURATION_OUT; - private final int audioEncoding = AudioCodecDefines.ENCODING; - private final int bufferSize = AudioTrack.getMinBufferSize(frequency, channelConfiguration, audioEncoding); - private final int byteBufferSize = bufferSize*2; + // Don't attempt to unbind from the service unless the client has received some + // information about the service's state. + private boolean shouldUnbind; - private void streamAudio(final Socket socket, AudioListener listener) throws IllegalArgumentException, IllegalStateException, IOException { - Log.i(TAG, "Setting up stream"); + // To invoke the bound service, first make sure that this value + // is not null. + private ListenService boundService; - final AudioTrack audioTrack = new AudioTrack(AudioManager.STREAM_MUSIC, - frequency, - channelConfiguration, - audioEncoding, - bufferSize, - AudioTrack.MODE_STREAM); + private final ServiceConnection connection = new ServiceConnection() { + public void onServiceConnected(ComponentName className, IBinder service) { + // This is called when the connection with the service has been + // established, giving us the service object we can use to + // interact with the service. Because we have bound to a explicit + // service that we know is running in our own process, we can + // cast its IBinder to a concrete class and directly access it. + ListenService bs = ((ListenService.ListenBinder) service).getService(); - setVolumeControlStream(AudioManager.STREAM_MUSIC); + Toast.makeText(ListenActivity.this, R.string.connect, + Toast.LENGTH_SHORT).show(); + final VolumeView volumeView = findViewById(R.id.volume); - final InputStream is = socket.getInputStream(); - int read = 0; + volumeView.setVolumeHistory(bs.getVolumeHistory()); + bs.setUpdateCallback(volumeView::postInvalidate); + bs.setErrorCallback(() -> { + TextView status = findViewById(R.id.textStatus); + status.setText(R.string.disconnected); + }); - audioTrack.play(); - - try { - final byte [] readBuffer = new byte[byteBufferSize]; - final short [] decodedBuffer = new short[byteBufferSize*2]; - - while(socket.isConnected() && read != -1 && !Thread.currentThread().isInterrupted()) { - read = is.read(readBuffer); - int decoded = CODEC.decode(decodedBuffer, readBuffer, read, 0); - - if(decoded > 0) { - audioTrack.write(decodedBuffer, 0, decoded); - short[] decodedBytes = new short[decoded]; - System.arraycopy(decodedBuffer, 0, decodedBytes, 0, decoded); - listener.onAudio(decodedBytes); - } - } - } catch (Exception e) { - Log.e(TAG, "Connection failed", e); - } finally { - audioTrack.stop(); - socket.close(); + boundService = bs; } + + public void onServiceDisconnected(ComponentName className) { + // This is called when the connection with the service has been + // unexpectedly disconnected -- that is, its process crashed. + // Because it is running in our same process, we should never + // see this happen. + boundService = null; + Toast.makeText(ListenActivity.this, R.string.disconnected, + Toast.LENGTH_SHORT).show(); + } + }; + + + void startAndBindService() { + final Context context = this; + final Intent intent = new Intent(context, ListenService.class); + intent.putExtra("name", name); + intent.putExtra("address", address); + intent.putExtra("port", port); + ContextCompat.startForegroundService(context, intent); + // Attempts to establish a connection with the service. We use an + // explicit class name because we want a specific service + // implementation that we know will be running in our own process + // (and thus won't be supporting component replacement by other + // applications). + if (bindService(intent, connection, Context.BIND_AUTO_CREATE)) { + shouldUnbind = true; + Log.i(TAG, "Bound service"); + } else { + Log.e(TAG, "Error: The requested service doesn't " + + "exist, or this client isn't allowed access to it."); + } + } + + void doUnbindAndStopService() { + if (shouldUnbind) { + // Release information about the service's state. + unbindService(connection); + shouldUnbind = false; + } + final Context context = this; + final Intent intent = new Intent(context, ListenService.class); + context.stopService(intent); } @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); - final Bundle b = getIntent().getExtras(); - _address = b.getString("address"); - _port = b.getInt("port"); - _name = b.getString("name"); - // Gets an instance of the NotificationManager service - _mNotifyMgr = - NotificationManagerCompat.from(this); + final Bundle bundle = getIntent().getExtras(); + if (bundle != null) { + address = bundle.getString("address"); + port = bundle.getInt("port"); + name = bundle.getString("name"); + startAndBindService(); + } + setVolumeControlStream(AudioManager.STREAM_MUSIC); setContentView(R.layout.activity_listen); - NotificationCompat.Builder mBuilder = - new NotificationCompat.Builder(ListenActivity.this) - .setOngoing(true) - .setSmallIcon(R.drawable.listening_notification) - .setContentTitle(getString(R.string.app_name)) - .setContentText(getString(R.string.listening)); + final TextView connectedText = findViewById(R.id.connectedTo); + connectedText.setText(name); - _mNotifyMgr.notify(mNotificationId, mBuilder.build()); - - final TextView connectedText = (TextView) findViewById(R.id.connectedTo); - connectedText.setText(_name); - - final TextView statusText = (TextView) findViewById(R.id.textStatus); - statusText.setText(R.string.listening); - - final VolumeView volumeView = (VolumeView) findViewById(R.id.volume); - - final AudioListener listener = audioData -> runOnUiThread(() -> volumeView.onAudioData(audioData)); - - - _listenThread = new Thread(() -> { - try { - final Socket socket = new Socket(_address, _port); - streamAudio(socket, listener); - } catch (IOException e) { - Log.e(TAG, "Failed to stream audio", e); - } - - if(!Thread.currentThread().isInterrupted()) { - // If this thread has not been interrupted, likely something - // bad happened with the connection to the child device. Play - // an alert to notify the user that the connection has been - // interrupted. - playAlert(); - - ListenActivity.this.runOnUiThread(() -> { - final TextView connectedText1 = (TextView) findViewById(R.id.connectedTo); - connectedText1.setText(""); - - final TextView statusText1 = (TextView) findViewById(R.id.textStatus); - statusText1.setText(R.string.disconnected); - NotificationCompat.Builder mBuilder1 = - new NotificationCompat.Builder(ListenActivity.this) - .setOngoing(false) - .setSmallIcon(R.drawable.listening_notification) - .setContentTitle(getString(R.string.app_name)) - .setContentText(getString(R.string.disconnected)); - _mNotifyMgr.notify(mNotificationId, mBuilder1.build()); - }); - } - }); - - _listenThread.start(); - } - - @Override - protected void onStop() { - _listenThread.interrupt(); - _listenThread = null; - super.onStop(); + final TextView statusText = findViewById(R.id.textStatus); + if (bundle != null) { + statusText.setText(R.string.listening); + } + else { + statusText.setText(R.string.error_please_retry); + } } @Override public void onDestroy() { + doUnbindAndStopService(); + boundService = null; super.onDestroy(); } - - private void playAlert() { - final MediaPlayer mp = MediaPlayer.create(this, R.raw.upward_beep_chromatic_fifths); - if(mp != null) { - Log.i(TAG, "Playing alert"); - mp.setOnCompletionListener(mp1 -> mp1.release()); - mp.start(); - } else { - Log.e(TAG, "Failed to play alert"); - } - } } diff --git a/app/src/main/java/de/rochefort/childmonitor/ListenService.java b/app/src/main/java/de/rochefort/childmonitor/ListenService.java new file mode 100644 index 0000000..8a7552c --- /dev/null +++ b/app/src/main/java/de/rochefort/childmonitor/ListenService.java @@ -0,0 +1,237 @@ +package de.rochefort.childmonitor; + +import static de.rochefort.childmonitor.AudioCodecDefines.CODEC; + +import android.app.Notification; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.app.Service; +import android.content.Intent; +import android.media.AudioManager; +import android.media.AudioTrack; +import android.media.MediaPlayer; +import android.os.Binder; +import android.os.Build; +import android.os.Bundle; +import android.os.IBinder; +import android.os.Messenger; +import android.support.v4.app.NotificationCompat; +import android.util.Log; +import android.widget.Toast; + +import java.io.IOException; +import java.io.InputStream; +import java.net.Socket; +import java.util.ArrayList; + +public class ListenService extends Service { + ArrayList clients = new ArrayList<>(); + + private static final String TAG = "ListenService"; + public static final String CHANNEL_ID = TAG; + public static final int ID = 1337; + + private final int frequency = AudioCodecDefines.FREQUENCY; + private final int channelConfiguration = AudioCodecDefines.CHANNEL_CONFIGURATION_OUT; + private final int audioEncoding = AudioCodecDefines.ENCODING; + private final int bufferSize = AudioTrack.getMinBufferSize(frequency, channelConfiguration, audioEncoding); + private final int byteBufferSize = bufferSize*2; + + private final IBinder binder = new ListenBinder(); + private NotificationManager notificationManager; + private String address; + private int port; + private Thread listenThread; + + private final VolumeHistory volumeHistory = new VolumeHistory(16_384); + + public ListenService() { + } + + @Override + public void onCreate() { + super.onCreate(); + notificationManager = (NotificationManager) getSystemService(NOTIFICATION_SERVICE); + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + Log.i(TAG, "Received start id " + startId + ": " + intent); + // Display a notification about us starting. We put an icon in the status bar. + createNotificationChannel(); + Notification n = buildNotification(intent); + startForeground(ID, n); + doListen(); + + return START_STICKY; + } + + @Override + public void onDestroy() { + Thread lt = listenThread; + if (lt != null) { + lt.interrupt(); + listenThread = null; + } + + // Cancel the persistent notification. + int NOTIFICATION = R.string.listening; + notificationManager.cancel(NOTIFICATION); + + stopForeground(true); + // Tell the user we stopped. + Toast.makeText(this, R.string.stopped, Toast.LENGTH_SHORT).show(); + } + + @Override + public IBinder onBind(Intent intent) { + return binder; + } + + public VolumeHistory getVolumeHistory() { + return volumeHistory; + } + + /** + * Show a notification while this service is running. + * + * @return + */ + private Notification buildNotification(Intent intent) { + // In this sample, we'll use the same text for the ticker and the expanded notification + CharSequence text = getText(R.string.listening); + + final Bundle bundle = intent.getExtras(); + if (bundle == null) { + return null; + } + address = bundle.getString("address"); + port = bundle.getInt("port"); + String _name = bundle.getString("name"); + + // The PendingIntent to launch our activity if the user selects this notification + PendingIntent contentIntent = PendingIntent.getActivity(this, 0, + new Intent(this, ListenActivity.class), PendingIntent.FLAG_IMMUTABLE); + + // Set the info for the views that show in the notification panel. + NotificationCompat.Builder b = new NotificationCompat.Builder(this, CHANNEL_ID); + b.setSmallIcon(R.drawable.listening_notification) // the status icon + .setOngoing(true) + .setTicker(text) // the status text + .setContentTitle(text) // the label of the entry + .setContentText(_name) // the contents of the entry + .setContentIntent(contentIntent); + return b.build(); + } + + + private void createNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + NotificationChannel serviceChannel = new NotificationChannel( + CHANNEL_ID, + "Foreground Service Channel", + NotificationManager.IMPORTANCE_DEFAULT + ); + notificationManager.createNotificationChannel(serviceChannel); + } + } + + public void setErrorCallback(Runnable errorCallback) { + this.mErrorCallback = errorCallback; + } + + public void setUpdateCallback(Runnable updateCallback) { + this.mUpdateCallback = updateCallback; + } + + public class ListenBinder extends Binder { + ListenService getService() { + return ListenService.this; + } + } + + private Runnable mErrorCallback; + private Runnable mUpdateCallback; + + private void doListen() { + listenThread = new Thread(() -> { + try { + final Socket socket = new Socket(address, port); + streamAudio(socket); + } catch (IOException e) { + Log.e(TAG, "Failed to stream audio", e); + } + + if (!Thread.currentThread().isInterrupted()) { + // If this thread has not been interrupted, likely something + // bad happened with the connection to the child device. Play + // an alert to notify the user that the connection has been + // interrupted. + playAlert(); + + final Runnable errorCallback = mErrorCallback; + if (errorCallback != null) { + errorCallback.run(); + } + } + }); + + listenThread.start(); + } + + + private void streamAudio(final Socket socket) throws IllegalArgumentException, IllegalStateException, IOException { + Log.i(TAG, "Setting up stream"); + + final AudioTrack audioTrack = new AudioTrack(AudioManager.STREAM_MUSIC, + frequency, + channelConfiguration, + audioEncoding, + bufferSize, + AudioTrack.MODE_STREAM); + + final InputStream is = socket.getInputStream(); + int read = 0; + + audioTrack.play(); + + try { + final byte [] readBuffer = new byte[byteBufferSize]; + final short [] decodedBuffer = new short[byteBufferSize*2]; + + while (socket.isConnected() && read != -1 && !Thread.currentThread().isInterrupted()) { + read = is.read(readBuffer); + int decoded = CODEC.decode(decodedBuffer, readBuffer, read, 0); + + if (decoded > 0) { + audioTrack.write(decodedBuffer, 0, decoded); + short[] decodedBytes = new short[decoded]; + System.arraycopy(decodedBuffer, 0, decodedBytes, 0, decoded); + volumeHistory.onAudioData(decodedBytes); + final Runnable updateCallback = mUpdateCallback; + if (updateCallback != null) { + updateCallback.run(); + } + } + } + } catch (Exception e) { + Log.e(TAG, "Connection failed", e); + } finally { + audioTrack.stop(); + socket.close(); + } + } + + + private void playAlert() { + final MediaPlayer mp = MediaPlayer.create(this, R.raw.upward_beep_chromatic_fifths); + if(mp != null) { + Log.i(TAG, "Playing alert"); + mp.setOnCompletionListener(MediaPlayer::release); + mp.start(); + } else { + Log.e(TAG, "Failed to play alert"); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/de/rochefort/childmonitor/VolumeHistory.java b/app/src/main/java/de/rochefort/childmonitor/VolumeHistory.java new file mode 100644 index 0000000..8a09bdf --- /dev/null +++ b/app/src/main/java/de/rochefort/childmonitor/VolumeHistory.java @@ -0,0 +1,65 @@ +package de.rochefort.childmonitor; + +import android.support.v4.util.CircularArray; + +public class VolumeHistory { + private double mMaxVolume = 0.25; + + private double mVolumeNorm = 1.0 / mMaxVolume; + private final CircularArray mHistory; + private final int mMaxHistory; + + VolumeHistory(int maxHistory) { + mMaxHistory = maxHistory; + mHistory = new CircularArray<>(maxHistory); + } + + + public double getVolumeNorm() { + return mVolumeNorm; + } + + public double get(int i) { + return mHistory.get(i); + } + + public int size() { + return mHistory.size(); + } + + protected synchronized void addLast(double volume) { + if (volume > mMaxVolume) { + mMaxVolume = volume; + mVolumeNorm = 1.0 / volume; + } + mHistory.addLast(volume); + mHistory.removeFromStart(mHistory.size() - mMaxHistory); + } + + public void onAudioData(short[] data) { + if (data.length < 1) { + return; + } + + final double scale = 1.0 / 128.0; + double sum = 0; + for (final short datum : data) { + final double rel = datum * scale; + sum += rel * rel; + } + final double volume = sum / data.length; + addLast(volume); + } + + public synchronized VolumeHistory getSnapshot(int length) { + length = Math.min(length, size()); + VolumeHistory copy = new VolumeHistory(length); + copy.mMaxVolume = this.mMaxVolume; + copy.mVolumeNorm = this.mVolumeNorm; + for (int i = 0; i < length; ++i) { + copy.mHistory.addLast(mHistory.get(i)); + } + + return copy; + } +} diff --git a/app/src/main/java/de/rochefort/childmonitor/VolumeView.java b/app/src/main/java/de/rochefort/childmonitor/VolumeView.java index b338bcb..37bda35 100644 --- a/app/src/main/java/de/rochefort/childmonitor/VolumeView.java +++ b/app/src/main/java/de/rochefort/childmonitor/VolumeView.java @@ -24,14 +24,9 @@ import android.support.annotation.Nullable; import android.util.AttributeSet; import android.view.View; -import java.util.LinkedList; - public class VolumeView extends View { - private static final int MAX_HISTORY = 10_000; - private double volume; - private double maxVolume; private Paint paint; - private LinkedList volumeHistory; + private VolumeHistory _volumeHistory; public VolumeView(Context context) { super(context); @@ -49,37 +44,25 @@ public class VolumeView extends View { } private void init() { - volume = 0; - maxVolume = 0.25; paint = new Paint(); - volumeHistory = new LinkedList<>(); paint.setColor(Color.rgb(255, 127, 0)); } - public void onAudioData(short[] data) { - double sum = 0; - for (int i = 0; i < data.length; i++) { - double rel = data[i] / ((double)128); - sum += Math.pow(rel, 2); - } - volume = sum / data.length; - if (volume > maxVolume) { - maxVolume = volume; - } - volumeHistory.addLast(volume); - while (volumeHistory.size() > MAX_HISTORY) { - volumeHistory.removeFirst(); - } - invalidate(); - } - @Override protected void onDraw(Canvas canvas) { - int height = canvas.getHeight(); - int width = canvas.getWidth(); - double relativeBrightness = 0; - double normalizedVolume = volume / maxVolume; - relativeBrightness = Math.max(0.3, normalizedVolume); + final VolumeHistory volumeHistory = _volumeHistory; + if (volumeHistory == null) { + return; + } + + final int height = canvas.getHeight(); + final int width = canvas.getWidth(); + + final VolumeHistory history = volumeHistory.getSnapshot(width); + final int size = history.size(); // Size is at most width + final double volumeNorm = history.getVolumeNorm(); + final double normalizedVolume = history.get(size - 1); + final double relativeBrightness = Math.max(0.3, normalizedVolume); int blue; int rest; if (relativeBrightness > 0.5) { @@ -89,28 +72,22 @@ public class VolumeView extends View { blue = (int) (255 * (relativeBrightness - 0.2) / 0.3); rest = 0; } - int rgb = Color.rgb(rest, rest, blue); + final int rgb = Color.rgb(rest, rest, blue); canvas.drawColor(rgb); - double margins = height * 0.1; - double graphHeight = height - 2*margins; - int leftMost = Math.max(0, volumeHistory.size() - width); - int yPrev = (int) (height - margins); - for (int i = leftMost; i < volumeHistory.size() && i - leftMost < width; i++) { - int xNext = i - leftMost; - int yNext = (int) (margins + graphHeight - volumeHistory.get(i) / maxVolume * (graphHeight)); - int xPrev; - if (i == leftMost) { - xPrev = xNext; - } else { - xPrev = xNext - 1; - } - if (i == leftMost && i > 0){ - yPrev = (int) (margins + graphHeight - volumeHistory.get(i-1) / maxVolume * (graphHeight)); - } + final double margins = height * 0.1; + final double graphHeight = height - 2.0 * margins; + final double graphScale = graphHeight * volumeNorm; + int xPrev = 0; + int yPrev = ((int) (margins + graphHeight - volumeHistory.get(0) * graphScale)); + for (int xNext = 1; xNext < size; ++xNext) { + int yNext = (int) (margins + graphHeight - volumeHistory.get(xNext) * graphScale); canvas.drawLine(xPrev, yPrev, xNext, yNext, paint); + xPrev = xNext; yPrev = yNext; - } + } + public void setVolumeHistory(VolumeHistory volumeHistory) { + this._volumeHistory = volumeHistory; } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e2c09d2..1fbfb21 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -33,4 +33,5 @@ Select child from a list of discovered children on the network Select Child by Address Enter the address and port of the child + Error, please retry.