package info.kghost.android.openvpn; import info.kghost.android.openvpn.OpenvpnInstaller.Result; import java.io.File; import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.IOException; import java.io.ObjectInputStream; import java.io.ObjectOutputStream; import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import android.app.DialogFragment; import android.app.FragmentTransaction; import android.content.BroadcastReceiver; import android.content.ComponentName; import android.content.Context; import android.content.DialogInterface; import android.content.Intent; import android.content.IntentFilter; import android.content.ServiceConnection; import android.net.VpnService; import android.os.Bundle; import android.os.IBinder; import android.os.Parcelable; import android.os.RemoteException; import android.preference.Preference; import android.preference.Preference.OnPreferenceClickListener; import android.preference.PreferenceActivity; import android.preference.PreferenceCategory; import android.preference.PreferenceScreen; import android.util.Log; import android.view.ContextMenu; import android.view.ContextMenu.ContextMenuInfo; import android.view.MenuItem; import android.view.View; import android.widget.AdapterView.AdapterContextMenuInfo; import android.widget.Toast; /** * The preference activity for configuring VPN settings. */ public class VpnSettings extends PreferenceActivity { // Key to the field exchanged for profile editing. static final String KEY_VPN_PROFILE = "vpn_profile"; private static final String TAG = VpnSettings.class.getSimpleName(); private static final String PREF_INFO_VPN = "openvpn_installed_info"; private static final String PREF_ADD_VPN = "add_new_vpn"; private static final String PREF_VIEW_LOG = "view_log"; private static final String PREF_VPN_LIST = "vpn_list"; private File PROFILES_ROOT; private static final String PROFILE_OBJ_FILE = ".pobj"; private static final int REQUEST_ADD_OR_EDIT_PROFILE = 1; private static final int REQUEST_CONNECT = 2; private static final int CONTEXT_MENU_CONNECT_ID = ContextMenu.FIRST + 0; private static final int CONTEXT_MENU_DISCONNECT_ID = ContextMenu.FIRST + 1; private static final int CONTEXT_MENU_EDIT_ID = ContextMenu.FIRST + 2; private static final int CONTEXT_MENU_DELETE_ID = ContextMenu.FIRST + 3; static final int OK_BUTTON = DialogInterface.BUTTON_POSITIVE; private PreferenceScreen mInfoVpn; private PreferenceCategory mVpnListContainer; // profile name --> VpnPreference private Map<String, VpnPreference> mVpnPreferenceMap; private List<OpenvpnProfile> mVpnProfileList; private OpenvpnProfile mConnectingProfile; private String mConnectingUsername; private String mConnectingPassword; private VpnStatus mStatus; private OpenvpnInstaller installer; private IVpnService mIVpnService; private ServiceConnection mConnection = new ServiceConnection() { @Override public void onServiceConnected(ComponentName name, IBinder service) { mIVpnService = IVpnService.Stub.asInterface(service); try { mStatus = mIVpnService.checkStatus(); updatePreferenceList(); } catch (RemoteException e) { Log.e(getClass().getName(), "Unable to connect service", e); ErrorMsgDialog dialog = new ErrorMsgDialog(); dialog.setMessage(e.getLocalizedMessage()); showDialog(dialog); } } @Override public void onServiceDisconnected(ComponentName name) { mIVpnService = null; } }; private BroadcastReceiver mReceiver = new BroadcastReceiver() { @Override public void onReceive(Context context, Intent intent) { VpnStatus s = intent.getParcelableExtra("connection_state"); if (s != null) { mStatus = s; updatePreferenceList(); } } }; @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); PROFILES_ROOT = new File(getFilesDir(), "V2"); addPreferencesFromResource(R.xml.vpn_settings); // restore VpnProfile list and construct VpnPreference map mVpnListContainer = (PreferenceCategory) findPreference(PREF_VPN_LIST); // set up the "add vpn" preference mInfoVpn = (PreferenceScreen) findPreference(PREF_INFO_VPN); ((PreferenceScreen) findPreference(PREF_ADD_VPN)) .setOnPreferenceClickListener(new OnPreferenceClickListener() { public boolean onPreferenceClick(Preference preference) { startVpnEditor(new OpenvpnProfile()); return true; } }); ((PreferenceScreen) findPreference(PREF_VIEW_LOG)) .setOnPreferenceClickListener(new OnPreferenceClickListener() { public boolean onPreferenceClick(Preference preference) { if (mIVpnService != null) { try { LogDialog dialog = new LogDialog(); dialog.setLog(mIVpnService.getLog()); showDialog(dialog); } catch (RemoteException e) { } } return true; } }); // for long-press gesture on a profile preference registerForContextMenu(getListView()); retrieveVpnListFromStorage(); IntentFilter filter = new IntentFilter(); filter.addAction("info.kghost.android.openvpn.connectivity"); registerReceiver(mReceiver, filter); bindService(new Intent(this, OpenVpnService.class), mConnection, Context.BIND_AUTO_CREATE); } @Override protected void onSaveInstanceState(Bundle outState) { super.onSaveInstanceState(outState); outState.putParcelable("profile", mConnectingProfile); outState.putString("username", mConnectingUsername); outState.putString("password", mConnectingPassword); } @Override protected void onRestoreInstanceState(Bundle state) { super.onRestoreInstanceState(state); mConnectingProfile = state.getParcelable("profile"); mConnectingUsername = state.getString("username"); mConnectingPassword = state.getString("password"); } @Override protected void onStart() { super.onStart(); installer = new OpenvpnInstaller(); installer.install(this, new OpenvpnInstaller.Callback() { @Override public void done(Result result) { if (result.isInstalled()) { mInfoVpn.setSummary(result.getText()); } else { ErrorMsgDialog dialog = new ErrorMsgDialog(); dialog.setMessage(result.getText()); showDialog(dialog); } } }); updatePreferenceList(); } @Override protected void onStop() { installer.cancel(); installer = null; super.onStop(); } @Override protected void onDestroy() { unbindService(mConnection); unregisterReceiver(mReceiver); unregisterForContextMenu(getListView()); super.onDestroy(); } @Override public void onCreateContextMenu(ContextMenu menu, View v, ContextMenuInfo menuInfo) { super.onCreateContextMenu(menu, v, menuInfo); OpenvpnProfile p = getProfile(getProfilePositionFrom((AdapterContextMenuInfo) menuInfo)); if (p != null) { menu.setHeaderTitle(p.getName()); menu.add(0, CONTEXT_MENU_CONNECT_ID, 0, R.string.vpn_menu_connect) .setEnabled(canConnect()); menu.add(0, CONTEXT_MENU_DISCONNECT_ID, 0, R.string.vpn_menu_disconnect).setEnabled(canDisconnect(p)); menu.add(0, CONTEXT_MENU_EDIT_ID, 0, R.string.vpn_menu_edit) .setEnabled(true); menu.add(0, CONTEXT_MENU_DELETE_ID, 0, R.string.vpn_menu_delete) .setEnabled(true); } } @Override public boolean onContextItemSelected(MenuItem item) { int position = getProfilePositionFrom((AdapterContextMenuInfo) item .getMenuInfo()); OpenvpnProfile p = getProfile(position); switch (item.getItemId()) { case CONTEXT_MENU_CONNECT_ID: connect(p); return true; case CONTEXT_MENU_DISCONNECT_ID: disconnect(); return true; case CONTEXT_MENU_EDIT_ID: startVpnEditor(p); return true; case CONTEXT_MENU_DELETE_ID: deleteProfile(p); return true; } return super.onContextItemSelected(item); } @Override protected void onActivityResult(final int requestCode, final int resultCode, final Intent data) { if (requestCode == REQUEST_CONNECT) { if (mConnectingProfile == null) { Log.w(TAG, "profile is null"); return; } if (resultCode == RESULT_OK) { if (mIVpnService != null) try { mIVpnService.connect(mConnectingProfile, mConnectingUsername, mConnectingPassword); } catch (RemoteException e) { Toast.makeText(this, e.getLocalizedMessage(), Toast.LENGTH_LONG); } else Toast.makeText(this, "Havn't bound to vpn service", Toast.LENGTH_LONG); } mConnectingProfile = null; } else if (requestCode == REQUEST_ADD_OR_EDIT_PROFILE) { if (resultCode == RESULT_CANCELED || data == null) { Log.d(TAG, "no result returned by editor"); return; } OpenvpnProfile p = data.getParcelableExtra(KEY_VPN_PROFILE); if (p == null) { Log.e(TAG, "null object returned by editor"); return; } int index = getProfileIndexFromId(p.getId()); if (checkDuplicateName(p, index)) { final OpenvpnProfile profile = p; Util.showErrorMessage(this, String.format( getString(R.string.vpn_error_duplicate_name), p.getName()), new DialogInterface.OnClickListener() { public void onClick(DialogInterface dialog, int w) { startVpnEditor(profile); } }); return; } try { if (index < 0) { addProfile(p); Util.showShortToastMessage(this, String.format( getString(R.string.vpn_profile_added), p.getName())); } else { replaceProfile(index, p); Util.showShortToastMessage(this, String.format( getString(R.string.vpn_profile_replaced), p.getName())); } } catch (IOException e) { final OpenvpnProfile profile = p; Util.showErrorMessage(this, e + ": " + e.getLocalizedMessage(), new DialogInterface.OnClickListener() { public void onClick(DialogInterface dialog, int w) { startVpnEditor(profile); } }); } } else { throw new RuntimeException("unknown request code: " + requestCode); } } private boolean canConnect() { if (mStatus == null) return false; switch (mStatus.state) { case IDLE: return true; case PREPARING: case CONNECTING: case DISCONNECTING: case CANCELLED: case CONNECTED: case UNUSABLE: case UNKNOWN: return false; default: return false; } } private boolean canDisconnect(OpenvpnProfile p) { if (mStatus == null) return false; switch (mStatus.state) { case CONNECTING: case CONNECTED: return mStatus.name.equals(p.getName()); case PREPARING: case IDLE: case DISCONNECTING: case CANCELLED: case UNUSABLE: case UNKNOWN: return false; default: return false; } } private void updatePreferenceList() { if (mStatus == null) { for (VpnSettings.VpnPreference pref : mVpnPreferenceMap.values()) { pref.setEnabled(false); } return; } else { switch (mStatus.state) { case IDLE: for (VpnSettings.VpnPreference pref : mVpnPreferenceMap .values()) { pref.setSummary(""); pref.setEnabled(true); } return; case CONNECTING: for (Map.Entry<String, VpnSettings.VpnPreference> pref : mVpnPreferenceMap .entrySet()) { if (mStatus.name.equals(pref.getKey())) { pref.getValue().setSummary( this.getString(R.string.vpn_connecting)); } else { pref.getValue().setSummary(""); } pref.getValue().setEnabled(false); } return; case CONNECTED: for (Map.Entry<String, VpnSettings.VpnPreference> pref : mVpnPreferenceMap .entrySet()) { if (mStatus.name.equals(pref.getKey())) { pref.getValue().setSummary( this.getString(R.string.vpn_connected)); pref.getValue().setEnabled(true); } else { pref.getValue().setSummary(""); pref.getValue().setEnabled(false); } } return; case PREPARING: for (Map.Entry<String, VpnSettings.VpnPreference> pref : mVpnPreferenceMap .entrySet()) { if (mStatus.name.equals(pref.getKey())) { pref.getValue().setSummary( this.getString(R.string.vpn_preparing)); } else { pref.getValue().setSummary(""); } pref.getValue().setEnabled(false); } return; case DISCONNECTING: case CANCELLED: case UNUSABLE: case UNKNOWN: default: for (VpnSettings.VpnPreference pref : mVpnPreferenceMap .values()) { pref.setSummary(""); pref.setEnabled(false); } return; } } } private int getProfileIndexFromId(String id) { int index = 0; for (OpenvpnProfile p : mVpnProfileList) { if (p.getId().equals(id)) { return index; } else { index++; } } return -1; } // Replaces the profile at index in mVpnProfileList with p. // Returns true if p's name is a duplicate. private boolean checkDuplicateName(OpenvpnProfile p, int index) { VpnPreference pref = mVpnPreferenceMap.get(p.getName()); if ((pref != null) && (index >= 0) && (index < mVpnProfileList.size())) { // not a duplicate if p is to replace the profile at index if (pref.mProfile == mVpnProfileList.get(index)) pref = null; } return (pref != null); } private int getProfilePositionFrom(AdapterContextMenuInfo menuInfo) { // excludes mVpnListContainer and the preferences above it return menuInfo.position - mVpnListContainer.getOrder() - 1; } // position: position in mVpnProfileList private OpenvpnProfile getProfile(int position) { return ((position >= 0) ? mVpnProfileList.get(position) : null); } // position: position in mVpnProfileList private void deleteProfile(final OpenvpnProfile p) { DeleteConformDialog dialog = new DeleteConformDialog(); dialog.setId(p.getId()); showDialog(dialog); } // Randomly generates an ID for the profile. // The ID is unique and only set once when the profile is created. private void setProfileId(OpenvpnProfile profile) { String id; while (true) { id = String .valueOf(Math.abs(Double.doubleToLongBits(Math.random()))); if (id.length() >= 8) break; } for (OpenvpnProfile p : mVpnProfileList) { if (p.getId().equals(id)) { setProfileId(profile); return; } } profile.setId(id); } private void addProfile(OpenvpnProfile p) throws IOException { setProfileId(p); saveProfileToStorage(p); mVpnProfileList.add(p); addPreferenceFor(p); } // Adds a preference in mVpnListContainer private VpnPreference addPreferenceFor(OpenvpnProfile p) { VpnPreference pref = new VpnPreference(this, p); mVpnPreferenceMap.put(p.getName(), pref); mVpnListContainer.addPreference(pref); pref.setOnPreferenceClickListener(new Preference.OnPreferenceClickListener() { public boolean onPreferenceClick(Preference pref) { connectOrDisconnect(((VpnPreference) pref).mProfile); return true; } }); pref.setEnabled(false); return pref; } // index: index to mVpnProfileList private void replaceProfile(int index, OpenvpnProfile p) throws IOException { Map<String, VpnPreference> map = mVpnPreferenceMap; OpenvpnProfile oldProfile = mVpnProfileList.set(index, p); VpnPreference pref = map.remove(oldProfile.getName()); if (pref.mProfile != oldProfile) { throw new RuntimeException("inconsistent state!"); } p.setId(oldProfile.getId()); // TODO: remove copyFiles once the setId() code propagates. // Copy config files and remove the old ones if they are in different // directories. if (Util.copyFiles(getProfileDir(oldProfile), getProfileDir(p))) { removeProfileFromStorage(oldProfile); } saveProfileToStorage(p); pref.setProfile(p); map.put(p.getName(), pref); } private void startVpnEditor(final OpenvpnProfile profile) { Intent intent = new Intent(this, VpnEditor.class); intent.putExtra(KEY_VPN_PROFILE, (Parcelable) profile); startActivityForResult(intent, REQUEST_ADD_OR_EDIT_PROFILE); } private synchronized void connect(final OpenvpnProfile p) { if (((OpenvpnProfile) p).getUserAuth()) { mConnectingProfile = p; AuthDialog dialog = new AuthDialog(); dialog.setUsername(p.getSavedUsername()); showDialog(dialog); } else { connect(p, null, null); } } private synchronized void connect(final OpenvpnProfile p, String username, String password) { Intent intent = VpnService.prepare(this); mConnectingProfile = p; mConnectingUsername = username; mConnectingPassword = password; if (intent != null) { startActivityForResult(intent, REQUEST_CONNECT); } else { onActivityResult(REQUEST_CONNECT, RESULT_OK, null); } } private synchronized void disconnect() { if (mIVpnService != null) try { mIVpnService.disconnect(); } catch (RemoteException e) { Toast.makeText(this, e.getLocalizedMessage(), Toast.LENGTH_LONG); } else Toast.makeText(this, "Havn't bound to vpn service", Toast.LENGTH_LONG); } private synchronized void connectOrDisconnect(final OpenvpnProfile p) { if (mStatus != null && mStatus.state == VpnStatus.VpnState.IDLE) connect(p); else disconnect(); } private File getProfileDir(OpenvpnProfile p) { return new File(PROFILES_ROOT, p.getId()); } private void saveProfileToStorage(OpenvpnProfile p) throws IOException { File f = getProfileDir(p); if (!f.exists()) f.mkdirs(); ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream( new File(f, PROFILE_OBJ_FILE))); oos.writeObject(p); oos.close(); } private void removeProfileFromStorage(OpenvpnProfile p) { Util.deleteFile(getProfileDir(p)); } private void retrieveVpnListFromStorage() { mVpnPreferenceMap = new LinkedHashMap<String, VpnPreference>(); mVpnProfileList = Collections .synchronizedList(new ArrayList<OpenvpnProfile>()); mVpnListContainer.removeAll(); File root = PROFILES_ROOT; String[] dirs = root.list(); if (dirs == null) return; for (String dir : dirs) { File f = new File(new File(root, dir), PROFILE_OBJ_FILE); if (!f.exists()) continue; try { OpenvpnProfile p = deserialize(f); if (p == null) continue; if (!checkIdConsistency(dir, p)) continue; mVpnProfileList.add(p); } catch (IOException e) { Log.e(TAG, "retrieveVpnListFromStorage()", e); } } Collections.sort(mVpnProfileList, new Comparator<OpenvpnProfile>() { @Override public int compare(OpenvpnProfile p1, OpenvpnProfile p2) { return p1.getName().compareTo(p2.getName()); } @Override public boolean equals(Object p) { // not used return false; } }); for (OpenvpnProfile p : mVpnProfileList) { addPreferenceFor(p); } } // A sanity check. Returns true if the profile directory name and profile ID // are consistent. private boolean checkIdConsistency(String dirName, OpenvpnProfile p) { if (!dirName.equals(p.getId())) { Log.d(TAG, "ID inconsistent: " + dirName + " vs " + p.getId()); return false; } else { return true; } } private OpenvpnProfile deserialize(File profileObjectFile) throws IOException { try { ObjectInputStream ois = new ObjectInputStream(new FileInputStream( profileObjectFile)); OpenvpnProfile p = (OpenvpnProfile) ois.readObject(); ois.close(); return p; } catch (ClassNotFoundException e) { Log.d(TAG, "deserialize a profile", e); return null; } } private static class VpnPreference extends Preference { OpenvpnProfile mProfile; VpnPreference(Context c, OpenvpnProfile p) { super(c); setProfile(p); } void setProfile(OpenvpnProfile p) { mProfile = p; setTitle(p.getName()); } } private void showDialog(DialogFragment dialog) { FragmentTransaction ft = getFragmentManager().beginTransaction(); ft.addToBackStack(null); dialog.show(ft, null); } public void doAuthDialogCallback(boolean saveUsername, String username, String password) { if (mConnectingProfile != null) { if (saveUsername && username != null && !username.equals(mConnectingProfile.getSavedUsername())) { mConnectingProfile.setSavedUsername(username); try { saveProfileToStorage(mConnectingProfile); } catch (IOException e) { Toast.makeText(this, e.getLocalizedMessage(), Toast.LENGTH_LONG); } } connect(mConnectingProfile, username, password); } } public void doDeleteProfile(String id) { int position = getProfileIndexFromId(id); if (position >= 0) { OpenvpnProfile p = mVpnProfileList.remove(position); VpnPreference pref = mVpnPreferenceMap.remove(p.getName()); mVpnListContainer.removePreference(pref); removeProfileFromStorage(p); } } }