view src/com/five_ten_sg/connectbot/service/TerminalManager.java @ 397:2f2b5a244a4d

add queue to buffer monitor socket writes to prevent blocking on socket output stream write
author Carl Byington <carl@five-ten-sg.com>
date Wed, 15 Oct 2014 17:55:59 -0700
parents ebcb4aea03ec
children
line wrap: on
line source

/*
 * ConnectBot: simple, powerful, open-source SSH client for Android
 * Copyright 2007 Kenny Root, Jeffrey Sharkey
 *
 * 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 com.five_ten_sg.connectbot.service;

import java.io.IOException;
import java.lang.ref.WeakReference;
import java.security.KeyPair;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.util.Arrays;
import java.util.HashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Timer;
import java.util.TimerTask;

import com.five_ten_sg.connectbot.R;
import com.five_ten_sg.connectbot.bean.HostBean;
import com.five_ten_sg.connectbot.bean.PubkeyBean;
import com.five_ten_sg.connectbot.transport.TransportFactory;
import com.five_ten_sg.connectbot.util.HostDatabase;
import com.five_ten_sg.connectbot.util.PreferenceConstants;
import com.five_ten_sg.connectbot.util.PubkeyDatabase;
import com.five_ten_sg.connectbot.util.PubkeyUtils;
import android.app.Service;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.content.SharedPreferences.OnSharedPreferenceChangeListener;
import android.content.res.AssetFileDescriptor;
import android.content.res.Configuration;
import android.content.res.Resources;
import android.media.AudioManager;
import android.media.MediaPlayer;
import android.media.MediaPlayer.OnCompletionListener;
import android.net.Uri;
import android.os.Binder;
import android.os.Handler;
import android.os.IBinder;
import android.os.Message;
import android.os.Vibrator;
import android.preference.PreferenceManager;
import android.util.Log;

/**
 * Manager for SSH connections that runs as a service. This service holds a list
 * of currently connected SSH bridges that are ready for connection up to a GUI
 * if needed.
 *
 * @author jsharkey
 */
public class TerminalManager extends Service implements BridgeDisconnectedListener, OnSharedPreferenceChangeListener {
    public final static String TAG = "ConnectBot.TerminalManager";

    public List<TerminalBridge> bridges = new LinkedList<TerminalBridge>();
    public Map<HostBean, WeakReference<TerminalBridge>> mHostBridgeMap =
        new HashMap<HostBean, WeakReference<TerminalBridge>>();
    public Map<String, WeakReference<TerminalBridge>> mNicknameBridgeMap =
        new HashMap<String, WeakReference<TerminalBridge>>();

    public TerminalBridge defaultBridge = null;

    public List<HostBean> disconnected = new LinkedList<HostBean>();

    public Handler disconnectHandler = null;

    public Map<String, KeyHolder> loadedKeypairs = new HashMap<String, KeyHolder>();

    public Resources res;

    public HostDatabase hostdb;
    public PubkeyDatabase pubkeydb;

    protected SharedPreferences prefs;

    final private IBinder binder = new TerminalBinder();

    private ConnectivityReceiver connectivityManager;
    private ConnectionNotifier connectionNotifier = new ConnectionNotifier();

    private MediaPlayer mediaPlayer;

    private Timer pubkeyTimer;

    private Timer idleTimer;
    private final long IDLE_TIMEOUT = 300000; // 5 minutes

    private Vibrator vibrator;
    private volatile boolean wantKeyVibration;
    public static final long VIBRATE_DURATION = 30;

    private boolean wantBellVibration;

    private boolean resizeAllowed = true;

    private int fullScreen = 0;

    private boolean savingKeys;

    protected List<WeakReference<TerminalBridge>> mPendingReconnect
        = new LinkedList<WeakReference<TerminalBridge>>();

    public boolean hardKeyboardHidden;

