[JAVA/JsCh] Un client SSH pour Java

La mise en place d’un client SSH robuste pour Java n’est pas aussi simple qu’il n’y parait.

Il existe bon nombre d’API, mais peu de comparatifs. Qui plus est, les documentations sont, de manière générale, peu explicites.

Je vais vous fournir ici un client SSH prêt à être utilisé dans tout type de projet. Il est basé sur l’API JsCh (qui à mon avis est la plus rapide à prendre en main) ainsi qu’une classe utilitaire de Apache permettant d’implémenter l’échange de fichiers avec le protocole SCP (une petite présentation ici).

Ci-dessous donc :

  • la classe SSHClient qui contient  l’essentiel du code fonctionnel pour se connecter à un hôte SSH et dialoguer (envoi de commandes…)
  • la classe Apache ajoutant la couche Scp pour l’échange de fichiers

Il ne vous reste plus qu’à modifier, à l’intérieur de la classe SSHClient, les logins pour accéder à la machine distante.

Ce code est bien entendu libre et peut être modifié à volonté.

SSHClient.java

package com;

import java.io.BufferedReader;
import java.io.File;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.io.PipedInputStream;
import java.io.PipedOutputStream;
import java.util.ArrayList;
import java.util.List;
import java.util.Properties;
import java.util.regex.Pattern;

import org.apache.log4j.Logger;

import com.jcraft.jsch.Channel;
import com.jcraft.jsch.JSch;
import com.jcraft.jsch.JSchException;
import com.jcraft.jsch.Session;
import com.jcraft.jsch.UserInfo;

public class SSHClient {

    /** Channel modes */
    public static final String  EXEC_MODE               = "exec";

    public static final String  SHELL_MODE              = "shell";

    public static final String  SFTP_MODE               = "sftp";

    /** Authentication */
    private static String       USER;

    private static String       PWD;

    private static String       HOST;

    /** Utils */
    private String              CMD_INVITE;

    private PipedInputStream    fromServer;

    private OutputStream        toServer;

    private Channel             channel;

    private Session             session;

    private static final String TERMINATOR              = "zDonez";

    private String              lastCommand             = "";

    private Pattern             alphaNumeric            = Pattern
                                                            .compile("([^a-zA-z0-9])");

    private int                 nbChannel               = 0;

    private String              previousMode;

    private int                 SERVER_RESPONSE_TIMEOUT = 1500;

    /** Low-level layer to provide ssh functions */
    private Scp                 scp;

    /** Shell */
    private JSch                shell;

    public SSHClient() throws Exception {
        shell = new JSch();
        USER = "test";
        PWD = "test";
        HOST = "test";
        CMD_INVITE = HOST + ":" + USER + "#";
        initSession();
    }

    /**
     * Init SSH session
     * @throws Exception 
     * @throws JSchException
     */
    private void initSession() throws Exception {
        try {
            session = shell.getSession(USER, HOST, 22);
            MyUserInfo ui = new MyUserInfo();
            ui.setPassword(PWD);
            session.setUserInfo(ui);

            Properties config = new Properties();
            config.put("StrictHostKeyChecking", "no");
            session.setConfig(config);

            session.connect();
            // session.connect(30000); // making a connection with timeout.
            scp = new Scp(session);
        } catch (JSchException e) {
            e.printStackTrace();
            LOGGER.error(e);
            throw new Exception(e.getLocalizedMessage());
        }
    }

    /**
     * Connects to remote machine
     * @param mode Type of channel to open : can be EXEC_MODE, SHELL_MODE, SFTP_MODE
     * @throws Exception 
     * @throws JSchException
     * @throws IOException
     * @throws InterruptedException
     */
    private void connect(String mode) throws Exception {

        if (!isConnected()) {
            try {
                channel = session.openChannel(mode);
                previousMode = mode;
                // If Shell mode, customize I/O streams to get response
                if (mode.equals(SHELL_MODE)) {
                    initShellMode();
                    channel.connect();
                }
            } catch (Exception e) {
                e.printStackTrace();
                LOGGER.error(e);
                throw new Exception(e.getLocalizedMessage());
            }
        } else {
            // To avoid 10 files upload limitation in session
            if (nbChannel == 9) {
                disconnect();
                nbChannel = 0;
                connect(mode);
            }
            // Change channel
            if (!previousMode.equals(mode)) {
                previousMode = mode;
                channel.disconnect();
                connect(mode);
            }
            nbChannel++;
        }
    }

    /**
     * Init shell mode
     * @throws IOException
     */
    private void initShellMode() throws IOException {
        PipedOutputStream po = new PipedOutputStream();
        fromServer = new PipedInputStream(po);
        channel.setOutputStream(po);

        toServer = new PipedOutputStream();
        PipedInputStream pi = new PipedInputStream((PipedOutputStream) toServer);
        channel.setInputStream(pi);
    }

    /**
     * Check if client is connected
     * @return YES or NO
     */
    public boolean isConnected() {
        return (channel != null && channel.isConnected());
    }

    /**
     * Disconnect channel and session
     */
    public void disconnect() {
        if (isConnected()) {
            channel.disconnect();
            session.disconnect();
        }
    }

    /**
     * Disconnect client and open new session
     * @throws Exception 
     */
    public void reconnect() throws Exception {
        disconnect();
        initSession();
    }

    /**
     * Send simple SHELL command
     * @param command
     * @return server response
     * @throws Exception 
     * @throws IOException
     * @throws InterruptedException
     */
    public String sendShell(String command) throws Exception {
        connect(SHELL_MODE);
        lastCommand = command;
        command += "; echo \"" + TERMINATOR + "\" \n";
        try {
            toServer.write(command.getBytes());
            // To ensure server answer reception
            // and manage a communication time out.
            try {
                Thread.sleep(SERVER_RESPONSE_TIMEOUT);
            } catch (InterruptedException e) {
            }
        } catch (IOException e) {
            e.printStackTrace();
            LOGGER.error(e);
            throw new Exception(e.getLocalizedMessage());
        }
        return getServerResponse();
    }

    /**
     * Retrieve server response to SHELL command
     * @return the response
     * @throws Exception 
     * @throws IOException
     * @throws InterruptedException
     */
    private String getServerResponse() throws Exception {
        StringBuffer builder = new StringBuffer();
        String result = null;
        try {
            int count = 0;
            String line = "";

            BufferedReader reader = new BufferedReader(new InputStreamReader(
                fromServer));
            if (reader.ready()) {
                for (int i = 0; true; i++) {
                    try {
                        line = reader.readLine();
                    } catch (IOException e) {
                        LOGGER.warn("Communication seems to be closed...");
                        break;
                    }
                    builder.append(line).append("\n");
                    if (line.indexOf(TERMINATOR) != -1 && (++count > 1)) {
                        break;
                    }
                }
                result = builder.toString();
                int beginIndex = result.indexOf(TERMINATOR + "\"")
                    + ((TERMINATOR + "\"").length());
                result = result.substring(beginIndex);
                result = result.replaceAll(escape(TERMINATOR), "").trim();
                System.out.println("Command : " + lastCommand + " -> Result : "
                    + result);
                return result;
            } else {
                throw new Exception("Server did not answer in the time ("
                    + SERVER_RESPONSE_TIMEOUT + "ms) to command ("
                    + lastCommand + ")");
            }
        } catch (IOException e) {
            e.printStackTrace();
            LOGGER.error(e);
            throw new Exception(e.getLocalizedMessage());
        }
    }

    private String escape(String subjectString) {
        return alphaNumeric.matcher(subjectString).replaceAll("\\\\$1");
    }

    /**
     * Execute remote script
     * @param scriptPath
     * @throws Exception
     */
    public String executeScript(String scriptPath, String[] params)
        throws Exception {
        String commandLine = scriptPath;
        for (String p : params) {
            commandLine += " " + p;
        }
        return sendShell(commandLine);
    }

    /**
     * Send a file on server (implementation of exec 'scp -t rfile' command)
     * @param localFilePath
     * @param remoteFilePath
     * @throws Exception
     */
    public void sendFile(String localFilePath, String remoteFilePath)
        throws Exception {
        String remoteTargetDir = remoteFilePath.substring(0, remoteFilePath
            .lastIndexOf("/") + 1);
        String remoteTargetName = remoteFilePath.substring(remoteTargetDir
            .length());
        if (nbChannel == 9) {
            nbChannel = 0;
            reconnect();
        }
        nbChannel++;
        scp.put(localFilePath, remoteTargetDir, remoteTargetName, "0777");
    }

