package com.cgutman.androidremotedebugger; import java.util.concurrent.atomic.AtomicBoolean; import com.cgutman.adblib.AdbCrypto; import com.cgutman.androidremotedebugger.console.CommandHistory; import com.cgutman.androidremotedebugger.console.ConsoleBuffer; import com.cgutman.androidremotedebugger.devconn.DeviceConnection; import com.cgutman.androidremotedebugger.devconn.DeviceConnectionListener; import com.cgutman.androidremotedebugger.service.ShellService; import com.cgutman.androidremotedebugger.ui.Dialog; import com.cgutman.androidremotedebugger.ui.SpinnerDialog; import android.os.Bundle; import android.os.IBinder; import android.app.Activity; import android.app.Service; import android.view.ContextMenu; import android.view.KeyEvent; import android.view.Menu; import android.view.MenuItem; import android.view.MotionEvent; import android.view.View; import android.view.ContextMenu.ContextMenuInfo; import android.view.View.OnKeyListener; import android.view.View.OnLongClickListener; import android.view.View.OnTouchListener; import android.view.ViewTreeObserver; import android.view.inputmethod.EditorInfo; import android.widget.EditText; import android.widget.ScrollView; import android.widget.TextView; import android.widget.TextView.OnEditorActionListener; import android.content.ComponentName; import android.content.Intent; import android.content.ServiceConnection; public class AdbShell extends Activity implements DeviceConnectionListener, OnKeyListener, OnEditorActionListener { private TextView shellView; private EditText commandBox; private ScrollView shellScroller; private String hostName; private int port; private DeviceConnection connection; private Intent service; private ShellService.ShellServiceBinder binder; private SpinnerDialog connectWaiting; private final static String PREFS_FILE = "AdbCmdHistoryPrefs"; private static final int MAX_COMMAND_HISTORY = 15; private CommandHistory commandHistory; private boolean updateGui; private AtomicBoolean updateQueued = new AtomicBoolean(); private AtomicBoolean updateRequired = new AtomicBoolean(); private boolean autoScrollEnabled = true; private boolean userScrolling = false; private boolean scrollViewAtBottom = true; private ConsoleBuffer lastConsoleBuffer; private StringBuilder commandBuffer = new StringBuilder(); private static final int MENU_ID_CTRL_C = 1; private static final int MENU_ID_AUTOSCROLL = 2; private static final int MENU_ID_EXIT = 3; private ServiceConnection serviceConn = new ServiceConnection() { @Override public void onServiceConnected(ComponentName arg0, IBinder arg1) { binder = (ShellService.ShellServiceBinder)arg1; if (connection != null) { binder.removeListener(connection, AdbShell.this); } connection = AdbShell.this.connectOrLookupConnection(hostName, port); } @Override public void onServiceDisconnected(ComponentName arg0) { binder = null; } }; @Override public void onNewIntent(Intent shellIntent) { hostName = shellIntent.getStringExtra("IP"); port = shellIntent.getIntExtra("Port", -1); if (hostName == null || port == -1) { finish(); return; } setTitle("ADB Shell - "+hostName+":"+port); if (binder == null) { /* Bind the service if we're not bound already. After binding, the callback will * perform the initial connection. */ getApplicationContext().bindService(service, serviceConn, Service.BIND_AUTO_CREATE); } else { /* We're already bound, so do the connect or lookup immediately */ if (connection != null) { binder.removeListener(connection, this); } connection = connectOrLookupConnection(hostName, port); } } private DeviceConnection startConnection(String host, int port) { /* Display the connection progress spinner */ connectWaiting = SpinnerDialog.displayDialog(this, "Connecting to "+hostName+":"+port, "Please make sure the target device has network ADB enabled.\n\n"+ "You may need to accept a prompt on the target device if you are connecting "+ "to it for the first time from this device.", true); /* Create the connection object */ DeviceConnection conn = binder.createConnection(host, port); /* Add this activity as a connection listener */ binder.addListener(conn, this); /* Begin the async connection process */ conn.startConnect(); return conn; } private DeviceConnection connectOrLookupConnection(String host, int port) { DeviceConnection conn = binder.findConnection(host, port); if (conn == null) { /* No existing connection, so start the connection process */ conn = startConnection(host, port); } else { /* Add ourselves as a new listener of this connection */ binder.addListener(conn, this); } return conn; } @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_adb_shell); /* Setup our controls */ shellView = (TextView) findViewById(R.id.shellView); commandBox = (EditText) findViewById(R.id.command); shellScroller = (ScrollView) findViewById(R.id.shellScroller); OnLongClickListener showMenu = new OnLongClickListener() { @Override public boolean onLongClick(View view) { openContextMenu(commandBox); return true; } }; final ViewTreeObserver.OnScrollChangedListener onScrollChangedListener = new ViewTreeObserver.OnScrollChangedListener() { @Override public void onScrollChanged() { View view = (View) shellScroller.getChildAt(0); int diff = view.getBottom() - (shellScroller.getHeight() + shellScroller.getScrollY()); if (diff <= 0) { doAsyncGuiUpdate(); scrollViewAtBottom = true; } else { scrollViewAtBottom = false; } } }; shellScroller.setOnTouchListener(new OnTouchListener() { @Override public boolean onTouch(View view, MotionEvent event) { ViewTreeObserver observer = AdbShell.this.shellScroller.getViewTreeObserver(); switch (event.getActionMasked()) { case MotionEvent.ACTION_MOVE: case MotionEvent.ACTION_DOWN: observer.addOnScrollChangedListener(onScrollChangedListener); userScrolling = true; break; case MotionEvent.ACTION_UP: case MotionEvent.ACTION_CANCEL: if (scrollViewAtBottom) { doAsyncGuiUpdate(); } userScrolling = false; break; } /* Don't consume the event */ return false; } }); commandBox.setImeActionLabel("Run", EditorInfo.IME_ACTION_DONE); commandBox.setOnEditorActionListener(this); commandBox.setOnKeyListener(this); commandBox.setOnLongClickListener(showMenu); registerForContextMenu(commandBox); registerForContextMenu(shellView); /* Pull previous command history (if any) */ commandHistory = CommandHistory.loadCommandHistoryFromPrefs(MAX_COMMAND_HISTORY, this, PREFS_FILE); service = new Intent(this, ShellService.class); getApplicationContext().startService(service); onNewIntent(getIntent()); } @Override protected void onDestroy() { /* Save the command history first */ commandHistory.save(); if (binder != null && connection != null) { /* Tell the service about our impending doom */ binder.notifyDestroyingActivity(connection); /* Dissociate our activity's listener */ binder.removeListener(connection, this); } /* If the connection hasn't actually finished yet, * close it before terminating */ if (connectWaiting != null) { AdbUtils.safeClose(connection); } /* Unbind from the service since we're going away */ if (service != null) { getApplicationContext().unbindService(serviceConn); } Dialog.closeDialogs(); SpinnerDialog.closeDialogs(); super.onDestroy(); } @Override public void onResume() { /* Tell the service about our UI state change */ if (binder != null) { binder.notifyResumingActivity(connection); } /* There might be changes we need to display */ updateTerminalView(); /* Start updating the GUI again */ updateGui = true; super.onResume(); } @Override public void onPause() { /* Tell the service about our UI state change */ if (binder != null) { binder.notifyPausingActivity(connection); } /* Stop updating the GUI for now */ updateGui = false; super.onPause(); } @Override public void onCreateContextMenu(ContextMenu menu, View v, ContextMenuInfo menuInfo) { super.onCreateContextMenu(menu, v, menuInfo); if (v == commandBox) { commandHistory.populateMenu(menu); } else { menu.add(Menu.NONE, MENU_ID_CTRL_C, Menu.NONE, "Send Ctrl+C"); MenuItem autoscroll = menu.add(Menu.NONE, MENU_ID_AUTOSCROLL, Menu.NONE, "Auto-scroll terminal"); autoscroll.setCheckable(true); autoscroll.setChecked(autoScrollEnabled); menu.add(Menu.NONE, MENU_ID_EXIT, Menu.NONE, "Exit Terminal"); } } @Override public boolean onContextItemSelected(MenuItem item) { if (item.getItemId() == 0) { commandBox.setText(item.getTitle()); } else { switch (item.getItemId()) { case MENU_ID_CTRL_C: if (connection != null) { connection.queueBytes(new byte[]{0x03}); /* Force scroll to the bottom */ scrollViewAtBottom = true; doAsyncGuiUpdate(); } break; case MENU_ID_AUTOSCROLL: item.setChecked(!item.isChecked()); autoScrollEnabled = item.isChecked(); break; case MENU_ID_EXIT: AdbUtils.safeClose(connection); finish(); break; } } return true; } @Override public boolean onEditorAction(TextView textView, int actionId, KeyEvent event) { /* We always return false because we want to dismiss the keyboard */ if (commandBox.getText().length() == 0 || connection == null) return false; if (actionId == EditorInfo.IME_ACTION_DONE) { String text = commandBox.getText().toString(); /* Append the command to our command buffer (which is empty) */ commandBuffer.append(text); /* Add the command to the previous command list */ commandHistory.add(text); /* Append a newline since it's not included in the command itself */ commandBuffer.append('\n'); /* Send it to the device */ connection.queueCommand(commandBuffer.toString()); /* Clear the textbox and command buffer */ commandBuffer.setLength(0); commandBox.setText(""); /* Force scroll to the bottom */ scrollViewAtBottom = true; doAsyncGuiUpdate(); return true; } return false; } @Override public boolean onKey(View v, int keyCode, KeyEvent event) { if (keyCode == KeyEvent.KEYCODE_ENTER) { /* Just call the onEditorAction function to handle this for us */ return onEditorAction((TextView)v, EditorInfo.IME_ACTION_DONE, event); } else { return false; } } private void updateTerminalView() { if (lastConsoleBuffer != null) { lastConsoleBuffer.updateTextView(shellView); } if (autoScrollEnabled) { shellView.post(new Runnable() { public void run() { if (scrollViewAtBottom) { shellScroller.smoothScrollTo(0, shellView.getBottom()); } } }); } } @Override public void notifyConnectionEstablished(DeviceConnection devConn) { connectWaiting.dismiss(); connectWaiting = null; } @Override public void notifyConnectionFailed(DeviceConnection devConn, Exception e) { connectWaiting.dismiss(); connectWaiting = null; Dialog.displayDialog(this, "Connection Failed", e.getMessage(), true); } @Override public void notifyStreamFailed(DeviceConnection devConn, Exception e) { Dialog.displayDialog(this, "Connection Terminated", e.getMessage(), true); } @Override public void notifyStreamClosed(DeviceConnection devConn) { Dialog.displayDialog(this, "Connection Closed", "The connection was gracefully closed.", true); } @Override public AdbCrypto loadAdbCrypto(DeviceConnection devConn) { return AdbUtils.readCryptoConfig(getFilesDir()); } @Override public boolean canReceiveData() { /* We just handle console updates */ return false; } @Override public void receivedData(DeviceConnection devConn, byte[] data, int offset, int length) { } @Override public boolean isConsole() { return true; } private void setGuiDirty() { /* Remember that a GUI update is needed */ updateRequired.set(true); } private void doAsyncGuiUpdate() { /* If no update is required, do nothing */ if (!updateRequired.get()) { return; } /* If an update isn't already queued, fire one off */ if (updateQueued.compareAndSet(false, true)) { new Thread(new Runnable() { @Override public void run() { /* Wait for a few milliseconds to avoid spamming GUI updates */ try { Thread.sleep(250); } catch (InterruptedException e) { return; } runOnUiThread(new Runnable() { @Override public void run() { /* We won't need an update again after this */ updateRequired.set(false); /* Redraw the terminal */ updateTerminalView(); /* This update is finished */ updateQueued.set(false); /* If someone updated the console between the time that we * started redrawing and when we finished, we need to update * the GUI again, otherwise the GUI update could be missed. */ if (updateRequired.get()) { doAsyncGuiUpdate(); } } }); } }).start(); } } @Override public void consoleUpdated(DeviceConnection devConn, ConsoleBuffer console) { lastConsoleBuffer = console; setGuiDirty(); if (updateGui && !userScrolling && scrollViewAtBottom) { doAsyncGuiUpdate(); } } }