WIP: Modernize the Stack Kotlin Edition

This commit is contained in:
Fabian Wiesel
2024-02-18 21:57:29 +01:00
parent 946de1d65b
commit cd6c4b60a6
24 changed files with 1398 additions and 1496 deletions

View File

@@ -1,13 +1,13 @@
apply plugin: 'com.android.application' apply plugin: 'com.android.application'
apply plugin: 'org.jetbrains.kotlin.android'
android { android {
compileSdk 26 compileSdk 34
defaultConfig { defaultConfig {
applicationId "de.rochefort.childmonitor" applicationId "de.rochefort.childmonitor"
minSdkVersion 21 minSdkVersion 21
//noinspection ExpiredTargetSdkVersion targetSdkVersion 34
targetSdkVersion 26
versionCode 11 versionCode 11
versionName "1.1" versionName "1.1"
} }
@@ -19,9 +19,7 @@ android {
} }
} }
dependencies { dependencies {
implementation "com.android.support:support-compat:26.1.0"
} }
namespace 'de.rochefort.childmonitor' namespace 'de.rochefort.childmonitor'
lint { lint {
@@ -29,7 +27,16 @@ android {
warning 'MissingTranslation' warning 'MissingTranslation'
} }
compileOptions { compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8 // Sets Java compatibility to Java 17
targetCompatibility JavaVersion.VERSION_1_8 sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
kotlin {
jvmToolchain(17)
} }
} }
dependencies {
implementation 'androidx.core:core:1.12.0'
implementation 'androidx.core:core-ktx:1.12.0'
}

View File

@@ -20,6 +20,10 @@
android:required="true" /> android:required="true" />
<uses-permission <uses-permission
android:name="android.permission.FOREGROUND_SERVICE"/> android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission
android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"/>
<uses-permission
android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE"/>
<application <application
android:allowBackup="true" android:allowBackup="true"
@@ -30,10 +34,12 @@
<service <service
android:name=".ListenService" android:name=".ListenService"
android:enabled="true" android:enabled="true"
android:foregroundServiceType="mediaPlayback"
android:exported="false"/> android:exported="false"/>
<service <service
android:name=".MonitorService" android:name=".MonitorService"
android:enabled="true" android:enabled="true"
android:foregroundServiceType="microphone"
android:exported="false"/> android:exported="false"/>
<activity <activity

View File

@@ -14,20 +14,21 @@
* You should have received a copy of the GNU General Public License * You should have received a copy of the GNU General Public License
* along with Child Monitor. If not, see <http://www.gnu.org/licenses/>. * along with Child Monitor. If not, see <http://www.gnu.org/licenses/>.
*/ */
package de.rochefort.childmonitor; package de.rochefort.childmonitor
import android.media.AudioFormat; import android.media.AudioFormat
import de.rochefort.childmonitor.audio.G711UCodec
import de.rochefort.childmonitor.audio.G711UCodec; class AudioCodecDefines private constructor() {
init {
throw IllegalStateException("Do not instantiate!")
}
public class AudioCodecDefines { companion object {
public static final int FREQUENCY = 8000; const val FREQUENCY = 8000
public static final int ENCODING = AudioFormat.ENCODING_PCM_16BIT; const val ENCODING = AudioFormat.ENCODING_PCM_16BIT
public static final G711UCodec CODEC = new G711UCodec(); val CODEC = G711UCodec()
public static final int CHANNEL_CONFIGURATION_IN = AudioFormat.CHANNEL_IN_MONO; const val CHANNEL_CONFIGURATION_IN = AudioFormat.CHANNEL_IN_MONO
public static final int CHANNEL_CONFIGURATION_OUT = AudioFormat.CHANNEL_OUT_MONO; const val CHANNEL_CONFIGURATION_OUT = AudioFormat.CHANNEL_OUT_MONO
private AudioCodecDefines() {
throw new IllegalStateException("Do not instantiate!");
} }
} }

View File

@@ -1,297 +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;
import android.app.Activity;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.net.nsd.NsdManager;
import android.net.nsd.NsdServiceInfo;
import android.net.wifi.WifiManager;
import android.os.Bundle;
import android.util.Log;
import android.widget.ArrayAdapter;
import android.widget.Button;
import android.widget.EditText;
import android.widget.ListView;
import android.widget.Toast;
import java.util.Objects;
public class DiscoverActivity extends Activity {
private static final String PREF_KEY_CHILD_DEVICE_ADDRESS = "childDeviceAddress";
private static final String PREF_KEY_CHILD_DEVICE_PORT = "childDevicePort";
final String TAG = "ChildMonitor";
private NsdManager nsdManager;
private NsdManager.DiscoveryListener discoveryListener;
@Override
protected void onCreate(Bundle savedInstanceState) {
Log.i(TAG, "ChildMonitor start");
nsdManager = (NsdManager)this.getSystemService(Context.NSD_SERVICE);
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_discover);
final Button discoverChildButton = findViewById(R.id.discoverChildButton);
discoverChildButton.setOnClickListener(v -> loadDiscoveryViaMdns());
final Button enterChildAddressButton = findViewById(R.id.enterChildAddressButton);
enterChildAddressButton.setOnClickListener(v -> loadDiscoveryViaAddress());
}
private void loadDiscoveryViaMdns() {
setContentView(R.layout.activity_discover_mdns);
startServiceDiscovery("_childmonitor._tcp.");
}
private void loadDiscoveryViaAddress() {
setContentView(R.layout.activity_discover_address);
final Button connectButton = findViewById(R.id.connectViaAddressButton);
final EditText addressField = findViewById(R.id.ipAddressField);
final EditText portField = findViewById(R.id.portField);
String preferredAddress = getPreferences(MODE_PRIVATE).getString(PREF_KEY_CHILD_DEVICE_ADDRESS, null);
if (preferredAddress != null && !preferredAddress.isEmpty()) {
addressField.setText(preferredAddress);
}
int preferredPort = getPreferences(MODE_PRIVATE).getInt(PREF_KEY_CHILD_DEVICE_PORT, -1);
if (preferredPort > 0) {
portField.setText(String.valueOf(preferredPort));
} else {
portField.setText("10000");
}
connectButton.setOnClickListener(v -> {
Log.i(TAG, "Connecting to child device via address");
final String addressString = addressField.getText().toString();
final String portString = portField.getText().toString();
if(addressString.length() == 0)
{
Toast.makeText(DiscoverActivity.this, R.string.invalidAddress, Toast.LENGTH_LONG).show();
return;
}
int port;
try {
port = Integer.parseInt(portString);
}
catch(NumberFormatException e)
{
Toast.makeText(DiscoverActivity.this, R.string.invalidPort, Toast.LENGTH_LONG).show();
return;
}
SharedPreferences.Editor preferencesEditor = getPreferences(MODE_PRIVATE).edit();
preferencesEditor.putString(PREF_KEY_CHILD_DEVICE_ADDRESS, addressString);
preferencesEditor.putInt(PREF_KEY_CHILD_DEVICE_PORT, port);
preferencesEditor.apply();
connectToChild(addressString, port, addressString);
});
}
@Override
protected void onDestroy() {
Log.i(TAG, "ChildMonitoring stop");
if(discoveryListener != null) {
Log.i(TAG, "Unregistering monitoring service");
nsdManager.stopServiceDiscovery(discoveryListener);
discoveryListener = null;
}
super.onDestroy();
}
public void startServiceDiscovery(final String serviceType) {
final NsdManager nsdManager = (NsdManager)this.getSystemService(Context.NSD_SERVICE);
if (nsdManager == null) {
Log.e(TAG, "Could not obtain nsdManager");
return;
}
WifiManager wifi = (WifiManager) this.getApplicationContext().getSystemService(Context.WIFI_SERVICE);
final Runnable multicastReleaser;
if (wifi != null) {
final WifiManager.MulticastLock multicastLock = wifi.createMulticastLock("multicastLock");
multicastLock.setReferenceCounted(true);
multicastLock.acquire();
multicastReleaser = () -> {
try {
multicastLock.release();
} catch (Exception ignored) {
//dont really care
}
};
} else {
multicastReleaser = () -> {
};
}
final ListView serviceTable = findViewById(R.id.ServiceTable);
final ArrayAdapter<ServiceInfoWrapper> availableServicesAdapter = new ArrayAdapter<>(this,
R.layout.available_children_list);
serviceTable.setAdapter(availableServicesAdapter);
serviceTable.setOnItemClickListener((parent, view, position, id) -> {
final ServiceInfoWrapper info = (ServiceInfoWrapper) parent.getItemAtPosition(position);
connectToChild(info.getAddress(), info.getPort(), info.getName());
});
// Instantiate a new DiscoveryListener
discoveryListener = new NsdManager.DiscoveryListener() {
// Called as soon as service discovery begins.
@Override
public void onDiscoveryStarted(String regType)
{
Log.d(TAG, "Service discovery started");
}
@Override
public void onServiceFound(NsdServiceInfo service) {
// A service was found! Do something with it.
Log.d(TAG, "Service discovery success: " + service);
if (!service.getServiceType().equals(serviceType)) {
// Service type is the string containing the protocol and
// transport layer for this service.
Log.d(TAG, "Unknown Service Type: " + service.getServiceType());
} else if (service.getServiceName().contains("ChildMonitor")) {
NsdManager.ResolveListener resolver = new NsdManager.ResolveListener() {
@Override
public void onResolveFailed(NsdServiceInfo serviceInfo, int errorCode) {
// Called when the resolve fails. Use the error code to debug.
Log.e(TAG, "Resolve failed: error " + errorCode + " for service: " + serviceInfo);
}
@Override
public void onServiceResolved(final NsdServiceInfo serviceInfo) {
Log.i(TAG, "Resolve Succeeded: " + serviceInfo);
DiscoverActivity.this.runOnUiThread(() -> {
for (int index=0; index < availableServicesAdapter.getCount(); index++) {
ServiceInfoWrapper item = availableServicesAdapter.getItem(index);
if (item != null && item.matches(serviceInfo)) {
// Prevent inserting duplicates
return;
}
}
availableServicesAdapter.add(new ServiceInfoWrapper(serviceInfo));
});
}
};
DiscoverActivity.this.nsdManager.resolveService(service, resolver);
} else {
Log.d(TAG, "Unknown Service name: " + service.getServiceName());
}
}
@Override
public void onServiceLost(NsdServiceInfo service) {
// When the network service is no longer available.
// Internal bookkeeping code goes here.
Log.e(TAG, "Service lost: " + service);
multicastReleaser.run();
}
@Override
public void onDiscoveryStopped(String serviceType) {
Log.i(TAG, "Discovery stopped: " + serviceType);
multicastReleaser.run();
}
@Override
public void onStartDiscoveryFailed(String serviceType, int errorCode) {
Log.e(TAG, "Discovery failed: Error code: " + errorCode);
nsdManager.stopServiceDiscovery(this);
multicastReleaser.run();
}
@Override
public void onStopDiscoveryFailed(String serviceType, int errorCode) {
Log.e(TAG, "Discovery failed: Error code: " + errorCode);
nsdManager.stopServiceDiscovery(this);
multicastReleaser.run();
}
};
nsdManager.discoverServices(
serviceType, NsdManager.PROTOCOL_DNS_SD, discoveryListener
);
}
private void connectToChild(final String address, final int port, final String name) {
final Intent i = new Intent(getApplicationContext(), ListenActivity.class);
final Bundle b = new Bundle();
b.putString("address", address);
b.putInt("port", port);
b.putString("name", name);
i.putExtras(b);
startActivity(i);
}
}
class ServiceInfoWrapper {
private final NsdServiceInfo info;
public ServiceInfoWrapper(NsdServiceInfo info)
{
this.info = info;
}
public boolean matches(NsdServiceInfo other) {
return Objects.equals(this.info.getHost(), other.getHost()) && this.info.getPort() == other.getPort();
}
public String getAddress()
{
return info.getHost().getHostAddress();
}
public int getPort()
{
return info.getPort();
}
public String getName() {
// If there is more than one service with the same name on the network, it will
// have a number at the end, but will appear as the following:
// "ChildMonitor\\032(number)
// or
// "ChildMonitor\032(number)
// Replace \\032 and \032 with a " "
String serviceName = info.getServiceName();
serviceName = serviceName.replace("\\\\032", " ");
serviceName = serviceName.replace("\\032", " ");
return serviceName;
}
@Override
public String toString()
{
return getName();
}
}

