Merge branch 'master' of https://github.com/PhilippC/keepass2android
@@ -58,12 +58,12 @@ namespace keepass2android
|
||||
|
||||
}
|
||||
|
||||
private static string LogFilename
|
||||
public static string LogFilename
|
||||
{
|
||||
get { return Application.Context.FilesDir.CanonicalPath +"/keepass2android.log"; }
|
||||
}
|
||||
|
||||
private static bool LogToFile
|
||||
public static bool LogToFile
|
||||
{
|
||||
get
|
||||
{
|
||||
|
||||
@@ -97,7 +97,28 @@ public class FileEntry {
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
StringBuilder s = new StringBuilder("JavaFileStorage.FileEntry{").append(displayName).append("|")
|
||||
.append("path=").append(path).append(",sz=").append(sizeInBytes)
|
||||
.append(",").append(isDirectory ? "dir" : "file")
|
||||
.append(",lastMod=").append(lastModifiedTime);
|
||||
|
||||
StringBuilder perms = new StringBuilder();
|
||||
if (canRead)
|
||||
perms.append("r");
|
||||
if (canWrite)
|
||||
perms.append("w");
|
||||
if (perms.length() > 0) {
|
||||
s.append(",").append(perms);
|
||||
}
|
||||
|
||||
if (userData != null && userData.length() > 0)
|
||||
s.append(",userData=").append(userData);
|
||||
|
||||
return s.append("}").toString();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
package keepass2android.javafilestorage;
|
||||
|
||||
import android.util.Log;
|
||||
|
||||
import com.jcraft.jsch.Logger;
|
||||
|
||||
import java.io.FileWriter;
|
||||
import java.io.PrintWriter;
|
||||
import java.util.Map;
|
||||
|
||||
public class Kp2aJSchLogger implements Logger {
|
||||
|
||||
private static final String PREFIX = "KP2AJFS[JSch]";
|
||||
|
||||
private interface ILogger {
|
||||
void log(String message);
|
||||
}
|
||||
|
||||
private interface EntryToLogFactory {
|
||||
ILogger create(LogEntry e);
|
||||
}
|
||||
|
||||
private static final EntryToLogFactory ANDROID_FACTORY = e -> e.logger;
|
||||
|
||||
private static final class LogEntry {
|
||||
private final String levelTag;
|
||||
private final ILogger logger;
|
||||
|
||||
LogEntry(String levelTag, ILogger logger) {
|
||||
this.levelTag = levelTag;
|
||||
this.logger = logger;
|
||||
}
|
||||
}
|
||||
private static final ILogger DEBUG = msg -> Log.d(PREFIX, msg);
|
||||
private static final LogEntry DEBUG_ENTRY = new LogEntry("D", DEBUG);
|
||||
private static final ILogger ERROR = msg -> Log.e(PREFIX, msg);
|
||||
private static final LogEntry DEFAULT_ENTRY = DEBUG_ENTRY;
|
||||
|
||||
private static final Map<Integer, LogEntry> loggers = Map.of(
|
||||
Logger.DEBUG, DEBUG_ENTRY,
|
||||
Logger.INFO, new LogEntry("I", msg -> Log.i(PREFIX, msg)),
|
||||
Logger.WARN, new LogEntry("W", msg -> Log.w(PREFIX, msg)),
|
||||
Logger.ERROR, new LogEntry("E", ERROR),
|
||||
Logger.FATAL, new LogEntry("F", msg -> Log.wtf(PREFIX, msg))
|
||||
);
|
||||
|
||||
|
||||
private final EntryToLogFactory logFactory;
|
||||
|
||||
static Kp2aJSchLogger createAndroidLogger() {
|
||||
return new Kp2aJSchLogger(ANDROID_FACTORY);
|
||||
}
|
||||
|
||||
static Kp2aJSchLogger createFileLogger(String logFilename) {
|
||||
final String fName = logFilename;
|
||||
return new Kp2aJSchLogger(e -> createFileLogger(e, fName));
|
||||
}
|
||||
|
||||
private Kp2aJSchLogger(EntryToLogFactory logFactory) {
|
||||
this.logFactory = logFactory;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isEnabled(int level) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void log(int level, String message) {
|
||||
if (isEnabled(level))
|
||||
getLogger(level).log(message);
|
||||
}
|
||||
|
||||
private ILogger getLogger(int level) {
|
||||
LogEntry entry = loggers.get(level);
|
||||
if (entry == null)
|
||||
entry = DEFAULT_ENTRY;
|
||||
|
||||
return logFactory.create(entry);
|
||||
}
|
||||
|
||||
private static ILogger createFileLogger(LogEntry entry, String fName) {
|
||||
try {
|
||||
final PrintWriter p = new PrintWriter(new FileWriter(fName, true));
|
||||
return msg -> {
|
||||
try {
|
||||
String fullMsg = String.join(" ", entry.levelTag, PREFIX, msg);
|
||||
p.println(fullMsg);
|
||||
} catch (Exception e) {
|
||||
ERROR.log(e.getMessage());
|
||||
} finally {
|
||||
p.close();
|
||||
}
|
||||
};
|
||||
} catch (Exception e) {
|
||||
ERROR.log(e.getMessage());
|
||||
return entry.logger;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
package keepass2android.javafilestorage;
|
||||
|
||||
import android.util.Pair;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.BufferedWriter;
|
||||
import java.io.File;
|
||||
import java.io.FileNotFoundException;
|
||||
import java.io.FileWriter;
|
||||
import java.io.IOException;
|
||||
import java.io.StringReader;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import com.jcraft.jsch.JSch;
|
||||
import com.jcraft.jsch.JSchException;
|
||||
import com.jcraft.jsch.KeyPair;
|
||||
|
||||
class SftpPublicPrivateKeyUtils {
|
||||
|
||||
private enum Validity {
|
||||
NOT_ATTEMPTED, VALID, NOT_VALID;
|
||||
}
|
||||
|
||||
private static final String SFTP_CUSTOM_KEY_DIRNAME = "user_keys";
|
||||
|
||||
private static final String KP2A_PRIVATE_KEY_FILENAME = "id_kp2a_rsa";
|
||||
|
||||
private final File appBaseDir;
|
||||
|
||||
/**
|
||||
* Do NOT access this variable directly! Use {@link #baseDir()} instead.
|
||||
*/
|
||||
private final File customKeyBaseDir;
|
||||
private volatile Validity validDir = Validity.NOT_ATTEMPTED;
|
||||
|
||||
SftpPublicPrivateKeyUtils(String appBaseDir) {
|
||||
// Assume app base directory exists already
|
||||
this.appBaseDir = new File(appBaseDir);
|
||||
|
||||
// Intentionally skipping existence/creation checking in constructor
|
||||
// See baseDir()
|
||||
this.customKeyBaseDir = new File(appBaseDir, SFTP_CUSTOM_KEY_DIRNAME);
|
||||
}
|
||||
|
||||
private Pair<File, Boolean> baseDir() {
|
||||
if (validDir == Validity.NOT_ATTEMPTED) {
|
||||
synchronized (this) {
|
||||
if (!customKeyBaseDir.exists()) {
|
||||
customKeyBaseDir.mkdirs();
|
||||
}
|
||||
if (customKeyBaseDir.exists() && customKeyBaseDir.isDirectory()) {
|
||||
validDir = Validity.VALID;
|
||||
} else {
|
||||
validDir = Validity.NOT_VALID;
|
||||
}
|
||||
}
|
||||
}
|
||||
return new Pair<>(customKeyBaseDir, validDir == Validity.VALID);
|
||||
}
|
||||
|
||||
boolean deleteCustomKey(String keyName) throws FileNotFoundException {
|
||||
File f = getCustomKeyFile(keyName);
|
||||
return f.isFile() && f.delete();
|
||||
}
|
||||
|
||||
String[] getCustomKeyNames() {
|
||||
Pair<File, Boolean> base = baseDir();
|
||||
if (!base.second) {
|
||||
// Log it?
|
||||
return new String[]{};
|
||||
}
|
||||
return base.first.list();
|
||||
}
|
||||
|
||||
void savePrivateKeyContent(String keyName, String keyContent) throws IOException, Exception {
|
||||
keyContent = PrivateKeyValidator.ensureValidContent(keyContent);
|
||||
|
||||
File f = getCustomKeyFile(keyName);
|
||||
try (BufferedWriter w = new BufferedWriter(new FileWriter(f))) {
|
||||
w.write(keyContent);
|
||||
}
|
||||
}
|
||||
|
||||
String getCustomKeyFilePath(String customKeyName) throws FileNotFoundException {
|
||||
return getCustomKeyFile(customKeyName).getAbsolutePath();
|
||||
}
|
||||
|
||||
String resolveKeyFilePath(JSch jschInst, @Nullable String customKeyName) {
|
||||
// Custom private key configured
|
||||
if (customKeyName != null) {
|
||||
try {
|
||||
return getCustomKeyFilePath(customKeyName);
|
||||
} catch (FileNotFoundException e) {
|
||||
System.out.println(e);
|
||||
}
|
||||
}
|
||||
// Use KP2A's public/private key
|
||||
String keyFilePath = getAppKeyFileName();
|
||||
try{
|
||||
createKeyPair(jschInst, keyFilePath);
|
||||
} catch (Exception ex) {
|
||||
System.out.println(ex);
|
||||
}
|
||||
return keyFilePath;
|
||||
}
|
||||
|
||||
String createKeyPair(JSch jschInst) throws IOException, JSchException {
|
||||
return createKeyPair(jschInst, getAppKeyFileName());
|
||||
}
|
||||
|
||||
/**
|
||||
* Exposed for testing purposes only
|
||||
* @param keyName
|
||||
* @return
|
||||
*/
|
||||
String getSanitizedCustomKeyName(String keyName) {
|
||||
return PrivateKeyValidator.sanitizeKeyAsFilename(keyName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Exposed for testing purposes only.
|
||||
* @param keyContent
|
||||
* @return
|
||||
* @throws Exception
|
||||
*/
|
||||
String getValidatedCustomKeyContent(String keyContent) throws Exception {
|
||||
return PrivateKeyValidator.ensureValidContent(keyContent);
|
||||
}
|
||||
|
||||
private String createKeyPair(JSch jschInst, String key_filename) throws JSchException, IOException {
|
||||
String public_key_filename = key_filename + ".pub";
|
||||
File file = new File(key_filename);
|
||||
if (file.exists())
|
||||
return public_key_filename;
|
||||
int type = KeyPair.RSA;
|
||||
KeyPair kpair = KeyPair.genKeyPair(jschInst, type, 4096);
|
||||
kpair.writePrivateKey(key_filename);
|
||||
|
||||
kpair.writePublicKey(public_key_filename, "generated by Keepass2Android");
|
||||
//ret = "Fingerprint: " + kpair.getFingerPrint();
|
||||
kpair.dispose();
|
||||
return public_key_filename;
|
||||
}
|
||||
|
||||
private String getAppKeyFileName() {
|
||||
return new File(appBaseDir, KP2A_PRIVATE_KEY_FILENAME).getAbsolutePath();
|
||||
}
|
||||
|
||||
private File getCustomKeyFile(String customKeyName) throws FileNotFoundException {
|
||||
Pair<File, Boolean> base = baseDir();
|
||||
if (!base.second) {
|
||||
throw new FileNotFoundException("Custom key directory");
|
||||
}
|
||||
|
||||
String keyFileName = PrivateKeyValidator.sanitizeKeyAsFilename(customKeyName);
|
||||
if (!keyFileName.isEmpty()) {
|
||||
File keyFile = new File(base.first, keyFileName);
|
||||
// Protect against bad actors trying to navigate away from the base directory.
|
||||
// This is probably overkill, given sanitizeKeyAsFilename(...) but better safe than sorry.
|
||||
if (base.first.equals(keyFile.getParentFile())) {
|
||||
return keyFile;
|
||||
}
|
||||
}
|
||||
// The key was sanitized to nothing, or the parent check above failed.
|
||||
throw new FileNotFoundException("Malformed key name");
|
||||
}
|
||||
|
||||
|
||||
private static class PrivateKeyValidator {
|
||||
private static final Pattern CONTENT_FIRST_LINE = Pattern.compile("^-+BEGIN\\s[^\\s]+\\sPRIVATE\\sKEY-+$");
|
||||
private static final Pattern CONTENT_LAST_LINE = Pattern.compile("^-+END\\s[^\\s]+\\sPRIVATE\\sKEY-+$");
|
||||
|
||||
/**
|
||||
* Key-to-filename sanitizer solution sourced from:
|
||||
* <a href="https://www.b4x.com/android/forum/threads/sanitize-filename.82558/" />
|
||||
*/
|
||||
private static final Pattern KEY_SANITIZER = Pattern.compile("([^\\p{L}\\s\\d\\-_~,;:\\[\\]\\(\\).'])",
|
||||
Pattern.CASE_INSENSITIVE);
|
||||
|
||||
static String sanitizeKeyAsFilename(String key) {
|
||||
return KEY_SANITIZER.matcher(key.trim()).replaceAll("");
|
||||
}
|
||||
|
||||
static String ensureValidContent(String content) throws Exception {
|
||||
content = content.trim();
|
||||
|
||||
boolean isValid = true;
|
||||
try (BufferedReader r = new BufferedReader(new StringReader(content))) {
|
||||
boolean validFirst = false;
|
||||
String line;
|
||||
String last = null;
|
||||
while ((line = r.readLine()) != null) {
|
||||
if (!validFirst) {
|
||||
if (CONTENT_FIRST_LINE.matcher(line).matches()) {
|
||||
validFirst = true;
|
||||
} else {
|
||||
isValid = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
last = line;
|
||||
}
|
||||
if (!isValid || last == null || !CONTENT_LAST_LINE.matcher(last).matches()) {
|
||||
throw new RuntimeException("Malformed private key content");
|
||||
}
|
||||
} catch (Exception e) {
|
||||
android.util.Log.d(SftpStorage.class.getName(), "Invalid key content", e);
|
||||
throw e;
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,20 +2,24 @@ package keepass2android.javafilestorage;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.File;
|
||||
import java.io.FileNotFoundException;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.UnsupportedEncodingException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.TreeSet;
|
||||
|
||||
import com.jcraft.jsch.Channel;
|
||||
import com.jcraft.jsch.ChannelSftp;
|
||||
import com.jcraft.jsch.ChannelSftp.LsEntry;
|
||||
import com.jcraft.jsch.JSch;
|
||||
import com.jcraft.jsch.JSchException;
|
||||
import com.jcraft.jsch.KeyPair;
|
||||
import com.jcraft.jsch.Logger;
|
||||
import com.jcraft.jsch.Session;
|
||||
import com.jcraft.jsch.SftpATTRS;
|
||||
import com.jcraft.jsch.SftpException;
|
||||
@@ -26,10 +30,38 @@ import android.content.Intent;
|
||||
import android.os.Bundle;
|
||||
import android.util.Log;
|
||||
|
||||
@SuppressWarnings("unused") // Exposed by JavaFileStorageBindings
|
||||
public class SftpStorage extends JavaFileStorageBase {
|
||||
@FunctionalInterface
|
||||
interface ValueResolver<T> {
|
||||
/**
|
||||
* Takes a raw value and resolves it to either a String containing the String representation
|
||||
* of that value, or null. The latter signifying that the raw value could not be "resolved".
|
||||
*
|
||||
* @param value
|
||||
* @return String, or null if not resolvable
|
||||
*/
|
||||
String resolve(T value);
|
||||
}
|
||||
|
||||
public static final int DEFAULT_SFTP_PORT = 22;
|
||||
JSch jsch;
|
||||
public static final int UNSET_SFTP_CONNECT_TIMEOUT = -1;
|
||||
private static final String SFTP_CONNECT_TIMEOUT_OPTION_NAME = "connectTimeout";
|
||||
private static final String SFTP_KEYNAME_OPTION_NAME = "key";
|
||||
private static final String SFTP_KEYPASSPHRASE_OPTION_NAME = "phrase";
|
||||
|
||||
public static final String SSH_CFG_KEX = "kex";
|
||||
public static final String SSH_CFG_SERVER_HOST_KEY = "server_host_key";
|
||||
private static final Set<String> SSH_CFG_CSV_EXPANDABLE = Set.of(SSH_CFG_KEX, SSH_CFG_SERVER_HOST_KEY);
|
||||
private static final ValueResolver<Integer> cTimeoutResolver = c ->
|
||||
c == null || c == UNSET_SFTP_CONNECT_TIMEOUT ? null : String.valueOf(c);
|
||||
|
||||
private static final ValueResolver<String> nonBlankStringResolver = s ->
|
||||
s == null || s.isBlank() ? null : s;
|
||||
|
||||
private static final String TAG = "KP2AJFS";
|
||||
private static final String THREAD_TAG = TAG + "[thread]";
|
||||
private JSch jsch;
|
||||
|
||||
public class ConnectionInfo
|
||||
{
|
||||
@@ -37,13 +69,42 @@ public class SftpStorage extends JavaFileStorageBase {
|
||||
public String username;
|
||||
public String password;
|
||||
public String localPath;
|
||||
public String keyName;
|
||||
public String keyPassphrase;
|
||||
public int port;
|
||||
public int connectTimeoutSec = UNSET_SFTP_CONNECT_TIMEOUT;
|
||||
public final Map<String, String> configOpts = new HashMap<>();
|
||||
|
||||
|
||||
public String toString() {
|
||||
return "ConnectionInfo{host=" + host + ",port=" + port + ",user=" + username +
|
||||
",pwd=<hidden>,localPath=" + localPath + ",key=" + keyName +
|
||||
",phrase=<hidden>,connectTimeout=" + connectTimeoutSec +
|
||||
",cfgOpts=" + configOpts +
|
||||
"}";
|
||||
}
|
||||
}
|
||||
|
||||
private static Map<String, String> buildOptionMap(ConnectionInfo ci, boolean includeSensitive) {
|
||||
OptionMapBuilder b = new OptionMapBuilder()
|
||||
.addOption(SFTP_CONNECT_TIMEOUT_OPTION_NAME, ci.connectTimeoutSec, cTimeoutResolver)
|
||||
.addOption(SFTP_KEYNAME_OPTION_NAME, ci.keyName, nonBlankStringResolver);
|
||||
// Assume all config options are not sensitive and use the same resolver...
|
||||
for (Map.Entry<String, String> entry : ci.configOpts.entrySet()) {
|
||||
b.addOption(entry.getKey(), entry.getValue(), nonBlankStringResolver);
|
||||
}
|
||||
if (includeSensitive) {
|
||||
b.addOption(SFTP_KEYPASSPHRASE_OPTION_NAME, ci.keyPassphrase, nonBlankStringResolver);
|
||||
}
|
||||
return b.build();
|
||||
}
|
||||
|
||||
Context _appContext;
|
||||
private final SftpPublicPrivateKeyUtils _keyUtils;
|
||||
|
||||
public SftpStorage(Context appContext) {
|
||||
_appContext = appContext;
|
||||
|
||||
_keyUtils = new SftpPublicPrivateKeyUtils(getBaseDir());
|
||||
}
|
||||
|
||||
private static final String SFTP_PROTOCOL_ID = "sftp";
|
||||
@@ -65,15 +126,15 @@ public class SftpStorage extends JavaFileStorageBase {
|
||||
|
||||
@Override
|
||||
public InputStream openFileForRead(String path) throws Exception {
|
||||
|
||||
ChannelSftp c = init(path);
|
||||
ConnectionInfo cInfo = splitStringToConnectionInfo(path);
|
||||
ChannelSftp c = init(cInfo);
|
||||
|
||||
try {
|
||||
byte[] buff = new byte[8000];
|
||||
|
||||
int bytesRead = 0;
|
||||
|
||||
InputStream in = c.get(extractSessionPath(path));
|
||||
InputStream in = c.get(cInfo.localPath);
|
||||
ByteArrayOutputStream bao = new ByteArrayOutputStream();
|
||||
|
||||
while ((bytesRead = in.read(buff)) != -1) {
|
||||
@@ -105,14 +166,15 @@ public class SftpStorage extends JavaFileStorageBase {
|
||||
public void uploadFile(String path, byte[] data, boolean writeTransactional)
|
||||
throws Exception {
|
||||
|
||||
ChannelSftp c = init(path);
|
||||
ConnectionInfo cInfo = splitStringToConnectionInfo(path);
|
||||
ChannelSftp c = init(cInfo);
|
||||
try {
|
||||
InputStream in = new ByteArrayInputStream(data);
|
||||
String targetPath = extractSessionPath(path);
|
||||
String targetPath = cInfo.localPath;
|
||||
if (writeTransactional)
|
||||
{
|
||||
//upload to temporary location:
|
||||
String tmpPath = targetPath+".tmp";
|
||||
String tmpPath = targetPath + ".tmp";
|
||||
c.put(in, tmpPath);
|
||||
//remove previous file:
|
||||
try
|
||||
@@ -128,9 +190,9 @@ public class SftpStorage extends JavaFileStorageBase {
|
||||
}
|
||||
else
|
||||
{
|
||||
c.put(in, targetPath);
|
||||
c.put(in, targetPath);
|
||||
}
|
||||
|
||||
|
||||
tryDisconnect(c);
|
||||
} catch (Exception e) {
|
||||
tryDisconnect(c);
|
||||
@@ -142,53 +204,98 @@ public class SftpStorage extends JavaFileStorageBase {
|
||||
@Override
|
||||
public String createFolder(String parentPath, String newDirName)
|
||||
throws Exception {
|
||||
|
||||
ConnectionInfo cInfo = splitStringToConnectionInfo(parentPath);
|
||||
try {
|
||||
ChannelSftp c = init(parentPath);
|
||||
String newPath = concatPaths(parentPath, newDirName);
|
||||
c.mkdir(extractSessionPath(newPath));
|
||||
ChannelSftp c = init(cInfo);
|
||||
String newPath = concatPaths(cInfo.localPath, newDirName);
|
||||
c.mkdir(newPath);
|
||||
tryDisconnect(c);
|
||||
return newPath;
|
||||
|
||||
return buildFullPath(cInfo.host, cInfo.port, newPath,
|
||||
cInfo.username, cInfo.password, cInfo.connectTimeoutSec,
|
||||
cInfo.keyName, cInfo.keyPassphrase,
|
||||
cInfo.configOpts.get(SSH_CFG_KEX),
|
||||
cInfo.configOpts.get(SSH_CFG_SERVER_HOST_KEY));
|
||||
} catch (Exception e) {
|
||||
throw convertException(e);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private String extractUserPwdHostPort(String path) {
|
||||
String withoutProtocol = path
|
||||
.substring(getProtocolPrefix().length());
|
||||
return withoutProtocol.substring(0, withoutProtocol.indexOf("/"));
|
||||
}
|
||||
|
||||
private String extractSessionPath(String newPath) {
|
||||
String withoutProtocol = newPath
|
||||
.substring(getProtocolPrefix().length());
|
||||
return withoutProtocol.substring(withoutProtocol.indexOf("/"));
|
||||
int pathStartIdx = withoutProtocol.indexOf("/");
|
||||
int pathEndIdx = withoutProtocol.indexOf("?");
|
||||
if (pathEndIdx < 0) {
|
||||
pathEndIdx = withoutProtocol.length();
|
||||
}
|
||||
return withoutProtocol.substring(pathStartIdx, pathEndIdx);
|
||||
}
|
||||
|
||||
private String extractUserPwdHost(String path) {
|
||||
|
||||
private Map<String, String> extractOptionsMap(String path) throws UnsupportedEncodingException {
|
||||
String withoutProtocol = path
|
||||
.substring(getProtocolPrefix().length());
|
||||
return withoutProtocol.substring(0,withoutProtocol.indexOf("/"));
|
||||
|
||||
Map<String, String> options = new HashMap<>();
|
||||
|
||||
int extraOptsIdx = withoutProtocol.indexOf("?");
|
||||
if (extraOptsIdx > 0 && extraOptsIdx + 1 < withoutProtocol.length()) {
|
||||
String optsString = withoutProtocol.substring(extraOptsIdx + 1);
|
||||
String[] parts = optsString.split("&");
|
||||
for (String p : parts) {
|
||||
int sepIdx = p.indexOf('=');
|
||||
if (sepIdx > 0) {
|
||||
String key = decode(p.substring(0, sepIdx));
|
||||
String value = decode(p.substring(sepIdx + 1));
|
||||
options.put(key, value);
|
||||
} else {
|
||||
options.put(decode(p), "true");
|
||||
}
|
||||
}
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
private String concatPaths(String parentPath, String newDirName) {
|
||||
String res = parentPath;
|
||||
if (!res.endsWith("/"))
|
||||
res += "/";
|
||||
res += newDirName;
|
||||
return res;
|
||||
StringBuilder fp = new StringBuilder(parentPath);
|
||||
if (!parentPath.endsWith("/"))
|
||||
fp.append("/");
|
||||
return fp.append(newDirName).toString();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String createFilePath(String parentPath, String newFileName)
|
||||
public String createFilePath(final String parentUri, String newFileName)
|
||||
throws Exception {
|
||||
if (parentPath.endsWith("/") == false)
|
||||
parentPath += "/";
|
||||
return parentPath + newFileName;
|
||||
|
||||
String parentPath = parentUri;
|
||||
String params = null;
|
||||
int paramsIdx = parentUri.lastIndexOf("?");
|
||||
if (paramsIdx > 0) {
|
||||
params = parentUri.substring(paramsIdx);
|
||||
parentPath = parentPath.substring(0, paramsIdx);
|
||||
}
|
||||
|
||||
String newPath = concatPaths(parentPath, newFileName);
|
||||
|
||||
if (params != null) {
|
||||
newPath += params;
|
||||
}
|
||||
return newPath;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<FileEntry> listFiles(String parentPath) throws Exception {
|
||||
ConnectionInfo cInfo = splitStringToConnectionInfo(parentPath);
|
||||
ChannelSftp c = init(cInfo);
|
||||
|
||||
ChannelSftp c = init(parentPath);
|
||||
return listFiles(parentPath, c);
|
||||
|
||||
}
|
||||
|
||||
private void setFromAttrs(FileEntry fileEntry, SftpATTRS attrs) {
|
||||
@@ -212,23 +319,27 @@ public class SftpStorage extends JavaFileStorageBase {
|
||||
if (sftpEx.id == ChannelSftp.SSH_FX_NO_SUCH_FILE)
|
||||
return new FileNotFoundException(sftpEx.getMessage());
|
||||
}
|
||||
|
||||
|
||||
return e;
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public FileEntry getFileEntry(String filename) throws Exception {
|
||||
|
||||
ChannelSftp c = init(filename);
|
||||
ConnectionInfo cInfo = splitStringToConnectionInfo(filename);
|
||||
ChannelSftp c = init(cInfo);
|
||||
try {
|
||||
FileEntry fileEntry = new FileEntry();
|
||||
String sessionPath = extractSessionPath(filename);
|
||||
SftpATTRS attr = c.stat(sessionPath);
|
||||
SftpATTRS attr = c.stat(cInfo.localPath);
|
||||
setFromAttrs(fileEntry, attr);
|
||||
|
||||
// Full URI
|
||||
fileEntry.path = filename;
|
||||
fileEntry.displayName = getFilename(sessionPath);
|
||||
|
||||
fileEntry.displayName = getFilename(cInfo.localPath);
|
||||
|
||||
tryDisconnect(c);
|
||||
|
||||
return fileEntry;
|
||||
} catch (Exception e) {
|
||||
logDebug("Exception in getFileEntry! " + e);
|
||||
@@ -239,8 +350,9 @@ public class SftpStorage extends JavaFileStorageBase {
|
||||
|
||||
@Override
|
||||
public void delete(String path) throws Exception {
|
||||
ConnectionInfo cInfo = splitStringToConnectionInfo(path);
|
||||
ChannelSftp c = init(cInfo);
|
||||
|
||||
ChannelSftp c = init(path);
|
||||
delete(path, c);
|
||||
}
|
||||
|
||||
@@ -264,10 +376,11 @@ public class SftpStorage extends JavaFileStorageBase {
|
||||
tryDisconnect(c);
|
||||
throw convertException(e);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
private List<FileEntry> listFiles(String path, ChannelSftp c) throws Exception {
|
||||
|
||||
try {
|
||||
List<FileEntry> res = new ArrayList<FileEntry>();
|
||||
@SuppressWarnings("rawtypes")
|
||||
@@ -283,7 +396,7 @@ public class SftpStorage extends JavaFileStorageBase {
|
||||
||(lsEntry.getFilename().equals(".."))
|
||||
)
|
||||
continue;
|
||||
|
||||
|
||||
FileEntry fileEntry = new FileEntry();
|
||||
fileEntry.displayName = lsEntry.getFilename();
|
||||
fileEntry.path = createFilePath(path, fileEntry.displayName);
|
||||
@@ -313,100 +426,192 @@ public class SftpStorage extends JavaFileStorageBase {
|
||||
throws UnsupportedEncodingException {
|
||||
return java.net.URLDecoder.decode(encodedString, UTF_8);
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
protected String encode(final String unencoded)
|
||||
throws UnsupportedEncodingException {
|
||||
return java.net.URLEncoder.encode(unencoded, UTF_8);
|
||||
}
|
||||
|
||||
ChannelSftp init(String filename) throws JSchException, UnsupportedEncodingException {
|
||||
jsch = new JSch();
|
||||
ConnectionInfo ci = splitStringToConnectionInfo(filename);
|
||||
|
||||
Log.d("KP2AJFS", "init SFTP");
|
||||
|
||||
ChannelSftp init(ConnectionInfo cInfo) throws JSchException, UnsupportedEncodingException {
|
||||
jsch = new JSch();
|
||||
|
||||
Log.d(TAG, "init SFTP");
|
||||
|
||||
String base_dir = getBaseDir();
|
||||
jsch.setKnownHosts(base_dir + "/known_hosts");
|
||||
|
||||
String key_filename = getKeyFileName();
|
||||
try{
|
||||
createKeyPair(key_filename);
|
||||
} catch (Exception ex) {
|
||||
System.out.println(ex);
|
||||
}
|
||||
String key_filepath = _keyUtils.resolveKeyFilePath(jsch, cInfo.keyName);
|
||||
|
||||
try {
|
||||
jsch.addIdentity(key_filename);
|
||||
} catch (java.lang.Exception e)
|
||||
{
|
||||
jsch.addIdentity(key_filepath);
|
||||
} catch (java.lang.Exception e) {
|
||||
|
||||
}
|
||||
|
||||
Log.e("KP2AJFS[thread]", "getting session...");
|
||||
Session session = jsch.getSession(ci.username, ci.host, ci.port);
|
||||
Log.e("KP2AJFS", "creating SftpUserInfo");
|
||||
UserInfo ui = new SftpUserInfo(ci.password,_appContext);
|
||||
session.setUserInfo(ui);
|
||||
Log.e(THREAD_TAG, "getting session...");
|
||||
Session session = jsch.getSession(cInfo.username, cInfo.host, cInfo.port);
|
||||
|
||||
session.setConfig("PreferredAuthentications", "publickey,password");
|
||||
|
||||
session.connect();
|
||||
sessionConfigure(session, cInfo);
|
||||
sessionConnect(session, cInfo);
|
||||
|
||||
Channel channel = session.openChannel("sftp");
|
||||
channel.connect();
|
||||
ChannelSftp c = (ChannelSftp) channel;
|
||||
|
||||
logDebug("success: init Sftp");
|
||||
return c;
|
||||
|
||||
}
|
||||
|
||||
private void sessionConnect(Session session, ConnectionInfo cInfo) throws JSchException {
|
||||
if (cInfo.connectTimeoutSec != UNSET_SFTP_CONNECT_TIMEOUT) {
|
||||
session.connect(cInfo.connectTimeoutSec * 1000);
|
||||
} else {
|
||||
session.connect();
|
||||
}
|
||||
}
|
||||
|
||||
private void sessionConfigure(Session session, ConnectionInfo cInfo) {
|
||||
Log.e(TAG, "creating SftpUserInfo");
|
||||
UserInfo ui = new SftpUserInfo(cInfo.password, cInfo.keyPassphrase, _appContext);
|
||||
session.setUserInfo(ui);
|
||||
|
||||
session.setConfig("PreferredAuthentications", "publickey,password");
|
||||
|
||||
for (Map.Entry<String, String> e : cInfo.configOpts.entrySet()) {
|
||||
String cfgKey = e.getKey();
|
||||
String before = session.getConfig(cfgKey);
|
||||
String after = e.getValue();
|
||||
|
||||
if (SSH_CFG_CSV_EXPANDABLE.contains(cfgKey)) {
|
||||
SshConfigCsvValueResolver resolver = new SshConfigCsvValueResolver(cfgKey, after);
|
||||
after = resolver.resolve(before);
|
||||
}
|
||||
session.setConfig(cfgKey, after);
|
||||
}
|
||||
}
|
||||
|
||||
private String getBaseDir() {
|
||||
return _appContext.getFilesDir().getAbsolutePath();
|
||||
}
|
||||
|
||||
private String getKeyFileName() {
|
||||
return getBaseDir() + "/id_kp2a_rsa";
|
||||
public boolean deleteCustomKey(String keyName) throws FileNotFoundException {
|
||||
return _keyUtils.deleteCustomKey(keyName);
|
||||
}
|
||||
|
||||
public String[] getCustomKeyNames() {
|
||||
return _keyUtils.getCustomKeyNames();
|
||||
}
|
||||
|
||||
@SuppressWarnings("unused") // Exposed by JavaFileStorageBindings
|
||||
public String createKeyPair() throws IOException, JSchException {
|
||||
return createKeyPair(getKeyFileName());
|
||||
|
||||
return _keyUtils.createKeyPair(jsch);
|
||||
}
|
||||
|
||||
private String createKeyPair(String key_filename) throws JSchException, IOException {
|
||||
String public_key_filename = key_filename + ".pub";
|
||||
File file = new File(key_filename);
|
||||
if (file.exists())
|
||||
return public_key_filename;
|
||||
int type = KeyPair.RSA;
|
||||
KeyPair kpair = KeyPair.genKeyPair(jsch, type, 4096);
|
||||
kpair.writePrivateKey(key_filename);
|
||||
@SuppressWarnings("unused") // Exposed by JavaFileStorageBindings
|
||||
public void savePrivateKeyContent(String keyName, String keyContent) throws IOException, Exception {
|
||||
_keyUtils.savePrivateKeyContent(keyName, keyContent);
|
||||
}
|
||||
|
||||
kpair.writePublicKey(public_key_filename, "generated by Keepass2Android");
|
||||
//ret = "Fingerprint: " + kpair.getFingerPrint();
|
||||
kpair.dispose();
|
||||
return public_key_filename;
|
||||
@SuppressWarnings("unused") // Exposed by JavaFileStorageBindings
|
||||
public void setJschLogging(boolean enabled, String logFilename) {
|
||||
Logger impl = null;
|
||||
if (enabled) {
|
||||
if (logFilename != null) {
|
||||
impl = Kp2aJSchLogger.createFileLogger(logFilename);
|
||||
} else {
|
||||
impl = Kp2aJSchLogger.createAndroidLogger();
|
||||
}
|
||||
}
|
||||
JSch.setLogger(impl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Exposed for testing purposes only.
|
||||
* @param keyName
|
||||
* @return
|
||||
*/
|
||||
public String sanitizeCustomKeyName(String keyName) {
|
||||
return _keyUtils.getSanitizedCustomKeyName(keyName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Exposed for testing purposes only.
|
||||
* @param keyContent
|
||||
* @return
|
||||
* @throws Exception
|
||||
*/
|
||||
public String getValidatedCustomKeyContent(String keyContent) throws Exception {
|
||||
return _keyUtils.getValidatedCustomKeyContent(keyContent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Exposed for testing purposes only.
|
||||
* @param currentValues
|
||||
* @param spec
|
||||
* @return
|
||||
* @throws Exception
|
||||
*/
|
||||
public String resolveCsvValues(String currentValues, String spec) {
|
||||
return new SshConfigCsvValueResolver("test", spec)
|
||||
.resolve(currentValues);
|
||||
}
|
||||
|
||||
public ConnectionInfo splitStringToConnectionInfo(String filename)
|
||||
throws UnsupportedEncodingException {
|
||||
|
||||
ConnectionInfo ci = new ConnectionInfo();
|
||||
ci.host = extractUserPwdHost(filename);
|
||||
ci.host = extractUserPwdHostPort(filename);
|
||||
|
||||
String userPwd = ci.host.substring(0, ci.host.indexOf('@'));
|
||||
ci.username = decode(userPwd.substring(0, userPwd.indexOf(":")));
|
||||
ci.password = decode(userPwd.substring(userPwd.indexOf(":")+1));
|
||||
int sepIdx = userPwd.indexOf(":");
|
||||
if (sepIdx > 0) {
|
||||
ci.username = decode(userPwd.substring(0, sepIdx));
|
||||
ci.password = decode(userPwd.substring(sepIdx + 1));
|
||||
} else {
|
||||
ci.username = userPwd;
|
||||
ci.password = null;
|
||||
}
|
||||
|
||||
ci.host = ci.host.substring(ci.host.indexOf('@') + 1);
|
||||
ci.port = DEFAULT_SFTP_PORT;
|
||||
int portSeparatorIndex = ci.host.indexOf(":");
|
||||
|
||||
int portSeparatorIndex = ci.host.lastIndexOf(':');
|
||||
if (portSeparatorIndex >= 0)
|
||||
{
|
||||
ci.port = Integer.parseInt(ci.host.substring(portSeparatorIndex+1));
|
||||
ci.port = Integer.parseInt(ci.host.substring(portSeparatorIndex + 1));
|
||||
ci.host = ci.host.substring(0, portSeparatorIndex);
|
||||
}
|
||||
// Encode/decode required to support IPv6 (colons break host:port parse logic)
|
||||
// See Bug #2350
|
||||
ci.host = decode(ci.host);
|
||||
|
||||
ci.localPath = extractSessionPath(filename);
|
||||
|
||||
Map<String, String> options = extractOptionsMap(filename);
|
||||
|
||||
if (options.containsKey(SFTP_CONNECT_TIMEOUT_OPTION_NAME)) {
|
||||
String optVal = options.get(SFTP_CONNECT_TIMEOUT_OPTION_NAME);
|
||||
try {
|
||||
ci.connectTimeoutSec = Integer.parseInt(optVal);
|
||||
} catch (NumberFormatException nan) {
|
||||
logDebug(SFTP_CONNECT_TIMEOUT_OPTION_NAME + " option not a number: " + optVal);
|
||||
}
|
||||
}
|
||||
if (options.containsKey(SFTP_KEYNAME_OPTION_NAME)) {
|
||||
ci.keyName = options.get(SFTP_KEYNAME_OPTION_NAME);
|
||||
}
|
||||
if (options.containsKey(SFTP_KEYPASSPHRASE_OPTION_NAME)) {
|
||||
ci.keyPassphrase = options.get(SFTP_KEYPASSPHRASE_OPTION_NAME);
|
||||
}
|
||||
|
||||
for (String cfgKey : SSH_CFG_CSV_EXPANDABLE) {
|
||||
if (options.containsKey(cfgKey)) {
|
||||
ci.configOpts.put(cfgKey, options.get(cfgKey));
|
||||
}
|
||||
}
|
||||
|
||||
return ci;
|
||||
}
|
||||
|
||||
@@ -443,12 +648,18 @@ public class SftpStorage extends JavaFileStorageBase {
|
||||
try
|
||||
{
|
||||
ConnectionInfo ci = splitStringToConnectionInfo(path);
|
||||
return getProtocolPrefix()+ci.username+"@"+ci.host+ci.localPath;
|
||||
StringBuilder dName = new StringBuilder(getProtocolPrefix())
|
||||
.append(ci.username)
|
||||
.append("@")
|
||||
.append(ci.host)
|
||||
.append(ci.localPath);
|
||||
appendOptions(dName, buildOptionMap(ci, false));
|
||||
return dName.toString();
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
return extractSessionPath(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -470,22 +681,105 @@ public class SftpStorage extends JavaFileStorageBase {
|
||||
@Override
|
||||
public void onActivityResult(FileStorageSetupActivity activity,
|
||||
int requestCode, int resultCode, Intent data) {
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
public String buildFullPath( String host, int port, String localPath, String username, String password) throws UnsupportedEncodingException
|
||||
{
|
||||
if (port != DEFAULT_SFTP_PORT)
|
||||
host += ":"+String.valueOf(port);
|
||||
return getProtocolPrefix()+encode(username)+":"+encode(password)+"@"+host+localPath;
|
||||
|
||||
public String buildFullPath(String host, int port, String localPath,
|
||||
String username, String password,
|
||||
int connectTimeoutSec,
|
||||
String keyName, String keyPassphrase,
|
||||
String kexAlgorithms, String shkAlgorithms)
|
||||
throws UnsupportedEncodingException {
|
||||
|
||||
StringBuilder uri = new StringBuilder(getProtocolPrefix()).append(encode(username));
|
||||
if (password != null) {
|
||||
uri.append(":").append(encode(password));
|
||||
}
|
||||
uri.append("@");
|
||||
// Encode/decode required to support IPv6 (colons break host:port parse logic)
|
||||
// See Bug #2350
|
||||
uri.append(encode(host));
|
||||
|
||||
if (port != DEFAULT_SFTP_PORT) {
|
||||
uri.append(":").append(port);
|
||||
}
|
||||
if (localPath != null && localPath.startsWith("/")) {
|
||||
uri.append(localPath);
|
||||
}
|
||||
|
||||
appendOptions(uri, new OptionMapBuilder()
|
||||
.addOption(SFTP_CONNECT_TIMEOUT_OPTION_NAME, connectTimeoutSec, cTimeoutResolver)
|
||||
.addOption(SFTP_KEYNAME_OPTION_NAME, keyName, nonBlankStringResolver)
|
||||
.addOption(SFTP_KEYPASSPHRASE_OPTION_NAME, keyPassphrase, nonBlankStringResolver)
|
||||
.addOption(SSH_CFG_KEX, kexAlgorithms, nonBlankStringResolver)
|
||||
.addOption(SSH_CFG_SERVER_HOST_KEY, shkAlgorithms, nonBlankStringResolver)
|
||||
.build());
|
||||
|
||||
return uri.toString();
|
||||
}
|
||||
|
||||
private void appendOptions(StringBuilder uri, Map<String, String> opts)
|
||||
throws UnsupportedEncodingException {
|
||||
|
||||
boolean first = true;
|
||||
// Sort for stability/consistency
|
||||
Set<Map.Entry<String, String>> sortedEntries = new TreeSet<>(new EntryComparator<>());
|
||||
sortedEntries.addAll(opts.entrySet());
|
||||
for (Map.Entry<String, String> me : sortedEntries) {
|
||||
if (first) {
|
||||
uri.append("?");
|
||||
first = false;
|
||||
} else {
|
||||
uri.append("&");
|
||||
}
|
||||
uri.append(encode(me.getKey())).append("=").append(encode(me.getValue()));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public void prepareFileUsage(Context appContext, String path) {
|
||||
//nothing to do
|
||||
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* A comparator that compares Map.Entry objects by their keys, via natural ordering.
|
||||
*
|
||||
* @param <T> the Map.Entry key type, that must implement Comparable.
|
||||
*/
|
||||
private static class EntryComparator<T extends Comparable<T>> implements Comparator<Map.Entry<T, ?>> {
|
||||
@Override
|
||||
public int compare(Map.Entry<T, ?> o1, Map.Entry<T, ?> o2) {
|
||||
return o1.getKey().compareTo(o2.getKey());
|
||||
}
|
||||
}
|
||||
|
||||
private static class OptionMapBuilder {
|
||||
private final Map<String, String> options = new HashMap<>();
|
||||
|
||||
/**
|
||||
* Attempts to add a raw value <code>oVal</code> to the underlying option map with key <code>oName</code>
|
||||
* iff the <code>resolver</code> produces a non-null output when invoked using the raw value.
|
||||
*
|
||||
* @param oName the name/key associated with the value, if added
|
||||
* @param oVal the raw value attempting to be added
|
||||
* @param resolver the resolver that determines if the value will be added
|
||||
*
|
||||
* @return OptionMapBuilder (updated)
|
||||
* @param <T> the raw value type
|
||||
*/
|
||||
<T> OptionMapBuilder addOption(final String oName, T oVal, ValueResolver<T> resolver) {
|
||||
String resolved = resolver.resolve(oVal);
|
||||
if (resolved != null) {
|
||||
options.put(oName, resolved);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
Map<String, String> build() {
|
||||
return new HashMap<>(options);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,17 +116,19 @@ public class SftpUserInfo implements UserInfo {
|
||||
Context _appContext;
|
||||
|
||||
String _password;
|
||||
String _passphrase;
|
||||
|
||||
public SftpUserInfo(String password, Context appContext)
|
||||
public SftpUserInfo(String password, String passphrase, Context appContext)
|
||||
{
|
||||
_password = password;
|
||||
_passphrase = passphrase;
|
||||
_appContext = appContext;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getPassphrase() {
|
||||
|
||||
return null;
|
||||
|
||||
return _passphrase;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -137,12 +139,12 @@ public class SftpUserInfo implements UserInfo {
|
||||
|
||||
@Override
|
||||
public boolean promptPassword(String message) {
|
||||
return true;
|
||||
return _password != null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean promptPassphrase(String message) {
|
||||
return false; //passphrase not supported
|
||||
return _passphrase != null;
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
package keepass2android.javafilestorage;
|
||||
|
||||
import android.util.Log;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* A class that manipulates CSV String values based on a list of CSV "spec" definitions, where each definition
|
||||
* can describe one of the following:
|
||||
*
|
||||
* - Prepend to existing list: +something
|
||||
* - Append to end of existing list: something+
|
||||
* - Remove a specific value: -something
|
||||
* - Remove values matching prefix: -something*
|
||||
* - Remove values matching suffix: -*something
|
||||
* - Remove values matching substring: -*something*
|
||||
* - Remove values matching prefix and suffix: -some*thing
|
||||
*
|
||||
* Otherwise CSV of values completely replace original config values
|
||||
*
|
||||
* Examples:
|
||||
* <code>
|
||||
* var r = new SshConfigCsvValueResolver("foo", "addToEnd+,-remove*,+addToBeginning,-*del*");
|
||||
* r.resolve("one,removeTwo,three,removeThree,four") --> "addToBeginning,one,three,four,addToEnd"
|
||||
* r.resolve("one,my-del,del-me,two,foodelbar,three") --> "addToBeginning,one,two,three,addToEnd"
|
||||
*
|
||||
* r = new SshConfigCsvValueResolver("foo", "replace,the,config");
|
||||
* r.resolve("one,two,three,four") --> "replace,the,config"
|
||||
* </code>
|
||||
*
|
||||
*/
|
||||
class SshConfigCsvValueResolver {
|
||||
interface Matcher {
|
||||
boolean matches(String s);
|
||||
}
|
||||
private final String cfgKey;
|
||||
private static final String TAG = "KP2AJFS[sshcfg]";
|
||||
|
||||
private static final String DELIM = ",";
|
||||
private static final char ADD = '+';
|
||||
private static final char REMOVE = '-';
|
||||
private static final char WILD = '*';
|
||||
private final List<String> prepends;
|
||||
private final List<String> appends;
|
||||
private final List<Matcher> removes;
|
||||
private final List<String> replaces;
|
||||
|
||||
/**
|
||||
* Creates a new resolver.
|
||||
*
|
||||
* @param cfgKey - configuration key name (used for logging)
|
||||
* @param incomingSpec - A CSV String of "spec" definitions that will be used to
|
||||
* (potentially) modify incoming CSV String values.
|
||||
*/
|
||||
SshConfigCsvValueResolver(String cfgKey, String incomingSpec) {
|
||||
List<String> prepends = new ArrayList<>();
|
||||
List<String> appends = new ArrayList<>();
|
||||
List<Matcher> removes = new ArrayList<>();
|
||||
List<String> replaces = new ArrayList<>();
|
||||
|
||||
for (String iVal : incomingSpec.split(DELIM)) {
|
||||
if (iVal.isBlank()) {
|
||||
continue;
|
||||
}
|
||||
int evLen = iVal.length();
|
||||
if (iVal.charAt(0) == ADD && evLen > 1) {
|
||||
prepends.add(iVal.substring(1));
|
||||
} else if (iVal.charAt(iVal.length() - 1) == ADD && evLen > 1) {
|
||||
appends.add(iVal.substring(0, evLen - 1));
|
||||
} else if (iVal.charAt(iVal.length() - 1) == REMOVE && evLen > 1) {
|
||||
removes.add(createMatcher(iVal.substring(1)));
|
||||
} else {
|
||||
// This looks like a straight replace
|
||||
replaces.add(iVal);
|
||||
}
|
||||
}
|
||||
this.cfgKey = cfgKey;
|
||||
this.prepends = Collections.unmodifiableList(prepends);
|
||||
this.appends = Collections.unmodifiableList(appends);
|
||||
this.removes = Collections.unmodifiableList(removes);
|
||||
this.replaces = Collections.unmodifiableList(replaces);
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes a CSV String and (potentially) modifies it according to the "spec" entries of this resolver.
|
||||
*
|
||||
* @param existingValues - the original CSV String
|
||||
* @return an updated representation of <code>existingValues</code>, based on the defined "spec"
|
||||
* entries of this resolver.
|
||||
*/
|
||||
public String resolve(String existingValues) {
|
||||
List<String> newValues;
|
||||
// If there's even one replace, it wins over everything and the rest is thrown out
|
||||
if (!replaces.isEmpty()) {
|
||||
if (!(prepends.isEmpty() || appends.isEmpty() || removes.isEmpty())) {
|
||||
Log.w(TAG, "Discarded SSH cfg parts: key=" + cfgKey +
|
||||
", prepends=" + prepends + ", appends=" + appends +
|
||||
", removes=" + removes);
|
||||
}
|
||||
newValues = replaces;
|
||||
} else {
|
||||
// Otherwise we rebuild from existing and incoming values
|
||||
newValues = createResolvedValues(existingValues);
|
||||
}
|
||||
return String.join(DELIM, newValues);
|
||||
}
|
||||
|
||||
private List<String> createResolvedValues(String existingValues) {
|
||||
List<String> newValues = new ArrayList<>(prepends);
|
||||
for (String a : existingValues.split(DELIM)) {
|
||||
if (!shouldRemove(a)) {
|
||||
newValues.add(a);
|
||||
}
|
||||
}
|
||||
newValues.addAll(appends);
|
||||
return newValues;
|
||||
}
|
||||
|
||||
private boolean shouldRemove(String s) {
|
||||
s = normalize(s);
|
||||
for (Matcher m : removes) {
|
||||
if (m.matches(s)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private Matcher createMatcher(String val) {
|
||||
final String v = normalize(val);
|
||||
Matcher impl = s -> v.equals(s);
|
||||
|
||||
int wildcardIdx = v.indexOf(WILD);
|
||||
if (wildcardIdx < 0) {
|
||||
return impl;
|
||||
}
|
||||
|
||||
// *blah *blah* blah* some*thing
|
||||
// endsWith substring startsWith startsWith && endsWith
|
||||
String subStr = null;
|
||||
String suffix = null;
|
||||
String prefix = null;
|
||||
int vLen = v.length();
|
||||
|
||||
if (v.charAt(0) == WILD && vLen > 1) {
|
||||
if (vLen > 2 && v.charAt(vLen - 1) == WILD) {
|
||||
//substring
|
||||
subStr = v.substring(1, vLen - 1);
|
||||
} else {
|
||||
// endsWith
|
||||
suffix = v.substring(1);
|
||||
}
|
||||
} else if (v.charAt(vLen - 1) == WILD && vLen > 1) {
|
||||
// beginsWith
|
||||
prefix = v.substring(0, v.length() - 1);
|
||||
} else if (wildcardIdx > 0) {
|
||||
// startsWith && endsWith
|
||||
prefix = v.substring(0, wildcardIdx);
|
||||
suffix = v.substring(wildcardIdx + 1);
|
||||
}
|
||||
|
||||
if (subStr != null) {
|
||||
final String sub = subStr;
|
||||
impl = s -> s.contains(sub);
|
||||
} else if (prefix != null || suffix != null) {
|
||||
final String pre = prefix;
|
||||
final String suf = suffix;
|
||||
impl = s -> (pre == null || s.startsWith(pre)) && (suf == null || s.endsWith(suf));
|
||||
}
|
||||
return impl;
|
||||
}
|
||||
|
||||
private static String normalize(String s) {
|
||||
return s == null ? null : s.toLowerCase();
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 9.2 KiB After Width: | Height: | Size: 7.2 KiB |
|
Before Width: | Height: | Size: 5.1 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 11 KiB |
@@ -538,12 +538,12 @@ public class MainActivity extends Activity implements JavaFileStorage.FileStorag
|
||||
}
|
||||
|
||||
static JavaFileStorage createStorageToTest(Context ctx, Context appContext, boolean simulateRestart) {
|
||||
//storageToTest = new SftpStorage(ctx.getApplicationContext());
|
||||
storageToTest = new SftpStorage(ctx.getApplicationContext());
|
||||
//storageToTest = new PCloudFileStorage(ctx, "yCeH59Ffgtm");
|
||||
//storageToTest = new SkyDriveFileStorage("000000004010C234", appContext);
|
||||
|
||||
|
||||
storageToTest = new GoogleDriveAppDataFileStorage();
|
||||
//storageToTest = new GoogleDriveAppDataFileStorage();
|
||||
/*storageToTest = new WebDavStorage(new ICertificateErrorHandler() {
|
||||
@Override
|
||||
public boolean onValidationError(String error) {
|
||||
@@ -690,6 +690,13 @@ public class MainActivity extends Activity implements JavaFileStorage.FileStorag
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private void populateCsvMockValues(View view) {
|
||||
EditText etSpecs = view.findViewById(R.id.mock_csv_specs);
|
||||
etSpecs.setText("-bar,+first,-*d*");
|
||||
EditText etCfgs = view.findViewById(R.id.mock_csv_cfg);
|
||||
etCfgs.setText("foo,del1,bar,del2");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void performManualFileSelect(boolean isForSave, final int requestCode,
|
||||
String protocolId)
|
||||
@@ -697,12 +704,13 @@ public class MainActivity extends Activity implements JavaFileStorage.FileStorag
|
||||
if (protocolId.equals("sftp"))
|
||||
{
|
||||
final View view = getLayoutInflater().inflate(R.layout.sftp_credentials, null);
|
||||
final SftpStorage sftpStorage = (SftpStorage)storageToTest;
|
||||
|
||||
populateCsvMockValues(view);
|
||||
|
||||
view.findViewById(R.id.send_public_key).setOnClickListener(v -> {
|
||||
Intent sendIntent = new Intent();
|
||||
|
||||
|
||||
SftpStorage sftpStorage = (SftpStorage)storageToTest;
|
||||
try {
|
||||
String pub_filename = sftpStorage.createKeyPair();
|
||||
|
||||
@@ -715,39 +723,140 @@ public class MainActivity extends Activity implements JavaFileStorage.FileStorag
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Toast.makeText(this,"Failed to create key pair: " + ex.getMessage(), Toast.LENGTH_LONG);
|
||||
return;
|
||||
Toast.makeText(this,"Failed to create key pair: " + ex.getMessage(), Toast.LENGTH_LONG).show();
|
||||
}
|
||||
|
||||
|
||||
});
|
||||
|
||||
view.findViewById(R.id.list_private_keys).setOnClickListener(v -> {
|
||||
String[] keys = sftpStorage.getCustomKeyNames();
|
||||
Toast.makeText(this, "keys: " + String.join(",", keys), Toast.LENGTH_LONG).show();
|
||||
});
|
||||
|
||||
view.findViewById(R.id.add_private_key).setOnClickListener(v -> {
|
||||
EditText etKeyName = view.findViewById(R.id.private_key_name);
|
||||
String keyName = etKeyName.getText().toString();
|
||||
EditText etKeyContent = view.findViewById(R.id.private_key_content);
|
||||
String keyContent = etKeyContent.getText().toString();
|
||||
|
||||
try {
|
||||
sftpStorage.savePrivateKeyContent(keyName, keyContent);
|
||||
Toast.makeText(this, "Add successful", Toast.LENGTH_LONG).show();
|
||||
}
|
||||
catch (Exception e) {
|
||||
Toast.makeText(this, "Add failed: " + e.getMessage(), Toast.LENGTH_LONG).show();
|
||||
}
|
||||
});
|
||||
|
||||
view.findViewById(R.id.delete_private_key).setOnClickListener(v -> {
|
||||
EditText etKeyName = view.findViewById(R.id.private_key_name);
|
||||
String keyName = etKeyName.getText().toString();
|
||||
|
||||
String exMessage = null;
|
||||
boolean success = false;
|
||||
try {
|
||||
success = sftpStorage.deleteCustomKey(keyName);
|
||||
}
|
||||
catch (Exception e) {
|
||||
exMessage = e.getMessage();
|
||||
}
|
||||
StringBuilder msg = new StringBuilder("Delete ");
|
||||
msg.append(success ? "succeeded" : "FAILED");
|
||||
if (exMessage != null) {
|
||||
msg.append(" (").append(exMessage).append(")");
|
||||
}
|
||||
Toast.makeText(this, msg.toString(), Toast.LENGTH_LONG).show();
|
||||
});
|
||||
|
||||
view.findViewById(R.id.validate_private_key).setOnClickListener(v -> {
|
||||
EditText etKeyName = view.findViewById(R.id.private_key_name);
|
||||
String inKeyName = etKeyName.getText().toString();
|
||||
|
||||
if (!inKeyName.isEmpty()) {
|
||||
String keyResponse;
|
||||
try {
|
||||
keyResponse = sftpStorage.sanitizeCustomKeyName(inKeyName);
|
||||
} catch (Exception e) {
|
||||
keyResponse = "EX:" + e.getMessage();
|
||||
}
|
||||
String msg = "key: [" + inKeyName + "] -> [" + keyResponse + "]";
|
||||
Toast.makeText(this, msg, Toast.LENGTH_LONG).show();
|
||||
}
|
||||
|
||||
EditText etKeyContent = view.findViewById(R.id.private_key_content);
|
||||
String inKeyContent = etKeyContent.getText().toString();
|
||||
String msg;
|
||||
if (!inKeyContent.isEmpty()) {
|
||||
try {
|
||||
// We could print the key, but I don't it's that helpful
|
||||
sftpStorage.getValidatedCustomKeyContent(inKeyContent);
|
||||
msg = "Key content is valid";
|
||||
} catch (Exception e) {
|
||||
msg = "Invalid key content: " + e.getMessage();
|
||||
}
|
||||
Toast.makeText(this, msg, Toast.LENGTH_LONG).show();
|
||||
}
|
||||
});
|
||||
|
||||
view.findViewById(R.id.resolve_mock_csv).setOnClickListener(v -> {
|
||||
EditText etSpecs = view.findViewById(R.id.mock_csv_specs);
|
||||
String specs = etSpecs.getText().toString();
|
||||
EditText etCfg = view.findViewById(R.id.mock_csv_cfg);
|
||||
String cfg = etCfg.getText().toString();
|
||||
if (!specs.isBlank() && !cfg.isBlank()) {
|
||||
String result = sftpStorage.resolveCsvValues(cfg, specs);
|
||||
Toast.makeText(this, result, Toast.LENGTH_LONG).show();
|
||||
}
|
||||
});
|
||||
|
||||
view.findViewById(R.id.reset_mock_csv).setOnClickListener(v -> {
|
||||
populateCsvMockValues(view);
|
||||
});
|
||||
|
||||
new AlertDialog.Builder(this)
|
||||
.setView(view)
|
||||
.setTitle("Enter SFTP credentials")
|
||||
.setPositiveButton("OK",new DialogInterface.OnClickListener() {
|
||||
.setPositiveButton("OK", (dialog, which) -> {
|
||||
|
||||
@Override
|
||||
public void onClick(DialogInterface dialog, int which) {
|
||||
Toast.makeText(MainActivity.this, "Hey", Toast.LENGTH_LONG).show();
|
||||
|
||||
Toast.makeText(MainActivity.this, "Hey", Toast.LENGTH_LONG).show();
|
||||
|
||||
SftpStorage sftpStorage = (SftpStorage)storageToTest;
|
||||
try {
|
||||
EditText etHost = ((EditText)view.findViewById(R.id.sftp_host));
|
||||
String host = etHost.getText().toString();
|
||||
EditText etUser = ((EditText)view.findViewById(R.id.sftp_user));
|
||||
String user = etUser.getText().toString();
|
||||
EditText etPwd = ((EditText)view.findViewById(R.id.sftp_password));
|
||||
String pwd = etPwd.getText().toString();
|
||||
EditText etPort = ((EditText)view.findViewById(R.id.sftp_port));
|
||||
int port = Integer.parseInt(etPort.getText().toString());
|
||||
EditText etInitDir = ((EditText)view.findViewById(R.id.sftp_initial_dir));
|
||||
String initialDir = etInitDir.getText().toString();
|
||||
onReceivePathForFileSelect(requestCode, sftpStorage.buildFullPath( host, port, initialDir, user, pwd));
|
||||
} catch (UnsupportedEncodingException e) {
|
||||
// TODO Auto-generated catch block
|
||||
e.printStackTrace();
|
||||
SftpStorage sftpStorage1 = (SftpStorage)storageToTest;
|
||||
try {
|
||||
EditText etHost = view.findViewById(R.id.sftp_host);
|
||||
String host = etHost.getText().toString();
|
||||
EditText etUser = view.findViewById(R.id.sftp_user);
|
||||
String user = etUser.getText().toString();
|
||||
EditText etPwd = view.findViewById(R.id.sftp_password);
|
||||
String pwd = etPwd.getText().toString();
|
||||
EditText etPort = view.findViewById(R.id.sftp_port);
|
||||
int port = Integer.parseInt(etPort.getText().toString());
|
||||
EditText etInitDir = view.findViewById(R.id.sftp_initial_dir);
|
||||
String initialDir = etInitDir.getText().toString();
|
||||
EditText etConnectTimeout = view.findViewById(R.id.sftp_connect_timeout);
|
||||
int connectTimeout = SftpStorage.UNSET_SFTP_CONNECT_TIMEOUT;
|
||||
String ctStr = etConnectTimeout.getText().toString();
|
||||
if (!ctStr.isEmpty()) {
|
||||
try {
|
||||
int ct = Integer.parseInt(ctStr);
|
||||
if (connectTimeout != ct) {
|
||||
connectTimeout = ct;
|
||||
}
|
||||
} catch (NumberFormatException parseEx) {
|
||||
}
|
||||
}
|
||||
EditText etKeyName = view.findViewById(R.id.private_key_name);
|
||||
String keyName = etKeyName.getText().toString();
|
||||
EditText etKeyPassphrase = view.findViewById(R.id.private_key_passphrase);
|
||||
String keyPassphrase = etKeyPassphrase.getText().toString();
|
||||
EditText etKex = view.findViewById(R.id.kex);
|
||||
String kex = etKex.getText().toString();
|
||||
EditText etShk = view.findViewById(R.id.shk);
|
||||
String shk = etShk.getText().toString();
|
||||
|
||||
onReceivePathForFileSelect(requestCode, sftpStorage1.buildFullPath(
|
||||
host, port, initialDir, user, pwd, connectTimeout,
|
||||
keyName, keyPassphrase, kex, shk));
|
||||
} catch (UnsupportedEncodingException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
})
|
||||
.create()
|
||||
|
||||
@@ -3,69 +3,217 @@
|
||||
android:orientation="vertical"
|
||||
android:layout_width="fill_parent"
|
||||
android:layout_height="fill_parent"
|
||||
android:layout_margin="12dip"
|
||||
>
|
||||
<LinearLayout
|
||||
android:orientation="horizontal"
|
||||
android:layout_width="fill_parent"
|
||||
android:layout_height="wrap_content">
|
||||
<EditText
|
||||
android:layout_margin="12dip">
|
||||
<LinearLayout
|
||||
android:orientation="horizontal"
|
||||
android:layout_width="fill_parent"
|
||||
android:layout_height="wrap_content">
|
||||
<EditText
|
||||
android:id="@+id/sftp_host"
|
||||
android:layout_width="fill_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="10"
|
||||
android:singleLine="true"
|
||||
android:inputType="textNoSuggestions"
|
||||
android:text=""
|
||||
android:hint="@string/hint_sftp_host" />
|
||||
<TextView
|
||||
android:hint="@string/hint_sftp_host" />
|
||||
<TextView
|
||||
android:id="@+id/portsep"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text=":" />
|
||||
<EditText
|
||||
<EditText
|
||||
android:id="@+id/sftp_port"
|
||||
android:layout_width="fill_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="15"
|
||||
android:singleLine="true"
|
||||
android:inputType="number"
|
||||
android:text="22"
|
||||
android:hint="@string/hint_sftp_port" />
|
||||
</LinearLayout>
|
||||
<EditText
|
||||
android:inputType="number"
|
||||
android:text="22"
|
||||
android:hint="@string/hint_sftp_port" />
|
||||
<EditText
|
||||
android:id="@+id/sftp_connect_timeout"
|
||||
android:layout_width="fill_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="14"
|
||||
android:singleLine="true"
|
||||
android:inputType="number"
|
||||
android:text=""
|
||||
android:hint="@string/hint_sftp_connect_timeout" />
|
||||
</LinearLayout>
|
||||
<LinearLayout
|
||||
android:orientation="horizontal"
|
||||
android:layout_width="fill_parent"
|
||||
android:layout_height="wrap_content">
|
||||
<EditText
|
||||
android:id="@+id/sftp_user"
|
||||
android:layout_width="fill_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:singleLine="true"
|
||||
android:text=""
|
||||
android:hint="@string/hint_username" />
|
||||
|
||||
<EditText
|
||||
android:hint="@string/hint_username" />
|
||||
<EditText
|
||||
android:id="@+id/sftp_password"
|
||||
android:layout_width="fill_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:inputType="textPassword"
|
||||
android:singleLine="true"
|
||||
android:text=""
|
||||
android:hint="@string/hint_pass"
|
||||
android:importantForAccessibility="no" />
|
||||
|
||||
<TextView android:id="@+id/initial_dir"
|
||||
android:hint="@string/hint_pass"
|
||||
android:importantForAccessibility="no" />
|
||||
</LinearLayout>
|
||||
<LinearLayout
|
||||
android:orientation="horizontal"
|
||||
android:layout_width="fill_parent"
|
||||
android:layout_height="wrap_content">
|
||||
<TextView android:id="@+id/initial_dir"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginLeft="4dip"
|
||||
android:layout_marginTop="4dip"
|
||||
|
||||
android:text="@string/initial_directory" />
|
||||
<EditText
|
||||
<EditText
|
||||
android:id="@+id/sftp_initial_dir"
|
||||
android:layout_width="fill_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginLeft="8dip"
|
||||
android:singleLine="true"
|
||||
android:text="/home/philipp"
|
||||
/>
|
||||
<Button android:id="@+id/send_public_key"
|
||||
android:layout_width="fill_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="send public key" />
|
||||
|
||||
android:text="/home/philipp"
|
||||
/>
|
||||
</LinearLayout>
|
||||
<LinearLayout
|
||||
android:orientation="horizontal"
|
||||
android:layout_width="fill_parent"
|
||||
android:layout_height="wrap_content">
|
||||
<EditText android:id="@+id/kex"
|
||||
android:layout_width="fill_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:singleLine="true"
|
||||
android:inputType="textNoSuggestions"
|
||||
android:text=""
|
||||
android:hint="KEX Algs" />
|
||||
<EditText android:id="@+id/shk"
|
||||
android:layout_width="fill_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:singleLine="true"
|
||||
android:inputType="textNoSuggestions"
|
||||
android:text=""
|
||||
android:hint="Server Host Key Algs" />
|
||||
</LinearLayout>
|
||||
<Button android:id="@+id/send_public_key"
|
||||
android:layout_width="fill_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="send public key" />
|
||||
<TextView
|
||||
android:layout_width="fill_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_horizontal"
|
||||
android:layout_marginTop="15dp"
|
||||
android:textStyle="bold"
|
||||
android:text="Private Keys Functions" />
|
||||
<LinearLayout
|
||||
android:orientation="horizontal"
|
||||
android:layout_width="fill_parent"
|
||||
android:layout_height="wrap_content">
|
||||
<Button android:id="@+id/list_private_keys"
|
||||
android:layout_width="fill_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="List" />
|
||||
<Button android:id="@+id/add_private_key"
|
||||
android:layout_width="fill_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="Add" />
|
||||
<Button android:id="@+id/delete_private_key"
|
||||
android:layout_width="fill_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="Delete" />
|
||||
<Button android:id="@+id/validate_private_key"
|
||||
android:layout_width="fill_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="Validate" />
|
||||
</LinearLayout>
|
||||
<LinearLayout
|
||||
android:orientation="horizontal"
|
||||
android:layout_width="fill_parent"
|
||||
android:layout_height="wrap_content">
|
||||
<EditText android:id="@+id/private_key_name"
|
||||
android:layout_width="fill_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:singleLine="true"
|
||||
android:inputType="textNoSuggestions"
|
||||
android:text=""
|
||||
android:hint="key name" />
|
||||
<EditText android:id="@+id/private_key_passphrase"
|
||||
android:layout_width="fill_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:singleLine="true"
|
||||
android:inputType="textNoSuggestions"
|
||||
android:text=""
|
||||
android:hint="passphrase (optional)" />
|
||||
</LinearLayout>
|
||||
<EditText android:id="@+id/private_key_content"
|
||||
android:layout_width="fill_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="textMultiLine"
|
||||
android:lines="4"
|
||||
android:text=""
|
||||
android:hint="key content" />
|
||||
<TextView
|
||||
android:layout_width="fill_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_horizontal"
|
||||
android:layout_marginTop="15dp"
|
||||
android:textStyle="bold"
|
||||
android:text="CSV Resolver Functions" />
|
||||
<LinearLayout
|
||||
android:orientation="horizontal"
|
||||
android:layout_width="fill_parent"
|
||||
android:layout_height="wrap_content">
|
||||
<EditText android:id="@+id/mock_csv_specs"
|
||||
android:layout_width="fill_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:singleLine="true"
|
||||
android:inputType="textNoSuggestions"
|
||||
android:text=""
|
||||
android:hint="Test specs" />
|
||||
<EditText android:id="@+id/mock_csv_cfg"
|
||||
android:layout_width="fill_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:singleLine="true"
|
||||
android:inputType="textNoSuggestions"
|
||||
android:text=""
|
||||
android:hint="Test config" />
|
||||
</LinearLayout>
|
||||
<LinearLayout
|
||||
android:orientation="horizontal"
|
||||
android:layout_width="fill_parent"
|
||||
android:layout_height="wrap_content">
|
||||
<Button android:id="@+id/reset_mock_csv"
|
||||
android:layout_width="fill_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginLeft="50dp"
|
||||
android:layout_marginRight="5dp"
|
||||
android:text="Reset" />
|
||||
<Button android:id="@+id/resolve_mock_csv"
|
||||
android:layout_width="fill_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginRight="50dp"
|
||||
android:layout_marginLeft="5dp"
|
||||
android:text="Resolve" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 4.7 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 7.5 KiB After Width: | Height: | Size: 6.0 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 7.9 KiB |
@@ -333,6 +333,7 @@
|
||||
|
||||
<string name="hint_sftp_host">host</string>
|
||||
<string name="hint_sftp_port">port</string>
|
||||
<string name="hint_sftp_connect_timeout">timeout sec</string>
|
||||
|
||||
<string name="select_storage_type">Select the storage type:</string>
|
||||
|
||||
@@ -524,6 +525,6 @@ Initial public release
|
||||
<item>Do not accept invalid certificates</item>
|
||||
</string-array>
|
||||
|
||||
<string name="initial_directory">Initial directory (optional):</string>
|
||||
<string name="initial_directory">Initial dir (optional):</string>
|
||||
|
||||
</resources>
|
||||
|
||||
|
Before Width: | Height: | Size: 511 B After Width: | Height: | Size: 391 B |
|
Before Width: | Height: | Size: 760 B After Width: | Height: | Size: 634 B |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 913 B |
|
Before Width: | Height: | Size: 730 B After Width: | Height: | Size: 434 B |
|
Before Width: | Height: | Size: 940 B After Width: | Height: | Size: 540 B |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 461 B After Width: | Height: | Size: 277 B |
|
Before Width: | Height: | Size: 332 B After Width: | Height: | Size: 192 B |
|
Before Width: | Height: | Size: 498 B After Width: | Height: | Size: 326 B |
|
Before Width: | Height: | Size: 811 B After Width: | Height: | Size: 659 B |
|
Before Width: | Height: | Size: 715 B After Width: | Height: | Size: 442 B |
|
Before Width: | Height: | Size: 1001 B After Width: | Height: | Size: 577 B |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 892 B |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 745 B After Width: | Height: | Size: 451 B |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 614 B |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 903 B |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 833 B After Width: | Height: | Size: 432 B |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 5.9 KiB After Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 226 B After Width: | Height: | Size: 166 B |
|
Before Width: | Height: | Size: 807 B After Width: | Height: | Size: 507 B |
|
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 690 B |
|
Before Width: | Height: | Size: 681 B After Width: | Height: | Size: 427 B |
|
Before Width: | Height: | Size: 548 B After Width: | Height: | Size: 345 B |
|
Before Width: | Height: | Size: 438 B After Width: | Height: | Size: 274 B |
|
Before Width: | Height: | Size: 200 B After Width: | Height: | Size: 152 B |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 972 B |
|
Before Width: | Height: | Size: 379 B After Width: | Height: | Size: 227 B |
|
Before Width: | Height: | Size: 301 B After Width: | Height: | Size: 186 B |
|
Before Width: | Height: | Size: 404 B After Width: | Height: | Size: 237 B |
|
Before Width: | Height: | Size: 413 B After Width: | Height: | Size: 240 B |
|
Before Width: | Height: | Size: 341 B After Width: | Height: | Size: 214 B |
|
Before Width: | Height: | Size: 388 B After Width: | Height: | Size: 227 B |
|
Before Width: | Height: | Size: 413 B After Width: | Height: | Size: 244 B |
|
Before Width: | Height: | Size: 367 B After Width: | Height: | Size: 221 B |
|
Before Width: | Height: | Size: 417 B After Width: | Height: | Size: 246 B |
|
Before Width: | Height: | Size: 417 B After Width: | Height: | Size: 245 B |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 753 B |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 843 B |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 215 B After Width: | Height: | Size: 159 B |
|
Before Width: | Height: | Size: 1012 B After Width: | Height: | Size: 999 B |
|
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 6.8 KiB After Width: | Height: | Size: 6.1 KiB |
|
Before Width: | Height: | Size: 774 B After Width: | Height: | Size: 637 B |
|
Before Width: | Height: | Size: 5.9 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 6.1 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 6.2 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 6.4 KiB After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 6.4 KiB After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 6.3 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 6.6 KiB After Width: | Height: | Size: 5.6 KiB |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 983 B |
|
Before Width: | Height: | Size: 697 B After Width: | Height: | Size: 505 B |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 994 B |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 792 B After Width: | Height: | Size: 515 B |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 589 B |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 729 B After Width: | Height: | Size: 533 B |