    /**
     * Get files 
     * @param remotePath
     * @param localDestinationPath
     * @throws Exception 
     * @throws IOException 
     */
    public List getResultsFiles(String remotePath,
        String localDestinationPath) throws Exception {
        List filesPaths = new ArrayList();
        // To ensure well connection
        reconnect();
        // List files
        String list = sendShell("ls " + remotePath);
        if (list != null) {
            if (list.indexOf(CMD_INVITE) != -1) {
                list = list.substring(list.indexOf(CMD_INVITE)
                    + CMD_INVITE.length());
            }
            if (list.indexOf("not found") != -1) {
                throw new Exception(
                    "No output files!");
            }
            List files = getFilesList(list);
            if (files.size() > 0) {
                File tmp = null;
                for (String f : files) {
                    tmp = new File(localDestinationPath, f);
                    if (tmp.exists()) {
                        tmp.delete();
                    }
                    if (nbChannel == 9) {
                        nbChannel = 0;
                        reconnect();
                    }
                    nbChannel++;
                    String absPath = tmp.getAbsolutePath();
                    filesPaths.add(absPath);
                    scp.get(remotePath + f, absPath);
                }
                return filesPaths;
            } else {
                throw new Exception(
                    "No output files!");
            }
        } else {
            throw new Exception(
                "Impossible to retreive files list!");
        }
    }

    /**
     * Compute file list received from server ("ls" command) to a list of files
     * @param lsResult
     * @return
     */
    private List getFilesList(String lsResult) {
        List files = new ArrayList();
        String[] tabl = lsResult.split("\\n");
        for (String s : tabl) {
            s = s.trim();
            if (!"".equals(s)) {
                if (s.indexOf(" ") == -1) {
                    files.add(s);
                } else {
                    String[] tabl2 = s.split(" ");
                    for (String s2 : tabl2) {
                        s2 = s2.trim();
                        if (!"".equals(s2)) {
                            files.add(s2);
                        }
                    }
                }
            }
        }
        return files;
    }

    /**
     * Move command shell to specified directory
     * @param path
     * @throws Exception
     */
    public void moveToDir(String path) throws Exception {
        sendShell("cd " + path);
    }

    /**
     * Local class that implements logging credentials
     */
    private static class MyUserInfo implements UserInfo {

        private String password;

        public void setPassword(String password) {
            this.password = password;

        }

        public String getPassphrase() {
            return null;
        }

        public String getPassword() {
            return password;
        }

        public boolean promptPassword(String arg0) {
            return true;
        }

        public boolean promptPassphrase(String arg0) {
            return true;
        }

        public boolean promptYesNo(String arg0) {
            return true;
        }

        public void showMessage(String arg0) {
            System.out.println(arg0);
        }
    }
}

SCP.java

/*
 *  Licensed to the Apache Software Foundation (ASF) under one or more
 *  contributor license agreements.  See the NOTICE file distributed with
 *  this work for additional information regarding copyright ownership.
 *  The ASF licenses this file to You 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.
 *
 */
package com;

import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;

import org.apache.log4j.Logger;

import com.jcraft.jsch.Channel;
import com.jcraft.jsch.ChannelExec;
import com.jcraft.jsch.JSchException;
import com.jcraft.jsch.Session;

