Browse Source

Merge branch 'issue-47' of isundil/pass into master

isundil 7 years ago
parent
commit
5836a8dd60
32 changed files with 1450 additions and 287 deletions
  1. 6 1
      app/build.gradle
  2. 1 1
      app/src/main/AndroidManifest.xml
  3. 88 0
      app/src/main/java/info/knacki/pass/git/BaseGitProtocol.java
  4. 2 10
      app/src/main/java/info/knacki/pass/git/GitInterface.java
  5. 29 4
      app/src/main/java/info/knacki/pass/git/GitInterfaceFactory.java
  6. 35 2
      app/src/main/java/info/knacki/pass/git/GitSha1.java
  7. 96 178
      app/src/main/java/info/knacki/pass/git/HttpGitProtocol.java
  8. 112 0
      app/src/main/java/info/knacki/pass/git/Pacman.java
  9. 155 0
      app/src/main/java/info/knacki/pass/git/PacmanBuilder.java
  10. 337 0
      app/src/main/java/info/knacki/pass/git/SSHGitProtocol.java
  11. 23 8
      app/src/main/java/info/knacki/pass/git/entities/GitCommit.java
  12. 95 16
      app/src/main/java/info/knacki/pass/git/entities/GitObject.java
  13. 14 0
      app/src/main/java/info/knacki/pass/git/entities/GitPackable.java
  14. 27 0
      app/src/main/java/info/knacki/pass/git/entities/GitPackableFactory.java
  15. 21 2
      app/src/main/java/info/knacki/pass/git/entities/GitPackableUtil.java
  16. 5 1
      app/src/main/java/info/knacki/pass/git/entities/GitRef.java
  17. 68 0
      app/src/main/java/info/knacki/pass/io/AppendableInputStream.java
  18. 6 2
      app/src/main/java/info/knacki/pass/io/CharsetHelper.java
  19. 0 1
      app/src/main/java/info/knacki/pass/io/FileUtils.java
  20. 12 11
      app/src/main/java/info/knacki/pass/io/NetworkUtils.java
  21. 5 0
      app/src/main/java/info/knacki/pass/io/PathUtils.java
  22. 123 0
      app/src/main/java/info/knacki/pass/io/ssh/JSchWrapper.java
  23. 23 0
      app/src/main/java/info/knacki/pass/io/ssh/SSHConnection.java
  24. 7 0
      app/src/main/java/info/knacki/pass/io/ssh/SSHFactory.java
  25. 36 10
      app/src/main/java/info/knacki/pass/settings/SettingsManager.java
  26. 75 11
      app/src/main/java/info/knacki/pass/settings/ui/SettingsActivity.java
  27. 36 28
      app/src/main/java/info/knacki/pass/ui/GitPullActivity.java
  28. 2 0
      app/src/main/java/info/knacki/pass/ui/MainActivity.java
  29. 3 0
      app/src/main/res/values-fr/lang.xml
  30. 3 0
      app/src/main/res/values/lang.xml
  31. 2 1
      app/src/main/res/values/strings.xml
  32. 3 0
      app/src/main/res/xml/pref_vcs.xml

+ 6 - 1
app/build.gradle

