package au.com.southsky.jfreesane; import java.awt.image.BufferedImage; import java.io.BufferedInputStream; import java.io.Closeable; import java.io.IOException; import java.net.InetAddress; import java.net.InetSocketAddress; import java.net.Socket; import java.util.List; import java.util.concurrent.TimeUnit; import java.util.logging.Level; import java.util.logging.Logger; /** * Represents a conversation taking place with a SANE daemon. * * @author James Ring ([email protected]) */ public final class SaneSession implements Closeable { private static final int READ_BUFFER_SIZE = 1 << 20; // 1mb private static final int DEFAULT_PORT = 6566; private final Socket socket; private final SaneOutputStream outputStream; private final SaneInputStream inputStream; private final int connectionTimeoutMillis; private final int socketTimeoutMillis; private SanePasswordProvider passwordProvider = SanePasswordProvider.usingDotSanePassFile(); private SaneSession(Socket socket, int connectionTimeoutMillis, int socketTimeoutMillis) throws IOException { this.socket = socket; this.outputStream = new SaneOutputStream(socket.getOutputStream()); this.inputStream = new SaneInputStream(this, socket.getInputStream()); this.connectionTimeoutMillis = connectionTimeoutMillis; this.socketTimeoutMillis = socketTimeoutMillis; } /** * Returns the current password provider. By default, this password provider will be supplied by * {@link SanePasswordProvider#usingDotSanePassFile}, but you may override that with * {@link #setPasswordProvider}. */ public SanePasswordProvider getPasswordProvider() { return passwordProvider; } /** * Sets the {@link SanePasswordProvider password provider} to use if the SANE daemon asks for * credentials when accessing a resource. Throws {@link NullPointerException} if * {@code passwordProvider} is {@code null}. */ public void setPasswordProvider(SanePasswordProvider passwordProvider) { this.passwordProvider = Preconditions.checkNotNull(passwordProvider); } /** * Establishes a connection to the SANE daemon running on the given host on the default SANE port * with no connection timeout. * * @param saneAddress the address of the SANE server * @return a {@code SaneSession} that is connected to the remote SANE server * @throws IOException if any error occurs while communicating with the SANE server */ public static SaneSession withRemoteSane(InetAddress saneAddress) throws IOException { return withRemoteSane(saneAddress, DEFAULT_PORT); } /** * Establishes a connection to the SANE daemon running on the given host on the default SANE port * with the given connection timeout. * * @param saneAddress the address of the SANE server * @param timeout the timeout for connections to the SANE server, zero implies no connection * timeout, must not be greater than {@link Integer#MAX_VALUE} milliseconds. * @param timeUnit connection timeout unit * @param soTimeout the timeout for reads from the SANE server, zero implies no read timeout * @param soTimeUnit socket timeout unit * @return a {@code SaneSession} that is connected to the remote SANE server * @throws IOException if any error occurs while communicating with the SANE server */ public static SaneSession withRemoteSane( InetAddress saneAddress, long timeout, TimeUnit timeUnit, long soTimeout, TimeUnit soTimeUnit) throws IOException { return withRemoteSane(saneAddress, DEFAULT_PORT, timeout, timeUnit, soTimeout, soTimeUnit); } /** * Establishes a connection to the SANE daemon running on the given host on the default SANE port * with the given connection timeout. * * @param saneAddress the address of the SANE server * @param timeout the timeout for connections to the SANE server, zero implies no connection * timeout, must not be greater than {@link Integer#MAX_VALUE} milliseconds. * @param timeUnit connection timeout unit * @return a {@code SaneSession} that is connected to the remote SANE server * @throws IOException if any error occurs while communicating with the SANE server */ public static SaneSession withRemoteSane(InetAddress saneAddress, long timeout, TimeUnit timeUnit) throws IOException { return withRemoteSane(saneAddress, DEFAULT_PORT, timeout, timeUnit, 0, TimeUnit.MILLISECONDS); } /** * Establishes a connection to the SANE daemon running on the given host on the given port with no * connection timeout. * * @param saneAddress the address of the SANE server * @param port the port of the SANE server * @return a {@code SaneSession} that is connected to the remote SANE server * @throws IOException if any error occurs while communicating with the SANE server */ public static SaneSession withRemoteSane(InetAddress saneAddress, int port) throws IOException { return withRemoteSane(saneAddress, port, 0, TimeUnit.MILLISECONDS, 0, TimeUnit.MILLISECONDS); } /** * Establishes a connection to the SANE daemon running on the given host on the given port. If the * connection cannot be established within the given timeout, * {@link java.net.SocketTimeoutException} is thrown. * * @param saneAddress the address of the SANE server * @param port the port of the SANE server * @param timeout the timeout for connections to the SANE server, zero implies no connection * timeout, must not be greater than {@link Integer#MAX_VALUE} milliseconds. * @param timeUnit connection timeout unit * @param soTimeout the timeout for reads from the SANE server, zero implies no read timeout * @param soTimeUnit socket timeout unit * @return a {@code SaneSession} that is connected to the remote SANE server * @throws IOException if any error occurs while communicating with the SANE server */ public static SaneSession withRemoteSane( InetAddress saneAddress, int port, long timeout, TimeUnit timeUnit, long soTimeout, TimeUnit soTimeUnit) throws IOException { return withRemoteSane( new InetSocketAddress(saneAddress, port), timeout, timeUnit, soTimeout, soTimeUnit); } /** * Establishes a connection to the SANE daemon running on the given host on the given port. If the * connection cannot be established within the given timeout, * {@link java.net.SocketTimeoutException} is thrown. * * @param saneSocketAddress the socket address of the SANE server * @param timeout the timeout for connections to the SANE server, zero implies no connection * timeout, must not be greater than {@link Integer#MAX_VALUE} milliseconds. * @param timeUnit connection timeout unit * @param soTimeout the timeout for reads from the SANE server, zero implies no read timeout * @param soTimeUnit socket timeout unit * @return a {@code SaneSession} that is connected to the remote SANE server * @throws IOException if any error occurs while communicating with the SANE server */ public static SaneSession withRemoteSane( InetSocketAddress saneSocketAddress, long timeout, TimeUnit timeUnit, long soTimeout, TimeUnit soTimeUnit) throws IOException { long connectTimeoutMillis = timeUnit.toMillis(timeout); Preconditions.checkArgument( connectTimeoutMillis >= 0 && connectTimeoutMillis <= Integer.MAX_VALUE, "Timeout must be between 0 and Integer.MAX_VALUE milliseconds"); // If the user specifies a non-zero timeout that rounds to 0 milliseconds, // set the timeout to 1 millisecond instead. if (timeout > 0 && connectTimeoutMillis == 0) { Logger.getLogger(SaneSession.class.getName()) .log( Level.WARNING, "Specified timeout of {0} {1} rounds to 0ms and was clamped to 1ms", new Object[] {timeout, timeUnit}); } Socket socket = new Socket(); socket.setTcpNoDelay(true); long soTimeoutMillis = 0; if (soTimeUnit != null && soTimeout > 0) { soTimeoutMillis = soTimeUnit.toMillis(soTimeout); Preconditions.checkArgument( soTimeoutMillis >= 0 && soTimeoutMillis <= Integer.MAX_VALUE, "Socket timeout must be between 0 and Integer.MAX_VALUE milliseconds"); socket.setSoTimeout((int) soTimeoutMillis); } socket.connect(saneSocketAddress, (int) connectTimeoutMillis); SaneSession session = new SaneSession(socket, (int) connectTimeoutMillis, (int) soTimeoutMillis); session.initSane(); return session; } /** * Returns the device with the give name. Opening the device will fail if the named device does * not exist. * * @return a new {@link SaneDevice} with the given name associated with the current session, never * {@code null} * @throws IOException if an error occurs while communicating with the SANE daemon */ public SaneDevice getDevice(String name) throws IOException { return new SaneDevice(this, name, "", "", ""); } /** * Lists the devices known to the SANE daemon. * * @return a list of devices that may be opened, see {@link SaneDevice#open} * @throws IOException if an error occurs while communicating with the SANE daemon * @throws SaneException if the SANE backend returns an error in response to this request */ public List<SaneDevice> listDevices() throws IOException, SaneException { outputStream.write(SaneRpcCode.SANE_NET_GET_DEVICES); outputStream.flush(); return inputStream.readDeviceList(); } /** * Closes the connection to the SANE server. This is done immediately by closing the socket. * * @throws IOException if an error occurred while closing the connection */ @Override public void close() throws IOException { try { outputStream.write(SaneRpcCode.SANE_NET_EXIT); outputStream.close(); } finally { socket.close(); } } SaneDeviceHandle openDevice(SaneDevice device) throws IOException, SaneException { outputStream.write(SaneRpcCode.SANE_NET_OPEN); outputStream.write(device.getName()); outputStream.flush(); SaneWord status = inputStream.readWord(); SaneWord handle = inputStream.readWord(); String resource = inputStream.readString(); if (status.integerValue() != 0) { throw new SaneException(SaneStatus.fromWireValue(status.integerValue())); } if (!resource.isEmpty()) { if (!authorize(resource)) { throw new SaneException(SaneStatus.STATUS_ACCESS_DENIED); } status = inputStream.readWord(); handle = inputStream.readWord(); // Read the resource string inputStream.readString(); if (status.integerValue() != 0) { throw new SaneException(SaneStatus.fromWireValue(status.integerValue())); } } return new SaneDeviceHandle(handle); } BufferedImage acquireImage(SaneDevice device, ScanListener listener) throws IOException, SaneException { SaneImage.Builder builder = new SaneImage.Builder(); SaneParameters parameters = null; listener.scanningStarted(device); int currentFrame = 0; do { SaneDeviceHandle handle = device.getHandle(); outputStream.write(SaneRpcCode.SANE_NET_START); outputStream.write(handle.getHandle()); outputStream.flush(); SaneWord startStatus = inputStream.readWord(); int port = inputStream.readWord().integerValue(); SaneWord byteOrder = inputStream.readWord(); String resource = inputStream.readString(); if (startStatus.integerValue() != 0) { throw SaneException.fromStatusWord(startStatus); } if (!resource.isEmpty()) { if (!authorize(resource)) { throw new SaneException(SaneStatus.STATUS_ACCESS_DENIED); } int status = inputStream.readWord().integerValue(); port = inputStream.readWord().integerValue(); byteOrder = inputStream.readWord(); // Throw away the resource string, we don't attempt to authenticate again anyway. inputStream.readString(); if (status != 0) { throw new SaneException(SaneStatus.fromWireValue(status)); } } // Ask the server for the parameters of this scan outputStream.write(SaneRpcCode.SANE_NET_GET_PARAMETERS); outputStream.write(handle.getHandle()); outputStream.flush(); InetSocketAddress dataAddress = new InetSocketAddress(socket.getInetAddress(), port); try (Socket imageSocket = new Socket()) { imageSocket.setSoTimeout(socketTimeoutMillis); imageSocket.connect(dataAddress, connectionTimeoutMillis); int status = inputStream.readWord().integerValue(); if (status != 0) { throw new IOException("Unexpected status (" + status + ") in get_parameters"); } parameters = inputStream.readSaneParameters(); // As a convenience to our listeners, try to figure out how many frames // will be read. Usually this will be 1, except in the case of older // three-pass color scanners. listener.frameAcquisitionStarted( device, parameters, currentFrame, getLikelyTotalFrameCount(parameters)); FrameReader frameStream = new FrameReader( device, parameters, new BufferedInputStream(imageSocket.getInputStream(), READ_BUFFER_SIZE), 0x4321 == byteOrder.integerValue(), listener); builder.addFrame(frameStream.readFrame()); } currentFrame++; } while (!parameters.isLastFrame()); listener.scanningFinished(device); SaneImage image = builder.build(); return image.toBufferedImage(); } private int getLikelyTotalFrameCount(SaneParameters parameters) { switch (parameters.getFrameType()) { case RED: case GREEN: case BLUE: return 3; default: return 1; } } void closeDevice(SaneDeviceHandle handle) throws IOException { // RPC code outputStream.write(SaneRpcCode.SANE_NET_CLOSE); outputStream.write(handle.getHandle()); outputStream.flush(); // read the dummy value from the wire, if it doesn't throw an exception // we assume the close was successful inputStream.readWord(); } void cancelDevice(SaneDeviceHandle handle) throws IOException { // RPC code outputStream.write(SaneRpcCode.SANE_NET_CANCEL); outputStream.write(handle.getHandle()); outputStream.flush(); // read the dummy value from the wire, if it doesn't throw an exception // we assume the cancel was successful inputStream.readWord(); } private void initSane() throws IOException { // RPC code outputStream.write(SaneRpcCode.SANE_NET_INIT); // version number outputStream.write(SaneWord.forSaneVersion(1, 0, 3)); // username outputStream.write(System.getProperty("user.name")); outputStream.flush(); inputStream.readWord(); inputStream.readWord(); } /** * Authorize the resource for access. * * @throws IOException if an error occurs while communicating with the SANE daemon */ boolean authorize(String resource) throws IOException { if (passwordProvider == null) { throw new IOException( "Authorization failed - no password provider present " + "(you must call setPasswordProvider)"); } if (passwordProvider.canAuthenticate(resource)) { // RPC code FOR SANE_NET_AUTHORIZE outputStream.write(SaneRpcCode.SANE_NET_AUTHORIZE); outputStream.write(resource); outputStream.write(passwordProvider.getUsername(resource)); // TODO(sjamesr): resource is not currently used, see writePassword. writePassword(/* resource, */ passwordProvider.getPassword(resource)); outputStream.flush(); // Read dummy reply and discard (according to the spec, it is unused). inputStream.readWord(); return true; } return false; } /** * Write password to outputstream depending on resource provided by saned. */ private void writePassword(/* String resource ,*/ String password) throws IOException { outputStream.write(password); // The code below always prints passwords in the clear, because Splitter.on takes // a separator string, not a regular expression. We can't fix it now due to a bug // in old versions of saned, which Linux distributions like Ubuntu still ship. // TODO(sjamesr): revive this code when Ubuntu gets a new sane-backends release, // see https://bugs.launchpad.net/ubuntu/+source/sane-backends/+bug/1858051. // TODO(sjamesr): when reviving, remove Guava dependency. /* List<String> resourceParts = Splitter.on("\\$MD5\\$").splitToList(resource); if (resourceParts.size() == 1) { // Write in clean outputStream.write(password); } else { outputStream.write( "$MD5$" + SanePasswordEncoder.derivePassword(resourceParts.get(1), password)); } */ } SaneOutputStream getOutputStream() { return outputStream; } SaneInputStream getInputStream() { return inputStream; } }