/**
 * This class is using the scp client to transfer data and information for the repository. 
 * 

* It is based on the SCPClient from the ganymed ssh library from Christian Plattner, * released under a BSD style license. *

* To minimize the dependency to the ssh library and because we needed some additional * functionality, we decided to copy'n'paste the single class rather than to inherit or * delegate it somehow. *

* Nevertheless credit should go to the original author. */ public class Scp { private static final int MODE_LENGTH = 4; private static final int SEND_FILE_BUFFER_LENGTH = 40000; private static final int SEND_BYTES_BUFFER_LENGTH = 512; private static final int MIN_TLINE_LENGTH = 8; private static final int CLINE_SPACE_INDEX2 = 5; private static final int CLINE_SPACE_INDEX1 = 4; private static final int MIN_C_LINE_LENGTH = 8; private static final int DEFAULT_LINE_BUFFER_LENGTH = 30; private static final int BUFFER_SIZE = 64 * 1024; /* * Maximum length authorized for scp lines. * This is a random limit - if your path names are longer, then adjust it. */ private static final int MAX_SCP_LINE_LENGTH = 8192; private Session session; public class FileInfo { private String filename; private long length; private long lastModified; /** * @param filename * The filename to set. */ public void setFilename(String filename) { this.filename = filename; } /** * @return Returns the filename. */ public String getFilename() { return filename; } /** * @param length * The length to set. */ public void setLength(long length) { this.length = length; } /** * @return Returns the length. */ public long getLength() { return length; } /** * @param lastModified * The lastModified to set. */ public void setLastModified(long lastModified) { this.lastModified = lastModified; } /** * @return Returns the lastModified. */ public long getLastModified() { return lastModified; } } public Scp(Session session) { if (session == null) { throw new IllegalArgumentException("Cannot accept null argument!"); } this.session = session; } private void readResponse(InputStream is) throws IOException, Exception { if (is.available() > 0) { int c = is.read(); if (c == 0) { return; } if (c == -1) { throw new Exception("Remote scp terminated unexpectedly."); } if ((c != 1) && (c != 2)) { throw new Exception("Remote scp sent illegal error code."); } if (c == 2) { throw new Exception("Remote scp terminated with error."); } String err = receiveLine(is); throw new Exception("Remote scp terminated with error (" + err + ")."); } } private String receiveLine(InputStream is) throws IOException, Exception { StringBuffer sb = new StringBuffer(DEFAULT_LINE_BUFFER_LENGTH); while (true) { if (sb.length() > MAX_SCP_LINE_LENGTH) { throw new Exception("Remote scp sent a too long line"); } int c = is.read(); if (c < 0) { throw new Exception("Remote scp terminated unexpectedly."); } if (c == '\n') { break; } sb.append((char) c); } return sb.toString(); } private void parseCLine(String line, FileInfo fileInfo) throws Exception { /* Minimum line: "xxxx y z" ---> 8 chars */ long len; if (line.length() < MIN_C_LINE_LENGTH) { throw new Exception( "Malformed C line sent by remote SCP binary, line too short."); } if ((line.charAt(CLINE_SPACE_INDEX1) != ' ') || (line.charAt(CLINE_SPACE_INDEX2) == ' ')) { throw new Exception( "Malformed C line sent by remote SCP binary."); } int lengthNameSep = line.indexOf(' ', CLINE_SPACE_INDEX2); if (lengthNameSep == -1) { throw new Exception( "Malformed C line sent by remote SCP binary."); } String lengthSubstring = line.substring(CLINE_SPACE_INDEX2, lengthNameSep); String nameSubstring = line.substring(lengthNameSep + 1); if ((lengthSubstring.length() <= 0) || (nameSubstring.length() <= 0)) { throw new Exception( "Malformed C line sent by remote SCP binary."); } if ((CLINE_SPACE_INDEX2 + 1 + lengthSubstring.length() + nameSubstring .length()) != line.length()) { throw new Exception( "Malformed C line sent by remote SCP binary."); } try { len = Long.parseLong(lengthSubstring); } catch (NumberFormatException e) { throw new Exception( "Malformed C line sent by remote SCP binary, cannot parse file length."); } if (len < 0) { throw new Exception( "Malformed C line sent by remote SCP binary, illegal file length."); } fileInfo.setLength(len); fileInfo.setFilename(nameSubstring); } private void parseTLine(String line, FileInfo fileInfo) throws Exception { /* Minimum line: "0 0 0 0" ---> 8 chars */ long modtime; long firstMsec; long atime; long secondMsec; if (line.length() < MIN_TLINE_LENGTH) { throw new Exception( "Malformed T line sent by remote SCP binary, line too short."); } int firstMsecBegin = line.indexOf(" ") + 1; if (firstMsecBegin == 0 || firstMsecBegin >= line.length()) { throw new Exception( "Malformed T line sent by remote SCP binary, line not enough data."); } int atimeBegin = line.indexOf(" ", firstMsecBegin + 1) + 1; if (atimeBegin == 0 || atimeBegin >= line.length()) { throw new Exception( "Malformed T line sent by remote SCP binary, line not enough data."); } int secondMsecBegin = line.indexOf(" ", atimeBegin + 1) + 1; if (secondMsecBegin == 0 || secondMsecBegin >= line.length()) { throw new Exception( "Malformed T line sent by remote SCP binary, line not enough data."); } try { modtime = Long.parseLong(line.substring(0, firstMsecBegin - 1)); firstMsec = Long.parseLong(line.substring(firstMsecBegin, atimeBegin - 1)); atime = Long.parseLong(line.substring(atimeBegin, secondMsecBegin - 1)); secondMsec = Long.parseLong(line.substring(secondMsecBegin)); } catch (NumberFormatException e) { LOGGER.error(e); throw new Exception( "Malformed C line sent by remote SCP binary, cannot parse file length."); } if (modtime < 0 || firstMsec < 0 || atime < 0 || secondMsec < 0) { throw new Exception( "Malformed C line sent by remote SCP binary, illegal file length."); } fileInfo.setLastModified(modtime); } private void sendFile(Channel channel, String localFile, String remoteName, String mode) throws IOException, Exception { byte[] buffer = new byte[BUFFER_SIZE]; OutputStream os = new BufferedOutputStream(channel.getOutputStream(), SEND_FILE_BUFFER_LENGTH); InputStream is = new BufferedInputStream(channel.getInputStream(), SEND_BYTES_BUFFER_LENGTH); try { if (channel.isConnected()) { channel.start(); } else { channel.connect(); } } catch (JSchException e1) { throw (IOException) new IOException("Channel connection problems") .initCause(e1); } readResponse(is); File f = new File(localFile); long remain = f.length(); String cMode = mode; if (cMode == null) { cMode = "0600"; } String cline = "C" + cMode + " " + remain + " " + remoteName + "\n"; os.write(cline.getBytes()); os.flush(); readResponse(is); FileInputStream fis = null; try { fis = new FileInputStream(f); while (remain > 0) { try { Thread.sleep(100); } catch (InterruptedException e) { } int trans; if (remain > buffer.length) { trans = buffer.length; } else { trans = (int) remain; } if (fis.read(buffer, 0, trans) != trans) { throw new IOException("Cannot read enough from local file " + localFile); } os.write(buffer, 0, trans); remain -= trans; } fis.close(); } catch (Exception e) { if (fis != null) { fis.close(); } LOGGER.error(e); throw new Exception(e); } os.write(0); os.flush(); readResponse(is); os.write("E\n".getBytes()); os.flush(); } /** * Receive a file via scp and store it in a stream * * @param channel * ssh channel to use * @param file * to receive from remote * @param target * to store file into (if null, get only file info) * @return file information of the file we received * @throws IOException * in case of network or protocol trouble * @throws Exception * in case of problems on the target system (connection is fine) */ private FileInfo receiveStream(Channel channel, String file, OutputStream targetStream) throws IOException, Exception { byte[] buffer = new byte[BUFFER_SIZE]; OutputStream os = channel.getOutputStream(); InputStream is = channel.getInputStream(); try { if (channel.isConnected()) { channel.start(); } else { channel.connect(); } } catch (JSchException e1) { throw (IOException) new IOException("Channel connection problems") .initCause(e1); } os.write(0x0); os.flush(); FileInfo fileInfo = new FileInfo(); while (true) { int c = is.read(); if (c < 0) { throw new Exception("Remote scp terminated unexpectedly."); } String line = receiveLine(is); if (c == 'T') { parseTLine(line, fileInfo); os.write(0x0); os.flush(); continue; } if ((c == 1) || (c == 2)) { throw new Exception("Remote SCP error: " + line); } if (c == 'C') { parseCLine(line, fileInfo); break; } throw new Exception("Remote SCP error: " + ((char) c) + line); } if (targetStream != null) { os.write(0x0); os.flush(); try { long remain = fileInfo.getLength(); while (remain > 0) { int trans; if (remain > buffer.length) { trans = buffer.length; } else { trans = (int) remain; } int thisTimeReceived = is.read(buffer, 0, trans); if (thisTimeReceived < 0) { throw new IOException( "Remote scp terminated connection unexpectedly"); } targetStream.write(buffer, 0, thisTimeReceived); remain -= thisTimeReceived; } targetStream.close(); } catch (IOException e) { if (targetStream != null) { targetStream.close(); } LOGGER.error(e); throw (e); } readResponse(is); os.write(0x0); os.flush(); } return fileInfo; } /** * @return * @throws JSchException */ private ChannelExec getExecChannel() throws JSchException { ChannelExec channel; channel = (ChannelExec) session.openChannel("exec"); return channel; } /** * Copy a local file to a remote site, uses the specified mode when creating the file on the * remote side. * * @param localFile * Path and name of local file. Must be absolute. * @param remoteTargetDir * Remote target directory where the file has to end up (optional) * @param remoteTargetName * file name to use on the target system * @param mode * a four digit string (e.g., 0644, see "man chmod", "man open") * @throws IOException * in case of network problems * @throws Exception * in case of problems on the target system (connection ok) */ public void put(String localFile, String remoteTargetDir, String remoteTargetName, String mode) throws Exception { ChannelExec channel = null; if ((localFile == null) || (remoteTargetName == null)) { throw new IllegalArgumentException("Null argument."); } if (mode != null) { if (mode.length() != MODE_LENGTH) { throw new IllegalArgumentException("Invalid mode."); } for (int i = 0; i < mode.length(); i++) { if (!Character.isDigit(mode.charAt(i))) { throw new IllegalArgumentException("Invalid mode."); } } } String cmd = "scp -t "; if (mode != null) { cmd = cmd + "-p "; } if (remoteTargetDir != null && remoteTargetDir.length() > 0) { cmd = cmd + "-d " + remoteTargetDir; } try { channel = getExecChannel(); channel.setCommand(cmd); sendFile(channel, localFile, remoteTargetName, mode); channel.disconnect(); } catch (JSchException e) { if (channel != null) { channel.disconnect(); } e.printStackTrace(); LOGGER.error(e); throw new Exception("Error during SCP transfer." + e.getMessage()); } catch (Exception e) { e.printStackTrace(); LOGGER.error(e); throw new Exception(e.getLocalizedMessage()); } } /** * Download a file from the remote server to a local file. * * @param remoteFile * Path and name of the remote file. * @param localTarget * Local file where to store the data. Must be absolute. * @throws IOException * in case of network problems * @throws Exception * in case of problems on the target system (connection ok) */ public void get(String remoteFile, String localTarget) throws Exception { try { File f = new File(localTarget); FileOutputStream fop = new FileOutputStream(f); get(remoteFile, fop); } catch (IOException e) { e.printStackTrace(); LOGGER.error(e); throw new Exception(e.getLocalizedMessage()); } } /** * Download a file from the remote server into an OutputStream * * @param remoteFile * Path and name of the remote file. * @param localTarget * OutputStream to store the data. * @throws IOException * in case of network problems * @throws Exception * in case of problems on the target system (connection ok) */ private void get(String remoteFile, OutputStream localTarget) throws IOException, Exception { ChannelExec channel = null; if ((remoteFile == null) || (localTarget == null)) { throw new IllegalArgumentException("Null argument."); } String cmd = "scp -p -f " + remoteFile; try { channel = getExecChannel(); channel.setCommand(cmd); receiveStream(channel, remoteFile, localTarget); channel.disconnect(); } catch (JSchException e) { if (channel != null) { channel.disconnect(); } throw (IOException) new IOException("Error during SCP transfer." + e.getMessage()).initCause(e); } } /** * Initiates an SCP sequence but stops after getting fileinformation header * * @param remoteFile * to get information for * @return the file information got * @throws IOException * in case of network problems * @throws Exception * in case of problems on the target system (connection ok) */ public FileInfo getFileinfo(String remoteFile) throws IOException, Exception { ChannelExec channel = null; FileInfo fileInfo = null; if (remoteFile == null) { throw new IllegalArgumentException("Null argument."); } String cmd = "scp -p -f \"" + remoteFile + "\""; try { channel = getExecChannel(); channel.setCommand(cmd); fileInfo = receiveStream(channel, remoteFile, null); channel.disconnect(); } catch (JSchException e) { throw (IOException) new IOException("Error during SCP transfer." + e.getMessage()).initCause(e); } finally { if (channel != null) { channel.disconnect(); } } return fileInfo; } }


Fichier(s) joint(s) :



Eclipse (Swing) - Ajouter des composants dans un JTable

Afin d’améliorer l’ergonomie de certaines interfaces, il peut être utile d’ajouter des éléments interactifs (zones de texte, boutons radio…) directement dans les cellules d’un tableau.

Pour ce faire, l’API Swing permet de mettre rapidement en place des TableCellRenderer qui définissent le contenu des cellules. De base, une cellule est en fait un JLabel : on peut donc améliorer l’affichage en le remplaçant par une zone de texte srollable. C’est à partir de ce moment que les ennuis commencent…