@@ -3,7 +3,7 @@ apply plugin: 'com.android.application'
 android {
     compileSdkVersion 28
     defaultConfig {
-        applicationId "info.knacki.pass"
+        applicationId "info.knacki.pass2"
         minSdkVersion 15
         targetSdkVersion 28
         versionCode 1
@@ -16,6 +16,9 @@ android {
             minifyEnabled false
             proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
         }
+        debug {
+            applicationIdSuffix ".debug"
+        }
     }
     buildToolsVersion '28.0.3'
     compileOptions {
@@ -37,4 +40,6 @@ dependencies {
     implementation 'commons-http:commons-http:1.1'
     implementation 'org.bouncycastle:bcpg-jdk15on:1.60'
     implementation 'org.bouncycastle:bcprov-jdk15on:1.60'
+    implementation 'org.bouncycastle:bcprov-jdk15on:1.60'
+    implementation 'com.jcraft:jsch:0.1.55'
 }

+ 1 - 1
app/src/main/AndroidManifest.xml

@@ -59,7 +59,7 @@
 
         <provider
             android:name="android.support.v4.content.FileProvider"
-            android:authorities="info.knacki.pass.provider"
+            android:authorities="${applicationId}.provider"
             android:grantUriPermissions="true">
             <meta-data
                 android:name="android.support.FILE_PROVIDER_PATHS"

+ 88 - 0
app/src/main/java/info/knacki/pass/git/BaseGitProtocol.java

@@ -0,0 +1,88 @@
+package info.knacki.pass.git;
+
+import java.util.logging.Logger;
+
+import info.knacki.pass.git.entities.GitCommit;
+import info.knacki.pass.git.entities.GitRef;
+import info.knacki.pass.io.CharsetHelper;
+import info.knacki.pass.io.OnResponseListener;
+import info.knacki.pass.io.OnStreamResponseListener;
+import info.knacki.pass.settings.SettingsManager;
+
+public abstract class BaseGitProtocol implements GitInterface {
+    protected final SettingsManager.Git fConfig;
+    private final Logger log = Logger.getLogger(BaseGitProtocol.class.getName());
+
+    BaseGitProtocol(SettingsManager.Git config) {
+        fConfig = config;
+    }
+
+    abstract protected void PushPack(GitCommit commit, Pacman pack, OnStreamResponseListener<Void> resp);
+    abstract protected void FetchCommit(GitRef ref, OnStreamResponseListener<GitCommit> response);
+
+    void GetHeadCommitFromRef(GitRef myRef, final OnStreamResponseListener<GitCommit> response) {
+        if (myRef != null) {
+            response.OnMsg("Checking out branch " +myRef.GetBranchName() +" revision " +myRef.GetHash());
+            FetchCommit(myRef, new OnStreamResponseListener<GitCommit>() {
+                @Override
+                public void OnResponse(final GitCommit result) {
+                    response.OnResponse(result);
+                }
+
+                @Override
+                public void OnMsg(String message) {
+                    response.OnMsg(message);
+                }
+
+                @Override
+                public void OnError(String msg, Throwable e) {
+                    response.OnError(msg, e);
+                }
+            });
+        } else {
+            response.OnError("Branch " +fConfig.GetBranch() + " not found on remote", null);
+        }
+    }
+
+    private GitRef GetRefFromBranchName(GitRef[] refs, String branchName) {
+        if (refs != null)
+            for (GitRef ref: refs)
+                if (ref.GetBranch().equals(branchName))
+                    return ref;
+        return null;
+    }
+
+    GitRef GetHeadRef(GitRef[] refs) {
+        return GetRefFromBranchName(refs, fConfig.GetBranch());
+    }
+
+    public void FetchHead(final OnStreamResponseListener<GitCommit> response) {
+        GetRefs(new OnResponseListener<GitRef[]>() {
+            @Override
+            public void OnResponse(final GitRef[] result) {
+                GitRef myRef = GetHeadRef(result);
+                GetHeadCommitFromRef(myRef, response);
+            }
+
+            @Override
+            public void OnError(final String msg, final Throwable e) {
+                response.OnError(msg, e);
+            }
+        });
+    }
+
+    byte[] GitLine(String line) {
+        String lineResult;
+        if (line == null) {
+            lineResult = "0000";
+        } else {
+            lineResult = String.format("%04x%s\n", line.length() +5, line);
+        }
+        return CharsetHelper.StringToByteArray(lineResult);
+    }
+
+    @Override
+    public void PushCommitBuilder(GitCommit.Builder commit, OnStreamResponseListener<Void> resp) {
+        PushPack(commit.Build(), new Pacman(commit.PreparePack()), resp);
+    }
+}

+ 2 - 10
app/src/main/java/info/knacki/pass/git/GitInterface.java

@@ -7,16 +7,8 @@ import info.knacki.pass.io.OnResponseListener;
 import info.knacki.pass.io.OnStreamResponseListener;
 
 public interface GitInterface {
-
     void GetRefs(OnResponseListener<GitRef[]> callback);
-
-    void FetchCommit(GitRef ref, OnResponseListener<GitCommit> response);
-
-    void FetchTree(GitCommit ci, OnStreamResponseListener<GitObject.GitTree> response);
-
     void FetchHead(final OnStreamResponseListener<GitCommit> response);
-
-    void FetchBlob(GitObject.GitBlob blob, OnResponseListener<byte[]> response);
-
-    void PushBlobs(GitCommit.Builder commitBuilder, OnStreamResponseListener<Void> resp);
+    void FetchBlob(GitObject.GitBlob blob, OnStreamResponseListener<byte[]> response);
+    void PushCommitBuilder(GitCommit.Builder commit, OnStreamResponseListener<Void> resp);
 }

+ 29 - 4
app/src/main/java/info/knacki/pass/git/GitInterfaceFactory.java

@@ -1,11 +1,36 @@
 package info.knacki.pass.git;
 
+import android.support.annotation.NonNull;
+
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
 import info.knacki.pass.settings.SettingsManager;
 
 public class GitInterfaceFactory {
-    public static GitInterface factory(SettingsManager.Git config) {
-        if (config.GetUrl().startsWith("http://") || config.GetUrl().startsWith("https://"))
-            return new HttpGitProtocol(config);
-        return null;
+    private final static Logger log = Logger.getLogger(GitInterfaceFactory.class.getName());
+
+    public static class GitInterfaceException extends Throwable {
+        GitInterfaceException(String msg) {
+            super(msg);
+        }
+
+        GitInterfaceException(Throwable e) {
+            super(e.getMessage(), e);
+        }
+    }
+
+    public static @NonNull  GitInterface factory(SettingsManager.Git config) throws GitInterfaceException {
+        if (config == null)
+            throw new GitInterfaceException("No configuration found");
+        try {
+            if (config.GetUrl().startsWith("http://") || config.GetUrl().startsWith("https://"))
+                return new HttpGitProtocol(config);
+            return new SSHGitProtocol(config);
+        }
+        catch (Throwable e) {
+            log.log(Level.SEVERE, "Cannot create git interface: " +e.getMessage(), e);
+            throw new GitInterfaceException(e);
+        }
     }
 }

+ 35 - 2
app/src/main/java/info/knacki/pass/git/GitSha1.java

@@ -21,9 +21,13 @@ public class GitSha1 {
     }
 
     public static String BytesToString(byte[] sha1Bytes) {
+        return BytesToString(sha1Bytes, 0);
+    }
+
+    public static String BytesToString(byte[] sha1Bytes, int offset) {
         StringBuilder sb = new StringBuilder();
-        for (byte i: sha1Bytes)
-            sb.append(Integer.toString((i & 0xff) + 0x100, 16).substring(1));
+        for (int i =offset; i < Math.min(sha1Bytes.length -offset, offset +20); ++i)
+            sb.append(Integer.toString((sha1Bytes[i] & 0xff) + 0x100, 16).substring(1));
         return sb.toString();
     }
 
@@ -72,4 +76,33 @@ public class GitSha1 {
         byte[] raw = getRawSha1OfPackable(obj);
         return raw.length == 0 ? "" : BytesToString(raw);
     }
+
+    public static byte[] GetRawSha1OfData(GitPackable.eType type, byte[] data) {
+        try {
+            MessageDigest sha1Builder = MessageDigest.getInstance("SHA1");
+            sha1Builder.update((GitPackableUtil.GetObjTypeString(type) +" " +data.length).getBytes());
+            sha1Builder.update(new byte[] { 0 });
+            sha1Builder.update(data);
+            return sha1Builder.digest();
+        }
+        catch (NoSuchAlgorithmException e) {
+            return new byte[]{};
+        }
+    }
+
+    public static String GetSha1OfData(GitPackable.eType type, byte[] data) {
+        byte[] raw = GetRawSha1OfData(type, data);
+        return raw.length == 0 ? "" : BytesToString(raw);
+    }
+
+    public static boolean CompareChecksums(byte[] a, byte[] b) {
+        return CompareChecksums(a, b, 0);
+    }
+
+    public static boolean CompareChecksums(byte[] checksum, byte[] data, int offset) {
+        for (int i=0; i < checksum.length; ++i)
+            if (data[offset +i] != checksum[i])
+                return false;
+        return true;
+    }
 }

+ 96 - 178
app/src/main/java/info/knacki/pass/git/HttpGitProtocol.java

@@ -4,36 +4,28 @@ import java.io.IOException;
 import java.io.OutputStream;
 import java.net.MalformedURLException;
 import java.net.URL;
-import java.nio.ByteBuffer;
 import java.nio.charset.Charset;
 import java.util.HashMap;
-import java.util.Locale;
-import java.util.SortedSet;
 import java.util.logging.Level;
 import java.util.logging.Logger;
 
 import info.knacki.pass.git.entities.GitCommit;
 import info.knacki.pass.git.entities.GitObject;
-import info.knacki.pass.git.entities.GitPackable;
-import info.knacki.pass.git.entities.GitPackableUtil;
 import info.knacki.pass.git.entities.GitRef;
-import info.knacki.pass.io.CharsetHelper;
 import info.knacki.pass.io.OnResponseListener;
 import info.knacki.pass.io.OnStreamResponseListener;
-import info.knacki.pass.io.OutputStreamWithCheckSum;
 import info.knacki.pass.settings.SettingsManager;
 
 import static info.knacki.pass.io.NetworkUtils.ProtoPost;
 import static info.knacki.pass.io.NetworkUtils.protoGet;
 import static info.knacki.pass.io.NetworkUtils.protoInflateGet;
 
-class HttpGitProtocol implements GitInterface {
-    private final SettingsManager.Git fConfig;
+class HttpGitProtocol extends BaseGitProtocol {
     private GitRef[] fRefsCache = null;
     private static final Logger log = Logger.getLogger(HttpGitProtocol.class.getName());
 
     HttpGitProtocol(SettingsManager.Git config) {
-        fConfig = config;
+        super(config);
     }
 
     public void GetRefs(final OnResponseListener<GitRef[]> callback) {
@@ -56,6 +48,11 @@ class HttpGitProtocol implements GitInterface {
                                 break;
                             }
                         }
+                        if (fRefsCache[i] == null) {
+                            fRefsCache = null;
+                            callback.OnError("Cannot read references from repository", null);
+                            return;
+                        }
                     }
                     callback.OnResponse(fRefsCache);
                 }
@@ -70,22 +67,8 @@ class HttpGitProtocol implements GitInterface {
         }
     }
 
-    public void FetchCommit(final GitRef ref, final OnResponseListener<GitCommit> response) {
-        PullHash(ref.GetHash(), new OnResponseListener<byte[]>() {
-            @Override
-            public void OnResponse(byte[] result) {
-                response.OnResponse(new GitCommit(ref.GetHash(), new String(result, Charset.defaultCharset())));
-            }
-
-            @Override
-            public void OnError(String msg, Throwable e) {
-                response.OnError(msg, e);
-            }
-        });
-    }
-
     public void FetchTree(final GitCommit ci, final OnStreamResponseListener<GitObject.GitTree> response) {
-        class RecursiveFetchTreeWalker implements OnResponseListener<byte[]> {
+        class RecursiveFetchTreeWalker implements OnStreamResponseListener<byte[]> {
             private final GitObject.GitTree fRoot;
             private GitObject.GitTree fCurrentTree;
             private final OnStreamResponseListener<GitObject.GitTree> fResponseListener;
@@ -101,7 +84,7 @@ class HttpGitProtocol implements GitInterface {
 
             @Override
             public void OnResponse(byte[] result) {
-                FillTree(fCurrentTree, result);
+                fCurrentTree.Fill(result);
                 fCurrentTree = fRoot.FindNextNotInitializedTree();
                 if (fCurrentTree != null)
                     PullHash(fCurrentTree.Initialize().GetHash());
@@ -114,6 +97,11 @@ class HttpGitProtocol implements GitInterface {
                 fResponseListener.OnError(msg, e);
             }
 
+            @Override
+            public void OnMsg(String message) {
+                fResponseListener.OnMsg(message);
+            }
+
             private void PullHash(String hash) {
                 fResponseListener.OnMsg("Reading tree " +hash);
                 HttpGitProtocol.this.PullHash(hash, this);
@@ -122,101 +110,16 @@ class HttpGitProtocol implements GitInterface {
             private void PullHash(byte[] hash) {
                 PullHash(GitSha1.BytesToString(hash));
             }
-
-            private void FillTree(final GitObject.GitTree tree, byte[] data) {
-                int i = 0;
-
-                while (i < data.length) {
-                    byte[] mode = null;
-                    int len;
-                    for (len =0; i +len <data.length && len < 8; ++len) {
-                        if (data[i + len] == ' ') {
-                            mode = new byte[len];
-                            System.arraycopy(data, i, mode, 0, len);
-                            i += len + 1;
-                            break;
-                        }
-                    }
-                    if (mode == null)
-                        break;
-                    for (len =0; i +len < data.length && data[i +len] != 0; ++len);
-                    byte[] filenameBytes = new byte[len];
-                    System.arraycopy(data, i, filenameBytes, 0, len);
-                    i += len +1;
-                    if (i +20 > data.length)
-                        break;
-                    byte []sha1 = new byte[20];
-                    System.arraycopy(data, i, sha1, 0, 20);
-                    i += 20;
-                    tree.AddItem(GitObject.factory(tree, mode, CharsetHelper.ByteArrayToString(filenameBytes), sha1));
-                }
-            }
         }
 
         new RecursiveFetchTreeWalker(ci, response).run();
     }
 
-    @Override
-    public void FetchBlob(final GitObject.GitBlob blob, final OnResponseListener<byte[]> response) {
+    public void FetchBlob(final GitObject.GitBlob blob, final OnStreamResponseListener<byte[]> response) {
         PullHash(GitSha1.BytesToString(blob.GetHash()), response);
     }
 
-    public void FetchHead(final OnStreamResponseListener<GitCommit> response) {
-        GetRefs(new OnResponseListener<GitRef[]>() {
-            @Override
-            public void OnResponse(final GitRef[] result) {
-                GitRef myRef = null;
-                response.OnMsg("Found refs: ");
-                for (GitRef ref: result) {
-                    response.OnMsg("\t> "+ref.GetBranch());
-                    if (ref.GetBranch().equals(fConfig.GetBranch()))
-                        myRef = ref;
-                }
-                if (myRef != null) {
-                    response.OnMsg("Checking out branch " +myRef.GetBranchName() +" revision " +myRef.GetHash());
-                    FetchCommit(myRef, new OnResponseListener<GitCommit>() {
-                        @Override
-                        public void OnResponse(final GitCommit result) {
-                            response.OnMsg("Finished read commit");
-                            response.OnMsg(result.GetMessage());
-                            response.OnMsg("Reading tree #" +GitSha1.BytesToString(result.GetTreeHash()));
-                            FetchTree(result, new OnStreamResponseListener<GitObject.GitTree>() {
-                                @Override
-                                public void OnMsg(String message) {
-                                    response.OnMsg(message);
-                                }
-
-                                @Override
-                                public void OnResponse(GitObject.GitTree tree) {
-                                    response.OnMsg("Finished reading tree");
-                                    response.OnResponse(result);
-                                }
-
-                                @Override
-                                public void OnError(String msg, Throwable e) {
-                                    response.OnError(msg, e);
-                                }
-                            });
-                        }
-
-                        @Override
-                        public void OnError(String msg, Throwable e) {
-                            response.OnError(msg, e);
-                        }
-                    });
-                } else {
-                    response.OnError("Branch " +fConfig.GetBranch() + " not found on remote", null);
-                }
-            }
-
-            @Override
-            public void OnError(final String msg, final Throwable e) {
-                response.OnError(msg, e);
-            }
-        });
-    }
-
-    private void PullHash(String hash, final OnResponseListener<byte[]> response) {
+    protected void PullHash(String hash, final OnStreamResponseListener<byte[]> response) {
         URL url;
         try {
             url = new URL(fConfig.GetUrl() + "/objects/" + hash.substring(0, 2) + "/" + hash.substring(2));
@@ -228,81 +131,96 @@ class HttpGitProtocol implements GitInterface {
         protoInflateGet(url, fConfig, response);
     }
 
-    public boolean makePack(SortedSet<GitPackable> objectsToPack, OutputStream out) throws IOException
-    {
-        OutputStreamWithCheckSum msg = new OutputStreamWithCheckSum(out);
-        msg.write("PACK")
-            .write(new byte[] { 0, 0, 0, 2 } )
-            .write(ByteBuffer.allocate(4).putInt(objectsToPack.size()).array());
-        for (GitPackable i: objectsToPack) {
-            byte[] pack = i.GetPack();
-            if (null == pack)
-                return false;
-            msg.write(GitPackableUtil.getObjHeader(i.GetPackableType(), pack.length))
-                .write(GitPackableUtil.deflate(pack));
-            log.info("Writing pack " +i.GetPackableType() +", " +GitSha1.getSha1OfPackable(i) +new String(i.GetPack()));
-        }
-        msg.writeSha1();
-        return true;
+    public void FetchCommit(final GitRef ref, final OnStreamResponseListener<GitCommit> response) {
+        PullHash(ref.GetHash(), new OnStreamResponseListener<byte[]>() {
+            @Override
+            public void OnResponse(byte[] result) {
+                GitCommit ci = new GitCommit(ref.GetHash(), new String(result, Charset.defaultCharset()));
+                response.OnMsg("Finished read commit");
+                response.OnMsg(ci.GetMessage());
+                response.OnMsg("Reading tree #" +GitSha1.BytesToString(ci.GetTreeHash()));
+                FetchTree(ci, new OnStreamResponseListener<GitObject.GitTree>() {
+                    @Override
+                    public void OnMsg(String message) {
+                        response.OnMsg(message);
+                    }
+
+                    @Override
+                    public void OnResponse(GitObject.GitTree tree) {
+                        response.OnMsg("Finished reading tree");
+                        response.OnResponse(ci);
+                    }
+
+                    @Override
+                    public void OnError(String msg, Throwable e) {
+                        response.OnError(msg, e);
+                    }
+                });
+            }
+
+            @Override
+            public void OnError(String msg, Throwable e) {
+                response.OnError(msg, e);
+            }
+
+            @Override
+            public void OnMsg(String message) {
+                response.OnMsg(message);
+            }
+        });
     }
 
-    public void PushBlobs(final GitCommit.Builder commit, final OnStreamResponseListener<Void> response) {
+    @Override
+    public void PushPack(final GitCommit commit, Pacman pack, final OnStreamResponseListener<Void> response) {
         GetRefs(new OnResponseListener<GitRef[]>() {
             @Override
             public void OnResponse(final GitRef[] result) {
-                GitRef myRef = null;
-                for (GitRef ref: result) {
-                    if (ref.GetBranch().equals(fConfig.GetBranch()))
-                        myRef = ref;
+                final GitRef myRef = GetHeadRef(result);
+                if (myRef == null) {
+                    response.OnError("Branch " +fConfig.GetBranch() + " not found on remote for pushing", null);
+                    return;
                 }
-                if (myRef != null) {
-                    final GitRef finalRef = myRef;
-                    response.OnMsg("Pushing over " +myRef.GetBranch() +" revision " +myRef.GetHash());
-                    try {
-                        HashMap<String, String> headers = new HashMap<>();
-                        headers.put("Content-Type", "application/x-git-receive-pack-request");
-                        ProtoPost(new URL(fConfig.GetUrl() +"/git-receive-pack"), headers, fConfig, new OnResponseListener<byte[]>() {
-                            @Override
-                            public void OnResponse(byte[] result) {
-                                response.OnResponse(null);
-                            }
+                response.OnMsg("Pushing over " +myRef.GetBranch() +" revision " +myRef.GetHash());
+                try {
+                    HashMap<String, String> headers = new HashMap<>();
+                    headers.put("Content-Type", "application/x-git-receive-pack-request");
+                    ProtoPost(new URL(fConfig.GetUrl() +"/git-receive-pack"), headers, fConfig, new OnResponseListener<byte[]>() {
+                        @Override
+                        public void OnResponse(byte[] result) {
+                            response.OnResponse(null);
+                        }
 
-                            @Override
-                            public void OnError(String msg, Throwable e) {
-                                log.log(Level.SEVERE, "git-receive-pack error", e);
-                                response.OnError(msg, e);
-                            }
-                        }, new OnResponseListener<OutputStream>() {
-                            @Override
-                            public void OnResponse(OutputStream result) {
-                                byte[] msgLine = (finalRef.GetHash() + " " + GitSha1.BytesToString(GitSha1.getRawSha1OfPackable(commit.Build())) +" " +finalRef.GetBranch()).getBytes();
-                                try {
-                                    result.write(String.format(Locale.US, "%04X", msgLine.length +4).getBytes());
-                                    result.write(msgLine);
-                                    result.write("0000".getBytes());
-                                    if (!makePack(commit.PreparePack(), result)) {
-                                        OnError("Pack error", null);
-                                        return;
-                                    }
-                                    result.close();
-                                }
-                                catch (IOException e) {
-                                    log.log(Level.SEVERE, "Cannot git-upload-pack: " +e.getMessage(), e);
-                                    OnError(e.getMessage(), e);
+                        @Override
+                        public void OnError(String msg, Throwable e) {
+                            log.log(Level.SEVERE, "git-receive-pack error", e);
+                            response.OnError(msg, e);
+                        }
+                    }, new OnResponseListener<OutputStream>() {
+                        @Override
+                        public void OnResponse(OutputStream result) {
+                            try {
+                                result.write(GitLine(myRef.GetHash() + " " + GitSha1.BytesToString(GitSha1.getRawSha1OfPackable(commit)) +" " +myRef.GetBranch()));
+                                result.write(GitLine(null));
+                                if (!pack.Write(result)) {
+                                    OnError("Pack error", null);
+                                    return;
                                 }
+                                result.close();
                             }
-
-                            @Override
-                            public void OnError(String msg, Throwable e) {
-                                response.OnError(msg, e);
+                            catch (IOException e) {
+                                log.log(Level.SEVERE, "Cannot git-upload-pack: " +e.getMessage(), e);
+                                OnError(e.getMessage(), e);
                             }
-                        });
-                    }
-                    catch (IOException e) {
-                        response.OnError(e.getMessage(), e);
-                    }
-                } else {
-                    response.OnError("Branch " +fConfig.GetBranch() + " not found on remote for pushing", null);
+                        }
+
+                        @Override
+                        public void OnError(String msg, Throwable e) {
+                            response.OnError(msg, e);
+                        }
+                    });
+                }
+                catch (IOException e) {
+                    response.OnError(e.getMessage(), e);
                 }
             }
 

+ 112 - 0
app/src/main/java/info/knacki/pass/git/Pacman.java

@@ -0,0 +1,112 @@
+package info.knacki.pass.git;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.nio.ByteBuffer;
+import java.util.ArrayDeque;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.logging.Logger;
+
+import info.knacki.pass.git.entities.GitCommit;
+import info.knacki.pass.git.entities.GitObject;
+import info.knacki.pass.git.entities.GitPackable;
+import info.knacki.pass.git.entities.GitPackableUtil;
+import info.knacki.pass.io.OutputStreamWithCheckSum;
+
+class Pacman {
+    private static final Logger log = Logger.getLogger(Pacman.class.getName());
+    Collection<GitPackable> fPacked;
+    Map<GitPackable.eType, Collection<GitPackable>> fPackedByType;
+
+    public Pacman(Collection<GitPackable> objectsToPack) {
+        fPacked = objectsToPack;
+        fPackedByType = new HashMap<>();
+        for (GitPackable i: fPacked) {
+            Collection<GitPackable> col = fPackedByType.get(i.GetPackableType());
+            if (col == null) {
+                col = new ArrayDeque<>();
+                fPackedByType.put(i.GetPackableType(), col);
+            }
+            col.add(i);
+        }
+    }
+
+    private GitObject.GitTree FindTree(byte[] hash) {
+        Collection<GitPackable> col = fPackedByType.get(GitPackable.eType.eType_Tree);
+        if (col != null)
+            for (GitPackable i: col)
+                if (GitSha1.CompareChecksums(hash, GitSha1.getRawSha1OfPackable(i)))
+                    return (GitObject.GitTree) i;
+        return null;
+    }
+
+    private GitObject.GitRawData FindBlob(byte[] hash) {
+        Collection<GitPackable> col = fPackedByType.get(GitPackable.eType.eType_Blob);
+        if (col != null)
+            for (GitPackable i: col)
+                if (GitSha1.CompareChecksums(hash, GitSha1.getRawSha1OfPackable(i)))
+                    return (GitObject.GitRawData) i;
+        return null;
+    }
+
+    private void BuildTree(GitObject.GitTree root) throws PacmanBuilder.InvalidPackException {
+        GitObject.GitTree nextUninitialized = root;
+        do {
+            if (!nextUninitialized.IsRoot()) {
+                GitObject.GitTree realTree = FindTree(nextUninitialized.GetHash());
+                if (realTree == null)
+                    throw new PacmanBuilder.InvalidPackException("Cannot find object in pack");
+                realTree = new GitObject.GitTree(nextUninitialized.GetParent(), realTree, nextUninitialized.GetFilename());
+                nextUninitialized.GetParent().SetRealObject(nextUninitialized.GetFilename(), realTree);
+                nextUninitialized = realTree;
+            }
+            for (GitObject.GitBlob i: nextUninitialized.GetBlobs()) {
+                GitObject.GitRawData blob = FindBlob(i.GetHash());
+                if (blob == null)
+                    throw new PacmanBuilder.InvalidPackException("Cannot find object in pack");
+                nextUninitialized.SetRealObject(i.GetFilename(), new GitObject.GitRawData(nextUninitialized, i, blob));
+            }
+        } while ((nextUninitialized = root.FindNextNotInitializedTree()) != null);
+    }
+
+    private GitCommit BuildCommit(GitCommit ci) throws PacmanBuilder.InvalidPackException {
+        GitObject.GitTree tree = ci.GetTree();
+        if (tree == null) {
+            tree = FindTree(ci.GetTreeHash());
+            if (tree != null)
+                ci.SetTree(tree);
+        }
+        if (tree != null)
+            BuildTree(tree);
+        return ci;
+    }
+
+    public GitCommit BuildCommit(String hash) throws PacmanBuilder.InvalidPackException {
+        Collection<GitPackable> col = fPackedByType.get(GitPackable.eType.eType_Commit);
+        if (col != null)
+            for (GitPackable i: col)
+                if (hash.equals(((GitCommit) i).GetHash()))
+                    return BuildCommit((GitCommit) i);
+        return null;
+    }
+
+    public boolean Write(OutputStream out) throws IOException
+    {
+        OutputStreamWithCheckSum msg = new OutputStreamWithCheckSum(out);
+        msg.write("PACK")
+                .write(new byte[] { 0, 0, 0, 2 } )
+                .write(ByteBuffer.allocate(4).putInt(fPacked.size()).array());
+        for (GitPackable i: fPacked) {
+            byte[] pack = i.GetPack();
+            if (null == pack)
+                return false;
+            GitPackable.PackV2Header header = new GitPackable.PackV2Header(i.GetPackableType(), pack.length);
+            msg.write(GitPackableUtil.getObjHeader(header)).write(GitPackableUtil.deflate(pack));
+            log.info("Writing pack " +i.GetPackableType() +", " +GitSha1.getSha1OfPackable(i) +new String(i.GetPack()));
+        }
+        msg.writeSha1();
+        return true;
+    }
+}

+ 155 - 0
app/src/main/java/info/knacki/pass/git/PacmanBuilder.java

@@ -0,0 +1,155 @@
+package info.knacki.pass.git;
+
+import java.nio.ByteBuffer;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.ArrayDeque;
+import java.util.logging.Logger;
+import java.util.zip.DataFormatException;
+import java.util.zip.Inflater;
+
+import info.knacki.pass.git.entities.GitPackable;
+import info.knacki.pass.git.entities.GitPackableFactory;
+import info.knacki.pass.git.entities.GitPackableUtil;
+import info.knacki.pass.io.CharsetHelper;
+
+public class PacmanBuilder {
+    private final static Logger log = Logger.getLogger(PacmanBuilder.class.getName());
+
+    private byte[] fBuffer;
+    private int fPos;
+
+    public static class InvalidPackException extends Throwable {
+        InvalidPackException(String msg) {
+            super(msg);
+        }
+
+        InvalidPackException(String msg, Throwable cause) {
+            super(msg, cause);
+        }
+    }
+
+    public Pacman build(byte[] data) throws InvalidPackException {
+        if (data == null)
+            throw new InvalidPackException("No data provided");
+        final String header = CharsetHelper.ByteArrayToString(data, 0, 4);
+        if (!"PACK".equals(header))
+            throw new InvalidPackException("Invalid header " +header);
+
+        try {
+            MessageDigest checksum = MessageDigest.getInstance("SHA1");
+            checksum.update(data, 0, data.length - 20);
+            if (!GitSha1.CompareChecksums(checksum.digest(), data, data.length - 20))
+                throw new InvalidPackException("Checksum mismatch");
+        }
+        catch (NoSuchAlgorithmException e) {
+            throw new InvalidPackException("Cannot check pack integrity: " +e.getMessage(), e);
+        }
+
+        final int packVersion = ByteBuffer.wrap(data, 4, 4).getInt();
+        fBuffer = new byte[data.length -8 - 20];
+        fPos = 0;
+        System.arraycopy(data, 8, fBuffer, 0, fBuffer.length);
+
+        switch (packVersion) {
+            case 2:
+                return ReadPackV2();
+            default:
+                throw new InvalidPackException("Unsupported pack version " +packVersion);
+        }
+    }
+
+    private GitPackable.PackV2Header ReadPackV2Header() throws InvalidPackException {
+        int currentByte;
+        int nbBits = 4;
+
+        GitPackable.PackV2Header header = new GitPackable.PackV2Header();
+        currentByte = fBuffer[fPos++];
+        final int type = (currentByte & 0b01110000) >> 4;
+        header.type = GitPackableUtil.getObjType(type);
+        if (header.type == null)
+            throw new InvalidPackException("Unknown object type " +type +" in pack");
+        header.length = currentByte & 0b00001111;
+        while ((currentByte & 0b10000000) != 0) {
+            currentByte = fBuffer[fPos++];
+            header.length += (currentByte & 0b01111111) << nbBits;
+            nbBits += 7;
+        }
+        return header;
+    }
+
+    private byte[] InflateObject(GitPackable.PackV2Header header) throws InvalidPackException {
+        byte[] data = new byte[header.length];
+        try {
+            Inflater inf = new Inflater(false);
+            int totalRead = 0;
+            do {
+                if (inf.needsInput())
+                    inf.setInput(fBuffer, (int) (fPos +inf.getBytesRead()), fBuffer.length -fPos -(int)inf.getBytesRead());
+                totalRead += inf.inflate(data, totalRead, 1);
+            } while (!inf.finished() && totalRead < header.length);
+            fPos += inf.getBytesRead();
+        } catch (DataFormatException e) {
+            throw new InvalidPackException("Zip error: " + e.getMessage(), e);
+        }
+        return data;
+    }
+
+    private void InflateDiscard(int inflatedSize) throws InvalidPackException {
+        byte[] data = new byte[1];
+        try {
+            Inflater inf = new Inflater(false);
+            int totalRead = 0;
+            do {
+                if (inf.needsInput())
+                    inf.setInput(fBuffer, (int) (fPos +inf.getBytesRead()), fBuffer.length -fPos -(int)inf.getBytesRead());
+                totalRead += inf.inflate(data, 0, 1);
+            } while (!inf.finished() && totalRead < inflatedSize);
+            fPos += inf.getBytesRead();
+        } catch (DataFormatException e) {
+            throw new InvalidPackException("Zip error: " + e.getMessage(), e);
+        }
+    }
+
+    private Pacman ReadPackV2() throws InvalidPackException {
+        final int packCount = ByteBuffer.wrap(fBuffer, fPos, 4).getInt();
+        fPos += 4;
+        log.info("Found " +packCount +" object in pack file");
+        ArrayDeque<GitPackable> packed = new ArrayDeque<>();
+
+        for (int i =0; i < packCount; ++i) {
+            GitPackable.PackV2Header header = ReadPackV2Header();
+
+            switch (header.type) {
+                case eType_Blob:
+                case eType_Commit:
+                case eType_Tree:
+                case eType_Tag:
+                    byte[] inflated = InflateObject(header);
+                    String hash = GitSha1.GetSha1OfData(header.type, inflated);
+                    GitPackable pack = GitPackableFactory.build(header.type, hash, inflated);
+
+                    if (pack == null) {
+                        log.warning("Cannot parse object type " + header.type);
+                        continue;
+                    }
+                    packed.add(pack);
+                    log.info("Read " +GitSha1.getSha1OfPackable(pack) +" " + pack + " from pack");
+                    break;
+
+                case eType_RefDelta:
+                    log.info("Found ref_delta from base " +GitSha1.BytesToString(fBuffer, fPos));
+                    fPos += 20;
+                    InflateDiscard(header.length);
+                    break;
+
+                case eType_OfsDelta:
+                    while ((fBuffer[fPos++] & 0b10000000) != 0) {}
+                    InflateDiscard(header.length);
+                    break;
+            }
+        }
+        log.info("End reading pack, " +packed.size() +" object created");
+        return new Pacman(packed);
+    }
+}

+ 337 - 0
app/src/main/java/info/knacki/pass/git/SSHGitProtocol.java

@@ -0,0 +1,337 @@
+package info.knacki.pass.git;
+
+import java.io.BufferedReader;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.OutputStream;
+import java.security.InvalidParameterException;
+import java.util.ArrayDeque;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+import info.knacki.pass.git.entities.GitCommit;
+import info.knacki.pass.git.entities.GitObject;
+import info.knacki.pass.git.entities.GitRef;
+import info.knacki.pass.io.AppendableInputStream;
+import info.knacki.pass.io.CharsetHelper;
+import info.knacki.pass.io.FileUtils;
+import info.knacki.pass.io.OnErrorListener;
+import info.knacki.pass.io.OnResponseListener;
+import info.knacki.pass.io.OnStreamResponseListener;
+import info.knacki.pass.io.ssh.SSHConnection;
+import info.knacki.pass.io.ssh.SSHFactory;
+import info.knacki.pass.settings.SettingsManager;
+
+public class SSHGitProtocol extends BaseGitProtocol {
+    private final static Logger log = Logger.getLogger(SSHGitProtocol.class.getName());
+    private final SSHUrl fRepoUrl;
+
+    private final class ActiveSSHWrapper {
+        final SSHConnection fConnection;
+        final OutputStream fStdin;
+        final InputStream fStdout;
+        final InputStream fStderr;
+
+        ActiveSSHWrapper(SSHConnection con, OutputStream stdin, InputStream stdout, InputStream stderr) {
+            fConnection = con;
+            fStdin = stdin;
+            fStdout = stdout;
+            fStderr = stderr;
+        }
+    }
+
+    private ActiveSSHWrapper fActiveConnection = null;
+
+    private final class SSHUrl implements SSHConnection.ConnectionParams {
+        private final String repoHost;
+        final String repoName;
+
+        SSHUrl(String url) {
+            if (url.contains("://"))
+                url = url.substring(url.indexOf("://"));
+            int repoSep = url.indexOf(':');
+            if (repoSep < 0)
+                throw new InvalidParameterException("Cannot find repository name");
+            repoHost = url.substring(0, repoSep);
+            repoName = url.substring(repoSep + 1);
+        }
+
+        @Override
+        public String GetHostname() {
+            return repoHost;
+        }
+
+        @Override
+        public boolean HasAuthentication() {
+            return fConfig.HasAuthentication();
+        }
+
+        @Override
+        public String GetUser() {
+            return fConfig.GetUser();
+        }
+
+        @Override
+        public String GetPassword() {
+            return fConfig.GetPassword();
+        }
+
+        @Override
+        public String GetPrivateKey() {
+            return fConfig.GetPrivateKey();
+        }
+    }
+
+    SSHGitProtocol(SettingsManager.Git config) {
+        super(config);
+        fRepoUrl = new SSHUrl(config.GetUrl());
+    }
+
+    private byte[] ReadLine(InputStream in) throws IOException {
+        byte[] lineLenBytes = new byte[4];
+        if (in.read(lineLenBytes, 0, 4) < 4) {
+            return null;
+        }
+        int lineLen = 0;
+        try {
+            lineLen = Integer.parseInt(CharsetHelper.ByteArrayToString(lineLenBytes), 16);
+        }
+        catch (NumberFormatException e) {
+            ByteArrayOutputStream stream = new ByteArrayOutputStream();
+            stream.write(lineLenBytes);
+            byte[] tmp = new byte[1024];
+            int read = in.read(tmp);
+            stream.write(tmp, 0, read);
+            String error = CharsetHelper.ByteArrayToString(stream.toByteArray());
+            throw new NumberFormatException(error);
+        }
+        if (lineLen == 0) {
+            return null;
+        }
+
+        lineLen -= 4;
+        if (lineLen == 0)
+            return new byte[] {};
+        byte[] line = new byte[lineLen];
+        if (in.read(line) != lineLen) {
+            return null;
+        }
+        for (int i =0; i < lineLen; ++i)
+            if (line[i] == 0)
+                lineLen = i;
+        return line;
+    }
+
+    private GitRef[] GetRefs(ActiveSSHWrapper sshConnection, OnErrorListener errorListener) {
+        ArrayDeque<GitRef> references = new ArrayDeque<>();
+
+        try {
+            byte[] line;
+            while ((line = ReadLine(sshConnection.fStdout)) != null) {
+                GitRef ref = new GitRef(CharsetHelper.ByteArrayToString(line, 0, 40), CharsetHelper.ByteArrayToString(line, 41, line.length -42));
+
+                if (ref.GetBranch().startsWith("refs/heads/")) {
+                    references.add(ref);
+                    log.info("Found ref: " + ref);
+                }
+            }
+        }
+        catch (IOException | NumberFormatException e) {
+            final String errorMsg = "Cannot read from ssh command: " +e.getMessage();
+            log.log(Level.SEVERE, errorMsg, e);
+            errorListener.OnError(errorMsg, e);
+            return null;
+        }
+        GitRef[] refs = new GitRef[references.size()];
+        return references.toArray(refs);
+    }
+
+    private void ListenForErrors(InputStream stderr, OnErrorListener OnError) {
+        (new Thread() {
+            @Override
+            public void run() {
+                BufferedReader reader = new BufferedReader(new InputStreamReader(stderr));
+                String line;
+
+                try {
+                    while ((line = reader.readLine()) != null) {
+                        OnError.OnError(line, null);
+                    }
+                }
+                catch (IOException e) {
+                    log.log(Level.SEVERE, "Cannot read from stderr", e);
+                }
+            }
+        }).start();
+    }
+
+    @Override
+    public void GetRefs(OnResponseListener<GitRef[]> callback) {
+        if (fActiveConnection != null) {
+            GitRef[] refs = GetRefs(fActiveConnection, callback);
+            if (refs != null)
+                callback.OnResponse(refs);
+        } else {
+            AppendableInputStream stdin = new AppendableInputStream();
+
+            SSHFactory
+                    .createInstance(fRepoUrl, "git-upload-pack " + fRepoUrl.repoName)
+                    .SetOnConnectionReadyListener((connection, stdout, stderr) -> {
+                        ListenForErrors(stderr, (msg, exc) -> log.severe(msg));
+                        GitRef[] refs = GetRefs(new ActiveSSHWrapper(connection, stdin.GetWriter(), stdout, stderr), callback);
+                        connection.disconnect();
+                        if (refs != null)
+                            callback.OnResponse(refs);
+                    })
+                    .connect();
+        }
+    }
+
+    private byte[] PullPackData(ActiveSSHWrapper sshWrapper, String hash, OnStreamResponseListener<byte[]> listener) {
+        try {
+            ListenForErrors(sshWrapper.fStderr, (msg, e) -> listener.OnMsg(msg));
+            ByteArrayOutputStream out = new ByteArrayOutputStream();
+            out.write(GitLine("want " +hash));
+            out.write(GitLine("deepen 1"));
+            out.write(GitLine(null));
+            out.write(GitLine("done"));
+            log.severe(CharsetHelper.ByteArrayToString(out.toByteArray()));
+            sshWrapper.fStdin.write(out.toByteArray());
+            String line;
+            do {
+                byte[] b = ReadLine(sshWrapper.fStdout);
+                line = b == null ? null : CharsetHelper.ByteArrayToString(b);
+            } while (line == null || !"NAK\n".equals(line));
+            return FileUtils.ReadAllStream(sshWrapper.fStdout);
+        }
+        catch (IOException e) {
+            log.log(Level.SEVERE, "Cannot get hash " +hash +": " +e.getMessage(), e);
+            return null;
+        }
+    }
+
+    private void PullPackData(String hash, OnStreamResponseListener<byte[]> response) {
+        if (fActiveConnection != null) {
+            byte[] result = PullPackData(fActiveConnection, hash, response);
+            response.OnResponse(result);
+        } else {
+            AppendableInputStream stdin = new AppendableInputStream();
+
+            SSHFactory
+                    .createInstance(fRepoUrl, "git-upload-pack " + fRepoUrl.repoName)
+                    .SetOnConnectionReadyListener((connection, stdout, stderr) -> {
+                        ListenForErrors(stderr, (msg, exc) -> log.severe(msg));
+                        byte[] data = PullPackData(new ActiveSSHWrapper(connection, stdin.GetWriter(), stdout, stderr), hash, response);
+                        connection.disconnect();
+                        response.OnResponse(data);
+                    })
+                    .connect();
+        }
+    }
+
+    @Override
+    public void FetchCommit(GitRef headRef, OnStreamResponseListener<GitCommit> response) {
+        PullPackData(headRef.GetHash(), new OnStreamResponseListener<byte[]>() {
+            @Override
+            public void OnMsg(String message) {
+                response.OnMsg(message);
+            }
+
+            @Override
+            public void OnResponse(byte[] packData) {
+                try {
+                    Pacman pacman = new PacmanBuilder().build(packData);
+                    OnMsg("Read " +pacman.fPacked.size() +" objects from pack file");
+                    response.OnResponse(pacman.BuildCommit(headRef.GetHash()));
+                }
+                catch (PacmanBuilder.InvalidPackException e) {
+                    response.OnError("Cannot read pack object: " +e.getMessage(), e);
+                    return;
+                }
+            }
+
+            @Override
+            public void OnError(String msg, Throwable e) {
+                response.OnError(msg, e);
+            }
+        });
+    }
+
+    @Override
+    protected void PushPack(GitCommit commit, Pacman pack, OnStreamResponseListener<Void> resp) {
+        if (fActiveConnection != null)
+            fActiveConnection.fConnection.disconnect();
+        AppendableInputStream in = new AppendableInputStream();
+        SSHFactory
+            .createInstance(fRepoUrl, "git-receive-pack " +fRepoUrl.repoName)
+            .SetOnConnectionReadyListener((connection, stdout, stderr) -> {
+                fActiveConnection = new ActiveSSHWrapper(connection, in.GetWriter(), stdout, stderr);
+                GitRef ref = GetHeadRef(GetRefs(fActiveConnection, resp));
+                if (ref == null) {
+                    resp.OnError("Cannot push to " +fRepoUrl.repoName +": branch ref not found", null);
+                    return;
+                }
+                ListenForErrors(stdout, (msg, e) -> resp.OnMsg(msg));
+                ListenForErrors(stderr, (msg, e) -> resp.OnMsg(msg));
+                try {
+                    OutputStream w = in.GetWriter();
+                    w.write(GitLine(ref.GetHash() + " " + GitSha1.BytesToString(GitSha1.getRawSha1OfPackable(commit)) + " " + ref.GetBranch()));
+                    w.write(GitLine(null));
+                    if (!pack.Write(w)) {
+                        resp.OnError("Pack error", null);
+                        return;
+                    }
+                }
+                catch (IOException e) {
+                    resp.OnError("Cannot write pack: " +e.getMessage(), e);
+                }
+                resp.OnResponse(null);
+            })
+            .connect(in);
+    }
+
+    @Override
+    public void FetchBlob(GitObject.GitBlob blob, OnStreamResponseListener<byte[]> response) {
+        if (blob instanceof GitObject.GitRawData)
+            response.OnResponse(((GitObject.GitRawData) blob).GetData());
+        else
+            response.OnError("Unsupported object " +blob.GetHash(), new InvalidParameterException());
+    }
+
+    @Override
+    public void FetchHead(OnStreamResponseListener<GitCommit> response) {
+        AppendableInputStream stdin = new AppendableInputStream();
+
+        SSHFactory
+            .createInstance(fRepoUrl, "git-upload-pack " +fRepoUrl.repoName)
+            .SetOnConnectionReadyListener((connection, stdout, stderr) -> {
+                fActiveConnection = new ActiveSSHWrapper(connection, stdin.GetWriter(), stdout, stderr);
+                GitRef ref = GetHeadRef(GetRefs(fActiveConnection, response));
+                if (ref == null)
+                    return;
+                GetHeadCommitFromRef(ref, new OnStreamResponseListener<GitCommit>() {
+                    @Override
+                    public void OnMsg(String message) {
+                        response.OnMsg(message);
+                    }
+
+                    @Override
+                    public void OnResponse(GitCommit result) {
+                        fActiveConnection.fConnection.disconnect();
+                        fActiveConnection = null;
+                        response.OnResponse(result);
+                    }
+
+                    @Override
+                    public void OnError(String msg, Throwable e) {
+                        fActiveConnection.fConnection.disconnect();
+                        fActiveConnection = null;
+                        response.OnError(msg, e);
+                    }
+                });
+            })
+            .connect(stdin);
+    }
+}

+ 23 - 8
app/src/main/java/info/knacki/pass/git/entities/GitCommit.java

@@ -20,13 +20,13 @@ public class GitCommit implements GitPackable {
     private final String fMessage;
     private Date fTime;
 
-    public GitCommit(String hash, String sha1Content) {
-        int pos = sha1Content.indexOf('\0');
+    public GitCommit(String hash, String commitContent) {
+        int pos = commitContent.indexOf('\0');
 
         if (pos >= 0)
-            sha1Content = sha1Content.substring(pos +1);
+            commitContent = commitContent.substring(pos +1);
         StringBuilder message = null;
-        for (String line: sha1Content.split("\n")) {
+        for (String line: commitContent.split("\n")) {
             if (message != null)
                 message.append(line).append('\n');
             else if ("".equals(line))
@@ -91,16 +91,26 @@ public class GitCommit implements GitPackable {
 
     @Override
     public byte[] GetPack() {
-        String dateStr = (fTime.getTime() / 1000) +" " +(new SimpleDateFormat("Z", Locale.US).format(fTime));
-        String data = "tree " +GitSha1.BytesToString(fTree.GetHash()) +"\n" +
+        String dateStr = "";
+        if (fTime != null)
+            dateStr = " " +(fTime.getTime() / 1000) +" " +(new SimpleDateFormat("Z", Locale.US).format(fTime));
+        String data = "tree " +(fTree == null ? GitSha1.BytesToString(fTreeHash) : GitSha1.BytesToString(fTree.GetHash())) +"\n" +
                 "parent " +GetParent() +"\n" +
-                "author " +GetAuthor() +" " +dateStr +"\n" +
-                "committer " +GetCommitter() +" " +dateStr +"\n" +
+                "author " +GetAuthor() +dateStr +"\n" +
+                "committer " +GetCommitter() +dateStr +"\n" +
                 "\n" +
                 fMessage +"\n";
         return data.getBytes();
     }
 
+    public GitCommit SetTree(GitObject.GitTree tree) {
+        fTree = tree;
+        fTreeHash = GitSha1.getRawSha1OfPackable(tree);
+        return this;
+    }
+
+    public String GetHash() { return fHash; }
+
     public static class Builder {
         private final GitCommit co;
         private final GitObject.GitTree fTree;
@@ -156,4 +166,9 @@ public class GitCommit implements GitPackable {
             return packs;
         }
     }
+
+    @Override
+    public String toString() {
+        return "Commit " +fHash;
+    }
 }

+ 95 - 16
app/src/main/java/info/knacki/pass/git/entities/GitObject.java

@@ -7,7 +7,7 @@ import java.io.File;
 import java.io.FileInputStream;
 import java.io.FileNotFoundException;
 import java.io.IOException;
-import java.io.InputStream;
+import java.util.ArrayDeque;
 import java.util.HashMap;
 import java.util.Map;
 import java.util.SortedSet;
@@ -15,9 +15,11 @@ import java.util.TreeSet;
 import java.util.logging.Logger;
 
 import info.knacki.pass.git.GitSha1;
+import info.knacki.pass.io.CharsetHelper;
+import info.knacki.pass.io.FileUtils;
 
 public abstract class GitObject implements Comparable<GitObject>, GitPackable {
-    final GitTree fParent;
+    GitTree fParent;
     final byte[] fMode;
     final String fName;
     byte[] fSha1;
@@ -50,18 +52,7 @@ public abstract class GitObject implements Comparable<GitObject>, GitPackable {
         }
 
         public static byte[] GetFilePack(File f) throws IOException {
-            ByteArrayOutputStream out = new ByteArrayOutputStream();
-            InputStream input = new FileInputStream(f);
-            int numRead = 0;
-            while (numRead != -1)
-            {
-                byte[] buffer = new byte[1024];
-                numRead = input.read(buffer);
-                if (numRead > 0)
-                    out.write(buffer, 0, numRead);
-            }
-            input.close();
-            return out.toByteArray();
+            return FileUtils.ReadAllStream(new FileInputStream(f));
         }
 
         @Override
@@ -80,8 +71,40 @@ public abstract class GitObject implements Comparable<GitObject>, GitPackable {
         }
     }
 
+    public static class GitRawData extends GitBlob {
+        private final byte[] fData;
+
+        GitRawData(byte[] sha1, byte[] data) {
+            super(null, new byte[] { 49, 48, 48, 54, 52, 52 }, "", sha1);
+            fData = data;
+        }
+
+        public GitRawData(GitTree parent, byte[] mode, String name, byte[] sha1) {
+            super(parent, mode, name, sha1);
+            fData = null;
+        }
+
+        public GitRawData(GitTree parent, GitBlob emptyBlob, GitRawData prev) {
+            super(parent, new byte[] { 49, 48, 48, 54, 52, 52 }, emptyBlob.GetFilename(), prev.GetHash());
+            fData = prev.fData;
+        }
+
+        @Override
+        public eType GetPackableType() {
+            return eType.eType_Blob;
+        }
+
+        @Override
+        public byte[] GetPack() {
+            return GetData();
+        }
+
+        public byte[] GetData() { return fData; }
+    }
+
     public static class GitTree extends GitObject {
         HashMap<String, GitObject> fItems = null;
+        ArrayDeque<String> fItemOrder;
 
         private GitTree(GitTree parent, byte[] mode, String name, byte[] sha1) {
             super(parent, mode, name, sha1);
@@ -95,6 +118,8 @@ public abstract class GitObject implements Comparable<GitObject>, GitPackable {
                 GitObject oCopy = src instanceof GitTree ? new GitTree(this, (GitTree) src) : new GitBlob(this, (GitBlob) src);
                 fItems.put(o.getKey(), oCopy);
             }
+            for (String i: copy.fItemOrder)
+                fItemOrder.push(i);
         }
 
         GitTree(byte[] sha1) {
@@ -105,8 +130,18 @@ public abstract class GitObject implements Comparable<GitObject>, GitPackable {
             super(parent, new byte[] { '4', '0', '0', '0', '0' }, filename, null);
         }
 
+        public GitTree(GitTree parent, GitTree content, String filename) {
+            this(parent, filename);
+            fItems = content.fItems;
+            fItemOrder = content.fItemOrder;
+            for (GitObject i: fItems.values()) {
+                i.SetParent(this);
+            }
+        }
+
         public GitTree Initialize() {
             fItems = new HashMap<>();
+            fItemOrder = new ArrayDeque<>();
             return this;
         }
 
@@ -133,6 +168,7 @@ public abstract class GitObject implements Comparable<GitObject>, GitPackable {
 
         public GitTree AddItem(GitObject item) {
             fItems.put(item.fName, item);
+            fItemOrder.push(item.fName);
             return this;
         }
 
@@ -150,7 +186,10 @@ public abstract class GitObject implements Comparable<GitObject>, GitPackable {
         }
 
         public Iterable<GitObject> GetObjects() {
-            return new TreeSet<>(fItems.values());
+            ArrayDeque<GitObject> objects = new ArrayDeque<>();
+            for (String i: fItemOrder)
+                objects.push(fItems.get(i));
+            return objects;
         }
 
         public Iterable<GitBlob> GetBlobs() {
@@ -175,6 +214,12 @@ public abstract class GitObject implements Comparable<GitObject>, GitPackable {
             return GetFilename() +"/";
         }
 
+        public void SetRealObject(String key, GitObject obj) {
+            if (fItems.containsKey(key)) {
+                fItems.put(key, obj);
+            }
+        }
+
         private void FindAllBlobs(HashMap<String, GitBlob> data) {
             String rootPath = GetPath();
             for (GitBlob i: GetBlobs())
@@ -210,10 +255,12 @@ public abstract class GitObject implements Comparable<GitObject>, GitPackable {
                     newItem = new GitBlob(this, filename, f);
                     fItems.put(filename, newItem);
                 }
+                fItemOrder.push(filename);
             } else if (nextSlash == -1) {
                 Remove(filename);
                 newItem = new GitBlob(this, filename, f);
                 fItems.put(filename, newItem);
+                fItemOrder.push(filename);
             } else {
                 newItem = ((GitTree) obj).AddItem(path.substring(nextSlash + 1), f);
             }
@@ -223,10 +270,40 @@ public abstract class GitObject implements Comparable<GitObject>, GitPackable {
 
         public GitTree Remove(String filename) {
             fItems.remove(filename);
+            fItemOrder.remove(filename);
             fSha1 = null;
             return this;
         }
 
+        public void Fill(byte[] data) {
+            int i = 0;
+
+            while (i < data.length) {
+                byte[] mode = null;
+                int len;
+                for (len =0; i +len <data.length && len < 8; ++len) {
+                    if (data[i + len] == ' ') {
+                        mode = new byte[len];
+                        System.arraycopy(data, i, mode, 0, len);
+                        i += len + 1;
+                        break;
+                    }
+                }
+                if (mode == null)
+                    break;
+                for (len =0; i +len < data.length && data[i +len] != 0; ++len);
+                byte[] filenameBytes = new byte[len];
+                System.arraycopy(data, i, filenameBytes, 0, len);
+                i += len +1;
+                if (i +20 > data.length)
+                    break;
+                byte []sha1 = new byte[20];
+                System.arraycopy(data, i, sha1, 0, 20);
+                i += 20;
+                AddItem(GitObject.factory(this, mode, CharsetHelper.ByteArrayToString(filenameBytes), sha1));
+            }
+        }
+
         @Override
         public eType GetPackableType() {
             return eType.eType_Tree;
@@ -269,7 +346,7 @@ public abstract class GitObject implements Comparable<GitObject>, GitPackable {
         if (mode[0] == '4') {
             return new GitTree(parent, mode, name, sha1);
         }
-        return new GitBlob(parent, mode, name, sha1);
+        return new GitRawData(parent, mode, name, sha1);
     }
 
     public byte[] GetHash() {
@@ -295,6 +372,8 @@ public abstract class GitObject implements Comparable<GitObject>, GitPackable {
         return fParent;
     }
 
+    private void SetParent(GitTree t) { fParent = t; }
+
     public String GetMode() {
         return new String(fMode);
     }

+ 14 - 0
app/src/main/java/info/knacki/pass/git/entities/GitPackable.java

@@ -10,6 +10,20 @@ public interface GitPackable {
         eType_RefDelta
     }
 
+    class PackV2Header {
+        public eType type;
+        public int length;
+
+        public PackV2Header() {
+            this(null, 0);
+        }
+
+        public PackV2Header(eType type, int length) {
+            this.type = type;
+            this.length = length;
+        }
+    }
+
     eType GetPackableType();
     byte[] GetPack();
 }

+ 27 - 0
app/src/main/java/info/knacki/pass/git/entities/GitPackableFactory.java

@@ -0,0 +1,27 @@
+package info.knacki.pass.git.entities;
+
+import info.knacki.pass.git.GitSha1;
+import info.knacki.pass.io.CharsetHelper;
+
+public class GitPackableFactory {
+    public static GitPackable build(GitPackable.eType type, String hash, byte[] data) {
+        switch (type) {
+            case eType_Commit:
+                return new GitCommit(hash, CharsetHelper.ByteArrayToString(data));
+
+            case eType_Tree:
+                GitObject.GitTree tree = new GitObject.GitTree(GitSha1.StringToBytes(hash));
+                tree.Initialize().Fill(data);
+                return tree;
+
+            case eType_Blob:
+                return new GitObject.GitRawData(GitSha1.StringToBytes(hash), data);
+
+            case eType_OfsDelta:
+            case eType_RefDelta:
+            case eType_Tag:
+                break;
+        }
+        return null;
+    }
+}

+ 21 - 2
app/src/main/java/info/knacki/pass/git/entities/GitPackableUtil.java

@@ -23,6 +23,24 @@ public class GitPackableUtil {
         return -1;
     }
 
+    public static GitPackable.eType getObjType(int type) {
+        switch (type) {
+            case 1:
+                return GitPackable.eType.eType_Commit;
+            case 2:
+                return GitPackable.eType.eType_Tree;
+            case 3:
+                return GitPackable.eType.eType_Blob;
+            case 4:
+                return GitPackable.eType.eType_Tag;
+            case 6:
+                return GitPackable.eType.eType_OfsDelta;
+            case 7:
+                return GitPackable.eType.eType_RefDelta;
+        }
+        return null;
+    }
+
     public static String GetObjTypeString(GitPackable.eType type) {
         switch (type) {
             case eType_Commit:
@@ -49,10 +67,11 @@ public class GitPackableUtil {
         return nbBits;
     }
 
-    public static byte[] getObjHeader(GitPackable.eType type, long len) {
+    public static byte[] getObjHeader(GitPackable.PackV2Header header) {
+        int len = header.length;
         ByteArrayOutputStream buffer = new ByteArrayOutputStream();
         byte lastByte;
-        lastByte = (byte) ((getObjType(type)) << 4);
+        lastByte = (byte) ((getObjType(header.type)) << 4);
         int nbBitToWrite = bitToWrite(len);
         nbBitToWrite -= 4;
         lastByte |= (byte) (len & 0b1111);

+ 5 - 1
app/src/main/java/info/knacki/pass/git/entities/GitRef.java

@@ -8,7 +8,11 @@ public class GitRef {
 
     public GitRef(String hash, String branch) {
         fHash = hash;
-        fBranch = branch;
+        int pos = branch.indexOf('\0');
+        if (pos >= 0)
+            fBranch = branch.substring(0, pos);
+        else
+            fBranch = branch;
     }
 
     public String GetBranch() {

+ 68 - 0
app/src/main/java/info/knacki/pass/io/AppendableInputStream.java

@@ -0,0 +1,68 @@
+package info.knacki.pass.io;
+
+import org.bouncycastle.crypto.tls.ByteQueue;
+
+import java.io.InputStream;
+import java.io.OutputStream;
+
+public class AppendableInputStream extends InputStream {
+    private boolean fClosed;
+    private final ByteQueue buffer;
+    private final Writer fWriter;
+
+    public class Writer extends OutputStream {
+        @Override
+        public void write(int b) {
+            buffer.addData(new byte[] {(byte) (b & 0xFF)}, 0, 1);
+        }
+
+        public void AddBytes(byte[] bytes) {
+            buffer.addData(bytes, 0, bytes.length);
+        }
+
+        public void close() {
+            fClosed = true;
+        }
+    }
+
+    public AppendableInputStream() {
+        buffer = new ByteQueue(256);
+        fWriter = this.new Writer();
+        fClosed = false;
+    }
+
+    public Writer GetWriter() {
+        return fWriter;
+    }
+
+    @Override
+    public int read() {
+        if (buffer.available() > 0)
+            return buffer.removeData(1, 0)[0] & 0xFF;
+        return -1;
+    }
+
+    @Override
+    public int read(byte[] b) {
+        return read(b, 0, b.length);
+    }
+
+    @Override
+    public int read(byte[] b, int off, int len) {
+        if (fClosed)
+            return 0;
+        int maxRead = Math.min(len, available());
+        buffer.removeData(b, off, maxRead, 0);
+        return maxRead;
+    }
+
+    @Override
+    public int available() {
+        return fClosed ? 0 : buffer.available();
+    }
+
+    @Override
+    public void close() {
+        fWriter.close();
+    }
+}

+ 6 - 2
app/src/main/java/info/knacki/pass/io/CharsetHelper.java

@@ -9,13 +9,17 @@ public class CharsetHelper {
     public final static String DEFAULT_CHARSET = "UTF-8";
 
     public static String ByteArrayToString(byte[] arr) {
+        return ByteArrayToString(arr, 0, arr.length);
+    }
+
+    public static String ByteArrayToString(byte[] arr, int offset, int len) {
         try {
-            return new String(arr, DEFAULT_CHARSET);
+            return new String(arr, offset, Math.min(len, arr.length -offset), DEFAULT_CHARSET);
         }
         catch (UnsupportedEncodingException e) {
             log.severe("Unsupported charset " +DEFAULT_CHARSET);
         }
-        return new String(arr, Charset.defaultCharset());
+        return new String(arr, offset, Math.min(len, arr.length -offset), Charset.defaultCharset());
     }
 
     public static byte[] StringToByteArray(String str) {

+ 0 - 1
app/src/main/java/info/knacki/pass/io/FileUtils.java

@@ -1,7 +1,6 @@
 package info.knacki.pass.io;
 
 import java.io.BufferedReader;
-import java.io.ByteArrayInputStream;
 import java.io.ByteArrayOutputStream;
 import java.io.File;
 import java.io.FileReader;

+ 12 - 11
app/src/main/java/info/knacki/pass/io/NetworkUtils.java

@@ -21,24 +21,25 @@ import java.util.zip.InflaterInputStream;
 public class NetworkUtils {
     private final static Logger log = Logger.getLogger(NetworkUtils.class.getName());
 
-    public interface NetworkConfig {
+    public interface AuthConfig {
         boolean HasAuthentication();
         String GetUser();
         String GetPassword();
+        String GetPrivateKey();
     }
 
     static class DownloaderTask extends AsyncTask<Void, Void, Integer> {
         private final URL fUrl;
         private OnResponseListener<byte[]> fResp;
-        private final NetworkConfig fConfig;
+        private final AuthConfig fConfig;
 
-        DownloaderTask(URL url, NetworkConfig config, OnResponseListener<byte[]> resp) {
+        DownloaderTask(URL url, AuthConfig config, OnResponseListener<byte[]> resp) {
             fUrl = url;
             fResp = resp;
             fConfig = config;
         }
 
-        DownloaderTask(URL url, NetworkConfig config) {
+        DownloaderTask(URL url, AuthConfig config) {
             fUrl = url;
             fConfig = config;
         }
@@ -84,11 +85,11 @@ public class NetworkUtils {
     static class PostTask extends AsyncTask<Void, Void, Integer> {
         private final URL fUrl;
         private final OnResponseListener<byte[]> fResp;
-        private final NetworkConfig fConfig;
+        private final AuthConfig fConfig;
         private final OnResponseListener<OutputStream> fOnReadyWrite;
         private final Map<String, String> fHeaders;
 
-        PostTask(URL url, NetworkConfig config, Map<String, String> headers, OnResponseListener<byte[]> resp, OnResponseListener<OutputStream> onReadyWrite) {
+        PostTask(URL url, AuthConfig config, Map<String, String> headers, OnResponseListener<byte[]> resp, OnResponseListener<OutputStream> onReadyWrite) {
             fUrl = url;
             fResp = resp;
             fConfig = config;
@@ -167,7 +168,7 @@ public class NetworkUtils {
     }
 
     static class DownloaderWithInflaterTask extends DownloaderTask {
-        DownloaderWithInflaterTask(URL url, NetworkConfig config, OnResponseListener<byte[]> resp) {
+        DownloaderWithInflaterTask(URL url, AuthConfig config, OnResponseListener<byte[]> resp) {
             super(url, config, resp);
         }
 
@@ -196,7 +197,7 @@ public class NetworkUtils {
     static class StringDownloaderTask extends DownloaderTask {
         private final OnResponseListener<String> onResp;
 
-        StringDownloaderTask(URL url, NetworkConfig config, OnResponseListener<String> resultHandler) {
+        StringDownloaderTask(URL url, AuthConfig config, OnResponseListener<String> resultHandler) {
             super(url, config);
             SetResultHandler(new OnResponseListener<byte[]>() {
                 @Override
@@ -213,15 +214,15 @@ public class NetworkUtils {
         }
     }
 
-    public static void protoInflateGet(final URL url, NetworkConfig config, final OnResponseListener<byte[]> callback) {
+    public static void protoInflateGet(final URL url, AuthConfig config, final OnResponseListener<byte[]> callback) {
         new DownloaderWithInflaterTask(url, config, callback).execute();
     }
 
-    public static void ProtoPost(URL url, Map<String, String> headers, NetworkConfig config, OnResponseListener<byte[]> callback, OnResponseListener<OutputStream> onReadyWrite) {
+    public static void ProtoPost(URL url, Map<String, String> headers, AuthConfig config, OnResponseListener<byte[]> callback, OnResponseListener<OutputStream> onReadyWrite) {
         new PostTask(url, config, headers, callback, onReadyWrite).execute();
     }
 
-    public static void protoGet(final URL url, NetworkConfig config, final OnResponseListener<String> callback) {
+    public static void protoGet(final URL url, AuthConfig config, final OnResponseListener<String> callback) {
         new StringDownloaderTask(url, config, callback).execute();
     }
 }

+ 5 - 0
app/src/main/java/info/knacki/pass/io/PathUtils.java

@@ -7,6 +7,7 @@ public class PathUtils {
     @SuppressWarnings("SpellCheckingInspection")
     private static final String DATA_GIT_LOCAL = "gitfiles";
     private static final String DATA_FINGER_LOCAL = "fingerprint";
+    private static final String DATA_PRIVATE_SSH_KEY = "id_rsa";
     private static final String DATA_GPG_FILE = "key";
 
     private static String GetAppRootDir(Context ctx) {
@@ -29,6 +30,10 @@ public class PathUtils {
         return GetAppRootDir(ctx) +"/" +DATA_GPG_FILE;
     }
 
+    public static String GetPrivateRSAKey(Context ctx) {
+        return GetAppRootDir(ctx) +"/" +DATA_PRIVATE_SSH_KEY;
+    }
+
     public static final String TRASH_SUFFIX = ".trash";
 
     public static boolean IsHidden(String path) {

+ 123 - 0
app/src/main/java/info/knacki/pass/io/ssh/JSchWrapper.java

@@ -0,0 +1,123 @@
+package info.knacki.pass.io.ssh;
+
+import android.os.AsyncTask;
+
+import com.jcraft.jsch.ChannelExec;
+import com.jcraft.jsch.JSch;
+import com.jcraft.jsch.JSchException;
+import com.jcraft.jsch.Session;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.PipedInputStream;
+import java.io.PipedOutputStream;
+import java.security.InvalidParameterException;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+import info.knacki.pass.io.FileUtils;
+
+public class JSchWrapper extends AsyncTask<InputStream, Void, Void> implements SSHConnection {
+    private static final Logger log = Logger.getLogger(JSchWrapper.class.getName());
+    private final String fCommand;
+    private final ConnectionParams fAuth;
+    private OnConnectionReady fReadyListener;
+
+    private final PipedOutputStream stdout_out = new PipedOutputStream();
+    private final PipedOutputStream stderr_out = new PipedOutputStream();
+    private final PipedInputStream stdout = new PipedInputStream();
+    private final PipedInputStream stderr = new PipedInputStream();
+
+    private ChannelExec fChannel = null;
+
+    public JSchWrapper(ConnectionParams config, String command) {
+        fCommand = command;
+        fAuth = config;
+    }
+
+    @Override
+    public SSHConnection SetOnConnectionReadyListener(OnConnectionReady listener) {
+        fReadyListener = listener;
+        return this;
+    }
+
+    @Override
+    public byte[] GetOutputStd() throws IOException {
+        return FileUtils.ReadAllStream(stdout);
+    }
+
+    @Override
+    public byte[] GetOutputErr() throws IOException {
+        return FileUtils.ReadAllStream(stderr);
+    }
+
+    @Override
+    public SSHConnection connect() {
+        if (fChannel != null)
+            throw new InvalidParameterException("JSch Already connected");
+        execute();
+        return this;
+    }
+
+    @Override
+    public SSHConnection connect(InputStream stdin) {
+        if (fChannel != null)
+            throw new InvalidParameterException("JSch Already connected");
+        execute(stdin);
+        return this;
+    }
+
+    @Override
+    protected Void doInBackground(InputStream... in) {
+        InitConnection(in == null || in.length == 0 ? null : in[0]);
+        if (fChannel != null) {
+            try {
+                stderr.connect(stderr_out);
+                stdout.connect(stdout_out);
+            }
+            catch (IOException e) {
+                log.log(Level.SEVERE, "Cannot read from output: " +e.getMessage(), e);
+            }
+            if (fReadyListener != null)
+                fReadyListener.OnConnectionReady(this, stdout, stderr);
+        }
+        return null;
+    }
+
+    private void InitConnection(InputStream in) {
+        try {
+            // Connection settings
+            JSch connection = new JSch();
+            String privateKey = fAuth.GetPrivateKey();
+            Session session = connection.getSession(fAuth.GetUser(), fAuth.GetHostname());
+            session.setConfig("StrictHostKeyChecking", "no"); // FIXME
+            if (privateKey != null && !privateKey.isEmpty()) {
+                connection.addIdentity(privateKey); // FIXME key passphrase
+            } else {
+                session.setPassword(fAuth.GetPassword());
+            }
+            session.connect();
+            fChannel = (ChannelExec) session.openChannel("exec");
+
+            // Stream settings
+            if (in != null)
+                fChannel.setInputStream(in);
+            fChannel.setErrStream(stderr_out);
+            fChannel.setOutputStream(stdout_out);
+
+            // do connect
+            fChannel.setCommand(fCommand);
+            fChannel.connect();
+        }
+        catch (JSchException e) {
+            log.log(Level.SEVERE, "Cannot Initiate ssh command: " +e.getMessage(), e);
+            fChannel = null;
+        }
+    }
+
+    @Override
+    public void disconnect() {
+        fChannel.disconnect();
+        fChannel = null;
+    }
+}

+ 23 - 0
app/src/main/java/info/knacki/pass/io/ssh/SSHConnection.java

@@ -0,0 +1,23 @@
+package info.knacki.pass.io.ssh;
+
+import java.io.IOException;
+import java.io.InputStream;
+
+import info.knacki.pass.io.NetworkUtils;
+
+public interface SSHConnection {
+    interface OnConnectionReady {
+        void OnConnectionReady(SSHConnection connection, InputStream stdout, InputStream stderr);
+    }
+
+    interface ConnectionParams extends NetworkUtils.AuthConfig {
+        String GetHostname();
+    }
+
+    SSHConnection SetOnConnectionReadyListener(OnConnectionReady listener);
+    byte[] GetOutputStd() throws IOException;
+    byte[] GetOutputErr() throws IOException;
+    SSHConnection connect();
+    SSHConnection connect(InputStream input);
+    void disconnect();
+}

+ 7 - 0
app/src/main/java/info/knacki/pass/io/ssh/SSHFactory.java

@@ -0,0 +1,7 @@
+package info.knacki.pass.io.ssh;
+
+public class SSHFactory {
+    public static SSHConnection createInstance(SSHConnection.ConnectionParams sshParams, String command) {
+        return new JSchWrapper(sshParams, command);
+    }
+}

+ 36 - 10
app/src/main/java/info/knacki/pass/settings/SettingsManager.java

@@ -5,12 +5,14 @@ import android.content.SharedPreferences;
 import android.util.JsonReader;
 import android.util.MalformedJsonException;
 
+import java.io.File;
 import java.io.IOException;
 import java.io.StringReader;
 import java.util.logging.Level;
 import java.util.logging.Logger;
 
 import info.knacki.pass.io.NetworkUtils;
+import info.knacki.pass.io.PathUtils;
 
 public class SettingsManager {
     private static final Logger log = Logger.getLogger(SettingsManager.class.getName());
@@ -47,9 +49,9 @@ public class SettingsManager {
     }
 
     public static abstract class VCS {
-        static VCS factory(String system, String data) {
+        static VCS factory(Context ctx, String system, String data) {
             if ("git".equals(system))
-                return new Git(data);
+                return new Git(ctx, data);
             return null;
         }
 
@@ -57,15 +59,18 @@ public class SettingsManager {
         public abstract String GetData();
     }
 
-    public static class Git extends VCS implements NetworkUtils.NetworkConfig {
+    public static class Git extends VCS implements NetworkUtils.AuthConfig {
         String fUrl;
         String fUser;
         String fPassword;
+        boolean fPrivKey;
         String fBranch;
         String fUsername;
         String fUserEmail;
+        private final Context fContext;
 
-        Git(String data) {
+        Git(Context ctx, String data) {
+            fContext = ctx;
             if (null == data || data.isEmpty()) {
                 reset();
                 return;
@@ -99,21 +104,21 @@ public class SettingsManager {
                         case "email":
                             fUserEmail = reader.nextString();
                             break;
+                        case "privKey":
+                            fPrivKey = reader.nextBoolean();
+                            break;
                     }
                 }
                 reader.close();
             }
-            catch (MalformedJsonException e) {
-                log.log(Level.SEVERE, e.getMessage(), e);
-                reset();
-            }
             catch (IOException e) {
                 log.log(Level.SEVERE, e.getMessage(), e);
                 reset();
             }
         }
 
-        public Git() {
+        public Git(Context ctx) {
+            fContext = ctx;
             reset();
         }
 
@@ -124,6 +129,7 @@ public class SettingsManager {
             fPassword = "";
             fUsername = "";
             fUserEmail = "";
+            fPrivKey = false;
         }
 
         public String GetName() {
@@ -144,14 +150,32 @@ public class SettingsManager {
             fPassword = pass;
         }
 
+        @Override
         public String GetPassword() {
             return fPassword;
         }
 
+        public void SetPrivateKey(boolean usePrivateKey) {
+            fPrivKey = usePrivateKey;
+        }
+
+        @Override
+        public String GetPrivateKey() {
+            if (!fPrivKey)
+                return null;
+            String privKeyFile = PathUtils.GetPrivateRSAKey(fContext);
+            return new File(privKeyFile).exists() ? privKeyFile : null;
+        }
+
+        public boolean HasPrivateKey() {
+            return fPrivKey;
+        }
+
         public void SetUser(String user) {
             fUser = user;
         }
 
+        @Override
         public String GetUser() {
             return fUser;
         }
@@ -172,6 +196,7 @@ public class SettingsManager {
             return fBranch;
         }
 
+        @Override
         public boolean HasAuthentication() {
             return !"".equals(fUser);
         }
@@ -185,6 +210,7 @@ public class SettingsManager {
                     "\"url\":\"" +jsonEscape(fUrl) +"\"," +
                     "\"user\":\"" +jsonEscape(fUser) +"\"," +
                     "\"pass\":\"" +jsonEscape(fPassword) +"\"," +
+                    "\"privKey\":" +(fPrivKey ? "true" : "false") +"," +
                     "\"branch\":\"" +jsonEscape(fBranch) +"\"," +
                     "\"username\":\"" +jsonEscape(fUsername) +"\"," +
                     "\"email\":\"" +jsonEscape(fUserEmail) +"\"" +
@@ -210,7 +236,7 @@ public class SettingsManager {
 
     public static VCS GetVCS(Context ctx) {
         SharedPreferences prefs = GetPrefManager(ctx);
-        return VCS.factory(prefs.getString(VCS.class.getSimpleName(), ""), prefs.getString(VCS.class.getSimpleName()+"_data", ""));
+        return VCS.factory(ctx, prefs.getString(VCS.class.getSimpleName(), ""), prefs.getString(VCS.class.getSimpleName()+"_data", ""));
     }
 
     public static void SetVCS(Context ctx, VCS vcs) {

+ 75 - 11
app/src/main/java/info/knacki/pass/settings/ui/SettingsActivity.java

@@ -45,9 +45,10 @@ import info.knacki.pass.git.entities.GitRef;
 import info.knacki.pass.io.FileInterfaceFactory;
 import info.knacki.pass.io.FileMigratoryUtils;
 import info.knacki.pass.io.FileUtils;
+import info.knacki.pass.io.OnResponseListener;
+import info.knacki.pass.io.PathUtils;
 import info.knacki.pass.io.pgp.GPGStorageEngine;
 import info.knacki.pass.io.pgp.GPGUtil;
-import info.knacki.pass.io.OnResponseListener;
 import info.knacki.pass.settings.SettingsManager;
 import info.knacki.pass.ui.GitPullActivity;
 import info.knacki.pass.ui.alertPrompt.AlertPromptGenerator;
@@ -234,6 +235,8 @@ public class SettingsActivity extends AppCompatPreferenceActivity {
     }
 
     public static class VCSPreferenceFragment extends PreferenceFragment {
+        private static final int ACTIVITY_REQUEST_CODE_BROWSE_PRIVATE_KEY = 2;
+
         @Override
         public void onCreate(Bundle savedInstanceState) {
             super.onCreate(savedInstanceState);
@@ -254,10 +257,8 @@ public class SettingsActivity extends AppCompatPreferenceActivity {
             findPreference(getResources().getString(R.string.id_vcs_enable)).setOnPreferenceChangeListener(new PrefListener() {
                 @Override
                 void savePref(Preference preference, Object o) {
-                    if ((Boolean) o)
-                        SettingsManager.SetVCS(getActivity(), new SettingsManager.Git());
-                    else
-                        SettingsManager.SetVCS(getActivity(), null);
+                    Context ctx = getActivity();
+                    SettingsManager.SetVCS(ctx, (boolean) o ? new SettingsManager.Git(ctx) : null);
                 }
             });
             findPreference(getResources().getString(R.string.id_vcs_list)).setOnPreferenceChangeListener(new PrefListener() {
@@ -268,7 +269,7 @@ public class SettingsActivity extends AppCompatPreferenceActivity {
 
                     switch (Integer.parseInt((String) o)) {
                         case 0:
-                            newVcs = new SettingsManager.Git();
+                            newVcs = new SettingsManager.Git(getActivity());
                             break;
                         default:
                             return;
@@ -300,9 +301,20 @@ public class SettingsActivity extends AppCompatPreferenceActivity {
                 void savePref(Preference preference, Object o) {
                     SettingsManager.Git git = (SettingsManager.Git) SettingsManager.GetVCS(getActivity());
                     git.SetPassword(((String) o).trim());
+                    git.SetPrivateKey(false);
                     SettingsManager.SetVCS(getActivity(), git);
                 }
             });
+            findPreference(getResources().getString(R.string.id_vcs_git_private_key)).setOnPreferenceClickListener(new Preference.OnPreferenceClickListener() {
+                @Override
+                public boolean onPreferenceClick(Preference preference) {
+                    Intent i = new Intent(Intent.ACTION_GET_CONTENT);
+                    i.setType("*/*");
+                    i.addCategory(Intent.CATEGORY_OPENABLE);
+                    VCSPreferenceFragment.this.startActivityForResult(i, ACTIVITY_REQUEST_CODE_BROWSE_PRIVATE_KEY);
+                    return true;
+                }
+            });
             findPreference(getResources().getString(R.string.id_vcs_git_ci_username)).setOnPreferenceChangeListener(new PrefListener() {
                 @Override
                 void savePref(Preference preference, Object o) {
@@ -381,6 +393,9 @@ public class SettingsActivity extends AppCompatPreferenceActivity {
                     gitPass.setSummary(((SettingsManager.Git) versioning).GetPassword().length() == 0 ? "" : "******");
                     gitPass.setText(((SettingsManager.Git) versioning).GetPassword());
 
+                    Preference sshKey = FindPreference(R.string.id_vcs_git_private_key);
+                    sshKey.setSummary(((SettingsManager.Git) versioning).HasPrivateKey() ? R.string.pref_vcs_git_private_key_change : R.string.pref_vcs_git_private_key_set);
+
                     FindPreference(R.string.id_vcs_git_commit_info_category);
                     ListPreference gitBranches = (ListPreference) FindPreference(R.string.id_vcs_git_branches);
                     populateBranches(gitBranches, (SettingsManager.Git) versioning);
@@ -397,6 +412,7 @@ public class SettingsActivity extends AppCompatPreferenceActivity {
                 } else {
                     RemovePrefs(R.string.id_vcs_git_url,
                             R.string.id_vcs_git_user,
+                            R.string.id_vcs_git_private_key,
                             R.string.id_vcs_git_pass,
                             R.string.id_vcs_git_branches,
                             R.string.id_vcs_git_pull,
@@ -414,6 +430,7 @@ public class SettingsActivity extends AppCompatPreferenceActivity {
                 RemovePrefs(R.string.id_vcs_git_url,
                     R.string.id_vcs_git_user,
                     R.string.id_vcs_git_pass,
+                    R.string.id_vcs_git_private_key,
                     R.string.id_vcs_git_branches,
                     R.string.id_vcs_git_pull,
                     R.string.id_vcs_git_ci_username,
@@ -423,10 +440,24 @@ public class SettingsActivity extends AppCompatPreferenceActivity {
             }
         }
 
+        protected void clearBranches(final ListPreference gitBranches) {
+            gitBranches.setEntryValues(new CharSequence[]{});
+            gitBranches.setEntries(new CharSequence[]{});
+            gitBranches.setEnabled(false);
+        }
+
         protected void populateBranches(final ListPreference gitBranches, final SettingsManager.Git versioning) {
             gitBranches.setEnabled(false);
-            GitInterface gitInterface = GitInterfaceFactory.factory(versioning);
-            if (gitInterface != null) {
+            if (versioning != null) {
+                GitInterface gitInterface;
+                try {
+                    gitInterface = GitInterfaceFactory.factory(versioning);
+                }
+                catch (GitInterfaceFactory.GitInterfaceException e) {
+                    Toast.makeText(getActivity(), e.getMessage(), Toast.LENGTH_LONG).show();
+                    clearBranches(gitBranches);
+                    return;
+                }
                 gitInterface.GetRefs(new OnResponseListener<GitRef[]>() {
                     @Override
                     public void OnResponse(final GitRef[] result) {
@@ -457,9 +488,7 @@ public class SettingsActivity extends AppCompatPreferenceActivity {
                     }
                 });
             } else {
-                gitBranches.setEntryValues(new CharSequence[]{});
-                gitBranches.setEntries(new CharSequence[]{});
-                gitBranches.setEnabled(false);
+                clearBranches(gitBranches);
             }
         }
 
@@ -472,6 +501,41 @@ public class SettingsActivity extends AppCompatPreferenceActivity {
             }
             return super.onOptionsItemSelected(item);
         }
+
+        private boolean SetPrivateKeyFile(InputStream in) {
+            SettingsManager.Git git = (SettingsManager.Git) SettingsManager.GetVCS(getActivity());
+            if (in != null) {
+                try {
+                    File fout = new File(PathUtils.GetPrivateRSAKey(getActivity()));
+                    if (!fout.exists() && !fout.createNewFile()) {
+                        throw new FileNotFoundException();
+                    }
+                    FileUtils.pipe(in, new FileOutputStream(fout));
+                } catch (IOException e) {
+                    log.log(Level.SEVERE, "Cannot import private key: " + e.getMessage(), e);
+                    return false;
+                }
+            }
+            git.SetPrivateKey(in != null);
+            git.SetPassword("");
+            SettingsManager.SetVCS(getActivity(), git);
+            VCSPreferenceFragment.this.reload();
+            return true;
+        }
+
+        @Override
+        public void onActivityResult(int requestCode, int resultCode, Intent data) {
+            if (requestCode == ACTIVITY_REQUEST_CODE_BROWSE_PRIVATE_KEY && resultCode == RESULT_OK) {
+                try {
+                    final Uri intentData = data.getData();
+                    boolean importStatus = SetPrivateKeyFile(intentData == null ? null : getActivity().getContentResolver().openInputStream(intentData));
+                    Toast.makeText(getActivity(), getResources().getString(importStatus ? R.string.gpg_import_ok : R.string.gpg_import_ko), Toast.LENGTH_LONG).show();
+                } catch (FileNotFoundException e) {
+                    Toast.makeText(getActivity(), getResources().getString(R.string.file_not_found), Toast.LENGTH_LONG).show();
+                    GPGStorageEngine.GetDefaultEngine(getActivity()).RemoveGpgKey();
+                }
+            }
+        }
     }
 
     public static class GPGPreferenceFragment extends PreferenceFragment {

+ 36 - 28
app/src/main/java/info/knacki/pass/ui/GitPullActivity.java

@@ -62,8 +62,8 @@ public class GitPullActivity extends AppCompatActivity {
             return;
         }
         LOCAL_GIT_HASH_VERSION_FILE = PathUtils.GetGitFile(this);
-        fGitInterface = GitInterfaceFactory.factory((SettingsManager.Git) versioning);
-        if (fGitInterface != null) {
+        try {
+            fGitInterface = GitInterfaceFactory.factory((SettingsManager.Git) versioning);
             fGitInterface.FetchHead(new OnStreamResponseListener<GitCommit>() {
                 @Override
                 public void OnMsg(final String msg) {
@@ -79,16 +79,16 @@ public class GitPullActivity extends AppCompatActivity {
                 @Override
                 public void OnError(final String msg, Throwable e) {
                     GitPullActivity.this.runOnUiThread(() -> {
-                        ProgressBar pg = findViewById(R.id.progressBar);
-                        pg.setIndeterminate(false);
-                        pg.setMax(1);
-                        pg.setProgress(1);
-                        TextView logView = findViewById(R.id.logView);
-                        logView.append(msg +"\n");
-                        findViewById(R.id.close_bt).setEnabled(true);
+                        OnMsg(msg);
+                        FinishLoading();
                     });
                 }
             });
+        } catch (GitInterfaceFactory.GitInterfaceException e) {
+            GitPullActivity.this.runOnUiThread(() -> {
+                OnMsg(e.getMessage());
+                FinishLoading();
+            });
         }
         findViewById(R.id.close_bt).setOnClickListener(v -> GitPullActivity.this.finish());
     }
@@ -194,30 +194,28 @@ public class GitPullActivity extends AppCompatActivity {
                 RmEmptyDirs(i, false);
     }
 
+    void FinishLoading() {
+        ProgressBar pg = findViewById(R.id.progressBar);
+        pg.setIndeterminate(false);
+        pg.setMax(1);
+        pg.setProgress(1);
+        RmEmptyDirs(new File(PathUtils.GetPassDir(GitPullActivity.this)), true);
+        findViewById(R.id.close_bt).setEnabled(true);
+    }
+
     void SyncFiles(final GitLocal localVersion, final HashMap<String, GitObject.GitBlob> filesToPull, final Set<String> filesToPush) {
         final OnStreamResponseListener<Void> allDone = new OnStreamResponseListener<Void>() {
             @Override
             public void OnResponse(Void result) {
                 GitPullActivity.this.runOnUiThread(() -> {
-                    ProgressBar pg = findViewById(R.id.progressBar);
-                    pg.setIndeterminate(false);
-                    pg.setMax(1);
-                    pg.setProgress(1);
                     localVersion.Write(new File(LOCAL_GIT_HASH_VERSION_FILE));
-                    RmEmptyDirs(new File(PathUtils.GetPassDir(GitPullActivity.this)), true);
-                    findViewById(R.id.close_bt).setEnabled(true);
+                    FinishLoading();
                 });
             }
 
             @Override
             public void OnError(String msg, Throwable e) {
-                GitPullActivity.this.runOnUiThread(() -> {
-                    ProgressBar pg = findViewById(R.id.progressBar);
-                    pg.setIndeterminate(false);
-                    pg.setMax(1);
-                    pg.setProgress(1);
-                    findViewById(R.id.close_bt).setEnabled(true);
-                });
+                GitPullActivity.this.runOnUiThread(() -> FinishLoading());
             }
 
             @Override
@@ -241,7 +239,7 @@ public class GitPullActivity extends AppCompatActivity {
             afterFetching.run();
         } else {
             OnMsg("Updating local repository");
-            DownloadBlobs(filesToPull, localVersion, new OnResponseListener<Void>() {
+            DownloadBlobs(filesToPull, localVersion, new OnStreamResponseListener<Void>() {
                 @Override
                 public void OnResponse(Void result) {
                     afterFetching.run();
@@ -251,11 +249,16 @@ public class GitPullActivity extends AppCompatActivity {
                 public void OnError(String msg, Throwable e) {
                     allDone.OnError(msg, e);
                 }
+
+                @Override
+                public void OnMsg(String message) {
+                    OnMsg(message);
+                }
             });
         }
     }
 
-    void DownloadBlobs(Map<String, GitObject.GitBlob> blobs, final GitLocal localVersion, final OnResponseListener<Void> resp) {
+    void DownloadBlobs(Map<String, GitObject.GitBlob> blobs, final GitLocal localVersion, final OnStreamResponseListener<Void> resp) {
         if (blobs.size() == 0) {
             resp.OnResponse(null);
             return;
@@ -266,12 +269,12 @@ public class GitPullActivity extends AppCompatActivity {
             logView.append(" > " +i +"\n");
         final Stack<Map.Entry<String, GitObject.GitBlob>> files = new Stack<>();
         files.addAll(blobs.entrySet());
-        final OnResponseListener<byte[]> downloader = new OnResponseListener<byte[]>() {
+        final OnStreamResponseListener<byte[]> downloader = new OnStreamResponseListener<byte[]>() {
             @Override
             public void OnResponse(final byte[] result) {
                 final String filename = files.peek().getKey();
                 final GitObject.GitBlob blob = files.peek().getValue();
-                final OnResponseListener<byte[]> _this = this;
+                final OnStreamResponseListener<byte[]> _this = this;
 
                 GitPullActivity.this.runOnUiThread(() -> {
                     WriteFile(filename, localVersion, blob, result);
@@ -294,12 +297,17 @@ public class GitPullActivity extends AppCompatActivity {
                     resp.OnResponse(null);
                 }
             }
+
+            @Override
+            public void OnMsg(String message) {
+                resp.OnMsg(message);
+            }
         };
 
         DownloadNext(files, localVersion, downloader, resp);
     }
 
-    void DownloadNext(Stack<Map.Entry<String, GitObject.GitBlob>> files, GitLocal localCache, OnResponseListener<byte[]> downloader, OnResponseListener<Void> resp) {
+    void DownloadNext(Stack<Map.Entry<String, GitObject.GitBlob>> files, GitLocal localCache, OnStreamResponseListener<byte[]> downloader, OnResponseListener<Void> resp) {
         if (!files.empty()) {
             if (files.peek().getValue() == null) {
                 // remove file
@@ -344,7 +352,7 @@ public class GitPullActivity extends AppCompatActivity {
             final GitCommit.Builder commit = new GitCommit.Builder(fHeadCommit, config.GetUsername(), config.GetUserEmail(), COMMIT_MSG);
             for (String i : files)
                 commit.AddFile(i, new File(PathUtils.GetPassDir(this) + i));
-            fGitInterface.PushBlobs(commit, new OnStreamResponseListener<Void>() {
+            fGitInterface.PushCommitBuilder(commit, new OnStreamResponseListener<Void>() {
                 @Override
                 public void OnMsg(String message) {
                     resp.OnMsg(message);

+ 2 - 0
app/src/main/java/info/knacki/pass/ui/MainActivity.java

@@ -124,6 +124,8 @@ public class MainActivity extends AppCompatActivity implements PasswordEditListe
         ((ScrollView)findViewById(R.id.passwordListContainer)).addView(fPasswordListView);
 
         requestPermissions();
+
+        startActivity(new Intent(this, GitPullActivity.class));
     }
 
     @Override

+ 3 - 0
app/src/main/res/values-fr/lang.xml

@@ -29,6 +29,9 @@
     <string name="pref_vcs_url">Addresse du dépot git</string>
     <string name="pref_vcs_git_user_title">Nom d\'utilisateur</string>
     <string name="pref_vcs_git_pass_title">Mot de passe</string>
+    <string name="pref_vcs_git_private_key_title">Clée privée pour les connections SSH</string>
+    <string name="pref_vcs_git_private_key_set">Choisir sa clée privée</string>
+    <string name="pref_vcs_git_private_key_change">Changer sa clée privée</string>
     <string name="pref_vcs_branch_title">Branche GIT</string>
     <string name="pref_vcs_git_pull">Git pull</string>
     <string name="pref_vcs_git_auth_category">Authentication</string>

+ 3 - 0
app/src/main/res/values/lang.xml

@@ -35,6 +35,9 @@
     <string name="pref_vcs_url">Git repo address</string>
     <string name="pref_vcs_git_user_title">Username</string>
     <string name="pref_vcs_git_pass_title">Password</string>
+    <string name="pref_vcs_git_private_key_title">Private key for SSH connections</string>
+    <string name="pref_vcs_git_private_key_set">Set RSA private key</string>
+    <string name="pref_vcs_git_private_key_change">Change RSA private key</string>
     <string name="pref_vcs_branch_title">Git branch</string>
     <string name="pref_vcs_git_pull">Git pull</string>
     <string name="pref_vcs_git_auth_category">Authentication</string>

+ 2 - 1
app/src/main/res/values/strings.xml

@@ -1,5 +1,5 @@
 <resources>
-    <string name="app_name" translatable="false">pass</string>
+    <string name="app_name" translatable="false">pass 1.1</string>
     <string-array name="pref_vcs_list_values">
         <item>0</item>
     </string-array>
@@ -11,6 +11,7 @@
     <string name="id_vcs_git_url" translatable="false">id_vcs_git_url</string>
     <string name="id_vcs_git_user" translatable="false">id_vcs_git_user</string>
     <string name="id_vcs_git_pass" translatable="false">id_vcs_git_pass</string>
+    <string name="id_vcs_git_private_key" translatable="false">id_vcs_git_private_key</string>
     <string name="id_vcs_git_branches" translatable="false">id_vcs_git_branches</string>
     <string name="id_vcs_git_pull" translatable="false">id_vcs_git_pull</string>
     <string name="id_vcs_git_ci_username" translatable="false">id_vcs_git_ci_username</string>

+ 3 - 0
app/src/main/res/xml/pref_vcs.xml

@@ -28,6 +28,9 @@
         android:key="@string/id_vcs_git_pass"
         android:title="@string/pref_vcs_git_pass_title"
         android:inputType="textPassword"/>
+    <Preference
+        android:key="@string/id_vcs_git_private_key"
+        android:title="@string/pref_vcs_git_private_key_title"/>
 
     <PreferenceCategory
         android:title="@string/pref_vcs_git_commit_info_category"