Split out ListenService and VolumeHistory

This moves the model of the historic volume to its own class (VolumeHistory),
and extracts the listening out of the ListenActivity to the ListenService.
This commit is contained in:
Fabian Wiesel
2024-02-16 20:35:48 +01:00
parent b339602184
commit f6a429e545
7 changed files with 434 additions and 205 deletions

View File

@@ -18,12 +18,19 @@
<uses-permission <uses-permission
android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE"
android:required="true" /> android:required="true" />
<uses-permission
android:name="android.permission.FOREGROUND_SERVICE"/>
<application <application
android:allowBackup="true" android:allowBackup="true"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:label="@string/app_name" android:label="@string/app_name"
android:theme="@android:style/Theme.Holo"> android:theme="@android:style/Theme.Holo">
<service
android:name=".ListenService"
android:enabled="true"
android:exported="false"/>
<activity <activity
android:name=".StartActivity" android:name=".StartActivity"
android:configChanges="orientation|screenSize" android:configChanges="orientation|screenSize"

View File

@@ -1,21 +0,0 @@
/**
* This file is part of Child Monitor.
*
* Child Monitor is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Child Monitor is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Child Monitor. If not, see <http://www.gnu.org/licenses/>.
*/
package de.rochefort.childmonitor;
public interface AudioListener {
void onAudio(short[] audioBytes);
}

View File

@@ -16,169 +16,132 @@
*/ */
package de.rochefort.childmonitor; package de.rochefort.childmonitor;
import static de.rochefort.childmonitor.AudioCodecDefines.CODEC;
import android.app.Activity; 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.AudioManager;
import android.media.AudioTrack;
import android.media.MediaPlayer;
import android.os.Bundle; import android.os.Bundle;
import android.support.v4.app.NotificationCompat; import android.os.IBinder;
import android.support.v4.app.NotificationManagerCompat; import android.support.v4.content.ContextCompat;
import android.util.Log; import android.util.Log;
import android.widget.TextView; import android.widget.TextView;
import android.widget.Toast;
import java.io.IOException;
import java.io.InputStream;
import java.net.Socket;
import java.net.UnknownHostException;
public class ListenActivity extends Activity { public class ListenActivity extends Activity {
final String TAG = "ChildMonitor"; final String TAG = "ListenActivity";
// Sets an ID for the notification
final static int mNotificationId = 1;
String _address; String address;
int _port; int port;
String _name; String name;
NotificationManagerCompat _mNotifyMgr;
Thread _listenThread; // Don't attempt to unbind from the service unless the client has received some
private final int frequency = AudioCodecDefines.FREQUENCY; // information about the service's state.
private final int channelConfiguration = AudioCodecDefines.CHANNEL_CONFIGURATION_OUT; private boolean shouldUnbind;
private final int audioEncoding = AudioCodecDefines.ENCODING;
private final int bufferSize = AudioTrack.getMinBufferSize(frequency, channelConfiguration, audioEncoding);
private final int byteBufferSize = bufferSize*2;
private void streamAudio(final Socket socket, AudioListener listener) throws IllegalArgumentException, IllegalStateException, IOException { // To invoke the bound service, first make sure that this value
Log.i(TAG, "Setting up stream"); // is not null.
private ListenService boundService;
final AudioTrack audioTrack = new AudioTrack(AudioManager.STREAM_MUSIC, private final ServiceConnection connection = new ServiceConnection() {
frequency, public void onServiceConnected(ComponentName className, IBinder service) {
channelConfiguration, // This is called when the connection with the service has been
audioEncoding, // established, giving us the service object we can use to
bufferSize, // interact with the service. Because we have bound to a explicit
AudioTrack.MODE_STREAM); // 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(); volumeView.setVolumeHistory(bs.getVolumeHistory());
int read = 0; bs.setUpdateCallback(volumeView::postInvalidate);
bs.setErrorCallback(() -> {
TextView status = findViewById(R.id.textStatus);
status.setText(R.string.disconnected);
});
audioTrack.play(); boundService = bs;
}
try { public void onServiceDisconnected(ComponentName className) {
final byte [] readBuffer = new byte[byteBufferSize]; // This is called when the connection with the service has been
final short [] decodedBuffer = new short[byteBufferSize*2]; // 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();
}
};
while(socket.isConnected() && read != -1 && !Thread.currentThread().isInterrupted()) {
read = is.read(readBuffer);
int decoded = CODEC.decode(decodedBuffer, readBuffer, read, 0);
if(decoded > 0) { void startAndBindService() {
audioTrack.write(decodedBuffer, 0, decoded); final Context context = this;
short[] decodedBytes = new short[decoded]; final Intent intent = new Intent(context, ListenService.class);
System.arraycopy(decodedBuffer, 0, decodedBytes, 0, decoded); intent.putExtra("name", name);
listener.onAudio(decodedBytes); 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.");
} }
} }
} catch (Exception e) {
Log.e(TAG, "Connection failed", e); void doUnbindAndStopService() {
} finally { if (shouldUnbind) {
audioTrack.stop(); // Release information about the service's state.
socket.close(); unbindService(connection);
shouldUnbind = false;
} }
final Context context = this;
final Intent intent = new Intent(context, ListenService.class);
context.stopService(intent);
} }
@Override @Override
protected void onCreate(Bundle savedInstanceState) { protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState); super.onCreate(savedInstanceState);
final Bundle b = getIntent().getExtras(); final Bundle bundle = getIntent().getExtras();
_address = b.getString("address"); if (bundle != null) {
_port = b.getInt("port"); address = bundle.getString("address");
_name = b.getString("name"); port = bundle.getInt("port");
// Gets an instance of the NotificationManager service name = bundle.getString("name");
_mNotifyMgr = startAndBindService();
NotificationManagerCompat.from(this); }
setVolumeControlStream(AudioManager.STREAM_MUSIC);
setContentView(R.layout.activity_listen); setContentView(R.layout.activity_listen);
NotificationCompat.Builder mBuilder = final TextView connectedText = findViewById(R.id.connectedTo);
new NotificationCompat.Builder(ListenActivity.this) connectedText.setText(name);
.setOngoing(true)
.setSmallIcon(R.drawable.listening_notification)
.setContentTitle(getString(R.string.app_name))
.setContentText(getString(R.string.listening));
_mNotifyMgr.notify(mNotificationId, mBuilder.build()); final TextView statusText = findViewById(R.id.textStatus);
if (bundle != null) {
final TextView connectedText = (TextView) findViewById(R.id.connectedTo);
connectedText.setText(_name);
final TextView statusText = (TextView) findViewById(R.id.textStatus);
statusText.setText(R.string.listening); 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);
} }
else {
if(!Thread.currentThread().isInterrupted()) { statusText.setText(R.string.error_please_retry);
// 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();
} }
@Override @Override
public void onDestroy() { public void onDestroy() {
doUnbindAndStopService();
boundService = null;
super.onDestroy(); 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");
}
}
} }