Regardons tout d’abord le code nécessaire à la mise en place de cette zone de texte :

Définition du renderer personnalisé dans la dernière colonne du tableau :

Table table = new JTable() {
                    private static final long serialVersionUID = 3592907806336215067L;
                    public boolean isCellEditable(int rowIndex, int vColIndex) {
                        return (vColIndex==this.getColumnCount()-1);
                    }
                };
TableColumn col = table.getColumnModel().getColumn(resultColumnIndex);
TextAreaCellRenderer renderer = new TextAreaCellRenderer();
col.setCellRenderer(renderer);

Code du renderer :

import java.awt.Color;
import java.awt.Component;
 
import javax.swing.BorderFactory;
import javax.swing.JScrollPane;
import javax.swing.JTable;
import javax.swing.JTextArea;
import javax.swing.table.DefaultTableCellRenderer;
 
public class TextAreaCellRenderer extends DefaultTableCellRenderer {
 
    private static final long serialVersionUID = -3987688353662913146L;
 
    @Override
    public Component getTableCellRendererComponent(JTable table, Object value,
        boolean isSelected, boolean hasFocus, int row, int column) {
 
        // Customize it
        JTextArea textArea = new JTextArea(value.toString());
        textArea.setEditable(false);
        textArea.setWrapStyleWord(true);
        textArea.setLineWrap(true);
        JScrollPane pane = new JScrollPane(textArea);
        pane.setBorder(BorderFactory.createLineBorder(Color.BLACK, 0));
        pane.setVerticalScrollBarPolicy(JScrollPane.VERTICAL_SCROLLBAR_ALWAYS);
        pane
            .setHorizontalScrollBarPolicy(JScrollPane.HORIZONTAL_SCROLLBAR_NEVER);
        return pane;
    }
}

Ces quelques lignes suffisent à faire apparaître une zone de texte dans les cellules en question. Cependant, impossible d’utiliser les barres de défilement!! En effet, comme expliqué dans ce forum, Swing n’ajoute pas réellement la zone de texte dans la cellule, mais plutôt une image de celle-ci.

Pour rendre les barres de défilement actives, voici le principe à mettre en place :

Au clic sur la cellule, il faut entrer en mode édition. Ainsi, une nouvelle zone de texte sera définie comme CellEditor et sera utilisable!

Voici donc le complément de code à mettre en place :

Définition de l’éditeur personnalisé :

Table table = new JTable() {
                    private static final long serialVersionUID = 3592907806336215067L;
                    public boolean isCellEditable(int rowIndex, int vColIndex) {
                        return (vColIndex==this.getColumnCount()-1);
                    }
                };
TableColumn col = table.getColumnModel().getColumn(resultColumnIndex);
TextAreaCellRenderer renderer = new TextAreaCellRenderer();
TextAreaCellEditor editor = new TextAreaCellEditor();
col.setCellEditor(editor);
col.setCellRenderer(renderer);

Code de l’éditor :

import java.awt.Color;
import java.awt.Component;
 
import javax.swing.AbstractCellEditor;
import javax.swing.BorderFactory;
import javax.swing.JScrollPane;
import javax.swing.JTable;
import javax.swing.JTextArea;
import javax.swing.table.TableCellEditor;
 
public class TextAreaCellEditor extends AbstractCellEditor implements
    TableCellEditor {
 
    private static final long serialVersionUID = -9030406143699523582L;
 
    private String editingValue;
 
    @Override
    public Component getTableCellEditorComponent(JTable table, Object value,
        boolean isSelected, int row, int column) {
 
        editingValue = value.toString();
 
        // Customize it
        JTextArea textArea = new JTextArea(valueStr);
        textArea.setEditable(false);
        textArea.setWrapStyleWord(true);
        textArea.setLineWrap(true);
        JScrollPane pane = new JScrollPane(textArea);
        pane.setBorder(BorderFactory.createLineBorder(Color.BLACK, 0));
        pane.setVerticalScrollBarPolicy(JScrollPane.VERTICAL_SCROLLBAR_ALWAYS);
        pane
            .setHorizontalScrollBarPolicy(JScrollPane.HORIZONTAL_SCROLLBAR_NEVER);
        return pane;
    }
 
    @Override
    public Object getCellEditorValue() {
        return editingValue;
    }
 
    @Override
    public void cancelCellEditing() {
        super.cancelCellEditing();
    }
}

Maintenant, le contenu de la cellule est défilant.


Fichier(s) joint(s) :



Eclipse (PDE) - Gérer les activités

Lors du développement d’applications Éclipse, on peut rapidement être confronté à la gestion des droits d’accès utilisateurs sur certaines fonctionnalités.

Heureusement, cela peut facilement être géré par les Activités : vous trouverez un très bon guide dans la documentation officielle, mais pour résumer, on peut dire qu’une activité est un groupe de fonctionnalités régies par des règles d’utilisation ou d’accès.

Pour créer ces règles, dans le fichier "plugin.xml", il faut utiliser le point d’extension

org.eclipse.ui.activities

C’est ensuite le "pattern" qui définira la règle à appliquer : sous forme d’expression régulière, son format est le suivant :

plug-in-identifier + "/" + local-identifier

Voici un exemple :

Dans le fichier "plugin.xml", on définit l’activité suivante : 

 Puis on lui affecte un pattern :

Ainsi, nous avons défini la règle suivante : le profil "developer" n’aura accès qu’aux fonctionnalités du plugin

com.client

dont le nom contient

ui.commands.developer

Pour faire appliquer cette règle à l’IDE, voici le code à implémenter :

public class ProfileManager {
    private static final String ACTIVITY_DEVELOPER_ID    = "com.client.ui.activities.profile.developer";
    public static void refreshGUIAccordingToProfile(){
        IWorkbenchActivitySupport activitySupport = PlatformUI.getWorkbench()
            .getActivitySupport();
        // List of activities to be activated
        Set enabledActivities = new HashSet();
 
        // Retrieve list of activities currently activated
        Set currentlyEnabledActivities = PlatformUI.getWorkbench()
            .getActivitySupport().getActivityManager().getEnabledActivityIds();
        // Remove activities which are related to user's profile
        // I.e. Keep activities activated by user via Windows > Preferences
        for (String anActivity : currentlyEnabledActivities) {
            if (!isAProfileRelatedActivity(anActivity)) {
                enabledActivities.add(anActivity);
            }
        }
        enabledActivities.add(ACTIVITY_DEVELOPER_ID);
        activitySupport.setEnabledActivityIds(enabledActivities);
   }
}

Ce code est à appeler au lancement du plugin dans l’IDE : il fait appel simplement à l’interface de gestion des activités par défaut et y ajoute celle que nous venons de créer!

Vous disposer maintenant de tout le nécessaire pour paramétrer l’interface de votre application selon différent profils utilisateurs.


Fichier(s) joint(s) :

Eclipse (SWT) : sélectionner une cellule dans une Table

Cela pourrait paraître enfantin et pourtant, on peut plutôt y perdre quelques cheveux!! Attention, je parle pas ici des JTable de swing qui sont facilement utilisables et extensibles, mais bien de l’objet simpliste Table de SWT.

Le principe basique des Tables est d’afficher une liste de données en ligne (les colonnes représentent les attributs). Le constructeur de l’objet permet facilement d’autoriser la sélection d’une ligne entière, mais rien ne permet d’activer à l’aide de la souris la sélection d’une cellule : il faut pour cela un peu d’imagination. Jetons d’abord un œil au code avant de le commenter :

TableViewer tableViewer = new TableViewer(parent, SWT.MULTI | SWT.H_SCROLL
            | SWT.V_SCROLL | SWT.FULL_SELECTION);
tableViewer.getTable().addListener(SWT.MouseDown, new Listener() {
            TableItem item = null;
            int       oldi = -1;
            public void handleEvent(Event event) {
                Point pt = new Point(event.x, event.y);
                if (item != null && !item.isDisposed()) {
                    item.setBackground(oldi, Display.getCurrent()
                        .getSystemColor(SWT.COLOR_WHITE));
                    item.setForeground(oldi, Display.getCurrent()
                        .getSystemColor(SWT.COLOR_BLACK));
                }
                item = table.getItem(pt);
                if (item == null)
                    return;
                for (int i = 0; i < table.getColumnCount(); i++) {
                    Rectangle rect = item.getBounds(i);
                    if (rect.contains(pt)) {
                        table.deselectAll();
                        item.setBackground(i, Display.getCurrent()
                            .getSystemColor(SWT.COLOR_LIST_SELECTION));
                        item.setForeground(i, Display.getCurrent()
                            .getSystemColor(SWT.COLOR_WHITE));
                        oldi = i;
                        int index = table.indexOf(item);
                        System.out.println("Sélection de la ligne " + index + " - colonne " + i + " : valeur '"+ item.getText(i) +"'");
                    }
                }
            }
        });
    }