    @Override
    public void onCreate() {
        Log.i(TAG, "Starting service");
        prefs = PreferenceManager.getDefaultSharedPreferences(this);
        prefs.registerOnSharedPreferenceChangeListener(this);
        res = getResources();
        pubkeyTimer = new Timer("pubkeyTimer", true);
        hostdb = new HostDatabase(this);
        pubkeydb = new PubkeyDatabase(this);
        // load all marked pubkeys into memory
        updateSavingKeys();
        List<PubkeyBean> pubkeys = pubkeydb.getAllStartPubkeys();

        for (PubkeyBean pubkey : pubkeys) {
            try {
                PrivateKey privKey = PubkeyUtils.decodePrivate(pubkey.getPrivateKey(), pubkey.getType());
                PublicKey pubKey = PubkeyUtils.decodePublic(pubkey.getPublicKey(), pubkey.getType());
                KeyPair pair = new KeyPair(pubKey, privKey);
                addKey(pubkey, pair);
            }
            catch (Exception e) {
                Log.d(TAG, String.format("Problem adding key '%s' to in-memory cache", pubkey.getNickname()), e);
            }
        }

        vibrator = (Vibrator) getSystemService(Context.VIBRATOR_SERVICE);
        wantKeyVibration = prefs.getBoolean(PreferenceConstants.BUMPY_ARROWS, true);
        wantBellVibration = prefs.getBoolean(PreferenceConstants.BELL_VIBRATE, true);
        enableMediaPlayer();
        hardKeyboardHidden = (res.getConfiguration().hardKeyboardHidden ==
                              Configuration.HARDKEYBOARDHIDDEN_YES);
        final boolean lockingWifi = prefs.getBoolean(PreferenceConstants.WIFI_LOCK, true);
        connectivityManager = new ConnectivityReceiver(this, lockingWifi);
    }

    private void updateSavingKeys() {
        savingKeys = prefs.getBoolean(PreferenceConstants.MEMKEYS, true);
    }

    @Override
    public void onDestroy() {
        Log.i(TAG, "Destroying service");
        disconnectAll(true);

        if (hostdb != null) {
            hostdb.close();
            hostdb = null;
        }

        if (pubkeydb != null) {
            pubkeydb.close();
            pubkeydb = null;
        }

        synchronized (this) {
            if (idleTimer != null)
                idleTimer.cancel();

            if (pubkeyTimer != null)
                pubkeyTimer.cancel();
        }

        connectivityManager.cleanup();
        connectionNotifier.hideRunningNotification(this);
        disableMediaPlayer();
    }

    /**
     * Disconnect all currently connected bridges.
     */
    private void disconnectAll(final boolean immediate) {
        disconnectAll(immediate, false);
    }

    /**
     * Disconnect all currently connected bridges.
     */
    private void disconnectAll(final boolean immediate, boolean onlyRemote) {
        TerminalBridge[] tmpBridges = null;

        synchronized (bridges) {
            if (bridges.size() > 0) {
                tmpBridges = bridges.toArray(new TerminalBridge[bridges.size()]);
            }
        }

        if (tmpBridges != null) {
            // disconnect and dispose of any existing bridges
            for (int i = 0; i < tmpBridges.length; i++) {
                if (!onlyRemote || !(tmpBridges[i].transport instanceof com.five_ten_sg.connectbot.transport.Local))
                    tmpBridges[i].dispatchDisconnect(immediate);
            }
        }
    }

    /**
     * Open a new SSH session using the given parameters.
     */
    private TerminalBridge openConnection(HostBean host) throws IllegalArgumentException, IOException {
        // throw exception if terminal already open
        if (getConnectedBridge(host) != null) {
            throw new IllegalArgumentException("Connection already open for that nickname");
        }

        TerminalBridge bridge = new TerminalBridge(this, host, getApplicationInfo().dataDir);
        bridge.setOnDisconnectedListener(this);
        bridge.startConnection();

        synchronized (bridges) {
            bridges.add(bridge);
            WeakReference<TerminalBridge> wr = new WeakReference<TerminalBridge> (bridge);
            mHostBridgeMap.put(bridge.host, wr);
            mNicknameBridgeMap.put(bridge.host.getNickname(), wr);
        }

        synchronized (disconnected) {
            disconnected.remove(bridge.host);
        }

        if (bridge.isUsingNetwork()) {
            connectivityManager.incRef();
        }

        if (prefs.getBoolean(PreferenceConstants.CONNECTION_PERSIST, true)) {
            connectionNotifier.showRunningNotification(this);
        }

        // also update database with new connected time
        touchHost(host);
        return bridge;
    }

    public String getEmulation() {
        return prefs.getString(PreferenceConstants.EMULATION, "xterm-256color");
    }

    public int getScrollback() {
        int scrollback = 140;

        try {
            scrollback = Integer.parseInt(prefs.getString(PreferenceConstants.SCROLLBACK, "140"));
        }
        catch (Exception e) {
        }

        return scrollback;
    }

