/*
* This file is part of AirReceiver.
*
* AirReceiver 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.
* AirReceiver 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 AirReceiver. If not, see <http://www.gnu.org/licenses/>.
*/
package org.phlo.AirReceiver;
import java.io.IOException;
import java.io.InputStream;
import java.net.*;
import java.util.Arrays;
import java.util.Collections;
import java.util.Map;
import java.util.List;
import java.util.Properties;
import java.util.concurrent.*;
import java.util.logging.*;
import java.awt.*;
import java.awt.event.*;
import javax.swing.*;
import javax.jmdns.*;
import org.jboss.netty.bootstrap.ServerBootstrap;
import org.jboss.netty.channel.*;
import org.jboss.netty.channel.group.*;
import org.jboss.netty.channel.socket.nio.*;
import org.jboss.netty.handler.execution.*;
public class AirReceiver {
/* Load java.util.logging configuration */
static {
final InputStream loggingPropertiesStream =
AirReceiver.class.getClassLoader().getResourceAsStream("logging.properties");
try {
LogManager.getLogManager().readConfiguration(loggingPropertiesStream);
}
catch (final IOException e) {
throw new RuntimeException(e.getMessage(), e);
}
}
private static final Logger s_logger = Logger.getLogger(AirReceiver.class.getName());
public static final String Version = getVersion();
/**
* The hardware (MAC) address of the emulated Airport Express
*/
public static final byte[] HardwareAddressBytes = getHardwareAddress();
/**
* The hardware (MAC) address as a hexadecimal string
*/
public static final String HardwareAddressString = toHexString(HardwareAddressBytes);
/**
* The hostname of the emulated Airport Express
*/
public static final String HostName = getHostName();
/**
* The AirTunes/RAOP service type
*/
public static final String AirtunesServiceType = "_raop._tcp.local.";
/**
* The AirTunes/RAOP RTSP port
*/
public static final short AirtunesServiceRTSPPort = 5000;
/**
* The AirTunes/RAOP M-DNS service properties (TXT record)
*/
public static final Map<String, String> AirtunesServiceProperties = map(
"txtvers", "1",
"tp", "UDP",
"ch", "2",
"ss", "16",
"sr", "44100",
"pw", "false",
"sm", "false",
"sv", "false",
"ek", "1",
"et", "0,1",
"cn", "0,1",
"vn", "3"
);
/**
* Global executor service. Used e.g. to initialize the various netty channel factories
*/
public static final ExecutorService ExecutorService = Executors.newCachedThreadPool();
/**
* Channel execution handler. Spreads channel message handling over multiple threads
*/
public static final ExecutionHandler ChannelExecutionHandler = new ExecutionHandler(
new OrderedMemoryAwareThreadPoolExecutor(4, 0, 0)
);
/**
* Message dispayed in the "About" dialog
*/
private static final String AboutMessage =
" * AirReceiver " + Version + " *\n" +
"\n" +
"Copyright (c) 2011 Florian G. Pflug\n" +
"\n" +
"AirReceiver is free software: you can redistribute it and/or modify\n" +
"it under the terms of the GNU General Public License as published by\n" +
"the Free Software Foundation, either version 3 of the License, or\n" +
"(at your option) any later version.\n" +
"\n" +
"AirReceiver is distributed in the hope that it will be useful,\n" +
"but WITHOUT ANY WARRANTY; without even the implied warranty of\n" +
"MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n" +
"GNU General Public License for more details.\n" +
"\n" +
"You should have received a copy of the GNU General Public License\n" +
"along with AirReceiver. If not, see <http://www.gnu.org/licenses/>." +
"\n\n" +
" * Java ALAC Decoder *\n" +
"\n" +
"Copyright (c) 2011 Peter McQuillan";
/**
* JmDNS instances (one per IP address). Used to unregister the mDNS services
* during shutdown.
*/
private static final List<JmDNS> s_jmDNSInstances = new java.util.LinkedList<JmDNS>();
/**
* All open RTSP channels. Used to close all open challens during shutdown.
*/
private static ChannelGroup s_allChannels = new DefaultChannelGroup();
/**
* Channel handle that registeres the channel to be closed on shutdown
*/
public static final ChannelHandler CloseChannelOnShutdownHandler = new SimpleChannelUpstreamHandler() {
@Override
public void channelOpen(final ChannelHandlerContext ctx, final ChannelStateEvent e) throws Exception {
s_allChannels.add(e.getChannel());
super.channelOpen(ctx, e);
}
};
/**
* Map factory. Creates a Map from a list of keys and values
*
* @param keys_values key1, value1, key2, value2, ...
* @return a map mapping key1 to value1, key2 to value2, ...
*/
private static Map<String, String> map(final String... keys_values) {
assert keys_values.length % 2 == 0;
final Map<String, String> map = new java.util.HashMap<String, String>(keys_values.length / 2);
for(int i=0; i < keys_values.length; i+=2)
map.put(keys_values[i], keys_values[i+1]);
return Collections.unmodifiableMap(map);
}
/**
* Decides whether or nor a given MAC address is the address of some
* virtual interface, like e.g. VMware's host-only interface (server-side).
*
* @param addr a MAC address
* @return true if the MAC address is unsuitable as the device's hardware address
*/
public static boolean isBlockedHardwareAddress(final byte[] addr) {
if ((addr[0] & 0x02) != 0)
/* Locally administered */
return true;
else if ((addr[0] == 0x00) && (addr[1] == 0x50) && (addr[2] == 0x56))
/* VMware */
return true;
else if ((addr[0] == 0x00) && (addr[1] == 0x1C) && (addr[2] == 0x42))
/* Parallels */
return true;
else if ((addr[0] == 0x00) && (addr[1] == 0x25) && (addr[2] == (byte)0xAE))
/* Microsoft */
return true;
else
return false;
}
/**
* Reads the version from the version.properties file
* @return the version
*/
private static String getVersion() {
Properties versionProperties = new Properties();
final InputStream versionPropertiesStream =
AirReceiver.class.getClassLoader().getResourceAsStream("version.properties");
try {
versionProperties.load(versionPropertiesStream);
}
catch (final IOException e) {
throw new RuntimeException(e.getMessage(), e);
}
return versionProperties.getProperty("org.phlo.AirReceiver.version");
}
/**
* Returns a suitable hardware address.
*
* @return a MAC address
*/
private static byte[] getHardwareAddress() {
try {
/* Search network interfaces for an interface with a valid, non-blocked hardware address */
for(final NetworkInterface iface: Collections.list(NetworkInterface.getNetworkInterfaces())) {
if (iface.isLoopback())
continue;
if (iface.isPointToPoint())
continue;
try {
final byte[] ifaceMacAddress = iface.getHardwareAddress();
if ((ifaceMacAddress != null) && (ifaceMacAddress.length == 6) && !isBlockedHardwareAddress(ifaceMacAddress)) {
s_logger.info("Hardware address is " + toHexString(ifaceMacAddress) + " (" + iface.getDisplayName() + ")");
return Arrays.copyOfRange(ifaceMacAddress, 0, 6);
}
}
catch (final Throwable e) {
/* Ignore */
}
}
}
catch (final Throwable e) {
/* Ignore */
}
/* Fallback to the IP address padded to 6 bytes */
try {
final byte[] hostAddress = Arrays.copyOfRange(InetAddress.getLocalHost().getAddress(), 0, 6);
s_logger.info("Hardware address is " + toHexString(hostAddress) + " (IP address)");
return hostAddress;
}
catch (final Throwable e) {
/* Ignore */
}
/* Fallback to a constant */
s_logger.info("Hardware address is 00DEADBEEF00 (last resort)");
return new byte[] {(byte)0x00, (byte)0xDE, (byte)0xAD, (byte)0xBE, (byte)0xEF, (byte)0x00};
}
/**
* Returns the machine's host name
*
* @return the host name
*/
private static String getHostName() {
try {
return InetAddress.getLocalHost().getHostName().split("\\.")[0];
}
catch (final Throwable e) {
return "AirReceiver";
}
}
/**
* Converts an array of bytes to a hexadecimal string
*
* @param bytes array of bytes
* @return hexadecimal representation
*/
private static String toHexString(final byte[] bytes) {
final StringBuilder s = new StringBuilder();
for(final byte b: bytes) {
final String h = Integer.toHexString(0x100 | b);
s.append(h.substring(h.length() - 2, h.length()).toUpperCase());
}
return s.toString();
}
/**
* Shuts the AirReceiver down gracefully
*/
public static void onShutdown() {
/* Close channels */
final ChannelGroupFuture allChannelsClosed = s_allChannels.close();
/* Stop all mDNS responders */
synchronized(s_jmDNSInstances) {
for(final JmDNS jmDNS: s_jmDNSInstances) {
try {
jmDNS.unregisterAllServices();
s_logger.info("Unregistered all services on " + jmDNS.getInterface());
}
catch (final IOException e) {
s_logger.info("Failed to unregister some services");
}
}
}
/* Wait for all channels to finish closing */
allChannelsClosed.awaitUninterruptibly();
/* Stop the ExecutorService */
ExecutorService.shutdown();
/* Release the OrderedMemoryAwareThreadPoolExecutor */
ChannelExecutionHandler.releaseExternalResources();
}
public static void main(final String[] args) throws Exception {
/* Make sure AirReceiver shuts down gracefully */
Runtime.getRuntime().addShutdownHook(new Thread(new Runnable() {
@Override
public void run() {
onShutdown();
}
}));
/* Create about dialog */
final Dialog aboutDialog = new Dialog((Dialog)null);
final GridBagLayout aboutLayout = new GridBagLayout();
aboutDialog.setLayout(aboutLayout);
aboutDialog.setVisible(false);
aboutDialog.setTitle("About AirReceiver");
aboutDialog.setResizable(false);
{
/* Message */
final TextArea title = new TextArea(AboutMessage.split("\n").length + 1, 64);
title.setText(AboutMessage);
title.setEditable(false);
final GridBagConstraints titleConstraints = new GridBagConstraints();
titleConstraints.gridx = 1;
titleConstraints.gridy = 1;
titleConstraints.fill = GridBagConstraints.HORIZONTAL;
titleConstraints.insets = new Insets(0,0,0,0);
aboutLayout.setConstraints(title, titleConstraints);
aboutDialog.add(title);
}
{
/* Done button */
final Button aboutDoneButton = new Button("Done");
aboutDoneButton.addActionListener(new ActionListener() {
@Override public void actionPerformed(final ActionEvent evt) {
aboutDialog.setVisible(false);
}
});
final GridBagConstraints aboutDoneConstraints = new GridBagConstraints();
aboutDoneConstraints.gridx = 1;
aboutDoneConstraints.gridy = 2;
aboutDoneConstraints.anchor = GridBagConstraints.PAGE_END;
aboutDoneConstraints.fill = GridBagConstraints.NONE;
aboutDoneConstraints.insets = new Insets(0,0,0,0);
aboutLayout.setConstraints(aboutDoneButton, aboutDoneConstraints);
aboutDialog.add(aboutDoneButton);
}
aboutDialog.setVisible(false);
aboutDialog.setLocationByPlatform(true);
aboutDialog.pack();
/* Create tray icon */
final URL trayIconUrl = AirReceiver.class.getClassLoader().getResource("icon_32.png");
final TrayIcon trayIcon = new TrayIcon((new ImageIcon(trayIconUrl, "AirReceiver").getImage()));
trayIcon.setToolTip("AirReceiver");
trayIcon.setImageAutoSize(true);
final PopupMenu popupMenu = new PopupMenu();
final MenuItem aboutMenuItem = new MenuItem("About");
aboutMenuItem.addActionListener(new ActionListener() {
@Override
public void actionPerformed(final ActionEvent evt) {
aboutDialog.setLocationByPlatform(true);
aboutDialog.setVisible(true);
}
});
popupMenu.add(aboutMenuItem);
final MenuItem exitMenuItem = new MenuItem("Quit");
exitMenuItem.addActionListener(new ActionListener() {
@Override
public void actionPerformed(final ActionEvent evt) {
onShutdown();
System.exit(0);
}
});
popupMenu.add(exitMenuItem);
trayIcon.setPopupMenu(popupMenu);
SystemTray.getSystemTray().add(trayIcon);
/* Create AirTunes RTSP server */
final ServerBootstrap airTunesRtspBootstrap = new ServerBootstrap(new NioServerSocketChannelFactory(ExecutorService, ExecutorService));
airTunesRtspBootstrap.setPipelineFactory(new RaopRtspPipelineFactory());
airTunesRtspBootstrap.setOption("reuseAddress", true);
airTunesRtspBootstrap.setOption("child.tcpNoDelay", true);
airTunesRtspBootstrap.setOption("child.keepAlive", true);
s_allChannels.add(airTunesRtspBootstrap.bind(new InetSocketAddress(Inet4Address.getByName("0.0.0.0"), AirtunesServiceRTSPPort)));
s_logger.info("Launched RTSP service on port " + AirtunesServiceRTSPPort);
/* Create mDNS responders. */
synchronized(s_jmDNSInstances) {
for(final NetworkInterface iface: Collections.list(NetworkInterface.getNetworkInterfaces())) {
if (iface.isLoopback())
continue;
if (iface.isPointToPoint())
continue;
if (!iface.isUp())
continue;
for(final InetAddress addr: Collections.list(iface.getInetAddresses())) {
if (!(addr instanceof Inet4Address) && !(addr instanceof Inet6Address))
continue;
try {
/* Create mDNS responder for address */
final JmDNS jmDNS = JmDNS.create(addr, HostName + "-jmdns");
s_jmDNSInstances.add(jmDNS);
/* Publish RAOP service */
final ServiceInfo airTunesServiceInfo = ServiceInfo.create(
AirtunesServiceType,
HardwareAddressString + "@" + HostName + " (" + iface.getName() + ")",
AirtunesServiceRTSPPort,
0 /* weight */, 0 /* priority */,
AirtunesServiceProperties
);
jmDNS.registerService(airTunesServiceInfo);
s_logger.info("Registered AirTunes service '" + airTunesServiceInfo.getName() + "' on " + addr);
}
catch (final Throwable e) {
s_logger.log(Level.SEVERE, "Failed to publish service on " + addr, e);
}
}
}
}
}
}