Le principe est donc le suivant :

Lorsque l’utilisateur clic dans le tableau, on récupère la position x et y du clic et on lui demande de nous retourner l’élément situé sous la souris (l’objet item). Pas terrible n’est-ce pas?!

Au niveau du code on a donc bien récupéré notre objet, mais au niveau visuel, c’est toute la ligne du tableau qui est sélectionnée! Pour y remédier, on dé-sélectionne immédiatement tout, puis on affecte à notre cellule une couleur d’arrière-plan correspondant à la couleur d’un élément sélectionné… On garde en mémoire notre cellule pour lui redonner des couleurs normales lorsqu’une autre sera clicquée.

Je vous l’accorde, c’est du bricolage, mais l’objet Table de SWT est vraiment très basique et c’est le seul moyen d’arriver à ce résultat ( à moins que je ne me trompe…)


Fichier(s) joint(s) :



Eclipse (PDE) - Gérer les associations de fichiers

Cet article ouvre sur une nouvelle série de tutoriels consacrée au développement de plugins pour Eclipse.

L’environnement de développement utilisé est Eclipse RCP (l’IDE "par défaut" sans aucun plugin) et PDE (Plugin Environment Developement).

Je ne ferais pas de tutoriel complet sur la mise en place de cet environnement de développement, vous trouverez facilement tout ce qu’il faut sur le net, par exemple ici : http://www.eclipse.org/pde/

Ce premier sujet sera consacré aux méthodes à implémenter pour personnaliser les préférences de l’IDE.
Plus spécialement cette fois-ci, la personnalisation des associations de fichiers avec certains éditeurs. Deux cas se présentent :

L’extension de fichier considérée existe déjà dans la liste des extensions gérées dans les préférences (par défaut *.html ou *.htm) :

Dans ce cas le plus simple, le code suivant permet de modifier l’association par défaut :

IEditorRegistry registry = PlatformUI.getWorkbench().getEditorRegistry();
registry.setDefaultEditor("*.html", "org.eclipse.ui.DefaultTextEditor");
((EditorRegistry)registry).saveAssociations();

Ce code peut être implémenté dans une classe qui s’éxecute au lancement de l’IDE, ou dans tout autre classe, puisque la méthode saveAssociations() met à jour les registres.

L’extension de fichier considéré n’existe pas par défaut :

Dans ce cas, il est impossible d’ajouter cette nouvelle association au lancement de l’IDE en passant par les registres d’éditeurs.

Il faut créer un éditeur personnalisé et l’associer à la nouvelle extension à travers le fichier plugin.xml : il faut utiliser le point d’extension "org.eclipse.ui.editors" : la section extensions permet de spécifier toutes les nouvelles extensions de fichiers qui seront associées par défaut à cet éditeur.

Pour en savoir plus, un très bon tutoriel a été rédigé sur developpez.com, je vous conseille d’aller y jeter un oeil. Et pour quelques détails supplémentaires, un autre tutoriel Aptana.


Fichier(s) joint(s) :

Eclipse (PDE) - Modifier le comportement par défaut d’un composant graphique

Nous allons voir maintenant comment interagir avec l’interface de l’IDE pendant son lancement pour agir sur un composant graphique.

L’exemple utilisé ici est l’ajout d’un MouseListener sur le TreeViewer dans la vue "ProjectExplorer".

Premièrement, pour accéder à ce composant, il faut, au lancement de l’IDE, passer par la fenêtre active puis entrer dans la vue ProjectExplorer. Voici le code nécessaire :

IWorkbench workbench = PlatformUI.getWorkbench();
IWorkbenchWindow window = workbench.getWorkbenchWindows()[0];
IWorkbenchPage page = window.getActivePage();
IViewPart viewPart = null;
try {
      viewPart = page.showView("org.eclipse.ui.navigator.ProjectExplorer");
} catch (PartInitException e) {
      System.out.println(e);
}
CommonNavigator nav = (CommonNavigator) viewPart;
CommonViewer viewer = nav.getCommonViewer();
Tree tree = viewer.getTree();
tree.addMouseMoveListener(list);

Voici donc le déroulement :

  • On accède en premier au Workbench (ensemble de fenêtres),
  • On récupère la page active
  • On accède à la vue ProjectExplorer qui est de type CommonNavigator
  • On récupère l’arbre qui gère l’arborescence des ressources du projet,
  • On lui affecte un listener personnalisé pour une action précise.

Cependant, si vous exécutez ce code dans une classe au lancement de l’IDE, vous allez être confronté à une erreur :

"org.eclipse.swt.SWTException: Invalid thread access".

En effet, lorsque l’interface est en cours de création, le thread courant est l’IUThread d’Eclipse. Donc tout autre bout de code (thread) tentant d’accéder à un élément graphique en parallèle est rejeté.

Pour contrer ce problème, il faut accéder à l’UIThread et bloquer son exécution le temps de réaliser les actions requises. Pour ce faire, voici le code à implémenter :

Display.getDefault().syncExec(new Runnable() {
   public void run() {
     IWorkbench workbench = PlatformUI.getWorkbench();
     IWorkbenchWindow window = workbench.getWorkbenchWindows()[0];
     IWorkbenchPage page = window.getActivePage();
     IViewPart viewPart = null;
     try {
        viewPart = page.showView("org.eclipse.ui.navigator.ProjectExplorer");
     } catch (PartInitException e) {
        System.out.println(e);
     }
     CommonNavigator nav = (CommonNavigator) viewPart;
     CommonViewer viewer = nav.getCommonViewer();
     Tree tree = viewer.getTree();
     tree.addMouseMoveListener(list);
   }
});

Ainsi, on spécifie au thread courant d’exécuter de manière synchrone notre code.


Fichier(s) joint(s) :



[Non résolu] Swftools et zones de texte dynamiques

Un problème un fois de plus assez complexe.

Voici le contexte :

J’ai développé un moteur de recherche en Flash pour du contenu textuel au sein de fichiers SWF. Basé sur des zones de textes dynamiques et de polices embarquées, tout fonctionne très bien, les résultats de la recherche étant surlignés dans les textes (avec TextSnapShot).

Le coeur du problème :

Ce système fonctionne très bien avec des SWF créés à la main. Le soucis est que je désormais le faire fonctionner avec des SWF générés dynamiquement à partir de l’outil pdf2swf des Swftools.

Après décompilation des fichiers générés, on peut facilement constater que cet outil crée des zones de texte statiques pour tout ce qui est textuel.

Il faut donc pouvoir modifier le code source de Swftools pour lui faire générer plutôt des zones de texte dynamiques… Pas très simple sous Windows!!

Solution :

Pour le moment je n’ai pas trouvé de solution à mon problème. J’ai tout juste réussi à remplacer bêtement dans le code source tout les "StaticText" par "DynamicText", recompiler le tout, mais sans grand succès!! Un très bon tutorial pour recompiler Swftools sous Windows est disponible à cette adresse (accessible depuis la FAQ su site officiel)

Très peu de sites/forums/mailinglist abordent le sujet de la customization de swftools.

Alors j’en apelle à la communauté pour savoir si quelqu’un à déjà eu à faire ce genre de modification!! N’hésitez pas à fournir des propositions!


Fichier(s) joint(s) :



Papervision3D et moteur physique

Lors du développement d’une application 3D il est souvent intéressant d’ajouter des notions de physique, comme la gravité, les collisions, rebonds...

Il existe principalement deux moteurs de physique 3D pour Flash : Jiglib et WowEngine. Je vais expliquer dans cet article l’utilisation de WowEngine.

Voici comment il s’utilise dans une application, couplé avec PaperVision3D :

L’animation dispose, de façon bien distincte, du moteur de rendu et du moteur de physique. Ils sont entièrement indépendants. Le site du projet WowEngine présente un exemple très clair pour comprendre comment coupler les deux, regardons un peu le code (fournit ici) :