View File

@@ -0,0 +1,247 @@
/*
* 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
import android.app.Activity
import android.content.Intent
import android.net.nsd.NsdManager
import android.net.nsd.NsdManager.DiscoveryListener
import android.net.nsd.NsdServiceInfo
import android.net.wifi.WifiManager
import android.os.Bundle
import android.util.Log
import android.view.View
import android.widget.AdapterView
import android.widget.AdapterView.OnItemClickListener
import android.widget.ArrayAdapter
import android.widget.Button
import android.widget.EditText
import android.widget.ListView
import android.widget.Toast
val TAG = "ChildMonitor"
class DiscoverActivity : Activity() {
private var nsdManager: NsdManager? = null
private var discoveryListener: DiscoveryListener? = null
override fun onCreate(savedInstanceState: Bundle?) {
Log.i(TAG, "ChildMonitor start")
nsdManager = this.getSystemService(NSD_SERVICE) as NsdManager
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_discover)
val discoverChildButton = findViewById<Button>(R.id.discoverChildButton)
discoverChildButton.setOnClickListener { v: View? -> loadDiscoveryViaMdns() }
val enterChildAddressButton = findViewById<Button>(R.id.enterChildAddressButton)
enterChildAddressButton.setOnClickListener { v: View? -> loadDiscoveryViaAddress() }
}
private fun loadDiscoveryViaMdns() {
setContentView(R.layout.activity_discover_mdns)
startServiceDiscovery("_childmonitor._tcp.")
}
private fun loadDiscoveryViaAddress() {
setContentView(R.layout.activity_discover_address)
val connectButton = findViewById<Button>(R.id.connectViaAddressButton)
val addressField = findViewById<EditText>(R.id.ipAddressField)
val portField = findViewById<EditText>(R.id.portField)
val preferredAddress = getPreferences(MODE_PRIVATE).getString(PREF_KEY_CHILD_DEVICE_ADDRESS, null)
if (!preferredAddress.isNullOrEmpty()) {
addressField.setText(preferredAddress)
}
val preferredPort = getPreferences(MODE_PRIVATE).getInt(PREF_KEY_CHILD_DEVICE_PORT, -1)
if (preferredPort > 0) {
portField.setText(preferredPort.toString())
} else {
portField.setText("10000")
}
connectButton.setOnClickListener { v: View? ->
Log.i(TAG, "Connecting to child device via address")
val addressString = addressField.text.toString()
val portString = portField.text.toString()
if (addressString.isEmpty()) {
Toast.makeText(this@DiscoverActivity, R.string.invalidAddress, Toast.LENGTH_LONG).show()
return@setOnClickListener
}
val port: Int = try {
portString.toInt()
} catch (e: NumberFormatException) {
Toast.makeText(this@DiscoverActivity, R.string.invalidPort, Toast.LENGTH_LONG).show()
return@setOnClickListener
}
val preferencesEditor = getPreferences(MODE_PRIVATE).edit()
preferencesEditor.putString(PREF_KEY_CHILD_DEVICE_ADDRESS, addressString)
preferencesEditor.putInt(PREF_KEY_CHILD_DEVICE_PORT, port)
preferencesEditor.apply()
connectToChild(addressString, port, addressString)
}
}
override fun onDestroy() {
Log.i(TAG, "ChildMonitoring stop")
if (discoveryListener != null) {
Log.i(TAG, "Unregistering monitoring service")
nsdManager!!.stopServiceDiscovery(discoveryListener)
discoveryListener = null
}
super.onDestroy()
}
fun startServiceDiscovery(serviceType: String) {
val nsdManager = this.getSystemService(NSD_SERVICE) as NsdManager
if (nsdManager == null) {
Log.e(TAG, "Could not obtain nsdManager")
return
}
val wifi = this.applicationContext.getSystemService(WIFI_SERVICE) as WifiManager
val multicastReleaser: Runnable
multicastReleaser = if (wifi != null) {
val multicastLock = wifi.createMulticastLock("multicastLock")
multicastLock.setReferenceCounted(true)
multicastLock.acquire()
Runnable {
try {
multicastLock.release()
} catch (ignored: Exception) {
//dont really care
}
}
} else {
Runnable {}
}
val serviceTable = findViewById<ListView>(R.id.ServiceTable)
val availableServicesAdapter = ArrayAdapter<ServiceInfoWrapper>(this,
R.layout.available_children_list)
serviceTable.adapter = availableServicesAdapter
serviceTable.onItemClickListener = OnItemClickListener { parent: AdapterView<*>, view: View?, position: Int, id: Long ->
val info = parent.getItemAtPosition(position) as ServiceInfoWrapper
connectToChild(info.address, info.port, info.name)
}
// Instantiate a new DiscoveryListener
discoveryListener = object : DiscoveryListener {
// Called as soon as service discovery begins.
override fun onDiscoveryStarted(regType: String) {
Log.d(TAG, "Service discovery started")
}
override fun onServiceFound(service: NsdServiceInfo) {
// A service was found! Do something with it.
Log.d(TAG, "Service discovery success: $service")
if (service.serviceType != serviceType) {
// Service type is the string containing the protocol and
// transport layer for this service.
Log.d(TAG, "Unknown Service Type: " + service.serviceType)
} else if (service.serviceName.contains("ChildMonitor")) {
val resolver: NsdManager.ResolveListener = object : NsdManager.ResolveListener {
override fun onResolveFailed(serviceInfo: NsdServiceInfo, errorCode: Int) {
// Called when the resolve fails. Use the error code to debug.
Log.e(TAG, "Resolve failed: error $errorCode for service: $serviceInfo")
}
override fun onServiceResolved(serviceInfo: NsdServiceInfo) {
Log.i(TAG, "Resolve Succeeded: $serviceInfo")
runOnUiThread {
for (index in 0 until availableServicesAdapter.count) {
val item = availableServicesAdapter.getItem(index)
if (item != null && item.matches(serviceInfo)) {
// Prevent inserting duplicates
return@runOnUiThread
}
}
availableServicesAdapter.add(ServiceInfoWrapper(serviceInfo))
}
}
}
this@DiscoverActivity.nsdManager!!.resolveService(service, resolver)
} else {
Log.d(TAG, "Unknown Service name: " + service.serviceName)
}
}
override fun onServiceLost(service: NsdServiceInfo) {
// When the network service is no longer available.
// Internal bookkeeping code goes here.
Log.e(TAG, "Service lost: $service")
multicastReleaser.run()
}
override fun onDiscoveryStopped(serviceType: String) {
Log.i(TAG, "Discovery stopped: $serviceType")
multicastReleaser.run()
}
override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) {
Log.e(TAG, "Discovery failed: Error code: $errorCode")
nsdManager.stopServiceDiscovery(this)
multicastReleaser.run()
}
override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) {
Log.e(TAG, "Discovery failed: Error code: $errorCode")
nsdManager.stopServiceDiscovery(this)
multicastReleaser.run()
}
}
nsdManager.discoverServices(
serviceType, NsdManager.PROTOCOL_DNS_SD, discoveryListener
)
}
private fun connectToChild(address: String, port: Int, name: String) {
val i = Intent(applicationContext, ListenActivity::class.java)
val b = Bundle()
b.putString("address", address)
b.putInt("port", port)
b.putString("name", name)
i.putExtras(b)
startActivity(i)
}
companion object {
private const val PREF_KEY_CHILD_DEVICE_ADDRESS = "childDeviceAddress"
private const val PREF_KEY_CHILD_DEVICE_PORT = "childDevicePort"
}
}
internal class ServiceInfoWrapper(private val info: NsdServiceInfo) {
fun matches(other: NsdServiceInfo): Boolean {
return info.host == other.host && info.port == other.port
}
val address: String
get() = info.host.hostAddress
val port: Int
get() = info.port
val name: String
get() {
// If there is more than one service with the same name on the network, it will
// have a number at the end, but will appear as the following:
// "ChildMonitor\\032(number)
// or
// "ChildMonitor\032(number)
// Replace \\032 and \032 with a " "
var serviceName = info.serviceName
serviceName = serviceName.replace("\\\\032", " ")
serviceName = serviceName.replace("\\032", " ")
return serviceName
}
override fun toString(): String {
return name
}
}

View File

@@ -1,127 +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;
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.os.Bundle;
import android.os.IBinder;
import android.support.v4.content.ContextCompat;
import android.util.Log;
import android.widget.TextView;
import android.widget.Toast;
public class ListenActivity extends Activity {
private static final String TAG = "ListenActivity";
// Don't attempt to unbind from the service unless the client has received some
// information about the service's state.
private boolean shouldUnbind;
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();
Toast.makeText(ListenActivity.this, R.string.connect,
Toast.LENGTH_SHORT).show();
final TextView connectedText = findViewById(R.id.connectedTo);
connectedText.setText(bs.getChildDeviceName());
final VolumeView volumeView = findViewById(R.id.volume);
volumeView.setVolumeHistory(bs.getVolumeHistory());
bs.setUpdateCallback(volumeView::postInvalidate);
bs.setErrorCallback(ListenActivity.this::postErrorMessage);
}
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.
Toast.makeText(ListenActivity.this, R.string.disconnected,
Toast.LENGTH_SHORT).show();
}
};
void ensureServiceRunningAndBind(Bundle bundle) {
final Context context = this;
final Intent intent = new Intent(context, ListenService.class);
if (bundle != null) {
intent.putExtra("name", bundle.getString("name"));
intent.putExtra("address", bundle.getString("address"));
intent.putExtra("port", bundle.getInt("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 listen 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);
}
public void postErrorMessage() {
TextView status = findViewById(R.id.textStatus);
status.post(() -> status.setText(R.string.disconnected));
}
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
final Bundle bundle = getIntent().getExtras();
ensureServiceRunningAndBind(bundle);
setVolumeControlStream(AudioManager.STREAM_MUSIC);
setContentView(R.layout.activity_listen);
final TextView statusText = findViewById(R.id.textStatus);
statusText.setText(R.string.listening);
}
@Override
public void onDestroy() {
doUnbindAndStopService();
super.onDestroy();
}
}

View File

@@ -0,0 +1,122 @@
/*
* 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
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.os.Bundle
import android.os.IBinder
import android.util.Log
import android.widget.TextView
import android.widget.Toast
import androidx.core.content.ContextCompat
import de.rochefort.childmonitor.ListenService.ListenBinder
class ListenActivity : Activity() {
// Don't attempt to unbind from the service unless the client has received some
// information about the service's state.
private var shouldUnbind = false
private val connection: ServiceConnection = object : ServiceConnection {
override fun onServiceConnected(className: ComponentName, service: IBinder) {
// 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.
val bs = (service as ListenBinder).service
Toast.makeText(this@ListenActivity, R.string.connect,
Toast.LENGTH_SHORT).show()
val connectedText = findViewById<TextView>(R.id.connectedTo)
connectedText.text = bs.childDeviceName
val volumeView = findViewById<VolumeView>(R.id.volume)
volumeView.setVolumeHistory(bs.volumeHistory)
bs.setUpdateCallback { volumeView.postInvalidate() }
bs.setErrorCallback { postErrorMessage() }
}
override fun onServiceDisconnected(className: ComponentName) {
// 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.
Toast.makeText(this@ListenActivity, R.string.disconnected,
Toast.LENGTH_SHORT).show()
}
}
private fun ensureServiceRunningAndBind(bundle: Bundle?) {
val context: Context = this
val intent = Intent(context, ListenService::class.java)
if (bundle != null) {
intent.putExtra("name", bundle.getString("name"))
intent.putExtra("address", bundle.getString("address"))
intent.putExtra("port", bundle.getInt("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, BIND_AUTO_CREATE)) {
this.shouldUnbind = true
Log.i(TAG, "Bound listen service")
} else {
Log.e(TAG, "Error: The requested service doesn't " +
"exist, or this client isn't allowed access to it.")
}
}
private fun doUnbindAndStopService() {
if (this.shouldUnbind) {
// Release information about the service's state.
unbindService(connection)
this.shouldUnbind = false
}
val context: Context = this
val intent = Intent(context, ListenService::class.java)
context.stopService(intent)
}
fun postErrorMessage() {
val status = findViewById<TextView>(R.id.textStatus)
status.post { status.setText(R.string.disconnected) }
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val bundle = this.intent.extras
ensureServiceRunningAndBind(bundle)
this.volumeControlStream = AudioManager.STREAM_MUSIC
setContentView(R.layout.activity_listen)
val statusText = findViewById<TextView>(R.id.textStatus)
statusText.setText(R.string.listening)
}
public override fun onDestroy() {
doUnbindAndStopService()
super.onDestroy()
}
companion object {
private const val TAG = "ListenActivity"
}
}

View File

@@ -1,246 +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;
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.support.v4.app.NotificationCompat;
import android.util.Log;
import android.widget.Toast;
import java.io.IOException;
import java.io.InputStream;
import java.net.Socket;
public class ListenService extends Service {
private static final String TAG = "ListenService";
public static final String CHANNEL_ID = TAG;
public static final int ID = 902938409;
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 Thread listenThread;
private final VolumeHistory volumeHistory = new VolumeHistory(16_384);
private String childDeviceName;
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();
Bundle extras = intent.getExtras();
if (extras != null) {
String name = extras.getString("name");
childDeviceName = name;
Notification n = buildNotification(name);
startForeground(ID, n);
String address = extras.getString("address");
int port = extras.getInt("port");
doListen(address, port);
}
return START_REDELIVER_INTENT;
}
@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;
}
private Notification buildNotification(String name) {
// In this sample, we'll use the same text for the ticker and the expanded notification
CharSequence text = getText(R.string.listening);
// 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(String address, int port) {
Thread lt = 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 = lt;
lt.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");
}
}
public String getChildDeviceName() {
return childDeviceName;
}
}

View File

@@ -0,0 +1,212 @@
/*
* 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
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.content.pm.ServiceInfo
import android.media.AudioManager
import android.media.AudioTrack
import android.media.MediaPlayer
import android.os.Binder
import android.os.Build
import android.os.IBinder
import android.util.Log
import android.widget.Toast
import androidx.core.app.NotificationCompat
import androidx.core.app.ServiceCompat
import java.io.IOException
import java.net.Socket
class ListenService : Service() {
private val frequency: Int = AudioCodecDefines.Companion.FREQUENCY
private val channelConfiguration: Int = AudioCodecDefines.Companion.CHANNEL_CONFIGURATION_OUT
private val audioEncoding: Int = AudioCodecDefines.Companion.ENCODING
private val bufferSize = AudioTrack.getMinBufferSize(frequency, channelConfiguration, audioEncoding)
private val byteBufferSize = bufferSize * 2
private val binder: IBinder = ListenBinder()
private var notificationManager: NotificationManager? = null
private var listenThread: Thread? = null
val volumeHistory = VolumeHistory(16384)
var childDeviceName: String? = null
private set
override fun onCreate() {
super.onCreate()
this.notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
}
override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int {
Log.i(TAG, "Received start id $startId: $intent")
// Display a notification about us starting. We put an icon in the status bar.
createNotificationChannel()
val extras = intent.extras
if (extras != null) {
val name = extras.getString("name")
childDeviceName = name
val n = buildNotification(name)
val foregroundServiceType = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK else 0 // Keep the linter happy
ServiceCompat.startForeground(this, ID, n, foregroundServiceType)
val address = extras.getString("address")
val port = extras.getInt("port")
doListen(address, port)
}
return START_REDELIVER_INTENT
}
override fun onDestroy() {
val lt = this.listenThread
if (lt != null) {
lt.interrupt()
this.listenThread = null
}
// Cancel the persistent notification.
val NOTIFICATION = R.string.listening
notificationManager!!.cancel(NOTIFICATION)
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
// Tell the user we stopped.
Toast.makeText(this, R.string.stopped, Toast.LENGTH_SHORT).show()
}
override fun onBind(intent: Intent): IBinder {
return binder
}
private fun buildNotification(name: String?): Notification {
// In this sample, we'll use the same text for the ticker and the expanded notification
val text = getText(R.string.listening)
// The PendingIntent to launch our activity if the user selects this notification
val contentIntent = PendingIntent.getActivity(this, 0,
Intent(this, ListenActivity::class.java), PendingIntent.FLAG_IMMUTABLE)
// Set the info for the views that show in the notification panel.
val b = 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 fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val serviceChannel = NotificationChannel(
CHANNEL_ID,
"Foreground Service Channel",
NotificationManager.IMPORTANCE_DEFAULT
)
notificationManager!!.createNotificationChannel(serviceChannel)
}
}
fun setErrorCallback(errorCallback: Runnable?) {
mErrorCallback = errorCallback
}
fun setUpdateCallback(updateCallback: Runnable?) {
mUpdateCallback = updateCallback
}
inner class ListenBinder : Binder() {
val service: ListenService
get() = this@ListenService
}
private var mErrorCallback: Runnable? = null
private var mUpdateCallback: Runnable? = null
private fun doListen(address: String?, port: Int) {
val lt = Thread {
try {
val socket = Socket(address, port)
streamAudio(socket)
} catch (e: IOException) {
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()
val errorCallback = mErrorCallback
errorCallback?.run()
}
}
this.listenThread = lt
lt.start()
}
@Throws(IllegalArgumentException::class, IllegalStateException::class, IOException::class)
private fun streamAudio(socket: Socket) {
Log.i(TAG, "Setting up stream")
val audioTrack = AudioTrack(AudioManager.STREAM_MUSIC,
frequency,
channelConfiguration,
audioEncoding,
bufferSize,
AudioTrack.MODE_STREAM)
val inputStream = socket.getInputStream()
var read = 0
audioTrack.play()
try {
val readBuffer = ByteArray(byteBufferSize)
val decodedBuffer = ShortArray(byteBufferSize * 2)
while (socket.isConnected && read != -1 && !Thread.currentThread().isInterrupted) {
read = inputStream.read(readBuffer)
val decoded: Int = AudioCodecDefines.Companion.CODEC.decode(decodedBuffer, readBuffer, read, 0)
if (decoded > 0) {
audioTrack.write(decodedBuffer, 0, decoded)
val decodedBytes = ShortArray(decoded)
System.arraycopy(decodedBuffer, 0, decodedBytes, 0, decoded)
volumeHistory.onAudioData(decodedBytes)
val updateCallback = mUpdateCallback
updateCallback?.run()
}
}
} catch (e: Exception) {
Log.e(TAG, "Connection failed", e)
} finally {
audioTrack.stop()
socket.close()
}
}
private fun playAlert() {
val mp = MediaPlayer.create(this, R.raw.upward_beep_chromatic_fifths)
if (mp != null) {
Log.i(TAG, "Playing alert")
mp.setOnCompletionListener { obj: MediaPlayer -> obj.release() }
mp.start()
} else {
Log.e(TAG, "Failed to play alert")
}
}
companion object {
private const val TAG = "ListenService"
const val CHANNEL_ID = TAG
const val ID = 902938409
}
}

View File

@@ -1,143 +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;
import android.app.Activity;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.content.ServiceConnection;
import android.net.ConnectivityManager;
import android.net.LinkAddress;
import android.net.Network;
import android.net.NetworkInfo;
import android.os.Bundle;
import android.os.IBinder;
import android.support.v4.content.ContextCompat;
import android.util.Log;
import android.widget.TextView;
import android.widget.Toast;
import java.net.InetAddress;
import java.util.ArrayList;
import java.util.List;
public class MonitorActivity extends Activity {
final static String TAG = "ChildMonitor";
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 an explicit
// service that we know is running in our own process, we can
// cast its IBinder to a concrete class and directly access it.
MonitorService bs = ((MonitorService.MonitorBinder) service).getService();
bs.setMonitorActivity(MonitorActivity.this);
}
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.
Toast.makeText(MonitorActivity.this, R.string.disconnected,
Toast.LENGTH_SHORT).show();
}
};
private boolean shouldUnbind;
@Override
protected void onCreate(Bundle savedInstanceState) {
Log.i(TAG, "ChildMonitor start");
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_monitor);
final TextView addressText = findViewById(R.id.address);
List<String> listenAddresses = getListenAddresses();
if (!listenAddresses.isEmpty()) {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < listenAddresses.size(); i++) {
String listenAddress = listenAddresses.get(i);
sb.append(listenAddress);
if (i != listenAddresses.size() - 1) {
sb.append("\n\n");
}
}
addressText.setText(sb.toString());
} else {
addressText.setText(R.string.notConnected);
}
ensureServiceRunningAndBind();
}
private List<String> getListenAddresses() {
ConnectivityManager cm = (ConnectivityManager) getSystemService(Context.CONNECTIVITY_SERVICE);
List<String> listenAddresses = new ArrayList<>();
if (cm != null) {
for (Network network : cm.getAllNetworks()) {
NetworkInfo networkInfo = cm.getNetworkInfo(network);
boolean connected = networkInfo.isConnected();
if (connected) {
List<LinkAddress> linkAddresses = cm.getLinkProperties(network).getLinkAddresses();
for (LinkAddress linkAddress : linkAddresses) {
InetAddress address = linkAddress.getAddress();
if (!address.isLinkLocalAddress() && !address.isLoopbackAddress()) {
listenAddresses.add(address.getHostAddress() + " (" + networkInfo.getTypeName() + ")");
}
}
}
}
}
return listenAddresses;
}
@Override
public void onDestroy() {
doUnbindAndStopService();
super.onDestroy();
}
void ensureServiceRunningAndBind() {
final Context context = this;
final Intent intent = new Intent(context, MonitorService.class);
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 monitor 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, MonitorService.class);
context.stopService(intent);
}
}

View File

@@ -0,0 +1,134 @@
/*
* 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
import android.app.Activity
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.net.ConnectivityManager
import android.os.Bundle
import android.os.IBinder
import android.util.Log
import android.widget.TextView
import android.widget.Toast
import androidx.core.content.ContextCompat
import de.rochefort.childmonitor.MonitorService.MonitorBinder
class MonitorActivity : Activity() {
private val connection: ServiceConnection = object : ServiceConnection {
override fun onServiceConnected(className: ComponentName, service: IBinder) {
// 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 an explicit
// service that we know is running in our own process, we can
// cast its IBinder to a concrete class and directly access it.
val bs = (service as MonitorBinder).service
bs!!.setMonitorActivity(this@MonitorActivity)
}
override fun onServiceDisconnected(className: ComponentName) {
// 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.
Toast.makeText(this@MonitorActivity, R.string.disconnected,
Toast.LENGTH_SHORT).show()
}
}
private var shouldUnbind = false
override fun onCreate(savedInstanceState: Bundle?) {
Log.i(TAG, "ChildMonitor start")
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_monitor)
val addressText = findViewById<TextView>(R.id.address)
val listenAddresses = listenAddresses
if (listenAddresses.isNotEmpty()) {
val sb = StringBuilder()
for (i in listenAddresses.indices) {
val listenAddress = listenAddresses[i]
sb.append(listenAddress)
if (i != listenAddresses.size - 1) {
sb.append("\n\n")
}
}
addressText.text = sb.toString()
} else {
addressText.setText(R.string.notConnected)
}
ensureServiceRunningAndBind()
}
private val listenAddresses: List<String>
get() {
val cm = getSystemService(CONNECTIVITY_SERVICE) as ConnectivityManager
val listenAddresses: MutableList<String> = ArrayList()
for (network in cm.allNetworks) {
val networkInfo = cm.getNetworkInfo(network)
val connected = networkInfo!!.isConnected
if (connected) {
val linkAddresses = cm.getLinkProperties(network)!!.linkAddresses
for (linkAddress in linkAddresses) {
val address = linkAddress.address
if (!address.isLinkLocalAddress && !address.isLoopbackAddress) {
listenAddresses.add(address.hostAddress + " (" + networkInfo.typeName + ")")
}
}
}
}
return listenAddresses
}
public override fun onDestroy() {
doUnbindAndStopService()
super.onDestroy()
}
private fun ensureServiceRunningAndBind() {
val context: Context = this
val intent = Intent(context, MonitorService::class.java)
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, BIND_AUTO_CREATE)) {
this.shouldUnbind = true
Log.i(TAG, "Bound monitor service")
} else {
Log.e(TAG, "Error: The requested service doesn't " +
"exist, or this client isn't allowed access to it.")
}
}
private fun doUnbindAndStopService() {
if (this.shouldUnbind) {
// Release information about the service's state.
unbindService(connection)
this.shouldUnbind = false
}
val context: Context = this
val intent = Intent(context, MonitorService::class.java)
context.stopService(intent)
}
companion object {
const val TAG = "ChildMonitor"
}
}

View File

@@ -1,313 +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;
import static de.rochefort.childmonitor.AudioCodecDefines.CODEC;
import android.app.Notification;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.app.Service;
import android.content.Context;
import android.content.Intent;
import android.media.AudioRecord;
import android.media.MediaRecorder;
import android.net.nsd.NsdManager;
import android.net.nsd.NsdServiceInfo;
import android.os.Binder;
import android.os.Build;
import android.os.IBinder;
import android.support.v4.app.NotificationCompat;
import android.util.Log;
import android.widget.TextView;
import android.widget.Toast;
import java.io.IOException;
import java.io.OutputStream;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.Objects;
public class MonitorService extends Service {
final static String TAG = "MonitorService";
final static String CHANNEL_ID = TAG;
public static final int ID = 1338;
private final IBinder binder = new MonitorBinder();
private NsdManager nsdManager;
private NsdManager.RegistrationListener registrationListener;
private ServerSocket currentSocket;
private Object connectionToken;
private int currentPort;
private NotificationManager notificationManager;
private Thread monitorThread;
private MonitorActivity monitorActivity;
public void setMonitorActivity(MonitorActivity monitorActivity) {
this.monitorActivity = monitorActivity;
}
private void serviceConnection(Socket socket) {
final MonitorActivity ma = monitorActivity;
if (ma != null) {
ma.runOnUiThread(() -> {
final TextView statusText = monitorActivity.findViewById(R.id.textStatus);
statusText.setText(R.string.streaming);
});
}
final int frequency = AudioCodecDefines.FREQUENCY;
final int channelConfiguration = AudioCodecDefines.CHANNEL_CONFIGURATION_IN;
final int audioEncoding = AudioCodecDefines.ENCODING;
final int bufferSize = AudioRecord.getMinBufferSize(frequency, channelConfiguration, audioEncoding);
final AudioRecord audioRecord;
try {
audioRecord = new AudioRecord(
MediaRecorder.AudioSource.MIC,
frequency,
channelConfiguration,
audioEncoding,
bufferSize
);
} catch (SecurityException e) {
// This should never happen, we asked for permission before
throw new RuntimeException(e);
}
final int pcmBufferSize = bufferSize * 2;
final short[] pcmBuffer = new short[pcmBufferSize];
final byte[] ulawBuffer = new byte[pcmBufferSize];
try {
audioRecord.startRecording();
final OutputStream out = socket.getOutputStream();
socket.setSendBufferSize(pcmBufferSize);
Log.d(TAG, "Socket send buffer size: " + socket.getSendBufferSize());
while (socket.isConnected() && currentSocket != null && !Thread.currentThread().isInterrupted()) {
final int read = audioRecord.read(pcmBuffer, 0, bufferSize);
int encoded = CODEC.encode(pcmBuffer, read, ulawBuffer, 0);
out.write(ulawBuffer, 0, encoded);
}
} catch (Exception e) {
Log.e(TAG, "Connection failed", e);
} finally {
audioRecord.stop();
}
}
@Override
public void onCreate() {
Log.i(TAG, "ChildMonitor start");
super.onCreate();
notificationManager = (NotificationManager) getSystemService(NOTIFICATION_SERVICE);
nsdManager = (NsdManager) this.getSystemService(Context.NSD_SERVICE);
currentPort = 10000;
currentSocket = null;
}
@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();
startForeground(ID, n);
ensureMonitorThread();
return START_REDELIVER_INTENT;
}
private void ensureMonitorThread() {
Thread mt = monitorThread;
if (mt != null && mt.isAlive()) {
return;
}
final Object currentToken = new Object();
connectionToken = currentToken;
mt = new Thread(() -> {
while (Objects.equals(connectionToken, currentToken)) {
try (ServerSocket serverSocket = new ServerSocket(currentPort)) {
currentSocket = serverSocket;
// Store the chosen port.
final int localPort = serverSocket.getLocalPort();
// Register the service so that parent devices can
// locate the child device
registerService(localPort);
// Wait for a parent to find us and connect
try (Socket socket = serverSocket.accept()) {
Log.i(TAG, "Connection from parent device received");
// We now have a client connection.
// Unregister so no other clients will
// attempt to connect
unregisterService();
serviceConnection(socket);
}
} catch (Exception e) {
if (Objects.equals(connectionToken, currentToken)) {
// Just in case
currentPort++;
Log.e(TAG, "Failed to open server socket. Port increased to " + currentPort, e);
}
}
}
});
monitorThread = mt;
mt.start();
}
private void registerService(final int port) {
final NsdServiceInfo serviceInfo = new NsdServiceInfo();
serviceInfo.setServiceName("ChildMonitor on " + Build.MODEL);
serviceInfo.setServiceType("_childmonitor._tcp.");
serviceInfo.setPort(port);
registrationListener = new NsdManager.RegistrationListener() {
@Override
public void onServiceRegistered(NsdServiceInfo nsdServiceInfo) {
// Save the service name. Android may have changed it in order to
// resolve a conflict, so update the name you initially requested
// with the name Android actually used.
final String serviceName = nsdServiceInfo.getServiceName();
Log.i(TAG, "Service name: " + serviceName);
final MonitorActivity ma = monitorActivity;
if (ma != null) {
ma.runOnUiThread(() -> {
final TextView statusText = ma.findViewById(R.id.textStatus);
statusText.setText(R.string.waitingForParent);
final TextView serviceText = ma.findViewById(R.id.textService);
serviceText.setText(serviceName);
final TextView portText = ma.findViewById(R.id.port);
portText.setText(Integer.toString(port));
});
}
}
@Override
public void onRegistrationFailed(NsdServiceInfo serviceInfo, int errorCode) {
// Registration failed! Put debugging code here to determine why.
Log.e(TAG, "Registration failed: " + errorCode);
}
@Override
public void onServiceUnregistered(NsdServiceInfo arg0) {
// Service has been unregistered. This only happens when you call
// NsdManager.unregisterService() and pass in this listener.
Log.i(TAG, "Unregistering service");
}
@Override
public void onUnregistrationFailed(NsdServiceInfo serviceInfo, int errorCode) {
// Unregistration failed. Put debugging code here to determine why.
Log.e(TAG, "Unregistration failed: " + errorCode);
}
};
nsdManager.registerService(
serviceInfo, NsdManager.PROTOCOL_DNS_SD, registrationListener);
}
private void unregisterService() {
NsdManager.RegistrationListener currentListener = registrationListener;
if (currentListener != null) {
Log.i(TAG, "Unregistering monitoring service");
nsdManager.unregisterService(currentListener);
registrationListener = null;
}
}
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);
}
}
private Notification buildNotification() {
CharSequence text = "Child Device";
// 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
return b.build();
}
@Override
public void onDestroy() {
Thread mt = monitorThread;
if (mt != null) {
mt.interrupt();
monitorThread = null;
}
unregisterService();
connectionToken = null;
if (currentSocket != null) {
try {
currentSocket.close();
currentSocket = null;
} catch (IOException e) {
Log.e(TAG, "Failed to close active socket on port " + currentPort);
}
}
// 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();
super.onDestroy();
}
@Override
public IBinder onBind(Intent intent) {
return binder;
}
public class MonitorBinder extends Binder {
MonitorService getService() {
return MonitorService.this;
}
}
}

View File

@@ -0,0 +1,273 @@
/*
* 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
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.Service
import android.content.Intent
import android.content.pm.ServiceInfo
import android.media.AudioRecord
import android.media.MediaRecorder
import android.net.nsd.NsdManager
import android.net.nsd.NsdManager.RegistrationListener
import android.net.nsd.NsdServiceInfo
import android.os.Binder
import android.os.Build
import android.os.IBinder
import android.util.Log
import android.widget.TextView
import android.widget.Toast
import androidx.core.app.NotificationCompat
import androidx.core.app.ServiceCompat
import java.io.IOException
import java.net.ServerSocket
import java.net.Socket
class MonitorService : Service() {
private val binder: IBinder = MonitorBinder()
private var nsdManager: NsdManager? = null
private var registrationListener: RegistrationListener? = null
private var currentSocket: ServerSocket? = null
private var connectionToken: Any? = null
private var currentPort = 0
private var notificationManager: NotificationManager? = null
private var monitorThread: Thread? = null
private var monitorActivity: MonitorActivity? = null
fun setMonitorActivity(monitorActivity: MonitorActivity?) {
this.monitorActivity = monitorActivity
}
private fun serviceConnection(socket: Socket) {
val ma = this.monitorActivity
ma?.runOnUiThread {
val statusText = ma.findViewById<TextView>(R.id.textStatus)
statusText.setText(R.string.streaming)
}
val frequency: Int = AudioCodecDefines.Companion.FREQUENCY
val channelConfiguration: Int = AudioCodecDefines.Companion.CHANNEL_CONFIGURATION_IN
val audioEncoding: Int = AudioCodecDefines.Companion.ENCODING
val bufferSize = AudioRecord.getMinBufferSize(frequency, channelConfiguration, audioEncoding)
val audioRecord: AudioRecord = try {
AudioRecord(
MediaRecorder.AudioSource.MIC,
frequency,
channelConfiguration,
audioEncoding,
bufferSize
)
} catch (e: SecurityException) {
// This should never happen, we asked for permission before
throw RuntimeException(e)
}
val pcmBufferSize = bufferSize * 2
val pcmBuffer = ShortArray(pcmBufferSize)
val ulawBuffer = ByteArray(pcmBufferSize)
try {
audioRecord.startRecording()
val out = socket.getOutputStream()
socket.sendBufferSize = pcmBufferSize
Log.d(TAG, "Socket send buffer size: " + socket.sendBufferSize)
while (socket.isConnected && (this.currentSocket != null) && !Thread.currentThread().isInterrupted) {
val read = audioRecord.read(pcmBuffer, 0, bufferSize)
val encoded: Int = AudioCodecDefines.Companion.CODEC.encode(pcmBuffer, read, ulawBuffer, 0)
out.write(ulawBuffer, 0, encoded)
}
} catch (e: Exception) {
Log.e(TAG, "Connection failed", e)
} finally {
audioRecord.stop()
}
}
override fun onCreate() {
Log.i(TAG, "ChildMonitor start")
super.onCreate()
this.notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
this.nsdManager = this.getSystemService(NSD_SERVICE) as NsdManager
this.currentPort = 10000
this.currentSocket = null
}
override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int {
Log.i(TAG, "Received start id $startId: $intent")
// Display a notification about us starting. We put an icon in the status bar.
createNotificationChannel()
val n = buildNotification()
val foregroundServiceType = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE else 0 // Keep the linter happy
ServiceCompat.startForeground(this, ID, n, foregroundServiceType)
ensureMonitorThread()
return START_REDELIVER_INTENT
}
private fun ensureMonitorThread() {
var mt = this.monitorThread
if (mt != null && mt.isAlive) {
return
}
val currentToken = Any()
this.connectionToken = currentToken
mt = Thread {
while (this.connectionToken == currentToken) {
try {
ServerSocket(this.currentPort).use { serverSocket ->
this.currentSocket = serverSocket
// Store the chosen port.
val localPort = serverSocket.localPort
// Register the service so that parent devices can
// locate the child device
registerService(localPort)
serverSocket.accept().use { socket ->
Log.i(TAG, "Connection from parent device received")
// We now have a client connection.
// Unregister so no other clients will
// attempt to connect
unregisterService()
serviceConnection(socket)
}
}
} catch (e: Exception) {
if (this.connectionToken == currentToken) {
// Just in case
this.currentPort++
Log.e(TAG, "Failed to open server socket. Port increased to $currentPort", e)
}
}
}
}
this.monitorThread = mt
mt.start()
}
private fun registerService(port: Int) {
val serviceInfo = NsdServiceInfo()
serviceInfo.serviceName = "ChildMonitor on " + Build.MODEL
serviceInfo.serviceType = "_childmonitor._tcp."
serviceInfo.port = port
this.registrationListener = object : RegistrationListener {
override fun onServiceRegistered(nsdServiceInfo: NsdServiceInfo) {
// Save the service name. Android may have changed it in order to
// resolve a conflict, so update the name you initially requested
// with the name Android actually used.
val serviceName = nsdServiceInfo.serviceName
Log.i(TAG, "Service name: $serviceName")
val ma = monitorActivity
ma?.runOnUiThread {
val statusText = ma.findViewById<TextView>(R.id.textStatus)
statusText.setText(R.string.waitingForParent)
val serviceText = ma.findViewById<TextView>(R.id.textService)
serviceText.text = serviceName
val portText = ma.findViewById<TextView>(R.id.port)
portText.text = Integer.toString(port)
}
}
override fun onRegistrationFailed(serviceInfo: NsdServiceInfo, errorCode: Int) {
// Registration failed! Put debugging code here to determine why.
Log.e(TAG, "Registration failed: $errorCode")
}
override fun onServiceUnregistered(arg0: NsdServiceInfo) {
// Service has been unregistered. This only happens when you call
// NsdManager.unregisterService() and pass in this listener.
Log.i(TAG, "Unregistering service")
}
override fun onUnregistrationFailed(serviceInfo: NsdServiceInfo, errorCode: Int) {
// Unregistration failed. Put debugging code here to determine why.
Log.e(TAG, "Unregistration failed: $errorCode")
}
}
nsdManager!!.registerService(
serviceInfo, NsdManager.PROTOCOL_DNS_SD, registrationListener)
}
private fun unregisterService() {
val currentListener = this.registrationListener
if (currentListener != null) {
Log.i(TAG, "Unregistering monitoring service")
this.nsdManager!!.unregisterService(currentListener)
this.registrationListener = null
}
}
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val serviceChannel = NotificationChannel(
CHANNEL_ID,
"Foreground Service Channel",
NotificationManager.IMPORTANCE_DEFAULT
)
this.notificationManager!!.createNotificationChannel(serviceChannel)
}
}
private fun buildNotification(): Notification {
val text: CharSequence = "Child Device"
// Set the info for the views that show in the notification panel.
val b = 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
return b.build()
}
override fun onDestroy() {
val mt = this.monitorThread
if (mt != null) {
mt.interrupt()
this.monitorThread = null
}
unregisterService()
this.connectionToken = null
val sock = this.currentSocket
if (sock != null) {
try {
sock.close()
this.currentSocket = null
} catch (e: IOException) {
Log.e(TAG, "Failed to close active socket on port $currentPort")
}
}
// Cancel the persistent notification.
this.notificationManager!!.cancel(R.string.listening)
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
// Tell the user we stopped.
Toast.makeText(this, R.string.stopped, Toast.LENGTH_SHORT).show()
super.onDestroy()
}
override fun onBind(intent: Intent): IBinder {
return binder
}
inner class MonitorBinder : Binder() {
val service: MonitorService
get() = this@MonitorService
}
companion object {
const val TAG = "MonitorService"
const val CHANNEL_ID = TAG
const val ID = 1338
}
}

View File

@@ -1,96 +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;
import android.Manifest;
import android.app.Activity;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.os.Bundle;
import android.support.v4.app.ActivityCompat;
import android.support.v4.content.ContextCompat;
import android.util.Log;
import android.widget.Button;
public class StartActivity extends Activity {
static final String TAG = "ChildMonitor";
private final static int PERMISSIONS_REQUEST_RECORD_AUDIO = 298349824;
private final static int PERMISSIONS_REQUEST_MULTICAST = 298349825;
@Override
protected void onCreate(Bundle savedInstanceState) {
Log.i(TAG, "ChildMonitor launched");
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_start);
final Button monitorButton = findViewById(R.id.useChildDevice);
monitorButton.setOnClickListener(v -> {
Log.i(TAG, "Starting up monitor");
if (isAudioRecordingPermissionGranted()) {
startActivity(new Intent(getApplicationContext(), MonitorActivity.class));
} else {
requestAudioPermission();
}
});
final Button connectButton = findViewById(R.id.useParentDevice);
connectButton.setOnClickListener(v -> {
Log.i(TAG, "Starting connection activity");
if (isMulticastPermissionGranted()) {
Intent i = new Intent(getApplicationContext(), DiscoverActivity.class);
startActivity(i);
} else {
requestMulticastPermission();
}
});
}
private boolean isMulticastPermissionGranted() {
return ContextCompat.checkSelfPermission(StartActivity.this, Manifest.permission.CHANGE_WIFI_MULTICAST_STATE)
== PackageManager.PERMISSION_GRANTED;
}
private boolean isAudioRecordingPermissionGranted() {
return ContextCompat.checkSelfPermission(StartActivity.this, Manifest.permission.RECORD_AUDIO)
== PackageManager.PERMISSION_GRANTED;
}
private void requestAudioPermission() {
ActivityCompat.requestPermissions(StartActivity.this,
new String[]{Manifest.permission.RECORD_AUDIO},
PERMISSIONS_REQUEST_RECORD_AUDIO);
}
private void requestMulticastPermission() {
ActivityCompat.requestPermissions(StartActivity.this,
new String[]{Manifest.permission.CHANGE_WIFI_MULTICAST_STATE},
PERMISSIONS_REQUEST_MULTICAST);
}
@Override
public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) {
if (requestCode == PERMISSIONS_REQUEST_RECORD_AUDIO && grantResults.length > 0
&& grantResults[0] == PackageManager.PERMISSION_GRANTED) {
startActivity(new Intent(getApplicationContext(), MonitorActivity.class));
} else if (requestCode == PERMISSIONS_REQUEST_MULTICAST) {
// its okay if the permission was denied... the user will have to type the address manually
startActivity(new Intent(getApplicationContext(), DiscoverActivity.class));
}
}
}

View File

@@ -0,0 +1,87 @@
/*
* 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
import android.Manifest
import android.app.Activity
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Bundle
import android.util.Log
import android.view.View
import android.widget.Button
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
class StartActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
Log.i(TAG, "ChildMonitor launched")
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_start)
val monitorButton = findViewById<Button>(R.id.useChildDevice)
monitorButton.setOnClickListener { v: View? ->
Log.i(TAG, "Starting up monitor")
if (isAudioRecordingPermissionGranted) {
startActivity(Intent(applicationContext, MonitorActivity::class.java))
} else {
requestAudioPermission()
}
}
val connectButton = findViewById<Button>(R.id.useParentDevice)
connectButton.setOnClickListener { v: View? ->
Log.i(TAG, "Starting connection activity")
if (isMulticastPermissionGranted) {
val i = Intent(applicationContext, DiscoverActivity::class.java)
startActivity(i)
} else {
requestMulticastPermission()
}
}
}
private val isMulticastPermissionGranted: Boolean
get() = (ContextCompat.checkSelfPermission(this@StartActivity, Manifest.permission.CHANGE_WIFI_MULTICAST_STATE)
== PackageManager.PERMISSION_GRANTED)
private val isAudioRecordingPermissionGranted: Boolean
get() = (ContextCompat.checkSelfPermission(this@StartActivity, Manifest.permission.RECORD_AUDIO)
== PackageManager.PERMISSION_GRANTED)
private fun requestAudioPermission() {
ActivityCompat.requestPermissions(this@StartActivity, arrayOf(Manifest.permission.RECORD_AUDIO),
PERMISSIONS_REQUEST_RECORD_AUDIO)
}
private fun requestMulticastPermission() {
ActivityCompat.requestPermissions(this@StartActivity, arrayOf(Manifest.permission.CHANGE_WIFI_MULTICAST_STATE),
PERMISSIONS_REQUEST_MULTICAST)
}
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<String>, grantResults: IntArray) {
if (requestCode == PERMISSIONS_REQUEST_RECORD_AUDIO && grantResults.size > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
startActivity(Intent(applicationContext, MonitorActivity::class.java))
} else if (requestCode == PERMISSIONS_REQUEST_MULTICAST) {
// its okay if the permission was denied... the user will have to type the address manually
startActivity(Intent(applicationContext, DiscoverActivity::class.java))
}
}
companion object {
const val TAG = "ChildMonitor"
private const val PERMISSIONS_REQUEST_RECORD_AUDIO = 298349824
private const val PERMISSIONS_REQUEST_MULTICAST = 298349825
}
}

View File

@@ -1,77 +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;
import android.os.Handler;
import android.os.Looper;
import android.support.v4.util.CircularArray;
public class VolumeHistory {
private double maxVolume = 0.25;
private double volumeNorm = 1.0 / maxVolume;
private final CircularArray<Double> historyData;
private final int maxHistory;
private final Handler uiHandler;
VolumeHistory(int maxHistory) {
uiHandler = new Handler(Looper.getMainLooper());
this.maxHistory = maxHistory;
historyData = new CircularArray<>(maxHistory);
}
public double getVolumeNorm() {
return volumeNorm;
}
public double get(int i) {
return historyData.get(i);
}
public int size() {
return historyData.size();
}
private void addLast(double volume) {
// schedule editing of member vars on the ui event loop to avoid concurrency problems
uiHandler.post(() -> {
if (volume > maxVolume) {
maxVolume = volume;
volumeNorm = 1.0 / volume;
}
historyData.addLast(volume);
historyData.removeFromStart(historyData.size() - maxHistory);
});
}
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);
}
}

View File

@@ -0,0 +1,63 @@
/*
* 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
import android.os.Handler
import android.os.Looper
import androidx.collection.CircularArray
class VolumeHistory internal constructor(private val maxHistory: Int) {
private var maxVolume = 0.25
var volumeNorm = 1.0 / this.maxVolume
private set
private val historyData: CircularArray<Double> = CircularArray(maxHistory)
private val uiHandler: Handler = Handler(Looper.getMainLooper())
operator fun get(i: Int): Double {
return historyData[i]
}
fun size(): Int {
return historyData.size()
}
private fun addLast(volume: Double) {
// schedule editing of member vars on the ui event loop to avoid concurrency problems
uiHandler.post {
if (volume > this.maxVolume) {
this.maxVolume = volume
this.volumeNorm = 1.0 / volume
}
historyData.addLast(volume)
historyData.removeFromStart(historyData.size() - maxHistory)
}
}
fun onAudioData(data: ShortArray) {
if (data.isEmpty()) {
return
}
val scale = 1.0 / 128.0
var sum = 0.0
for (datum in data) {
val rel = datum * scale
sum += rel * rel
}
val volume = sum / data.size
addLast(volume)
}
}

View File

@@ -1,104 +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;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.support.annotation.Nullable;
import android.util.AttributeSet;
import android.view.View;
public class VolumeView extends View {
private final Paint paint;
private VolumeHistory volumeHistory;
public VolumeView(Context context) {
super(context);
this.paint = initPaint();
}
public VolumeView(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
this.paint = initPaint();
}
public VolumeView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
this.paint = initPaint();
}
private Paint initPaint() {
Paint paint = new Paint();
paint.setColor(Color.rgb(255, 127, 0));
return paint;
}
@Override
protected void onDraw(Canvas canvas) {
final VolumeHistory volumeHistory = this.volumeHistory;
if (volumeHistory == null) {
return;
}
final int height = getHeight();
final int width = getWidth();
final int size = volumeHistory.size(); // Size is at most width
final double volumeNorm = volumeHistory.getVolumeNorm();
final double relativeBrightness;
if (size > 0) {
final double normalizedVolume = volumeHistory.get(size - 1);
relativeBrightness = Math.max(0.3, normalizedVolume);
} else {
relativeBrightness = 0.3;
}
int blue;
int rest;
if (relativeBrightness > 0.5) {
blue = 255;
rest = (int) (2 * 255 * (relativeBrightness - 0.5));
} else {
blue = (int) (255 * (relativeBrightness - 0.2) / 0.3);
rest = 0;
}
final int rgb = Color.rgb(rest, rest, blue);
canvas.drawColor(rgb);
if (size == 0) {
return;
}
final double margins = height * 0.1;
final double graphHeight = height - 2.0 * margins;
int leftMost = Math.max(0, volumeHistory.size() - width);
final double graphScale = graphHeight * volumeNorm;
int xPrev = 0;
int yPrev = ((int) (margins + graphHeight - volumeHistory.get(leftMost) * graphScale));
int length = Math.min(size, width);
for (int xNext = 1; xNext < length-1; ++xNext) {
int yNext = (int) (margins + graphHeight - volumeHistory.get(leftMost + xNext) * graphScale);
canvas.drawLine(xPrev, yPrev, xNext, yNext, paint);
xPrev = xNext;
yPrev = yNext;
}
}
public void setVolumeHistory(VolumeHistory volumeHistory) {
this.volumeHistory = volumeHistory;
}
}

View File

@@ -0,0 +1,92 @@
/*
* 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
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.util.AttributeSet
import android.view.View
class VolumeView : View {
private val paint: Paint
private var volumeHistory: VolumeHistory? = null
constructor(context: Context?) : super(context) {
paint = initPaint()
}
constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs) {
paint = initPaint()
}
constructor(context: Context?, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) {
paint = initPaint()
}
private fun initPaint(): Paint {
val paint = Paint()
paint.color = Color.rgb(255, 127, 0)
return paint
}
override fun onDraw(canvas: Canvas) {
val volumeHistory = this.volumeHistory ?: return
val height = height
val width = width
val size = volumeHistory.size() // Size is at most width
val volumeNorm = volumeHistory.volumeNorm
val relativeBrightness: Double = if (size > 0) {
val normalizedVolume = volumeHistory[size - 1]
Math.max(0.3, normalizedVolume)
} else {
0.3
}
val blue: Int
val rest: Int
if (relativeBrightness > 0.5) {
blue = 255
rest = (2 * 255 * (relativeBrightness - 0.5)).toInt()
} else {
blue = (255 * (relativeBrightness - 0.2) / 0.3).toInt()
rest = 0
}
val rgb = Color.rgb(rest, rest, blue)
canvas.drawColor(rgb)
if (size == 0) {
return
}
val margins = height * 0.1
val graphHeight = height - 2.0 * margins
val leftMost = Math.max(0, volumeHistory.size() - width)
val graphScale = graphHeight * volumeNorm
var xPrev = 0
var yPrev = (margins + graphHeight - volumeHistory[leftMost] * graphScale).toInt()
val length = Math.min(size, width)
for (xNext in 1 until length - 1) {
val yNext = (margins + graphHeight - volumeHistory[leftMost + xNext] * graphScale).toInt()
canvas.drawLine(xPrev.toFloat(), yPrev.toFloat(), xNext.toFloat(), yNext.toFloat(), paint)
xPrev = xNext
yPrev = yNext
}
}
fun setVolumeHistory(volumeHistory: VolumeHistory?) {
this.volumeHistory = volumeHistory
}
}

View File

@@ -1,71 +0,0 @@
/*
* Copyright (C) 2009 The Android Open Source 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.
*
* Taken from https://android.googlesource.com/platform/external/nist-sip/+/6f95fdeab4481188b6260041b41d1db12b101266/src/com/android/sip/media/G711UCodec.java
*
*/
package de.rochefort.childmonitor.audio;
/**
* G.711 codec. This class provides u-law conversion.
*/
public class G711UCodec {
// s00000001wxyz...s000wxyz
// s0000001wxyza...s001wxyz
// s000001wxyzab...s010wxyz
// s00001wxyzabc...s011wxyz
// s0001wxyzabcd...s100wxyz
// s001wxyzabcde...s101wxyz
// s01wxyzabcdef...s110wxyz
// s1wxyzabcdefg...s111wxyz
private static final byte[] table13to8 = new byte[8192];
private static final short[] table8to16 = new short[256];
static {
// b13 --> b8
for (int p = 1, q = 0; p <= 0x80; p <<= 1, q+=0x10) {
for (int i = 0, j = (p << 4) - 0x10; i < 16; i++, j += p) {
int v = (i + q) ^ 0x7F;
byte value1 = (byte) v;
byte value2 = (byte) (v + 128);
for (int m = j, e = j + p; m < e; m++) {
table13to8[m] = value1;
table13to8[8191 - m] = value2;
}
}
}
// b8 --> b16
for (int q = 0; q <= 7; q++) {
for (int i = 0, m = (q << 4); i < 16; i++, m++) {
int v = (((i + 0x10) << q) - 0x10) << 3;
table8to16[m ^ 0x7F] = (short) v;
table8to16[(m ^ 0x7F) + 128] = (short) (65536 - v);
}
}
}
public int decode(short[] b16, byte[] ulaw, int count, int offset) {
for (int i = 0, j = offset; i < count; i++, j++) {
b16[i] = table8to16[ulaw[j] & 0xFF];
}
return count;
}
public int encode(short[] b16, int count, byte[] b8, int offset) {
for (int i = 0, j = offset; i < count; i++, j++) {
b8[j] = table13to8[(b16[i] >> 4) & 0x1FFF];
}
return count;
}
public int getSampleCount(int frameSize) {
return frameSize;
}
}