View File

@@ -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<Messenger> 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");
}
}
}

View File

@@ -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<Double> 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;
}
}

View File

@@ -24,14 +24,9 @@ import android.support.annotation.Nullable;
import android.util.AttributeSet; import android.util.AttributeSet;
import android.view.View; import android.view.View;
import java.util.LinkedList;
public class VolumeView extends View { public class VolumeView extends View {
private static final int MAX_HISTORY = 10_000;
private double volume;
private double maxVolume;
private Paint paint; private Paint paint;
private LinkedList<Double> volumeHistory; private VolumeHistory _volumeHistory;
public VolumeView(Context context) { public VolumeView(Context context) {
super(context); super(context);
@@ -49,37 +44,25 @@ public class VolumeView extends View {
} }
private void init() { private void init() {
volume = 0;
maxVolume = 0.25;
paint = new Paint(); paint = new Paint();
volumeHistory = new LinkedList<>();
paint.setColor(Color.rgb(255, 127, 0)); 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 @Override
protected void onDraw(Canvas canvas) { protected void onDraw(Canvas canvas) {
int height = canvas.getHeight(); final VolumeHistory volumeHistory = _volumeHistory;
int width = canvas.getWidth(); if (volumeHistory == null) {
double relativeBrightness = 0; return;
double normalizedVolume = volume / maxVolume; }
relativeBrightness = Math.max(0.3, normalizedVolume);
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 blue;
int rest; int rest;
if (relativeBrightness > 0.5) { if (relativeBrightness > 0.5) {
@@ -89,28 +72,22 @@ public class VolumeView extends View {
blue = (int) (255 * (relativeBrightness - 0.2) / 0.3); blue = (int) (255 * (relativeBrightness - 0.2) / 0.3);
rest = 0; rest = 0;
} }
int rgb = Color.rgb(rest, rest, blue); final int rgb = Color.rgb(rest, rest, blue);
canvas.drawColor(rgb); canvas.drawColor(rgb);
double margins = height * 0.1; final double margins = height * 0.1;
double graphHeight = height - 2*margins; final double graphHeight = height - 2.0 * margins;
int leftMost = Math.max(0, volumeHistory.size() - width); final double graphScale = graphHeight * volumeNorm;
int yPrev = (int) (height - margins); int xPrev = 0;
for (int i = leftMost; i < volumeHistory.size() && i - leftMost < width; i++) { int yPrev = ((int) (margins + graphHeight - volumeHistory.get(0) * graphScale));
int xNext = i - leftMost; for (int xNext = 1; xNext < size; ++xNext) {
int yNext = (int) (margins + graphHeight - volumeHistory.get(i) / maxVolume * (graphHeight)); int yNext = (int) (margins + graphHeight - volumeHistory.get(xNext) * graphScale);
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));
}
canvas.drawLine(xPrev, yPrev, xNext, yNext, paint); canvas.drawLine(xPrev, yPrev, xNext, yNext, paint);
xPrev = xNext;
yPrev = yNext; yPrev = yNext;
}
} }
public void setVolumeHistory(VolumeHistory volumeHistory) {
this._volumeHistory = volumeHistory;
} }
} }

View File

@@ -33,4 +33,5 @@
<string name="discoverChildDescription">Select child from a list of discovered children on the network</string> <string name="discoverChildDescription">Select child from a list of discovered children on the network</string>
<string name="enterChildAddress">Select Child by Address</string> <string name="enterChildAddress">Select Child by Address</string>
<string name="enterChildAddressDescription">Enter the address and port of the child</string> <string name="enterChildAddressDescription">Enter the address and port of the child</string>
<string name="error_please_retry">Error, please retry.</string>
</resources> </resources>