public function createOneSphere(x:int,y:int,z:int,radius:int): void
{
 //we create a physics sphere the param is position x,y,z the radius, if it's fixed or not and the mass
 var wowSphere:WSphere = new WSphere(x,y,z,radius,false,0.1);
 wowSphere.elasticity=0.5;
 wowSphere.friction=0.02;
 wow.addParticle(wowSphere);
 
 //and the 3dObject on the 3d renderer starting by the material...
 var material:WireframeMaterial=new WireframeMaterial(0,100,.1)
 //...and the 3DObject
 var sphere:Sphere = new Sphere(material , radius, 5, 3 );
 sphere.x=-wowSphere.px;
 sphere.y=-wowSphere.py;
 sphere.z=-wowSphere.pz;
 scene.addChild(sphere);
 //
 sphereArray.push({physics:wowSphere,render:sphere});
}
private function enterFrameHandler( event : Event ):void 
{
 //we update the physics engine
 wow.step()
 
 // we link the  physics sphere to the rendered sphere
 var ballCount:int=sphereArray.length;
 while(ballCount--){
  var physics:WSphere=sphereArray[ballCount].physics
  var render:Sphere=sphereArray[ballCount].render
  render.x=physics.px;
  render.y=-physics.py;
  //trace(physics.py)
  render.z=physics.pz;
 }
 //we update the 3d view
 renderer.renderScene(scene,camera, viewport);
} 

Après avoir initialisé Papervision et WowEngine (respectivement dans les méthodes "setupPapervision()" et "setupWow()"), cette méthode permet de créer une sphère dans l’environnement 3D réagissant aux lois de gravitation.

Le principe est le suivant :

D’une part, on crée, dans le moteur physique, une WSphere qui sera soumise à différentes contraintes comme l’élasticité (capacité de rebondir), les frictions (en plus de la gravité). D’autre part, on crée dans le moteur de rendu, une Sphere simple.

La liaison entre les deux est faite dans la méthode "enterFrameHandler" : on peut voir qu’elle met à jour la position de l’objet graphique (Sphere) selon la position de l’objet physique (WSphere).

La mise en place d’un moteur physique dans un projet 3D n’est donc pas plus compliqué que cela : il suffit de créer les deux moteurs indépendamment et de mettre périodiquement à jour le rendu en fonction de l’évolution des données physiques.


Fichier(s) joint(s) :



Flash Player 8/AS 2 et Localization: ma méthode

Tout d’abord, pourquoi un article sur Flash Player 8 et Actionscript 2, à l’heure de l’apogée de la technologie Flex/AS3 ? Réponse simple, au vu des statistiques de pénétration du player. En effet, Flex c’est génial, mais beaucoup d’utilisateurs sont encore munis de la version 8 du player, incompatible avec Flex.

Je viens donc de travailler sur une application dont la principale particularité est qu’elle est codée uniquement en AS2, pour cibler la majorité des utilisateurs. Le problème que j’ai rencontré est le suivant : je devais internationaliser l’interface, c’est-à-dire permettre de changer de langue à n’importe quel moment. Alors vous me direz, avec Flex c’est facile, avec les ResourceBundle. Certes, mais pour ce qui est de l’AS2, c’est une autre histoire...

J’ai donc fais pas mal de recherches sur le net, mais finalement rien de concluant : la majorité des solutions que j’ai trouvées consistait à créer un tableau, global à l’application, rescençant une liste de mot-clés associés à une langue. Ensuite, une fonction était chargée de changer la valeur dans toutes les zones de textes impliquées. C’est le principe utilisé par la classe la plus courante, distribuée par Shaoken. Ceci est parfois très pratique, sauf que dans mon cas, la liste des champs à manipuler est assez importante et répartie dans des profondeurs de clip d’autant plus complexe. Enfin mon application étant destinée à être customisée par des clients, je n’ai aucun moyen de connaitre précisément la liste des champs mis en place… Un vrai dilemne n’est-ce pas?

La seule solution qui s’offre donc à moi, est de me reposer sur un modèle évènementiel, comme notre cher Flex. Seconde embuche! Flash est plutôt capricieux quand il s’agit de gérer des évènements. Mais voilà mon raisonnement :

Il faudrait que j’utilise un objet global à mon application, qui gèrerait seul tout cet aspet de localization (langue courante, dictionnaire…). De plus, il faudrait que tout mes champs se mettent à jour lorsque l’utilisateur appuie sur un bouton. Mais je ne sais connait pas la liste des champs dans l’appli. Il faut donc que ce soit eux-mêmes qui prennent connaissance du changement de langue.

Donc deux étapes : créer cet objet global, jusque là rien de compliqué, une simple classe suffira. Cependant, il devra être capable de diffuser l’évènement du changement de langue... Il faudra donc étendre la classe EventDispatcher. Ensuite, pour les champs je vais devoir créer un composant, basé sur un champ texte dynamique, mais capable de répondre à un évènement. Soit!

Parlons peu, parlons bien. Commençons par la classe gérant la localization :

import mx.events.EventDispatcher;
import mx.xpath.XPathAPI;
import mx.utils.Delegate;
 
class data.com.Localizer extends EventDispatcher {
 
 private var dispatchEvent:Function;
 private var dispatchQueue:Function;
 public var addEventListener:Function;
 public var removeEventListener:Function;
 
 private var localeXML:XML;
 private var dictionnary:Object;
 
 public var language = "fr";
 public var dictionnaryPath = "";
 
 function Localizer(){
  mx.events.EventDispatcher.initialize(this);
  this.dictionnary = new Object();
  this.localeXML = new XML();
  this.localeXML.ignoreWhite = true;
  this.localeXML.onLoad = Delegate.create(this, dotranslate);
 }
 
 function loadDico(){
  this.localeXML.load(this.dictionnaryPath);
 }
 
 function dotranslate(loaded:Boolean){
  if(loaded){
   translate();
  } else {
   trace("Error loading dictionnary!");
  }
 }
 
 function translate(){
  var aNodes:Array = this.localeXML.firstChild.childNodes;
  var nMaxNodes:Number = aNodes.length;
  for (var i:Number = 0; i < nMaxNodes; i++){
   var sVarName:String = aNodes[i].attributes.id;
   var sVarValue = XPathAPI.selectSingleNode(aNodes[i], "/*/"+this.language).firstChild.nodeValue;
   if (sVarValue == undefined)
    sVarValue = XPathAPI.selectSingleNode(aNodes[i], "/*/default").firstChild.nodeValue;
   if (sVarValue == undefined) {
    trace("Localized XML node can't be selected, please verify your language or set a default value tag");
   }
   this.dictionnary[sVarName] = sVarValue;
 
  }
  dispatchEvent({target:this, type:"languageChanged"});
 }
}

Comme vous pouvez le voir, cette class n’est pas bien complexe et repose sur le code de Shaoken. Voici son fonctionnement : à la création de l’objet, j’initialise la classe EventDispatcher pour pouvoir ensuite utiliser ses méthodes. Le fichier XML "localeXML" contient le dictionnaire, nous verrons sa syntaxe juste après. La méthode "translate()" parse le fichier et remplit le tableau "this.dictionnary" en fonction de la langue choisie. Une fois le tableau remplit, on envoie l’évènement avertissant que la traduction est disponible.

Regardons maintenant la structure du XML en question :



 Traduire
 Translate
 Traduire


 ...


Pour chaque variable, on a la liste des textes dans chanque langue.

Passons maintenant au plus important, le composant! :

#initclip
import mx.utils.Delegate;
 
LTextField.prototype = new MovieClip();
 
function LTextField () {
 this.textFormatter = new TextFormat();
 this.init();
}
 
LTextField.prototype.init = function () {
 _root.languageWizard.addEventListener("languageChanged",Delegate.create(this,LTextField.prototype.translation));
}
 
LTextField.prototype.translation = function () {
 this.label_txt.text = _root.languageWizard.dictionnary[this.labelKey];
 this.textFormatter.size = this.fontSize;
 this.textFormatter.align = this.halign;
 this.label_txt.setTextFormat(this.textFormatter);
}
 
// Connect the class with the linkage ID for this movie clip
Object.registerClass("LocalizedTextField", LTextField);
#endinitclip

J’ai ainsi créé un simple clip, avec sur la scène un DynamicTextField nommé : "label_txt". Voici le principe : quand le composant est créé sur la scène, le constructeur est appelé, puis la fonction "init()" qui va ajouter sur notre objet global un écouteur de l’évènement de changement de langue, lié à la fonction d’actualisation du composant ("translation()"). Ainsi, je découple complètement le gestionnaire des objets acteurs.

Je peux donc placer mes champs texte n’importe où dans l’application, à n’importe quel niveau, puisqu’ils sont directement liés à mon objet global. Ce dernier est créé par une méthode aussi simple que :

_root.languageWizard = new Localizer();
_root.languageWizard.dictionnaryPath = "data/localization.xml";
_root.languageWizard.loadDico();