View File

@@ -0,0 +1,103 @@
/*
* Copyright (C) 2009 The Android Open Source 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.
*
* Taken from https://android.googlesource.com/platform/external/nist-sip/+/6f95fdeab4481188b6260041b41d1db12b101266/src/com/android/sip/media/G711UCodec.java
*
*/
package de.rochefort.childmonitor.audio
/**
* G.711 codec. This class provides u-law conversion.
*/
class G711UCodec {
fun decode(b16: ShortArray, ulaw: ByteArray, count: Int, offset: Int): Int {
var i = 0
var j = offset
while (i < count) {
b16[i] = table8to16[ulaw[j].toInt() and 0xFF]
i++
j++
}
return count
}
fun encode(b16: ShortArray, count: Int, b8: ByteArray, offset: Int): Int {
var i = 0
var j = offset
while (i < count) {
b8[j] = table13to8[b16[i].toInt() shr 4 and 0x1FFF]
i++
j++
}
return count
}
fun getSampleCount(frameSize: Int): Int {
return frameSize
}
companion object {
// s00000001wxyz...s000wxyz
// s0000001wxyza...s001wxyz
// s000001wxyzab...s010wxyz
// s00001wxyzabc...s011wxyz
// s0001wxyzabcd...s100wxyz
// s001wxyzabcde...s101wxyz
// s01wxyzabcdef...s110wxyz
// s1wxyzabcdefg...s111wxyz
private val table13to8 = ByteArray(8192)
private val table8to16 = ShortArray(256)
init {
// b13 --> b8
run {
var p = 1
var q = 0
while (p <= 0x80) {
var i = 0
var j = (p shl 4) - 0x10
while (i < 16) {
val v = i + q xor 0x7F
val value1 = v.toByte()
val value2 = (v + 128).toByte()
var m = j
val e = j + p
while (m < e) {
table13to8[m] = value1
table13to8[8191 - m] = value2
m++
}
i++
j += p
}
p = p shl 1
q += 0x10
}
}
// b8 --> b16
for (q in 0..7) {
var i = 0
var m = q shl 4
while (i < 16) {
val v = (i + 0x10 shl q) - 0x10 shl 3
table8to16[m xor 0x7F] = v.toShort()
table8to16[(m xor 0x7F) + 128] = (65536 - v).toShort()
i++
m++
}
}
}
}
}