    /**
     * Open a new connection by reading parameters from the given URI. Follows
     * format specified by an individual transport.
     */
    public TerminalBridge openConnection(Uri uri) throws Exception {
        HostBean host = TransportFactory.findHost(hostdb, uri);

        if (host == null)
            host = TransportFactory.getTransport(uri.getScheme()).createHost(uri);

        return openConnection(host);
    }

    /**
     * Update the last-connected value for the given nickname by passing through
     * to {@link HostDatabase}.
     */
    private void touchHost(HostBean host) {
        hostdb.touchHost(host);
    }

    /**
     * Find a connected {@link TerminalBridge} with the given HostBean.
     *
     * @param host the HostBean to search for
     * @return TerminalBridge that uses the HostBean
     */
    public TerminalBridge getConnectedBridge(HostBean host) {
        WeakReference<TerminalBridge> wr = mHostBridgeMap.get(host);

        if (wr != null) {
            return wr.get();
        }
        else {
            return null;
        }
    }

    /**
     * Find a connected {@link TerminalBridge} using its nickname.
     *
     * @param nickname
     * @return TerminalBridge that matches nickname
     */
    public TerminalBridge getConnectedBridge(final String nickname) {
        if (nickname == null) {
            return null;
        }

        WeakReference<TerminalBridge> wr = mNicknameBridgeMap.get(nickname);

        if (wr != null) {
            return wr.get();
        }
        else {
            return null;
        }
    }

    /**
     * Called by child bridge when somehow it's been disconnected.
     */
    public void onDisconnected(TerminalBridge bridge) {
        boolean shouldHideRunningNotification = false;

        synchronized (bridges) {
            // remove this bridge from our list
            bridges.remove(bridge);
            mHostBridgeMap.remove(bridge.host);
            mNicknameBridgeMap.remove(bridge.host.getNickname());

            if (bridge.isUsingNetwork()) {
                connectivityManager.decRef();
            }

            if (bridges.size() == 0 &&
                    mPendingReconnect.size() == 0) {
                shouldHideRunningNotification = true;
            }
        }

        synchronized (disconnected) {
            disconnected.add(bridge.host);
        }

        if (shouldHideRunningNotification) {
            connectionNotifier.hideRunningNotification(this);
        }

        // pass notification back up to gui
        if (disconnectHandler != null)
            Message.obtain(disconnectHandler, -1, bridge).sendToTarget();
    }

    public boolean isKeyLoaded(String nickname) {
        return loadedKeypairs.containsKey(nickname);
    }

    public void addKey(PubkeyBean pubkey, KeyPair pair) {
        addKey(pubkey, pair, false);
    }

    public void addKey(PubkeyBean pubkey, KeyPair pair, boolean force) {
        if (!savingKeys && !force)
            return;

        removeKey(pubkey.getNickname());
        byte[] sshPubKey = PubkeyUtils.extractOpenSSHPublic(pair);
        KeyHolder keyHolder = new KeyHolder();
        keyHolder.bean = pubkey;
        keyHolder.pair = pair;
        keyHolder.openSSHPubkey = sshPubKey;
        loadedKeypairs.put(pubkey.getNickname(), keyHolder);

        if (pubkey.getLifetime() > 0) {
            final String nickname = pubkey.getNickname();
            pubkeyTimer.schedule(new TimerTask() {
                @Override
                public void run() {
                    Log.d(TAG, "Unloading from memory key: " + nickname);
                    removeKey(nickname);
                }
            }, pubkey.getLifetime() * 1000);
        }

        Log.d(TAG, String.format("Added key '%s' to in-memory cache", pubkey.getNickname()));
    }

    public boolean removeKey(String nickname) {
        Log.d(TAG, String.format("Removed key '%s' from in-memory cache", nickname));
        return loadedKeypairs.remove(nickname) != null;
    }

    public boolean removeKey(byte[] publicKey) {
        String nickname = null;

        for (Entry<String, KeyHolder> entry : loadedKeypairs.entrySet()) {
            if (Arrays.equals(entry.getValue().openSSHPubkey, publicKey)) {
                nickname = entry.getKey();
                break;
            }
        }

        if (nickname != null) {
            Log.d(TAG, String.format("Removed key '%s' to in-memory cache", nickname));
            return removeKey(nickname);
        }
        else
            return false;
    }