Ensuite, voici le comportement du bouton permettant de passer d’une langue à l’autre :

function triggerLocale(){
 if(_root.languageWizard.language=="fr"){
  _root.languageWizard.language = "en";
  cbtTranslate.gotoAndStop(2);
 } else {
  _root.languageWizard.language = "fr";
  cbtTranslate.gotoAndStop(1);
 }
 _root.languageWizard.translate();
}
 
cbtTranslate.onPress = triggerLocale;

Maintenant, et parce que cela n’a pas été aussi simple qu’il n’y parait, je vous vous lister l’ensemble des difficultés que j’ai eu à créer ce code, afin de vous les épargner si vous avez à le réutiliser! :

  1. Pour ceux qui auraient oublié comment créer un composant dans Flash, voici un très bon tuto.
  2. Pour créer un objet capable de diffuser des évènements, il est indispensable d’étendre la classe EventDispatcher, et de déclarer toutes ses méthodes (removeEventListener…)
  3. Depuis le composant, lorsque l’on affecte un écouteur à l’objet global, il est impératif d’utiliser la classe Delagate, sans quoi Flash essaiera de trouver la méthode indiquée au même niveau que celui de l’objet (donc au niveau global (_root) et non pas dans le composant…)
  4. Enfin, je m’excuse auprès des puristes qui militent conter l’utilisation du _root, mais c’est une souplesse erreur que nous permet encore l’AS2

Fichier(s) joint(s) :



Aperçu des webservices de métadonnées musicales

Il y a deux jours, je me suis demandé : "Existe-t-il un/des webservice(s) qui permettraient de récupérer des informations sur un artiste/album/titre ?" La réponse est Oui! Voici donc un petit aperçu des trois grands webservices :

MusicBrainz

Musicbrainz est une base de données d’informations, entretenue par des utilisateurs, accessible depuis un navigateur web ou différents types d’application (lecteur multimédia, webservice…)

Son atout majeur est qu’il est accessible totalement librement, c’est-à-dire qu’il est le seul à ne pas nécessiter d’identification par une quelconque API_KEY. De plus, étant géré par des utilisateurs, il est en constante évolution et propose une quantité d’informations très intéressante.

Le point négatif est qu’il ne fournit pas toujours les informations désirées : si, par exemple, on cherche à récupérer la track list d’un album particulier, cela se complique un peu… Je n’ai pas tout exploré en détail mais je suppose que cela est du au fait que l’API n’est pas tout à fait complète.

La documentation sur l’API du service est cependant assez claire, même si elle mériterait à mon sens d’être un peu plus exhaustive et illustrée d’exemples.

LastFM

Le fameux service de diffusion audio en ligne propose également une API pour son webservice. Il propose des informations et des méthodes très intéressantes, mais à mon avis, un peu trop calquées sur ce que propose le site en lui-même : recherche des titres les mieux notés pour un artiste, des albums les plus écoutés pour un artiste… Il est donc impossible par exemple de récupérer la track list (oui encore! c’est ce que je cherche en fait) pour un album.

Je pense que ce service gagnerait à se généraliser un peu, même s’il est certain que les informations qu’il fournit sont déjà très pertinentes pour un site d’informations musicales avec classement des titres, artistes, etc.

Yahoo! Music

Dans le cadre de son Developer Network, le portail/moteur de recherche met également à disposition de tous une base d’informations musicales. Son API est un peu moins simple d’utilisation que les autres puisque moins basée sur le principe de folksonomy.

Elle permet en tout cas d’avoir des informations un peu plus génériques sur les artistes/albums.

Je ne l’ai pas encore beaucoup testé, mais il semble qu’il devient rapidement complexe de rechercher des informations précises, selon plusieurs critères : il est possible d’utiliser dans l’URL de recherche un paramètre &intersectsWith= pour multiplier les critères, mais encore une fois, la documentation gagnerait à être un peu plus claire et fournie.

Dans tous les cas, je continuerai dans les prochains jours à compléter cet article au fur et à mesure des mes expérimentations. Si vous avez déjà pratiquez l’un de ses webservices, n’hésitez pas à m’en faire part!


Fichier(s) joint(s) :



Formatter une cellule Excel depuis une page HTML générée

Une nouvelle problématique rencontrée ces jours derniers  : une application génère un fichier Excel (format .xls) contenant du code HTML (<table><tr><td>) que le logiciel pourra interpréter pour reconstituer un tableau classique.
Maintenant, on rempli le tableau avec les données manipulées... Mais voilà, une de ces données est sous la forme d’un nombre long, composé d’une petite dizaine de chiffres. En ouvrant le fichier Excel, je m’aperçois que mon nombre se retrouve affiché sous la forme suivante  :

9,0384E+11

Arf... Comment faire pour qu’il s’affiche dans son entier, sans décimale?... Des quotes? Voilà le résultat affiché :
"90384104738″

Presque ça… Mais en fait la solution n’est pas du tout dans cette direction!
Pour contrecarrer la fâcheuse habitude d’Excel de transformer les nombres, il faut utiliser... du CSS!! Oui, Oui c’est bien ça… Mais pas n’importe quel style, celui de Micro$oft!!

Alors là je pense que les puristes du CSS se seront déjà évanouis! Mais prenez le temps de regarder le petit bout de code ci-dessous, il n’en est pas moins très utile :

.xl28 {mso-style-parent:style0; mso-number-format:00000; text-align:left; white-space:normal;} 

Comme vous le voyez, on retrouve les tags classiques des styles générés par Word&co (mso-…). Examinons le tout de même : on comprend clairement qu’il permet de définir le format de la cellule en question (mso-number-format) comme numéraire sans décimale. Ceci aura pour conséquence d’autoriser l’affichage de notre nombre dans son entier! Vous n’avez plus qu’à appliquer cette classe à votre <td> et le tour est joué.

Vous trouverez d’autres exemples d’utilisation de ces styles CSS ici, ou bien sur ce forum, fournissant des exemples plus poussés (formatage de dates...). Eh oui, comme vous le constatez, le fameux adage se vérifie encore : "Si tu as un problème, regarde sur Internet, quelqu’un d’autre l’a forcément eu avant toi!" Encore faut-il savoir où chercher, je ne vous le fais pas dire… Mais c’est pour cela que ce blog existe après tout!!

Pour terminer, dans d’autres cas, si cette méthode ne marche pas, vous pouvez “forcer” Excel à afficher strictement le contenu désiré simplement en écrivant :

="mon-info"

Normalement, le logiciel gardera intacte la donnée.


Fichier(s) joint(s) :



Utiliser simplement FFMpeg

FFMpeg est un outil en ligne de commande permettant de convertir des fichiers audio et vidéo dans de multiples formats. Il est distribué sous la forme d’une librairie opensource. Qui dit ligne de commande, dit exécutable sur un serveur!! Cependant, son utilisation se révèle, du moins au premier abord, assez mystérieuse… Je vais donc vous donner quelques astuces pour le prendre en main rapidement.

Projets utilisant FFMpeg

Cet outil étant libre de droits, on le retrouve dans de nombreux logiciels, plus ou moins connus, comme DivXtoDVD, Mobile Media Converter ou encore PlayStation Portable Video Converter (vous pouvez consulter la liste exhaustive ici).

Mais l’outil qui va nous intéresser dans cet article est Pazera Free FLV to AVI Converter. En effet, il offre une interface graphique parfaite pour préparer ffmpeg à une utilisation sur serveur.

L’outil de Pazera

Voici comment se présente son interface :


Comme vous pouvez le constater, malgré un aspect un peu chargé au premier coup d’oeil, l’ensemble est plutôt clair et tout est visible sur un seul écran.

Si vous regardez la documentation de ffmpeg, vous verrez des exemples de fonctionnement comme celui-ci :

ffmpeg -f oss -i /dev/dsp -f video4linux2 -i /dev/video0 /tmp/out.mpg

Pas simple donc, même avec la doc, de connaître tous les arguments à utiliser, pour passer de tel format à tel format, activer tel codec audio/video...

C’est à cela que va donc nous servir Pazera. Grâce à son interface simpliste, vous n’avez qu’à sélectionner toutes les options que vous désirez et exporter la configuration ainsi créée avec File -> Save Bat File. Vous obtiendrez un fichier ressemblant à celui-ci :

@echo off
set ffmpeg="C:\logiciels\Red5\webapps\WebCamMailer\tools\ffmpeg\ffmpeg.exe"
set VCodec=-vn
set VParams=
set ACodec=-acodec wmav2
set AParams=-ab 128k -ar 44100 -ac 2
set AdvParams=-s 320x240
set AddParams=
%ffmpeg% -y -i "C:\Users\PeF\Desktop\mozinor_test.flv" %VCodec% %VParams% %ACodec% %AParams% %AdvParams% %AddParams% "C:\Users\PeF\Desktop\mozinor_test.avi"
pause

