/*
* Copyright (C) 2007 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package jackpal.androidterm.emulatorview;
import java.io.InputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.io.UnsupportedEncodingException;
import java.nio.ByteBuffer;
import java.nio.CharBuffer;
import java.nio.charset.Charset;
import java.nio.charset.CharsetEncoder;
import java.nio.charset.CodingErrorAction;
import android.os.Handler;
import android.os.Looper;
import android.os.Message;
/**
* A terminal session, consisting of a VT100 terminal emulator and its
* input and output streams.
* <p>
* You need to supply an {@link InputStream} and {@link OutputStream} to
* provide input and output to the terminal. For a locally running
* program, these would typically point to a tty; for a telnet program
* they might point to a network socket. Reader and writer threads will be
* spawned to do I/O to these streams. All other operations, including
* processing of input and output in {@link #processInput processInput} and
* {@link #write(byte[], int, int) write}, will be performed on the main thread.
* <p>
* Call {@link #setTermIn} and {@link #setTermOut} to connect the input and
* output streams to the emulator. When all of your initialization is
* complete, your initial screen size is known, and you're ready to
* start VT100 emulation, call {@link #initializeEmulator} or {@link
* #updateSize} with the number of rows and columns the terminal should
* initially have. (If you attach the session to an {@link EmulatorView},
* the view will take care of setting the screen size and initializing the
* emulator for you.)
* <p>
* When you're done with the session, you should call {@link #finish} on it.
* This frees emulator data from memory, stops the reader and writer threads,
* and closes the attached I/O streams.
*/
public class TermSession {
public void setKeyListener(TermKeyListener l) {
mKeyListener = l;
}
private TermKeyListener mKeyListener;
private ColorScheme mColorScheme = BaseTextRenderer.defaultColorScheme;
private UpdateCallback mNotify;
private OutputStream mTermOut;
private InputStream mTermIn;
private String mTitle;
private TranscriptScreen mTranscriptScreen;
private TerminalEmulator mEmulator;
private boolean mDefaultUTF8Mode;
private Thread mReaderThread;
private ByteQueue mByteQueue;
private byte[] mReceiveBuffer;
private Thread mWriterThread;
private ByteQueue mWriteQueue;
private Handler mWriterHandler;
private CharBuffer mWriteCharBuffer;
private ByteBuffer mWriteByteBuffer;
private CharsetEncoder mUTF8Encoder;
// Number of rows in the transcript
private static final int TRANSCRIPT_ROWS = 10000;
private static final int NEW_INPUT = 1;
private static final int NEW_OUTPUT = 2;
private static final int FINISH = 3;
private static final int EOF = 4;
/**
* Callback to be invoked when a {@link TermSession} finishes.
*
* @see TermSession#setUpdateCallback
*/
public interface FinishCallback {
/**
* Callback function to be invoked when a {@link TermSession} finishes.
*
* @param session The <code>TermSession</code> which has finished.
*/
void onSessionFinish(TermSession session);
}
private FinishCallback mFinishCallback;
private boolean mIsRunning = false;
private Handler mMsgHandler = new Handler() {
@Override
public void handleMessage(Message msg) {
if (!mIsRunning) {
return;
}
if (msg.what == NEW_INPUT) {
readFromProcess();
} else if (msg.what == EOF) {
new Handler(Looper.getMainLooper()).post(new Runnable() {
@Override
public void run() {
onProcessExit();
}
});
}
}
};
private UpdateCallback mTitleChangedListener;
public TermSession() {
this(false);
}
public TermSession(final boolean exitOnEOF) {
mWriteCharBuffer = CharBuffer.allocate(2);
mWriteByteBuffer = ByteBuffer.allocate(4);
mUTF8Encoder = Charset.forName("UTF-8").newEncoder();
mUTF8Encoder.onMalformedInput(CodingErrorAction.REPLACE);
mUTF8Encoder.onUnmappableCharacter(CodingErrorAction.REPLACE);
mReceiveBuffer = new byte[4 * 1024];
mByteQueue = new ByteQueue(4 * 1024);
mReaderThread = new Thread() {
private byte[] mBuffer = new byte[4096];
@Override
public void run() {
try {
while(true) {
int read = mTermIn.read(mBuffer);
if (read == -1) {
// EOF -- process exited
break;
}
int offset = 0;
while (read > 0) {
int written = mByteQueue.write(mBuffer,
offset, read);
offset += written;
read -= written;
mMsgHandler.sendMessage(
mMsgHandler.obtainMessage(NEW_INPUT));
}
}
} catch (IOException e) {
} catch (InterruptedException e) {
}
if (exitOnEOF) mMsgHandler.sendMessage(mMsgHandler.obtainMessage(EOF));
}
};
mReaderThread.setName("TermSession input reader");
mWriteQueue = new ByteQueue(4096);
mWriterThread = new Thread() {
private byte[] mBuffer = new byte[4096];
@Override
public void run() {
Looper.prepare();
mWriterHandler = new Handler() {
@Override
public void handleMessage(Message msg) {
if (msg.what == NEW_OUTPUT) {
writeToOutput();
} else if (msg.what == FINISH) {
Looper.myLooper().quit();
}
}
};
// Drain anything in the queue from before we started
writeToOutput();
Looper.loop();
}
private void writeToOutput() {
ByteQueue writeQueue = mWriteQueue;
byte[] buffer = mBuffer;
OutputStream termOut = mTermOut;
int bytesAvailable = writeQueue.getBytesAvailable();
int bytesToWrite = Math.min(bytesAvailable, buffer.length);
if (bytesToWrite == 0) {
return;
}
try {
writeQueue.read(buffer, 0, bytesToWrite);
termOut.write(buffer, 0, bytesToWrite);
termOut.flush();
} catch (IOException e) {
// Ignore exception
// We don't really care if the receiver isn't listening.
// We just make a best effort to answer the query.
e.printStackTrace();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
};
mWriterThread.setName("TermSession output writer");
}
protected void onProcessExit() {
finish();
}
/**
* Set the terminal emulator's window size and start terminal emulation.
*
* @param columns The number of columns in the terminal window.
* @param rows The number of rows in the terminal window.
*/
public void initializeEmulator(int columns, int rows) {
mTranscriptScreen = new TranscriptScreen(columns, TRANSCRIPT_ROWS, rows, mColorScheme);
mEmulator = new TerminalEmulator(this, mTranscriptScreen, columns, rows, mColorScheme);
mEmulator.setDefaultUTF8Mode(mDefaultUTF8Mode);
mEmulator.setKeyListener(mKeyListener);
mIsRunning = true;
mReaderThread.start();
mWriterThread.start();
}
/**
* Write data to the terminal output. The written data will be consumed by
* the emulation client as input.
* <p>
* <code>write</code> itself runs on the main thread. The default
* implementation writes the data into a circular buffer and signals the
* writer thread to copy it from there to the {@link OutputStream}.
* <p>
* Subclasses may override this method to modify the output before writing
* it to the stream, but implementations in derived classes should call
* through to this method to do the actual writing.
*
* @param data An array of bytes to write to the terminal.
* @param offset The offset into the array at which the data starts.
* @param count The number of bytes to be written.
*/
public void write(byte[] data, int offset, int count) {
try {
while (count > 0) {
int written = mWriteQueue.write(data, offset, count);
offset += written;
count -= written;
notifyNewOutput();
}
} catch (InterruptedException e) {
}
}
/**
* Write the UTF-8 representation of a String to the terminal output. The
* written data will be consumed by the emulation client as input.
* <p>
* This implementation encodes the String and then calls
* {@link #write(byte[], int, int)} to do the actual writing. It should
* therefore usually be unnecessary to override this method; override
* {@link #write(byte[], int, int)} instead.
*
* @param data The String to write to the terminal.
*/
public void write(String data) {
try {
byte[] bytes = data.getBytes("UTF-8");
write(bytes, 0, bytes.length);
} catch (UnsupportedEncodingException e) {
}
}
/**
* Write the UTF-8 representation of a single Unicode code point to the
* terminal output. The written data will be consumed by the emulation
* client as input.
* <p>
* This implementation encodes the code point and then calls
* {@link #write(byte[], int, int)} to do the actual writing. It should
* therefore usually be unnecessary to override this method; override
* {@link #write(byte[], int, int)} instead.
*
* @param codePoint The Unicode code point to write to the terminal.
*/
public void write(int codePoint) {
ByteBuffer byteBuf = mWriteByteBuffer;
if (codePoint < 128) {
// Fast path for ASCII characters
byte[] buf = byteBuf.array();
buf[0] = (byte) codePoint;
write(buf, 0, 1);
return;
}
CharBuffer charBuf = mWriteCharBuffer;
CharsetEncoder encoder = mUTF8Encoder;
charBuf.clear();
byteBuf.clear();
Character.toChars(codePoint, charBuf.array(), 0);
encoder.reset();
encoder.encode(charBuf, byteBuf, true);
encoder.flush(byteBuf);
write(byteBuf.array(), 0, byteBuf.position()-1);
}
/* Notify the writer thread that there's new output waiting */
private void notifyNewOutput() {
Handler writerHandler = mWriterHandler;
if (writerHandler == null) {
/* Writer thread isn't started -- will pick up data once it does */
return;
}
writerHandler.sendEmptyMessage(NEW_OUTPUT);
}
/**
* Get the {@link OutputStream} associated with this session.
*
* @return This session's {@link OutputStream}.
*/
public OutputStream getTermOut() {
return mTermOut;
}
/**
* Set the {@link OutputStream} associated with this session.
*
* @param termOut This session's {@link OutputStream}.
*/
public void setTermOut(OutputStream termOut) {
mTermOut = termOut;
}
/**
* Get the {@link InputStream} associated with this session.
*
* @return This session's {@link InputStream}.
*/
public InputStream getTermIn() {
return mTermIn;
}
/**
* Set the {@link InputStream} associated with this session.
*
* @param termIn This session's {@link InputStream}.
*/
public void setTermIn(InputStream termIn) {
mTermIn = termIn;
}
/**
* @return Whether the terminal emulation is currently running.
*/
public boolean isRunning() {
return mIsRunning;
}
TranscriptScreen getTranscriptScreen() {
return mTranscriptScreen;
}
TerminalEmulator getEmulator() {
return mEmulator;
}
/**
* Set an {@link UpdateCallback} to be invoked when the terminal emulator's
* screen is changed.
*
* @param notify The {@link UpdateCallback} to be invoked on changes.
*/
public void setUpdateCallback(UpdateCallback notify) {
mNotify = notify;
}
/**
* Notify the {@link UpdateCallback} registered by {@link
* #setUpdateCallback setUpdateCallback} that the screen has changed.
*/
protected void notifyUpdate() {
if (mNotify != null) {
mNotify.onUpdate();
}
}
/**
* Get the terminal session's title (may be null).
*/
public String getTitle() {
return mTitle;
}
/**
* Change the terminal session's title.
*/
public void setTitle(String title) {
mTitle = title;
notifyTitleChanged();
}
/**
* Set an {@link UpdateCallback} to be invoked when the terminal emulator's
* title is changed.
*
* @param listener The {@link UpdateCallback} to be invoked on changes.
*/
public void setTitleChangedListener(UpdateCallback listener) {
mTitleChangedListener = listener;
}
/**
* Notify the UpdateCallback registered for title changes, if any, that the
* terminal session's title has changed.
*/
protected void notifyTitleChanged() {
UpdateCallback listener = mTitleChangedListener;
if (listener != null) {
listener.onUpdate();
}
}
/**
* Change the terminal's window size. Will call {@link #initializeEmulator}
* if the emulator is not yet running.
* <p>
* You should override this method if your application needs to be notified
* when the screen size changes (for example, if you need to issue
* <code>TIOCSWINSZ</code> to a tty to adjust the window size). <em>If you
* do override this method, you must call through to the superclass
* implementation.</em>
*
* @param columns The number of columns in the terminal window.
* @param rows The number of rows in the terminal window.
*/
public void updateSize(int columns, int rows) {
if (mEmulator == null) {
initializeEmulator(columns, rows);
} else {
mEmulator.updateSize(columns, rows);
}
}
/**
* Retrieve the terminal's screen and scrollback buffer.
*
* @return A {@link String} containing the contents of the screen and
* scrollback buffer.
*/
public String getTranscriptText() {
return mTranscriptScreen.getTranscriptText();
}
/**
* Look for new input from the ptty, send it to the terminal emulator.
*/
private void readFromProcess() {
int bytesAvailable = mByteQueue.getBytesAvailable();
int bytesToRead = Math.min(bytesAvailable, mReceiveBuffer.length);
int bytesRead = 0;
try {
bytesRead = mByteQueue.read(mReceiveBuffer, 0, bytesToRead);
} catch (InterruptedException e) {
return;
}
// Give subclasses a chance to process the read data
processInput(mReceiveBuffer, 0, bytesRead);
notifyUpdate();
}
/**
* Process input and send it to the terminal emulator. This method is
* invoked on the main thread whenever new data is read from the
* InputStream.
* <p>
* The default implementation sends the data straight to the terminal
* emulator without modifying it in any way. Subclasses can override it to
* modify the data before giving it to the terminal.
*
* @param data A byte array containing the data read.
* @param offset The offset into the buffer where the read data begins.
* @param count The number of bytes read.
*/
protected void processInput(byte[] data, int offset, int count) {
mEmulator.append(data, offset, count);
}
/**
* Write something directly to the terminal emulator input, bypassing the
* emulation client, the session's {@link InputStream}, and any processing
* being done by {@link #processInput processInput}.
*
* @param data The data to be written to the terminal.
* @param offset The starting offset into the buffer of the data.
* @param count The length of the data to be written.
*/
protected final void appendToEmulator(byte[] data, int offset, int count) {
mEmulator.append(data, offset, count);
}
/**
* Set the terminal emulator's color scheme (default colors).
*
* @param scheme The {@link ColorScheme} to be used (use null for the
* default scheme).
*/
public void setColorScheme(ColorScheme scheme) {
if (scheme == null) {
scheme = BaseTextRenderer.defaultColorScheme;
}
mColorScheme = scheme;
if (mEmulator == null) {
return;
}
mEmulator.setColorScheme(scheme);
}
/**
* Set whether the terminal emulator should be in UTF-8 mode by default.
* <p>
* In UTF-8 mode, the terminal will handle UTF-8 sequences, allowing the
* display of text in most of the world's languages, but applications must
* encode C1 control characters and graphics drawing characters as the
* corresponding UTF-8 sequences.
*
* @param utf8ByDefault Whether the terminal emulator should be in UTF-8
* mode by default.
*/
public void setDefaultUTF8Mode(boolean utf8ByDefault) {
mDefaultUTF8Mode = utf8ByDefault;
if (mEmulator == null) {
return;
}
mEmulator.setDefaultUTF8Mode(utf8ByDefault);
}
/**
* Get whether the terminal emulator is currently in UTF-8 mode.
*
* @return Whether the emulator is currently in UTF-8 mode.
*/
public boolean getUTF8Mode() {
if (mEmulator == null) {
return mDefaultUTF8Mode;
} else {
return mEmulator.getUTF8Mode();
}
}
/**
* Set an {@link UpdateCallback} to be invoked when the terminal emulator
* goes into or out of UTF-8 mode.
*
* @param utf8ModeNotify The {@link UpdateCallback} to be invoked.
*/
public void setUTF8ModeUpdateCallback(UpdateCallback utf8ModeNotify) {
if (mEmulator != null) {
mEmulator.setUTF8ModeUpdateCallback(utf8ModeNotify);
}
}
/**
* Reset the terminal emulator's state.
*/
public void reset() {
mEmulator.reset();
notifyUpdate();
}
/**
* Set a {@link FinishCallback} to be invoked once this terminal session is
* finished.
*
* @param callback The {@link FinishCallback} to be invoked on finish.
*/
public void setFinishCallback(FinishCallback callback) {
mFinishCallback = callback;
}
/**
* Finish this terminal session. Frees resources used by the terminal
* emulator and closes the attached <code>InputStream</code> and
* <code>OutputStream</code>.
*/
public void finish() {
mIsRunning = false;
mEmulator.finish();
if (mTranscriptScreen != null) {
mTranscriptScreen.finish();
}
// Stop the reader and writer threads, and close the I/O streams
if (mWriterHandler != null) {
mWriterHandler.sendEmptyMessage(FINISH);
}
try {
mTermIn.close();
mTermOut.close();
} catch (IOException e) {
// We don't care if this fails
} catch (NullPointerException e) {
}
if (mFinishCallback != null) {
mFinishCallback.onSessionFinish(this);
}
}
}