    public KeyPair getKey(String nickname) {
        if (loadedKeypairs.containsKey(nickname)) {
            KeyHolder keyHolder = loadedKeypairs.get(nickname);
            return keyHolder.pair;
        }
        else
            return null;
    }

    public KeyPair getKey(byte[] publicKey) {
        for (KeyHolder keyHolder : loadedKeypairs.values()) {
            if (Arrays.equals(keyHolder.openSSHPubkey, publicKey))
                return keyHolder.pair;
        }

        return null;
    }

    public String getKeyNickname(byte[] publicKey) {
        for (Entry<String, KeyHolder> entry : loadedKeypairs.entrySet()) {
            if (Arrays.equals(entry.getValue().openSSHPubkey, publicKey))
                return entry.getKey();
        }

        return null;
    }

    private void stopWithDelay() {
        // TODO add in a way to check whether keys loaded are encrypted and only
        // set timer when we have an encrypted key loaded
        if (loadedKeypairs.size() > 0) {
            synchronized (this) {
                if (idleTimer == null)
                    idleTimer = new Timer("idleTimer", true);

                idleTimer.schedule(new IdleTask(), IDLE_TIMEOUT);
            }
        }
        else {
            Log.d(TAG, "Stopping service immediately");
            stopSelf();
        }
    }

    protected void stopNow() {
        if (bridges.size() == 0) {
            stopSelf();
        }
    }

    private synchronized void stopIdleTimer() {
        if (idleTimer != null) {
            idleTimer.cancel();
            idleTimer = null;
        }
    }

    public class TerminalBinder extends Binder {
        public TerminalManager getService() {
            return TerminalManager.this;
        }
    }

    @Override
    public IBinder onBind(Intent intent) {
        Log.i(TAG, "Someone bound to TerminalManager");
        setResizeAllowed(true);
        stopIdleTimer();
        // Make sure we stay running to maintain the bridges
        startService(new Intent(this, TerminalManager.class));
        return binder;
    }

    @Override
    public int onStartCommand(Intent intent, int flags, int startId) {
        /*
         * We want this service to continue running until it is explicitly
         * stopped, so return sticky.
         */
        return START_STICKY;
    }

    @Override
    public void onRebind(Intent intent) {
        super.onRebind(intent);
        setResizeAllowed(true);
        Log.i(TAG, "Someone rebound to TerminalManager");
        stopIdleTimer();
    }

    @Override
    public boolean onUnbind(Intent intent) {
        Log.i(TAG, "Someone unbound from TerminalManager");
        setResizeAllowed(true);

        if (bridges.size() == 0) {
            stopWithDelay();
        }

        return true;
    }

    private class IdleTask extends TimerTask {
        /* (non-Javadoc)
         * @see java.util.TimerTask#run()
         */
        @Override
        public void run() {
            Log.d(TAG, String.format("Stopping service after timeout of ~%d seconds", IDLE_TIMEOUT / 1000));
            TerminalManager.this.stopNow();
        }
    }

    public void tryKeyVibrate() {
        if (wantKeyVibration)
            vibrate();
    }

    private void vibrate() {
        if (vibrator != null)
            vibrator.vibrate(VIBRATE_DURATION);
    }

    private void enableMediaPlayer() {
        mediaPlayer = new MediaPlayer();
        float volume = prefs.getFloat(PreferenceConstants.BELL_VOLUME,
                                      PreferenceConstants.DEFAULT_BELL_VOLUME);
        mediaPlayer.setAudioStreamType(AudioManager.STREAM_NOTIFICATION);
        mediaPlayer.setOnCompletionListener(new BeepListener());
        AssetFileDescriptor file = res.openRawResourceFd(R.raw.bell);

        try {
            mediaPlayer.setDataSource(file.getFileDescriptor(), file
                                      .getStartOffset(), file.getLength());
            file.close();
            mediaPlayer.setVolume(volume, volume);
            mediaPlayer.prepare();
        }
        catch (IOException e) {
            Log.e(TAG, "Error setting up bell media player", e);
        }
    }

    private void disableMediaPlayer() {
        if (mediaPlayer != null) {
            mediaPlayer.release();
            mediaPlayer = null;
        }
    }

    public void playBeep() {
        if (mediaPlayer != null)
            mediaPlayer.start();

        if (wantBellVibration)
            vibrate();
    }

    private static class BeepListener implements OnCompletionListener {
        public void onCompletion(MediaPlayer mp) {
            mp.seekTo(0);
        }
    }