Et voilà, rien de plus simple! Une ligne de commande toute prête à être exécutée sur un serveur! Qui plus est, le fichier généré est très lisible et permet de facilement ajuster les paramètres au besoin à la main.

Exemple d’utilisation dans une classe JAVA

Maintenant, le plus important, voilà de quoi exécuter votre ligne de commande sur un serveur Java :

//on crée un processBuilder pour pouvoir récupere les logs de ffmpeg
ProcessBuilder builder = new ProcessBuilder(FLV2AVI_COMMAND_ARGS);
//on redirige le ErrorStream car FFMpeg envoie par defaut ses logs dans le stderr et pas stdout
builder.redirectErrorStream(true);
Process process = builder.start();
BufferedReader br = new BufferedReader(new InputStreamReader(process.getInputStream()));
StringBuffer sb = new StringBuffer();
String line;
while ((line = br.readLine()) != null) {
 sb.append(line).append("\n");
}
 
int exit = process.waitFor(); // 0 = exit normally
response = "OK";

Le principe est de construire un Process qui lancera notre commande et attendra la fin de son exécution avant de continuer le script.

Petite astuce supplémentaire : pour récupérer les logs de ffmpeg, on doit rediriger le flux d’erreur (stderr) vers la sortie standard (stdout). En effet, par défaut, ffmpeg est compilé de telle façon que toutes ses sorties sont traitées comme erreur. Pour les plus courageux, pour éviter ce workaround, vous pouvez toujours vous remonter les manches, modifier le code de ffmpeg et le recompiler!


Fichier(s) joint(s) :



Réparer des fichiers FLV mal encodés

Nous avons vu dans l’article précédent comment enregistrer des fichiers FLV à partir du flux vidéo diffusé par la webcam.

J’y avais mentionné un bug connu de Flash Player qui provoquait l’écriture de fichiers FLV mal formés : en effet, la méthode NetStream.close() ne se terminant pas correctement, ils ne sont pas finalisés et ne comportent pas les en-têtes attendus. Des lecteurs vidéo classiques (Media Player Classic pour ne nommer que lui) sont donc incapables de les lire. Seuls les composants Flash de diffusion vidéo pourront les utiliser sans souci... assez restrictif tout de même!

Un moyen de remédier à ce problème est d’utiliser le petit utilitaire FLVMDI (FLV MetaData Injector). Son rôle est de scruter le fichier en question pour récupérer les informations manquantes et les réécrires au début du fichier. Tout simple! Mais terriblement efficace...

De plus, vu qu’il s’agit simplement d’un exécutable qui prend en entrée un fichier FLV, il peut facilement être placé sur un serveur et exécuté par une routine si tôt qu’un nouvel enregistrement est créé!!

En espérant vous avoir fait gagner quelques heures de "googling"!!


Fichier(s) joint(s) :



Premier client Red5 (avec Flex)

Pour faire suite à mon précédent article illustrant comment créer une simple application Red5, je vais dans cet article vous illustrer comment s’y connecter simplement avec un client léger.

Le protocole de communication utilisé dans ce genre d’application se nomme RTMP (Real Time Messaging Protocol). Il a initialement été développé par Adobe afin de permettre aux applications Flash de communiquer en temps réel avec un serveur, grâce à de simples "messages".

Mais trève de théorie, voici sans plus tarder les quelques lignes qui vous permettront de vous connecter à votre serveur :



 
  
 

Comme vous pouvez le voir dans la méthode "connect", l’URL de l’application est basée sur le protocole RTMP.

Si tout se déroule correctement, une fenêtre pop-up s’ouvrira indiquant "Starting app...". Vous pouvez dès lors faire appel à d’autres méthodes, la connexion au serveur étant assurée.

Dans le cas où tout ne se déroulerait pas aussi simplement, vous pouvez consulter la page de Red5 Blog décrivant les différents messages d’erreurs possibles et leur description/cause.

Note :

Il est important de savoir que la moindre erreur dans votre code JAVA (NullPointerException…) entrainera un retour du type NetConnection.Connect.Failed dans Flex, sans plus d’information. En effet, il semblerait que, pour le moment, Red5 ne puisse pas faire suivre les erreurs levées jusqu’au client... (du moins à ma connaissance!). Alors si vous ne voyez pas pourquoi votre code ne fonctionne pas, n’hésitez pas à le bourrer de try...catch pour faire apparaître les éventuelles erreurs dans la console Red5!


Fichier(s) joint(s) :

Flex/Red5 : Enregistrer le flux de la webcam

Une question toute simple m’a brulé les lèvres ces jours derniers : comment capturer le flux vidéo de ma webcam et en faire une video FLV?

Je me suis décidé à écrire cet article lorsque je me suis aperçu, au fil de mes recherches sur le net, que cette problématique paraît tellement simple que personne n’en a donné une réponse claire et explicite…

Voici donc les quelques lignes (et surtout LA ligne!) qui va vous permettre d’enregistrer votre webcam :



 
  
 
 
 
  
 

Comme vous pouvez le constater, rien de plus simple! Il suffit de faire appel à la méthode NetStream.publish avec le paramètre "record". Un fichier FLV sera créé, portant le nom indiqué.

Hope this help!

Note :

Un bug connu du Flash Player 9, lié (je pense) à ce bug rapporté chez Adobe, persistant sous Flash Player 10 et résidant dans la méthode NetStream.close, provoque l’écriture de fichiers FLV mal formés et parfois donc illisibles… Je décrirais dans un prochain article une solution pour “nettoyer” vos fichiers et les rendre utilisables.


Fichier(s) joint(s) :



A la découverte de Red5!

Avant d’entrer dans le vif du sujet, commençons par présenter rapidement Red5, pour ceux qui ne connaissent pas :
Il s’agit simplement d’un serveur de streaming, permettant de gérer la diffusion de flux diverses, tels des flux audio, vidéos ou même des connexions vers d’autres serveurs (bases de données...). Il est l’équivalent gratuit et open source de Flash Media Server.

Je vais dans cet article vous donner les premières étapes nécessaires à la mise en place de votre première application.

Répartition des fichiers

Les fichiers JAVA

Toutes les classes utilisées pour créer le côté serveur de l’application sont en JAVA et doivent se situer à l’emplacement suivant :

[dossier_installation_red5]/webapps/[nom-de-l'appli]/WEB-INF/classes/

Les fichiers de configuration

Trois fichiers sont requis pour faire fonctionner votre application, dans le répertoire :

[dossier_installation_red5]/webapps/[nom-de-l'appli]/WEB-INF/

web.xml :

Sert à décrire le nom et le répertoire racine de votre application. Il suffit simplement de remplacer "param-value" dans le tag suivant :

********

red5-web.xml :

Indique au serveur quelle sera la classe qui vous aurez créée comme base à votre application (voire le paragraphe suivant sur la classe ApplicationAdapter). Vous devrez indiquer le nom complet de votre classe (package compris) dans ce tag :

***********

red5-web.properties :

Fichier de propriétés déclarant quelles URL doivent pointer vers votre application :

webapp.contextPath=/demo
webapp.virtualHosts=*, localhost, localhost:8088, 127.0.0.1:8088

La classe : ApplicationAdapter

Toute application Red5 doit étendre la classe native ApplicationAdapter pour permettre de gérer les évènement de bases : dé/connexion, démarrage/arrêt de l’application… Voici un exemple basic de ce type de classe :

package applicationAdapter;
 
import org.red5.server.adapter.ApplicationAdapter;
import org.red5.server.api.IConnection;
import org.apache.log4j.Logger;
 
public class Application extends ApplicationAdapter {
 
private static Logger logger = Logger.getLogger(Application.class);
 
public void appStop() {
logger.info("App stop");
}
 
public void appStart() {
logger.info("App start");
}
 
public boolean appConnect(IConnection conn, Object[] params) {
try {
logger.info("appConnect");
} catch (Exception e) {
e.printStackTrace();
}
return true;
}
 
public void appDisconnect(IConnection conn, Object[] params) {
logger.info("appDisconnect");
}
 
}

Votre application est maintenant prête à être démarrée. Elle sera accessible à l’addresse :

http://localhost:5080/[nom-de-l'appli]/

Dans le prochain article, je vous montrerai le code utilisé pour réaliser un simple client vers notre serveur Red5...


Fichier(s) joint(s) :