View File

@@ -1,17 +1,44 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules. // Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript { buildscript {
ext {
kotlin_version = '1.9.22'
}
repositories { repositories {
mavenCentral() mavenCentral()
google() google()
} }
dependencies { dependencies {
classpath 'com.android.tools.build:gradle:8.2.2' classpath 'com.android.tools.build:gradle:8.2.2'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
} }
} }
plugins {
id 'org.jetbrains.kotlin.jvm' version '1.9.22'
}
allprojects { allprojects {
repositories { repositories {
mavenCentral() mavenCentral()
google() google()
} }
} }
repositories {
mavenCentral()
}
dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8"
}
compileKotlin {
kotlinOptions {
jvmTarget = "17"
}
}
compileTestKotlin {
kotlinOptions {
jvmTarget = "17"
}
}

View File

@@ -20,6 +20,8 @@
# The aapt2 tool creates an APK which fails to install on Android 5 and below if it contains # The aapt2 tool creates an APK which fails to install on Android 5 and below if it contains
# a bug. Build tools 27.0.1 has a mitigation. Avoiding aapt2 also avoids hitting the bug. # a bug. Build tools 27.0.1 has a mitigation. Avoiding aapt2 also avoids hitting the bug.
# See: https://issuetracker.google.com/issues/64434571 # See: https://issuetracker.google.com/issues/64434571
android.enableJetifier=false
android.nonFinalResIds=false android.nonFinalResIds=false
android.nonTransitiveRClass=true android.nonTransitiveRClass=true
android.useAndroidX=true
org.gradle.configuration-cache=true org.gradle.configuration-cache=true

View File

@@ -1,7 +1,7 @@
#Tue Apr 14 20:27:54 CEST 2020 #Sun Feb 18 21:28:01 CET 2024
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionSha256Sum=9631d53cf3e74bfa726893aee1f8994fee4e060c401335946dba2156f440f24c
distributionUrl=https\://services.gradle.org/distributions/gradle-8.6-bin.zip
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.2.1-all.zip
distributionSha256Sum=7c3ad722e9b0ce8205b91560fd6ce8296ac3eadf065672242fd73c06b8eeb6ee