    /**
     * Send system notification to user for a certain host. When user selects
     * the notification, it will bring them directly to the ConsoleActivity
     * displaying the host.
     *
     * @param host
     */
    public void sendActivityNotification(HostBean host) {
        if (!prefs.getBoolean(PreferenceConstants.BELL_NOTIFICATION, false))
            return;

        connectionNotifier.showActivityNotification(this, host);
    }

    /* (non-Javadoc)
     * @see android.content.SharedPreferences.OnSharedPreferenceChangeListener#onSharedPreferenceChanged(android.content.SharedPreferences, java.lang.String)
     */
    public void onSharedPreferenceChanged(SharedPreferences sharedPreferences,
                                          String key) {
        if (PreferenceConstants.BELL.equals(key)) {
            boolean wantAudible = sharedPreferences.getBoolean(
                                      PreferenceConstants.BELL, true);

            if (wantAudible && mediaPlayer == null)
                enableMediaPlayer();
            else if (!wantAudible && mediaPlayer != null)
                disableMediaPlayer();
        }
        else if (PreferenceConstants.BELL_VOLUME.equals(key)) {
            if (mediaPlayer != null) {
                float volume = sharedPreferences.getFloat(
                                   PreferenceConstants.BELL_VOLUME,
                                   PreferenceConstants.DEFAULT_BELL_VOLUME);
                mediaPlayer.setVolume(volume, volume);
            }
        }
        else if (PreferenceConstants.BELL_VIBRATE.equals(key)) {
            wantBellVibration = sharedPreferences.getBoolean(
                                    PreferenceConstants.BELL_VIBRATE, true);
        }
        else if (PreferenceConstants.BUMPY_ARROWS.equals(key)) {
            wantKeyVibration = sharedPreferences.getBoolean(
                                   PreferenceConstants.BUMPY_ARROWS, true);
        }
        else if (PreferenceConstants.WIFI_LOCK.equals(key)) {
            final boolean lockingWifi = prefs.getBoolean(PreferenceConstants.WIFI_LOCK, true);
            connectivityManager.setWantWifiLock(lockingWifi);
        }
        else if (PreferenceConstants.MEMKEYS.equals(key)) {
            updateSavingKeys();
        }
    }

    /**
     * Allow {@link TerminalBridge} to resize when the parent has changed.
     * @param resizeAllowed
     */
    public void setResizeAllowed(boolean resizeAllowed) {
        this.resizeAllowed  = resizeAllowed;
    }

    public boolean isResizeAllowed() {
        return resizeAllowed;
    }

    public void setFullScreen(int fullScreen) {
        this.fullScreen = fullScreen;
    }

    public int getFullScreen() {
        return this.fullScreen;
    }

    public static class KeyHolder {
        public PubkeyBean bean;
        public KeyPair pair;
        public byte[] openSSHPubkey;
    }

    /**
     * Called when connectivity to the network is lost and it doesn't appear
     * we'll be getting a different connection any time soon.
     */
    public void onConnectivityLost() {
        final Thread t = new Thread() {
            @Override
            public void run() {
                disconnectAll(false, true);
            }
        };
        t.setName("Disconnector");
        t.start();
    }

    /**
     * Called when connectivity to the network is restored.
     */
    public void onConnectivityRestored() {
        final Thread t = new Thread() {
            @Override
            public void run() {
                reconnectPending();
            }
        };
        t.setName("Reconnector");
        t.start();
    }

    /**
     * Insert request into reconnect queue to be executed either immediately
     * or later when connectivity is restored depending on whether we're
     * currently connected.
     *
     * @param bridge the TerminalBridge to reconnect when possible
     */
    public void requestReconnect(TerminalBridge bridge) {
        synchronized (mPendingReconnect) {
            mPendingReconnect.add(new WeakReference<TerminalBridge> (bridge));

            if (!bridge.isUsingNetwork() ||
                    connectivityManager.isConnected()) {
                reconnectPending();
            }
        }
    }

    /**
     * Reconnect all bridges that were pending a reconnect when connectivity
     * was lost.
     */
    private void reconnectPending() {
        synchronized (mPendingReconnect) {
            for (WeakReference<TerminalBridge> ref : mPendingReconnect) {
                TerminalBridge bridge = ref.get();

                if (bridge == null) {
                    continue;
                }

                bridge.startConnection();
            }

            mPendingReconnect.clear();
        }
    }
}