// Copyright (c) 2009, Google Inc. // // 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 net.tawacentral.roger.secrets; import java.io.File; import java.security.NoSuchAlgorithmException; import java.security.SecureRandom; import java.text.MessageFormat; import java.util.ArrayList; import java.util.Collection; import java.util.Date; import java.util.List; import javax.crypto.Cipher; import android.app.AlertDialog; import android.app.Dialog; import android.app.ListActivity; import android.app.SearchManager; import android.content.DialogInterface; import android.content.Intent; import android.content.pm.PackageManager; import android.net.Uri; import android.os.Bundle; import android.text.ClipboardManager; import android.util.Log; import android.view.ContextMenu; import android.view.ContextMenu.ContextMenuInfo; import android.view.GestureDetector; import android.view.Gravity; import android.view.KeyEvent; import android.view.LayoutInflater; import android.view.Menu; import android.view.MenuInflater; import android.view.MenuItem; import android.view.MotionEvent; import android.view.View; import android.view.View.OnTouchListener; import android.view.animation.Animation; import android.view.animation.Animation.AnimationListener; import android.widget.AdapterView; import android.widget.AdapterView.AdapterContextMenuInfo; import android.widget.AdapterView.OnItemClickListener; import android.widget.AutoCompleteTextView; import android.widget.EditText; import android.widget.ListView; import android.widget.ScrollView; import android.widget.SeekBar; import android.widget.TextView; import android.widget.Toast; /** * An activity that handles two main functions: displaying the list of all * secrets, and modifying an existing secret. The reason that these two * activities are combined into one is to take advantage of the 3d page flip * effect, which basically happens inside one View. * * If the 3d transition could be done while transferring from the view of one * activity to the view of another, I would do that instead, since its more * natural for an android app. Because of this hack, I need to override the * behaviour of the back button to restore the "natural feel" of the back button * to the user. * * @author rogerta */ @SuppressWarnings({"deprecation","javadoc"}) public class SecretsListActivity extends ListActivity { private static final int DIALOG_DELETE_SECRET = 1; private static final int DIALOG_CONFIRM_RESTORE = 2; private static final int DIALOG_IMPORT_SUCCESS = 3; private static final int DIALOG_CHANGE_PASSWORD = 4; private static final int DIALOG_ENTER_RESTORE_PASSWORD = 5; private static final int DIALOG_SYNC = 6; // All RC_xxx constants should be different. private static final int RC_ACCESS_LOG = 1; private static final int RC_STORAGE_IMPORT = 2; private static final int RC_STORAGE_EXPORT = 3; private static final int RC_STORAGE_BACKUP = 4; private static final int RC_STORAGE_RESTORE = 5; private static final int PROGRESS_ROUNDS_OFFSET = 4; private static final String EMPTY_STRING = ""; public static final String EXTRA_ACCESS_LOG = "net.tawacentreal.secrets.accesslog"; public static final String UPGRADE_URL = "https://docs.google.com/document/d/1L0Bsmh5xmbEnpOfnU-Ps9jWShLnuAphRMn2gHWP0ViY/edit"; /** Tag for logging purposes. */ public static final String LOG_TAG = "SecretsListActivity"; public static final String STATE_IS_EDITING = "is_editing"; public static final String STATE_EDITING_POSITION = "editing_position"; public static final String STATE_EDITING_DESCRIPTION = "editing_description"; public static final String STATE_EDITING_USERNAME = "editing_username"; public static final String STATE_EDITING_PASSWORD = "editing_password"; public static final String STATE_EDITING_EMAIL = "editing_email"; public static final String STATE_EDITING_NOTES = "editing_notes"; private SecretsListAdapter secretsList; // list of secrets private Toast toast; // toast used to show password private GestureDetector detector; // detects taps and double taps private boolean isEditing; // true if changing a secret private int editingPosition; // position of item being edited private int cmenuPosition; // position of item for cmenu private View root; // root of the layout for this activity private View edit; // root view for the editing layout private File importedFile; // File that was imported private boolean isConfigChange; // being destroyed for config change? private String restorePoint; // That file that should be restored from private OnlineSyncAgent selectedOSA; // currently selected agent // This activity will only allow it self to be resumed in specific // circumstances, so that leaving the application will force the user to // re-enter the master password. Older versions used to check the state of // the keyguard, but this check is no longer reliable with Android 4.1. private boolean allowNextResume; // Allow the next onResume() /** Called when the activity is first created. */ @Override public void onCreate(Bundle state) { Log.d(LOG_TAG, "SecretsListActivity.onCreate"); super.onCreate(state); setContentView(R.layout.list); // If for any reason we get here and there is no secrets list, then we // cannot continue. Finish the activity and return. if (null == LoginActivity.getSecrets()) { finish(); return; } secretsList = new SecretsListAdapter(this, LoginActivity.getSecrets(), LoginActivity.getDeletedSecrets()); setTitle(); setListAdapter(secretsList); getListView().setTextFilterEnabled(true); // Setup the auto complete adapters for the username and email views. AutoCompleteTextView username = (AutoCompleteTextView) findViewById(R.id.list_username); AutoCompleteTextView email = (AutoCompleteTextView) findViewById(R.id.list_email); username.setAdapter(secretsList.getUsernameAutoCompleteAdapter()); email.setAdapter(secretsList.getEmailAutoCompleteAdapter()); // The 3d flip animation will be done on the root view of this activity. // Also get the edit group of views for use as the second view in the // animation. root = findViewById(R.id.list_container); edit = findViewById(R.id.edit_layout); // When the SEARCH key is pressed, make sure the global search dialog // is displayed. setDefaultKeyMode(DEFAULT_KEYS_SEARCH_LOCAL); // If there is state information, use it to initialize the activity. if (null != state) { isEditing = state.getBoolean(STATE_IS_EDITING); if (isEditing) { EditText description = (EditText) findViewById(R.id.list_description); EditText password = (EditText) findViewById(R.id.list_password); EditText notes = (EditText) findViewById(R.id.list_notes); editingPosition = state.getInt(STATE_EDITING_POSITION); description.setText(state.getCharSequence(STATE_EDITING_DESCRIPTION)); username.setText(state.getCharSequence(STATE_EDITING_USERNAME)); password.setText(state.getCharSequence(STATE_EDITING_PASSWORD)); email.setText(state.getCharSequence(STATE_EDITING_EMAIL)); notes.setText(state.getCharSequence(STATE_EDITING_NOTES)); getListView().setVisibility(View.GONE); edit.setVisibility(View.VISIBLE); } } // This listener handles click using the scroll wheel. if (OS.supportsScrollWheel()) { getListView().setOnItemClickListener(new OnItemClickListener() { @Override public void onItemClick(AdapterView<?> parent, View view, int position, long id) { onItemClicked(position); } }); } // The gesture detector is used to handle taps and double taps. Its // important to handle simple taps here and not just defer to the // onItemClickListener, otherwise a double tap will trigger both the // onItemClicked() behaviour as well as the double tap behaviour. By // handling simple taps here, we can use the onSingleTapConfirmed() to // only do the simple tap behaviour when we are sure there is no double // tap as well. // // However, in order to support device with a scroll wheel, I still need to // handle onItemClicked(), causing both behaviours. This can be // fixed for device that do not have a scroll wheel, in which case the // we do not need to handle onItemClicked(). However, the API to check // if the device has a scroll where was only introduced in Android 2.3. GestureDetector.SimpleOnGestureListener listener = new GestureDetector.SimpleOnGestureListener() { @Override public boolean onSingleTapConfirmed(MotionEvent e) { int position = getListView().pointToPosition((int) e.getX(), (int) e.getY()); onItemClicked(position); return true; } @Override public boolean onDoubleTap(MotionEvent e) { int position = getListView().pointToPosition((int) e.getX(), (int) e.getY()); if (AdapterView.INVALID_POSITION != position) { SetEditViews(position); animateToEditView(); hideToast(); } return true; } }; detector = new GestureDetector(this, listener); detector.setOnDoubleTapListener(listener); getListView().setOnTouchListener(new OnTouchListener() { @Override public boolean onTouch(View arg0, MotionEvent event) { return detector.onTouchEvent(event); } }); registerForContextMenu(getListView()); allowNextResume = true; } private void onItemClicked(int position) { if (AdapterView.INVALID_POSITION != position) { Secret secret = getSecret(position); CharSequence password = secret.getPassword(false); if (password.length() == 0) password = getText(R.string.no_password); showToast(password); // TODO(rogerta): to reliably record "view" access, we would want // to checkpoint the secrets and save them here. But doing so // causes unacceptable delays is displaying the toast. // FileUtils.saveSecrets(SecretsListActivity.this, // secretsList_.getAllSecrets()); } } @Override protected void onNewIntent(Intent intent) { // This method is invoked when the user performs a search from the global // search dialog. It is called each time the user presses the "Done" // button on the search keyboard. // Get the search string. Make it a full text search by ensuring the // string begins with a dot. setIntent(intent); String filter = intent.getStringExtra(SearchManager.QUERY); if (filter.charAt(0) != SecretsListAdapter.DOT) filter = SecretsListAdapter.DOT + filter; getListView().setFilterText(filter); // Try to hide the soft keyboard, if present. On some devices, setting // focus to a view that does not support keyboard input is enough. On // other devices where the the input method manager is implemented, try // to hide the keyboard explicitly. getListView().requestFocus(); OS.hideSoftKeyboard(this, getListView()); allowNextResume = true; } @Override public boolean onSearchRequested() { // Don't allow search in edit mode. return isEditing || super.onSearchRequested(); } /** Set the title for this activity. */ public void setTitle() { CharSequence title; int allCount = secretsList.getAllSecrets().size(); long lastSaved = FileUtils.getTimeOfLastOnlineBackup(this); String template = getText(R.string.last_saved_time_format).toString(); String lastSavedString = lastSaved == 0 ? "" : MessageFormat.format( template, new Date(lastSaved)); int count = secretsList.getCount(); if (allCount > 0) { if (allCount != count) { template = getText(R.string.list_title_filtered).toString(); title = MessageFormat .format(template, count, allCount, lastSavedString); } else { template = getText(R.string.list_title).toString(); title = MessageFormat.format(template, allCount, lastSavedString); } } else { title = getText(R.string.list_no_data); } setTitle(title); } @Override protected void onResume() { Log.d(LOG_TAG, "SecretsListActivity.onResume"); super.onResume(); // send roll call for OSAs. OnlineAgentManager.sendRollCallBroadcast(this); // Don't allow this activity to continue if it has not been explicitly // allowed. if (!allowNextResume) { Log.d(LOG_TAG, "onResume not allowed"); finish(); return; } allowNextResume = false; // Show instruction toast auto popup options menu if there are no secrets // in the list. This check used to be done in the onCreate() method above, // that could occasionally cause a crash when changing layout from // portrait to landscape, or back. Not sure why exactly, but I suspect // its because the UI elements are not actually ready to be rendered until // onResume() is called. if (0 == secretsList.getAllSecrets().size() && !isEditing) { showToast(getText(R.string.list_no_data)); getListView().post(new Runnable() { @Override public void run() { openOptionsMenu(); } }); } else if (FileUtils.isOnlineBackupTooOld(this)) { getListView().post(new Runnable() { @Override public void run() { showModalDialog(R.string.list_menu_backup, R.string.enable_online_backup, R.string.dialog_learn_how, 0, R.string.dialog_not_now, new DialogInterface.OnClickListener() { @Override public void onClick(DialogInterface dialog, int which) { if (which == DialogInterface.BUTTON_POSITIVE) { Intent intent = new Intent(Intent.ACTION_VIEW); intent.setData(Uri.parse(UPGRADE_URL)); startActivity(intent); } } }); } }); } } private void showModalDialog(int title, int message, int pos, int neut, int neg, DialogInterface.OnClickListener callback) { AlertDialog.Builder builder = new AlertDialog.Builder(this); builder.setMessage(message); builder.setTitle(title); if (pos != 0) builder.setPositiveButton(pos, callback); if (neut != 0) builder.setNeutralButton(neut, callback); if (neg != 0) builder.setNegativeButton(neg, callback); AlertDialog dialog = builder.create(); dialog.show(); } @Override public boolean onCreateOptionsMenu(Menu menu) { MenuInflater inflater = getMenuInflater(); inflater.inflate(R.menu.list_menu, menu); OS.configureSearchView(this, menu); return true; } @Override public boolean onPrepareOptionsMenu(Menu menu) { // We must always set the state of all the buttons, since we don't know // their states before this method is called. boolean secretsListEmpty = (secretsList == null) || secretsList.isEmpty(); menu.findItem(R.id.list_add).setVisible(!isEditing); menu.findItem(R.id.list_backup).setVisible(!isEditing && !secretsListEmpty); menu.findItem(R.id.list_search).setVisible(!isEditing); menu.findItem(R.id.list_restore).setVisible(!isEditing); menu.findItem(R.id.list_sync).setVisible(!isEditing); menu.findItem(R.id.list_import).setVisible(!isEditing); menu.findItem(R.id.list_export).setVisible(!isEditing && !secretsListEmpty); menu.findItem(R.id.list_menu_change_password).setVisible(!isEditing); menu.findItem(R.id.list_save).setVisible(isEditing); menu.findItem(R.id.list_generate_password).setVisible(isEditing); menu.findItem(R.id.list_discard).setVisible(isEditing); return true; } @Override public boolean onOptionsItemSelected(MenuItem item) { boolean handled = false; // TODO(rogerta): when using this menu to finish the editing activity, for // some reason the selected item in the list view is not highlighted. Need // to figure out what the interaction with the menu is. This does not // happen when using the back button to finish the editing activity. switch (item.getItemId()) { case R.id.list_add: SetEditViews(AdapterView.INVALID_POSITION); animateToEditView(); break; case R.id.list_search: onSearchRequested(); break; case R.id.list_save: saveSecret(); // NO BREAK case R.id.list_discard: animateFromEditView(); break; case R.id.list_generate_password: { String pwd = generatePassword(); EditText password = (EditText) findViewById(R.id.list_password); password.setText(pwd); break; } case R.id.list_backup: backupSecrets(); break; case R.id.list_restore: if (!OS.ensureStoragePermission(this, RC_STORAGE_RESTORE)) { allowNextResume = true; break; } showDialog(DIALOG_CONFIRM_RESTORE); break; case R.id.list_sync: syncRequested(); break; case R.id.list_export: exportSecrets(); break; case R.id.list_import: importSecrets(); break; case R.id.list_menu_change_password: showDialog(DIALOG_CHANGE_PASSWORD); break; default: break; } return handled; } @Override public void onCreateContextMenu(ContextMenu menu, View v, ContextMenuInfo menuInfo) { hideToast(); AdapterView.AdapterContextMenuInfo info = (AdapterContextMenuInfo) menuInfo; cmenuPosition = info.position; MenuInflater inflater = getMenuInflater(); inflater.inflate(R.menu.list_cmenu, menu); Secret secret = secretsList.getSecret(cmenuPosition); menu.setHeaderTitle(secret.getDescription()); } @Override public boolean onContextItemSelected(MenuItem item) { boolean handled = false; switch (item.getItemId()) { case R.id.list_edit: SetEditViews(cmenuPosition); animateToEditView(); break; case R.id.list_delete: if (AdapterView.INVALID_POSITION != cmenuPosition) { showDialog(DIALOG_DELETE_SECRET); } break; case R.id.list_access: { // TODO(rogerta): maybe just stuff the index into the intent instead // of serializing the whole secret, it seems to be slow. Secret secret = secretsList.getSecret(cmenuPosition); Intent intent = new Intent(this, AccessLogActivity.class); intent.putExtra(EXTRA_ACCESS_LOG, secret); startActivityForResult(intent, RC_ACCESS_LOG); allowNextResume = true; break; } case R.id.list_copy_password_to_clipboard: case R.id.list_copy_username_to_clipboard: { Secret secret = secretsList.getSecret(cmenuPosition); ClipboardManager cm = (ClipboardManager) getSystemService(CLIPBOARD_SERVICE); int typeId; if (item.getItemId() == R.id.list_copy_password_to_clipboard) { cm.setText(secret.getPassword(false)); typeId = R.string.password_copied_to_clipboard; } else { cm.setText(secret.getUsername()); typeId = R.string.username_copied_to_clipboard; } String template = getText(R.string.copied_to_clipboard).toString(); String typeOfCopy = getText(typeId).toString(); String msg = MessageFormat.format(template, secret.getDescription(), typeOfCopy); showToast(msg); break; } } return handled; } @Override protected void onActivityResult(int requestCode, int resultCode, Intent data) { if (requestCode == RC_ACCESS_LOG) { if (resultCode != RESULT_OK) finish(); } else { // This is an error, abort. finish(); } } /** * Callback received when a permissions request has been completed. */ @Override public void onRequestPermissionsResult(int code, String[] permissions, int[] grantResults) { if (grantResults.length == 1 && grantResults[0] != PackageManager.PERMISSION_GRANTED) { return; } switch (code) { case RC_STORAGE_BACKUP: backupSecrets(); break; case RC_STORAGE_RESTORE: showDialog(DIALOG_CONFIRM_RESTORE); break; case RC_STORAGE_EXPORT: exportSecrets(); break; case RC_STORAGE_IMPORT: importSecrets(); break; default: Log.e(LOG_TAG, "onRequestPermissionsResult: invalid code=" + code); break; } } /** Import from a CSV file on the SD card. */ private void importSecrets() { if (!OS.ensureStoragePermission(this, RC_STORAGE_IMPORT)) { allowNextResume = true; return; } importedFile = FileUtils.getFileToImport(); if (null == importedFile) { String template = getText(R.string.import_not_found).toString(); String msg = MessageFormat.format(template, FileUtils.getCsvFileNames()); showToast(msg); return; } ArrayList<Secret> secrets = new ArrayList<Secret>(); boolean allSucceeded = FileUtils.importSecrets(this, importedFile, secrets); if (!secrets.isEmpty()) { for (Secret secret : secrets) { secretsList.insert(secret); } secretsList.notifyDataSetChanged(); setTitle(); if (allSucceeded) { showDialog(DIALOG_IMPORT_SUCCESS); } else { String template = getText(R.string.import_partial).toString(); String msg = MessageFormat.format(template, importedFile.getName()); showToast(msg); } } else { String template = getText(R.string.import_failed).toString(); String msg = MessageFormat.format(template, importedFile.getName()); showToast(msg); } } private void deleteImportedFile() { if (null != importedFile) { importedFile.delete(); importedFile = null; } } private void exportSecrets() { if (!OS.ensureStoragePermission(this, RC_STORAGE_EXPORT)) { allowNextResume = true; return; } // Export everything to the SD card. // Export does not include deleted secrets if (FileUtils.exportSecrets(this, secretsList.getAllSecrets())) { showToast(R.string.export_succeeded); } else { showToast(R.string.export_failed); } } /** * Restore secrets from the given restore point. * * @param rp * The name of the restore point to restore from. * @param info * A CipherInfo structure describing the decryption cipher to use. * @param askForPassword * If the restore fails, show a dialog asking the user for the * password to restore from the backup. * * @return True if the restore succeeded, false otherwise. */ private boolean restoreSecrets(String rp, SecurityUtils.CipherInfo info, boolean askForPassword) { // Restore everything to the SD card. ArrayList<Secret> secrets = FileUtils.loadSecrets(this, rp, info); if (null == secrets) { if (askForPassword) { restorePoint = rp; showDialog(DIALOG_ENTER_RESTORE_PASSWORD); } return false; } LoginActivity.replaceSecrets(secrets); secretsList.notifyDataSetChanged(); setTitle(); return true; } private void backupSecrets() { if (!OS.ensureStoragePermission(this, RC_STORAGE_BACKUP)) { allowNextResume = true; return; } // Backup everything to the SD card. Cipher cipher = SecurityUtils.getEncryptionCipher(); byte[] salt = SecurityUtils.getSalt(); int rounds = SecurityUtils.getRounds(); if (FileUtils.backupSecrets(this, cipher, salt, rounds, secretsList.getAllAndDeletedSecrets())) { showToast(R.string.backup_succeeded); } else { showToast(R.string.error_save_secrets); } } /** Holds the currently chosen item in the restore dialog. */ private class RestoreDialogState { public int selected = 0; private List<String> restorePoints; /** Get an array of choices for the restore dialog. */ public CharSequence[] getRestoreChoices() { restorePoints = FileUtils.getRestorePoints(SecretsListActivity.this); return restorePoints.toArray(new CharSequence[restorePoints.size()]); } public String getSelectedRestorePoint() { return restorePoints.get(selected); } } /* Handle sync request * If sync operation active, give option to cancel it. Otherwise show * sync dialog only if more than one agent available. */ private void syncRequested() { if (OnlineAgentManager.isActive()) { DialogInterface.OnClickListener dialogClickListener = new DialogInterface.OnClickListener() { @Override public void onClick(DialogInterface dialog, int which) { switch (which) { case DialogInterface.BUTTON_POSITIVE: // Yes button clicked OnlineAgentManager.cancel(); break; case DialogInterface.BUTTON_NEGATIVE: // No button clicked break; } } }; AlertDialog.Builder builder = new AlertDialog.Builder(this); builder.setMessage(R.string.sync_active) .setPositiveButton(R.string.yes_option, dialogClickListener) .setNegativeButton(R.string.no_option, dialogClickListener).show(); } else if (normalizeSecrets(false)) { // test if we need to remove dups etc DialogInterface.OnClickListener dialogClickListener = new DialogInterface.OnClickListener() { @Override public void onClick(DialogInterface dialog, int which) { switch (which) { case DialogInterface.BUTTON_POSITIVE: // Yes button clicked normalizeSecrets(true); // actually remove dups etc. syncRequested(); break; case DialogInterface.BUTTON_NEGATIVE: // No button clicked break; } } }; AlertDialog.Builder builder = new AlertDialog.Builder(this); builder.setMessage(R.string.sync_normalization) .setPositiveButton(R.string.yes_option, dialogClickListener) .setNegativeButton(R.string.no_option, dialogClickListener).show(); } else { Collection<OnlineSyncAgent> agents = OnlineAgentManager.getAvailableAgents(); if (agents.size() > 1) { // show selection dialog if more than one agent available showDialog(DIALOG_SYNC); } else if (agents.size() == 1) { // send secrets to the one available OSA if (!OnlineAgentManager.sendSecrets(agents.iterator().next(), secretsList.getAllAndDeletedSecrets(), SecretsListActivity.this)) { showToast(R.string.error_osa_secrets); } } else { // no agents available showToast(R.string.no_osa_available); } } } /* * Here we adjust the collection of secrets to comply with the requirments * for sync. This is that the description field is unique so it acts as a * key to the secret. For this we: * - trim whitespace * - remove duplicates by appending " ##n" to the description * If we rename a secret to one which has been deleted, remove it from the * deleted secrets collection. * * If "action" is false, then just check if anything needs to be done, don't * actually do any renaming */ private boolean normalizeSecrets(boolean action) { ArrayList<Secret> secrets = LoginActivity.getSecrets(); ArrayList<String> newDescrs = new ArrayList<String>(); String lastDescr = ""; int incr = 1; boolean changed = false; for (Secret secret : secrets) { String descr = secret.getDescription().trim(); if (descr.equals(lastDescr)) { descr = descr + " ##" + incr++; } // if description changed, update it if (!secret.getDescription().equals(descr)) { if (action) { secret.setDescription(descr); } changed = true; newDescrs.add(descr); // remember the new name for possible deletion // if just trimmed, remember it as the last value if (incr == 1) { lastDescr = descr; } } else { lastDescr = descr; incr = 1; } } if (changed && action) { // remove any deleted with same name as renamed secrets ArrayList<Secret> deletedSecrets = LoginActivity.getDeletedSecrets(); if (deletedSecrets.size() > 0) { for (int i = deletedSecrets.size()-1; i > -1 ; i--) { Secret deletedSecret = deletedSecrets.get(i); if (newDescrs.contains(deletedSecret.getDescription())) { deletedSecrets.remove(i); } } } secretsList.notifyDataSetChanged(); String template = getText(R.string.num_normalized).toString(); showToast(MessageFormat.format(template, newDescrs.size())); } return changed; } /** * Callback from OSA manager with new/updated secrets. * * @param changedSecrets * @param agentName */ public void syncSecrets(ArrayList<Secret> changedSecrets, String agentName) { Log.d(LOG_TAG, "SecretsListActivity.syncSecrets, secrets: " + (changedSecrets == null ? changedSecrets : changedSecrets.size())); String template; if (changedSecrets != null) { secretsList.syncSecrets(changedSecrets); getListView().clearTextFilter(); setTitle(); template = getText(R.string.sync_succeeded).toString(); } else { template = getText(R.string.sync_failed).toString(); } showToast(MessageFormat.format(template, agentName)); } @Override public Dialog onCreateDialog(final int id) { Log.d(LOG_TAG, "SecretsListActivity.onCreateDialog, id=" + id); Dialog dialog = null; switch (id) { case DIALOG_DELETE_SECRET: { // NOTE: the assumption at this point is that position is valid, // otherwise we would never get here because of the check done // in onOptionsItemSelected(). DialogInterface.OnClickListener listener = new DialogInterface.OnClickListener() { @Override public void onClick(DialogInterface dialog, int which) { if (DialogInterface.BUTTON_POSITIVE == which) deleteSecret(cmenuPosition); } }; // NOTE: the message part of this dialog is dynamic, so its value is // set in onPrepareDialog() below. However, its important to set it // to something here, even the empty string, so that the setMessage() // call done later actually has an effect. dialog = new AlertDialog.Builder(this) .setTitle(R.string.list_menu_delete) .setIcon(android.R.drawable.ic_dialog_alert).setMessage(EMPTY_STRING) .setPositiveButton(R.string.login_reset_password_pos, listener) .setNegativeButton(R.string.login_reset_password_neg, null).create(); break; } case DIALOG_CONFIRM_RESTORE: { final RestoreDialogState state = new RestoreDialogState(); DialogInterface.OnClickListener itemListener = new DialogInterface.OnClickListener() { @Override public void onClick(DialogInterface dialog, int which) { state.selected = which; dialog.dismiss(); SecurityUtils.CipherInfo info = SecurityUtils.getCipherInfo(); if (restoreSecrets(state.getSelectedRestorePoint(), info, true)) showToast(R.string.restore_succeeded); } }; dialog = new AlertDialog.Builder(this) .setTitle(R.string.dialog_restore_title) .setIcon(android.R.drawable.ic_dialog_alert) .setSingleChoiceItems(state.getRestoreChoices(), state.selected, itemListener).create(); break; } case DIALOG_IMPORT_SUCCESS: { DialogInterface.OnClickListener listener = new DialogInterface.OnClickListener() { @Override public void onClick(DialogInterface dialog, int which) { if (DialogInterface.BUTTON1 == which) { deleteImportedFile(); } importedFile = null; } }; dialog = new AlertDialog.Builder(this) .setTitle(R.string.list_menu_import) .setIcon(android.R.drawable.ic_dialog_info).setMessage(EMPTY_STRING) .setPositiveButton(R.string.login_reset_password_pos, listener) .setNegativeButton(R.string.login_reset_password_neg, null).create(); break; } case DIALOG_CHANGE_PASSWORD: { DialogInterface.OnClickListener listener = new DialogInterface.OnClickListener() { @Override public void onClick(DialogInterface dialogi, int which) { AlertDialog dialog = (AlertDialog) dialogi; TextView password1 = (TextView) dialog.findViewById(R.id.password); TextView password2 = (TextView) dialog .findViewById(R.id.password_validation); String password = password1.getText().toString(); String p2 = password2.getText().toString(); if (!password.equals(p2) || password.length() == 0) { showToast(R.string.invalid_password); return; } SeekBar bar = (SeekBar) dialog.findViewById(R.id.cipher_strength); byte[] salt = SecurityUtils.getSalt(); int rounds = bar.getProgress() + PROGRESS_ROUNDS_OFFSET; SecurityUtils.CipherInfo info = SecurityUtils.createCiphers(password, salt, rounds); if (null != info) { SecurityUtils.saveCiphers(info); showToast(R.string.password_changed); } else { showToast(R.string.error_reset_password); } } }; LayoutInflater inflater = getLayoutInflater(); View view = inflater.inflate(R.layout.change_password, getListView(), false); dialog = new AlertDialog.Builder(this) .setTitle(R.string.list_menu_change_password) .setIcon(android.R.drawable.ic_dialog_info).setView(view) .setPositiveButton(R.string.list_menu_change_password, listener) .create(); final Dialog dialogFinal = dialog; SeekBar bar = (SeekBar) view.findViewById(R.id.cipher_strength); bar.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() { @Override public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { setCipherStrengthLabel(dialogFinal, progress + PROGRESS_ROUNDS_OFFSET); } @Override public void onStartTrackingTouch(SeekBar seekBar) { } @Override public void onStopTrackingTouch(SeekBar seekBar) { } }); break; } case DIALOG_ENTER_RESTORE_PASSWORD: { DialogInterface.OnClickListener listener = new DialogInterface.OnClickListener() { @Override public void onClick(DialogInterface dialogi, int which) { AlertDialog dialog = (AlertDialog) dialogi; TextView password1 = (TextView) dialog.findViewById(R.id.password); String password = password1.getText().toString(); FileUtils.SaltAndRounds saltAndRounds = FileUtils.getSaltAndRounds( null, restorePoint); String message = null; SecurityUtils.CipherInfo info = SecurityUtils.createCiphers(password, saltAndRounds.salt, saltAndRounds.rounds); if (restoreSecrets(restorePoint, info, false)) { SecurityUtils.clearCiphers(); SecurityUtils.saveCiphers(info); message = getText(R.string.password_changed).toString(); message += '\n'; message += getText(R.string.restore_succeeded).toString(); } else { // Try old encryption mechanisms - this may be a restore point // created by an older version of Secrets. See FileUtils load // methods for details. // Try V3. ArrayList<Secret> secrets = FileUtils.loadSecretsV3(SecretsListActivity.this, info, restorePoint); if (secrets == null) { // Try V2. Cipher cipher2 = SecurityUtils.createDecryptionCipherV2(password, saltAndRounds.salt, saltAndRounds.rounds); secrets = FileUtils.loadSecretsV2( SecretsListActivity.this, restorePoint, cipher2, saltAndRounds.salt, saltAndRounds.rounds); } if (secrets == null) { // Try V1. Cipher cipher1 = SecurityUtils.createDecryptionCipherV1(password); secrets = FileUtils.loadSecretsV1(SecretsListActivity.this, cipher1, restorePoint); } if (secrets != null) { LoginActivity.replaceSecrets(secrets); secretsList.notifyDataSetChanged(); setTitle(); message = getText(R.string.restore_succeeded).toString(); } } if (message == null) message = getText(R.string.restore_failed).toString(); showToast(message); } }; LayoutInflater inflater = getLayoutInflater(); View view = inflater.inflate(R.layout.change_password, getListView(), false); // For this dialog, we don't want to show the seek bar nor the // confirmation password field. view.findViewById(R.id.cipher_strength).setVisibility(View.GONE); view.findViewById(R.id.cipher_strength_label).setVisibility(View.GONE); view.findViewById(R.id.password_validation).setVisibility(View.GONE); view.findViewById(R.id.password_validation_label) .setVisibility(View.GONE); dialog = new AlertDialog.Builder(this) .setTitle(R.string.login_enter_password) .setIcon(android.R.drawable.ic_dialog_info).setView(view) .setPositiveButton(R.string.list_menu_restore, listener).create(); break; } case DIALOG_SYNC: { Log.d(LOG_TAG, "Showing sync selection dialog"); DialogInterface.OnClickListener itemListener = new DialogInterface.OnClickListener() { public void onClick(DialogInterface dialog, int which) { AlertDialog alertDialog = (AlertDialog) dialog; selectedOSA = (OnlineSyncAgent) alertDialog.getListView() .getItemAtPosition(which); Log.d(LOG_TAG, "Selected app: " + selectedOSA.getDisplayName()); dialog.dismiss(); // send secrets to the OSA if (!OnlineAgentManager.sendSecrets(selectedOSA, secretsList.getAllAndDeletedSecrets(), SecretsListActivity.this)) { showToast(R.string.error_osa_secrets); } } }; OnlineAgentAdapter adapter = new OnlineAgentAdapter( SecretsListActivity.this, android.R.layout.select_dialog_singlechoice, android.R.id.text1); String title = getString(R.string.dialog_sync_title); dialog = new AlertDialog.Builder(this).setTitle(title) .setIcon(android.R.drawable.ic_dialog_alert) .setSingleChoiceItems(adapter, 0, itemListener).create(); break; } default: break; } return dialog; } private void setCipherStrengthLabel(Dialog dialog, int rounds) { String template = getText(R.string.cipher_strength_label).toString(); String msg = MessageFormat.format(template, rounds); TextView text = (TextView) dialog.findViewById(R.id.cipher_strength_label); text.setText(msg); } @Override protected void onPrepareDialog(int id, Dialog dialog) { super.onPrepareDialog(id, dialog); switch (id) { case DIALOG_DELETE_SECRET: { AlertDialog alert = (AlertDialog) dialog; Secret secret = secretsList.getSecret(cmenuPosition); String template = getText(R.string.edit_menu_delete_secret_message) .toString(); String msg = MessageFormat.format(template, secret.getDescription()); alert.setMessage(msg); break; } case DIALOG_IMPORT_SUCCESS: { AlertDialog alert = (AlertDialog) dialog; String template = getText(R.string.edit_menu_import_secrets_message) .toString(); String msg = MessageFormat.format(template, importedFile.getName()); alert.setMessage(msg); break; } case DIALOG_CHANGE_PASSWORD: { SeekBar bar = (SeekBar) dialog.findViewById(R.id.cipher_strength); int rounds = SecurityUtils.getRounds(); bar.setProgress(rounds - PROGRESS_ROUNDS_OFFSET); setCipherStrengthLabel(dialog, rounds); TextView password1 = (TextView) dialog.findViewById(R.id.password); password1.setText(""); TextView password2 = (TextView) dialog .findViewById(R.id.password_validation); password2.setText(""); password1.requestFocus(); break; } case DIALOG_ENTER_RESTORE_PASSWORD: { TextView password1 = (TextView) dialog.findViewById(R.id.password); password1.setText(""); break; } } } /** * Trap the "back" button to simulate going back from the secret edit view to * the list view. Note this needs to be done in key-down and not key-up, since * the system's default action for "back" happen on key-down. */ @Override public boolean onKeyDown(int keyCode, KeyEvent event) { if (isEditing && KeyEvent.KEYCODE_BACK == keyCode) { saveSecret(); animateFromEditView(); return true; } return super.onKeyDown(keyCode, event); } /** * Called before the view is to be destroyed, so we can save state. Its * important to use this method for saving state if the user happens to * open/close the keyboard while the view is displayed. */ @Override protected void onSaveInstanceState(Bundle state) { Log.d(LOG_TAG, "SecretsListActivity.onSaveInstanceState"); super.onSaveInstanceState(state); // Save our state for later. state.putBoolean(STATE_IS_EDITING, isEditing); if (isEditing) { saveSecret(); EditText description = (EditText) findViewById(R.id.list_description); EditText username = (EditText) findViewById(R.id.list_username); EditText password = (EditText) findViewById(R.id.list_password); EditText email = (EditText) findViewById(R.id.list_email); EditText notes = (EditText) findViewById(R.id.list_notes); state.putInt(STATE_EDITING_POSITION, editingPosition); state.putCharSequence(STATE_EDITING_DESCRIPTION, description.getText()); state.putCharSequence(STATE_EDITING_USERNAME, username.getText()); state.putCharSequence(STATE_EDITING_PASSWORD, password.getText()); state.putCharSequence(STATE_EDITING_EMAIL, email.getText()); state.putCharSequence(STATE_EDITING_NOTES, notes.getText()); } Log.d(LOG_TAG, "SecretsListActivity.onSaveInstanceState"); } /** Called when the activity is no longer visible. */ @Override protected void onPause() { Log.d(LOG_TAG, "SecretsListActivity.onPause"); // Cancel any toast that may currently be displayed. if (null != toast) toast.cancel(); // Do the save in the background, so that we don't block the UI thread. // For people with lots and lots of secrets, it can take a long time to // save, and they may get a "force close" dialog if the save was done in // the UI thread. // // The issue is that we cannot give the user feedback about the save, // unless I use a notification (need to look into that). Also, because // the process hangs around, this thread should continue running until // completion even if the user switches to another task/application. ArrayList<Secret> secrets = secretsList.getAllAndDeletedSecrets(); Cipher cipher = SecurityUtils.getEncryptionCipher(); byte[] salt = SecurityUtils.getSalt(); int rounds = SecurityUtils.getRounds(); SaveService.execute(this, secrets, cipher, salt, rounds); super.onPause(); } /** * This method is called when the activity is being destroyed and recreated * due to a configuration change, such as the keyboard being opened or closed. * Its called after onPause() and onSaveInstanceState(), but before * onDestroy(). * * When this is called, I will set a boolean value so that onDestroy() will * not clear the secrets data. */ @Override public Object onRetainNonConfigurationInstance() { Log.d(LOG_TAG, "SecretsListActivity.onRetainNonConfigurationInstance"); isConfigChange = true; return super.onRetainNonConfigurationInstance(); } /** Called before activity is destroyed. */ @Override protected void onDestroy() { // Don't clear the secrets if this is a configuration change, since we // are going to need it immediately anyway. We do want to clear it in // other circumstances, otherwise the login activity will ignore attempt // to login again. if (!isConfigChange) { Log.d(LOG_TAG, "SecretsListActivity.onDestroy"); LoginActivity.clearSecrets(); } super.onDestroy(); } /** * Set the secret specified by the given position in the list into the edit * fields used to modify the secret. Position 0 means "add secret". * * @param position * Position of secret to edit. */ private void SetEditViews(int position) { editingPosition = position; EditText description = (EditText) findViewById(R.id.list_description); EditText username = (EditText) findViewById(R.id.list_username); EditText password = (EditText) findViewById(R.id.list_password); EditText email = (EditText) findViewById(R.id.list_email); EditText notes = (EditText) findViewById(R.id.list_notes); if (AdapterView.INVALID_POSITION == position) { description.setText(EMPTY_STRING); username.setText(EMPTY_STRING); password.setText(EMPTY_STRING); email.setText(EMPTY_STRING); notes.setText(EMPTY_STRING); description.requestFocus(); } else { Secret secret = secretsList.getSecret(position); description.setText(secret.getDescription()); username.setText(secret.getUsername()); password.setText(secret.getPassword(false)); email.setText(secret.getEmail()); notes.setText(secret.getNote()); password.requestFocus(); } ScrollView scroll = (ScrollView) findViewById(R.id.edit_layout); scroll.scrollTo(0, 0); } /** * Save the current values in the edit views into the current secret being * edited. If the current secret is at position 0, this means add a new * secret. * * Secrets will be added in alphabetical order by description. * * All secrets are flushed to persistent storage. */ private void saveSecret() { EditText description = (EditText) findViewById(R.id.list_description); EditText username = (EditText) findViewById(R.id.list_username); EditText password = (EditText) findViewById(R.id.list_password); EditText email = (EditText) findViewById(R.id.list_email); EditText notes = (EditText) findViewById(R.id.list_notes); // If all the text views are blank, then don't do anything if we are // supposed to be adding a secret. Also, if all the views are // the same as the current secret, don't do anything either. Secret secret; String description_text = description.getText().toString(); String username_text = username.getText().toString(); String password_text = password.getText().toString(); String email_text = email.getText().toString(); String note_text = notes.getText().toString(); if (AdapterView.INVALID_POSITION == editingPosition) { if (0 == description.getText().length() && 0 == username.getText().length() && 0 == password.getText().length() && 0 == email.getText().length() && 0 == notes.getText().length()) return; secret = new Secret(); } else { secret = secretsList.getSecret(editingPosition); if (description_text.equals(secret.getDescription()) && username_text.equals(secret.getUsername()) && password_text.equals(secret.getPassword(false)) && email_text.equals(secret.getEmail()) && note_text.equals(secret.getNote())) { secretsList.notifyDataSetChanged(); return; } secretsList.remove(editingPosition); } secret.setDescription(description.getText().toString()); secret.setUsername(username.getText().toString()); secret.setPassword(password.getText().toString(), (AdapterView.INVALID_POSITION == editingPosition ? false : true)); secret.setEmail(email.getText().toString()); secret.setNote(notes.getText().toString()); editingPosition = secretsList.insert(secret); secretsList.notifyDataSetChanged(); } /** * Delete the secret at the given position. If the user is currently editing a * secret, he is returned to the list. */ public void deleteSecret(int position) { if (AdapterView.INVALID_POSITION != position) { secretsList.delete(position); secretsList.notifyDataSetChanged(); // TODO(rogerta): is this is really a performance issue to save here? // if (!FileUtils.saveSecrets(this, secretsList_.getAllSecrets())) // showToast(R.string.error_save_secrets); if (isEditing) { // We need to clear the edit position so that when the animation is // done, the code does not try to make visible a secret that no longer // exists. This was causing a crash (issue 16). editingPosition = AdapterView.INVALID_POSITION; animateFromEditView(); } else { setTitle(); } } } /** * Show a toast on the screen with the given message. If a toast is already * being displayed, the message is replaced and timer is restarted. * * @param message * Resource id of the text to display in the toast. */ private void showToast(int message) { showToast(getText(message)); } /** * Show a toast on the screen with the given message. If a toast is already * being displayed, the message is replaced and timer is restarted. * * @param message * Text to display in the toast. */ private void showToast(CharSequence message) { if (null == toast) { toast = Toast.makeText(SecretsListActivity.this, message, Toast.LENGTH_LONG); toast.setGravity(Gravity.CENTER, 0, 0); } else { toast.setText(message); } toast.show(); } /** Hide the toast, if any. */ private void hideToast() { if (null != toast) { toast.cancel(); } } /** Get the secret at the specified position in the list. */ private Secret getSecret(int position) { return (Secret) getListAdapter().getItem(position); } /** * Start the view animation that transitions from the list of secrets to the * secret edit view. */ private void animateToEditView() { assert (!isEditing); isEditing = true; // Cancel any toast and soft keyboard that may currently be displayed. if (null != toast) toast.cancel(); OS.hideSoftKeyboard(this, getListView()); OS.invalidateOptionsMenu(this); View list = getListView(); int cx = root.getWidth() / 2; int cy = root.getHeight() / 2; Animation animation = new Flip3dAnimation(list, edit, cx, cy, true); animation.setAnimationListener(new AnimationListener() { @Override public void onAnimationEnd(Animation animation) { hideToast(); if (0 == secretsList.getCount()) { showToast(getText(R.string.edit_instructions)); } } @Override public void onAnimationRepeat(Animation animation) { } @Override public void onAnimationStart(Animation animation) { } }); root.startAnimation(animation); } /** * Start the view animation that transitions from the secret edit view to the * list of secrets. */ private void animateFromEditView() { assert (isEditing); isEditing = false; OS.hideSoftKeyboard(this, getListView()); OS.invalidateOptionsMenu(this); View list = getListView(); int cx = root.getWidth() / 2; int cy = root.getHeight() / 2; Animation animation = new Flip3dAnimation(list, edit, cx, cy, false); animation.setAnimationListener(new AnimationListener() { @Override public void onAnimationEnd(Animation animation) { if (AdapterView.INVALID_POSITION != editingPosition) { ListView listView = getListView(); listView.requestFocus(); int first = listView.getFirstVisiblePosition(); int last = listView.getLastVisiblePosition(); if (editingPosition < first || editingPosition > last) { listView.setSelection(editingPosition); } } setTitle(); if (1 == secretsList.getAllSecrets().size()) { showToast(getText(R.string.list_instructions)); } } @Override public void onAnimationRepeat(Animation animation) { } @Override public void onAnimationStart(Animation animation) { } }); root.startAnimation(animation); } /** Generate and return a difficult to guess password. */ private String generatePassword() { StringBuilder builder = new StringBuilder(8); try { SecureRandom r = SecureRandom.getInstance("SHA1PRNG"); final String p = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + "abcdefghijklmnopqrstuvwxyz" + "abcdefghijklmnopqrstuvwxyz" + "0123456789" + "0123456789" + "~!@#$%^&*()_+`-=[]{}|;':,./<>?"; for (int i = 0; i < 8; ++i) builder.append(p.charAt(r.nextInt(128))); } catch (NoSuchAlgorithmException ex) { Log.e(LOG_TAG, "generatePassword", ex); } return builder.toString(); } }