diff --git a/app/build.gradle b/app/build.gradle
index 70e52cbd5..dc6500cae 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -191,8 +191,15 @@ dependencies {
implementation "io.github.rburgst:okhttp-digest:$versions.okhttp_digest"
+ // Git sync over SSH
implementation "org.eclipse.jgit:org.eclipse.jgit:$versions.jgit"
- implementation "org.eclipse.jgit:org.eclipse.jgit.ssh.jsch:$versions.jgit"
+ implementation("org.eclipse.jgit:org.eclipse.jgit.ssh.apache:$versions.jgit") {
+ // Resolves DuplicatePlatformClasses lint error
+ exclude group: 'org.apache.sshd', module: 'sshd-osgi'
+ }
+ implementation 'androidx.security:security-crypto:1.1.0-alpha03'
+ implementation 'androidx.biometric:biometric-ktx:1.2.0-alpha04'
+
}
repositories {
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index f8c11f441..f0189c6b8 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -122,6 +122,11 @@
android:windowSoftInputMode="stateAlwaysHidden|adjustResize">
+
+
+
diff --git a/app/src/main/java/com/orgzly/android/App.java b/app/src/main/java/com/orgzly/android/App.java
index 9fc352ba6..56ec3044b 100644
--- a/app/src/main/java/com/orgzly/android/App.java
+++ b/app/src/main/java/com/orgzly/android/App.java
@@ -76,4 +76,7 @@ public static Context getAppContext() {
public static void setCurrentActivity(CommonActivity currentCommonActivity) {
currentActivity = currentCommonActivity;
}
+
+ public static CommonActivity getCurrentActivity() { return currentActivity; }
+
}
diff --git a/app/src/main/java/com/orgzly/android/AppIntent.java b/app/src/main/java/com/orgzly/android/AppIntent.java
index ec65c1e64..3ca7c5e55 100644
--- a/app/src/main/java/com/orgzly/android/AppIntent.java
+++ b/app/src/main/java/com/orgzly/android/AppIntent.java
@@ -38,6 +38,10 @@ public class AppIntent {
public static final String ACTION_SHOW_SNACKBAR = "com.orgzly.intent.action.SHOW_SNACKBAR";
+ public static final String ACTION_REJECT_REMOTE_HOST_KEY = "com.orgzly.intent.action.REJECT_REMOTE_HOST_KEY";
+ public static final String ACTION_ACCEPT_REMOTE_HOST_KEY = "com.orgzly.intent.action.ACCEPT_REMOTE_HOST_KEY";
+ public static final String ACTION_ACCEPT_AND_STORE_REMOTE_HOST_KEY = "com.orgzly.intent.action.ACCEPT_AND_STORE_REMOTE_HOST_KEY";
+
public static final String EXTRA_MESSAGE = "com.orgzly.intent.extra.MESSAGE";
public static final String EXTRA_BOOK_ID = "com.orgzly.intent.extra.BOOK_ID";
public static final String EXTRA_BOOK_PREFACE = "com.orgzly.intent.extra.BOOK_PREFACE";
diff --git a/app/src/main/java/com/orgzly/android/NotificationChannels.kt b/app/src/main/java/com/orgzly/android/NotificationChannels.kt
index 392ac84bc..0d691a895 100644
--- a/app/src/main/java/com/orgzly/android/NotificationChannels.kt
+++ b/app/src/main/java/com/orgzly/android/NotificationChannels.kt
@@ -20,6 +20,7 @@ object NotificationChannels {
const val REMINDERS = "reminders"
const val SYNC_PROGRESS = "sync-progress"
const val SYNC_FAILED = "sync-failed"
+ const val SYNC_PROMPT = "sync-prompt"
@JvmStatic
fun createAll(context: Context) {
@@ -28,6 +29,7 @@ object NotificationChannels {
createForReminders(context)
createForSyncProgress(context)
createForSyncFailed(context)
+ createForSyncPrompt(context)
}
}
@@ -111,4 +113,25 @@ object NotificationChannels {
context.getNotificationManager().createNotificationChannel(channel)
}
+
+ @RequiresApi(Build.VERSION_CODES.O)
+ private fun createForSyncPrompt(context: Context) {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
+ return
+ }
+
+ val id = SYNC_PROMPT
+ val name = "Sync prompt"
+ val description = "Display sync prompt"
+ val importance = NotificationManager.IMPORTANCE_HIGH
+
+ val channel = NotificationChannel(id, name, importance)
+
+ channel.description = description
+
+ channel.setShowBadge(false)
+
+ val mNotificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+ mNotificationManager.createNotificationChannel(channel)
+ }
}
\ No newline at end of file
diff --git a/app/src/main/java/com/orgzly/android/git/GitFileSynchronizer.java b/app/src/main/java/com/orgzly/android/git/GitFileSynchronizer.java
index d0dcc9773..0fdbc6fc1 100644
--- a/app/src/main/java/com/orgzly/android/git/GitFileSynchronizer.java
+++ b/app/src/main/java/com/orgzly/android/git/GitFileSynchronizer.java
@@ -1,11 +1,15 @@
package com.orgzly.android.git;
+import static com.orgzly.android.ui.AppSnackbarUtils.showSnackbar;
+
+import android.app.Activity;
import android.content.Context;
import android.net.Uri;
import android.util.Log;
import androidx.annotation.NonNull;
+import com.orgzly.R;
import com.orgzly.android.App;
import com.orgzly.android.util.MiscUtils;
@@ -14,7 +18,6 @@
import org.eclipse.jgit.api.MergeResult;
import org.eclipse.jgit.api.ResetCommand;
import org.eclipse.jgit.api.Status;
-import org.eclipse.jgit.api.TransportCommand;
import org.eclipse.jgit.api.errors.GitAPIException;
import org.eclipse.jgit.lib.Constants;
import org.eclipse.jgit.lib.ObjectId;
@@ -22,6 +25,7 @@
import org.eclipse.jgit.lib.Repository;
import org.eclipse.jgit.revwalk.RevCommit;
import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.transport.PushResult;
import org.eclipse.jgit.treewalk.TreeWalk;
import java.io.File;
@@ -33,14 +37,18 @@
import java.util.TimeZone;
public class GitFileSynchronizer {
- private static String TAG = GitFileSynchronizer.class.getSimpleName();
+ private final static String TAG = GitFileSynchronizer.class.getSimpleName();
+
+ private final Git git;
+ private final GitPreferences preferences;
+ private final Context context;
+ private final Activity currentActivity = App.getCurrentActivity();
- private Git git;
- private GitPreferences preferences;
public GitFileSynchronizer(Git g, GitPreferences prefs) {
git = g;
preferences = prefs;
+ context = App.getAppContext();
}
private GitTransportSetter transportSetter() {
@@ -67,12 +75,17 @@ public void retrieveLatestVersionOfFile(
MiscUtils.copyFile(repoDirectoryFile(repositoryPath), destination);
}
- private void fetch() throws GitAPIException {
- transportSetter()
- .setTransport(git.fetch()
- .setRemote(preferences.remoteName())
- .setRemoveDeletedRefs(true))
- .call();
+ private void fetch() throws IOException {
+ try {
+ transportSetter()
+ .setTransport(git.fetch()
+ .setRemote(preferences.remoteName())
+ .setRemoveDeletedRefs(true))
+ .call();
+ } catch (GitAPIException e) {
+ e.printStackTrace();
+ throw new IOException(e.getMessage());
+ }
}
public void checkoutSelected() throws GitAPIException {
@@ -93,22 +106,6 @@ public boolean mergeWithRemote() throws IOException {
return false;
}
- public boolean mergeAndPushToRemote() throws IOException {
- boolean success = mergeWithRemote();
- if (success) try {
- transportSetter().setTransport(git.push().setRemote(preferences.remoteName())).call();
- } catch (GitAPIException e) {}
- return success;
- }
-
- public void updateAndCommitFileFromRevision(
- File sourceFile, String repositoryPath,
- ObjectId fileRevision, RevCommit revision) throws IOException {
- ensureRepoIsClean();
- if (updateAndCommitFileFromRevision(sourceFile, repositoryPath, fileRevision))
- return;
- }
-
private String getShortHash(ObjectId hash) {
String shortHash = hash.getName();
try {
@@ -140,8 +137,8 @@ public boolean updateAndCommitFileFromRevisionAndMerge(
try {
git.branchDelete().setBranchNames(mergeBranch).call();
} catch (GitAPIException e) {}
- Boolean mergeSucceeded = true;
- Boolean doCleanup = false;
+ boolean mergeSucceeded = true;
+ boolean doCleanup = false;
try {
RevCommit mergeTarget = currentHead();
// Try to use the branch "orgzly-pre-sync-marker" to find a good point for branching off.
@@ -239,16 +236,38 @@ public void tryPushIfHeadDiffersFromRemote() {
}
public void tryPush() {
- final TransportCommand pushCommand = transportSetter().setTransport(
+ final var pushCommand = transportSetter().setTransport(
git.push().setRemote(preferences.remoteName()));
+ final Object monitor = new Object();
App.EXECUTORS.diskIO().execute(() -> {
try {
- pushCommand.call();
+ Iterable results = (Iterable) pushCommand.call();
+ // org.eclipse.jgit.api.PushCommand swallows some errors without throwing exceptions.
+ if (!results.iterator().next().getMessages().isEmpty()) {
+ if (currentActivity != null) {
+ showSnackbar(currentActivity, results.iterator().next().getMessages());
+ }
+ }
+ synchronized (monitor) {
+ monitor.notify();
+ }
} catch (GitAPIException e) {
- e.printStackTrace();
+ if (currentActivity != null) {
+ showSnackbar(
+ currentActivity,
+ String.format("Failed to push to remote: %s", e.getMessage())
+ );
+ }
}
});
+ synchronized (monitor) {
+ try {
+ monitor.wait();
+ } catch (InterruptedException e) {
+ e.printStackTrace();
+ }
+ }
}
private void gitResetMerge() throws IOException, GitAPIException {
@@ -274,7 +293,11 @@ public void setBranchAndGetLatest() throws IOException {
// Point a "marker" branch to the current head, so that we know a good starting commit
// for merge conflict branches.
git.branchCreate().setName("orgzly-pre-sync-marker").setForce(true).call();
- fetch();
+ } catch (GitAPIException e) {
+ throw new IOException(context.getString(R.string.git_sync_error_failed_set_marker_branch));
+ }
+ fetch();
+ try {
RevCommit current = currentHead();
RevCommit mergeTarget = getCommit(
String.format("%s/%s", preferences.remoteName(), git.getRepository().getBranch()));
@@ -290,8 +313,7 @@ public void setBranchAndGetLatest() throws IOException {
}
}
} catch (GitAPIException e) {
- e.printStackTrace();
- throw new IOException("Failed to update from remote");
+ throw new IOException(e.getMessage());
}
}
@@ -348,7 +370,7 @@ public void addAndCommitNewFile(File sourceFile, String repositoryPath) throws I
updateAndCommitFile(sourceFile, repositoryPath);
}
- private RevCommit updateAndCommitFile(
+ private void updateAndCommitFile(
File sourceFile, String repositoryPath) throws IOException {
File destinationFile = repoDirectoryFile(repositoryPath);
MiscUtils.copyFile(sourceFile, destinationFile);
@@ -359,7 +381,6 @@ private RevCommit updateAndCommitFile(
} catch (GitAPIException e) {
throw new IOException("Failed to commit changes.");
}
- return currentHead();
}
private void commit(String message) throws GitAPIException {
@@ -414,9 +435,8 @@ public boolean isEmptyRepo() throws IOException{
}
public ObjectId getFileRevision(String pathString, RevCommit commit) throws IOException {
- ObjectId objectId = TreeWalk.forPath(
+ return TreeWalk.forPath(
git.getRepository(), pathString, commit.getTree()).getObjectId(0);
- return objectId;
}
public boolean fileMatchesInRevisions(String pathString, RevCommit start, RevCommit end)
diff --git a/app/src/main/java/com/orgzly/android/git/GitPreferencesFromRepoPrefs.java b/app/src/main/java/com/orgzly/android/git/GitPreferencesFromRepoPrefs.java
index 1f84c955f..d1468add8 100644
--- a/app/src/main/java/com/orgzly/android/git/GitPreferencesFromRepoPrefs.java
+++ b/app/src/main/java/com/orgzly/android/git/GitPreferencesFromRepoPrefs.java
@@ -22,9 +22,7 @@ public GitTransportSetter createTransportSetter() {
return new HTTPSTransportSetter(username, password);
} else {
// assume SSH, since ssh:// usually isn't specified as the scheme when cloning via SSH.
- String sshKeyPath = repoPreferences.getStringValueWithGlobalDefault(
- R.string.pref_key_git_ssh_key_path, "orgzly");
- return new GitSSHKeyTransportSetter(sshKeyPath);
+ return new GitSshKeyTransportSetter();
}
}
diff --git a/app/src/main/java/com/orgzly/android/git/GitSSHKeyTransportSetter.java b/app/src/main/java/com/orgzly/android/git/GitSSHKeyTransportSetter.java
deleted file mode 100644
index 8b9872fb1..000000000
--- a/app/src/main/java/com/orgzly/android/git/GitSSHKeyTransportSetter.java
+++ /dev/null
@@ -1,50 +0,0 @@
-package com.orgzly.android.git;
-
-import com.jcraft.jsch.JSch;
-import com.jcraft.jsch.JSchException;
-import com.jcraft.jsch.Session;
-
-import org.eclipse.jgit.api.TransportCommand;
-import org.eclipse.jgit.api.TransportConfigCallback;
-import org.eclipse.jgit.transport.JschConfigSessionFactory;
-import org.eclipse.jgit.transport.OpenSshConfig;
-import org.eclipse.jgit.transport.SshSessionFactory;
-import org.eclipse.jgit.transport.SshTransport;
-import org.eclipse.jgit.transport.Transport;
-import org.eclipse.jgit.util.FS;
-
-public class GitSSHKeyTransportSetter implements GitTransportSetter {
- private String sshKeyPath;
- private SshSessionFactory sshSessionFactory;
- private TransportConfigCallback configCallback;
-
- public GitSSHKeyTransportSetter(String pathToSSHKey) {
- sshKeyPath = pathToSSHKey;
- sshSessionFactory = new JschConfigSessionFactory() {
- @Override
- protected void configure(OpenSshConfig.Host host, Session session ) {
- session.setConfig("StrictHostKeyChecking", "no");
- }
-
- @Override
- protected JSch createDefaultJSch(FS fs) throws JSchException {
- JSch defaultJSch = super.createDefaultJSch(fs);
- defaultJSch.addIdentity(sshKeyPath);
- return defaultJSch;
- }
-
- };
- configCallback = new TransportConfigCallback() {
- @Override
- public void configure(Transport transport) {
- SshTransport sshTransport = (SshTransport) transport;
- sshTransport.setSshSessionFactory(sshSessionFactory);
- }
- };
- }
-
- public TransportCommand setTransport(TransportCommand tc) {
- tc.setTransportConfigCallback(configCallback);
- return tc;
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/orgzly/android/git/GitSshKeyTransportSetter.kt b/app/src/main/java/com/orgzly/android/git/GitSshKeyTransportSetter.kt
new file mode 100644
index 000000000..d9058cb6b
--- /dev/null
+++ b/app/src/main/java/com/orgzly/android/git/GitSshKeyTransportSetter.kt
@@ -0,0 +1,88 @@
+package com.orgzly.android.git
+
+import android.content.Intent
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import com.orgzly.R
+import com.orgzly.android.App
+import com.orgzly.android.ui.SshKeygenActivity
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.runBlocking
+import org.eclipse.jgit.annotations.NonNull
+import org.eclipse.jgit.api.TransportCommand
+import org.eclipse.jgit.api.TransportConfigCallback
+import org.eclipse.jgit.internal.transport.sshd.OpenSshServerKeyDatabase
+import org.eclipse.jgit.transport.SshSessionFactory
+import org.eclipse.jgit.transport.SshTransport
+import org.eclipse.jgit.transport.Transport
+import org.eclipse.jgit.transport.sshd.ServerKeyDatabase
+import org.eclipse.jgit.transport.sshd.SshdSessionFactory
+import java.io.File
+import java.security.KeyPair
+
+class GitSshKeyTransportSetter: GitTransportSetter {
+ private val configCallback: TransportConfigCallback
+ private val activity = App.getCurrentActivity()
+ private val context = App.getAppContext()
+
+ init {
+ val factory: SshSessionFactory = object : SshdSessionFactory(null, null) {
+
+ override fun getHomeDirectory(): File { return context.filesDir }
+
+ override fun getDefaultPreferredAuthentications(): String { return "publickey" }
+
+ override fun createServerKeyDatabase(
+ @NonNull homeDir: File,
+ @NonNull sshDir: File
+ ): ServerKeyDatabase {
+ // We override this method because we want to set "askAboutNewFile" to False.
+ return OpenSshServerKeyDatabase(
+ false,
+ getDefaultKnownHostsFiles(sshDir)
+ )
+ }
+
+ override fun getDefaultKeys(@NonNull sshDir: File): Iterable? {
+ return if (SshKey.exists) {
+ listOf(SshKey.getKeyPair())
+ } else {
+ onMissingSshKeyFile()
+ null
+ }
+ }
+ }
+
+ SshSessionFactory.setInstance(factory)
+
+ // org.apache.sshd.common.config.keys.IdentityUtils freaks out if user.home is not set
+ System.setProperty("user.home", context.filesDir.toString())
+
+ configCallback = TransportConfigCallback { transport: Transport ->
+ val sshTransport = transport as SshTransport
+ sshTransport.sshSessionFactory = factory
+ }
+ }
+
+ override fun setTransport(tc: TransportCommand<*, *>): TransportCommand<*, *> {
+ tc.setTransportConfigCallback(configCallback)
+ tc.setCredentialsProvider(SshCredentialsProvider())
+ return tc
+ }
+
+ private fun onMissingSshKeyFile() {
+ if (activity != null) {
+ val builder = MaterialAlertDialogBuilder(activity)
+ .setMessage(R.string.git_ssh_on_missing_key_dialog_text)
+ .setTitle(R.string.git_ssh_on_missing_key_dialog_title)
+ builder.setPositiveButton(activity.getString(R.string.yes)) { _, _ ->
+ val intent =
+ Intent(activity.applicationContext, SshKeygenActivity::class.java)
+ activity.startActivity(intent)
+ }
+ builder.setNegativeButton(activity.getString(R.string.not_now)) {
+ dialog, _ -> dialog.dismiss()
+ }
+ runBlocking(Dispatchers.Main) { activity.alertDialog = builder.show() }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/orgzly/android/git/SshCredentialsProvider.java b/app/src/main/java/com/orgzly/android/git/SshCredentialsProvider.java
new file mode 100644
index 000000000..2e8bac6a5
--- /dev/null
+++ b/app/src/main/java/com/orgzly/android/git/SshCredentialsProvider.java
@@ -0,0 +1,135 @@
+package com.orgzly.android.git;
+
+import static com.orgzly.android.AppIntent.ACTION_ACCEPT_AND_STORE_REMOTE_HOST_KEY;
+import static com.orgzly.android.AppIntent.ACTION_ACCEPT_REMOTE_HOST_KEY;
+import static com.orgzly.android.AppIntent.ACTION_REJECT_REMOTE_HOST_KEY;
+import static com.orgzly.android.ui.notifications.Notifications.SYNC_SSH_REMOTE_HOST_KEY;
+
+import android.app.NotificationManager;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+
+import com.orgzly.android.App;
+import com.orgzly.android.ui.notifications.Notifications;
+
+import org.eclipse.jgit.errors.UnsupportedCredentialItem;
+import org.eclipse.jgit.transport.CredentialItem;
+import org.eclipse.jgit.transport.CredentialsProvider;
+import org.eclipse.jgit.transport.URIish;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public class SshCredentialsProvider extends CredentialsProvider {
+
+ private static final Object monitor = new Object();
+
+ public static final String DENY = "Reject";
+ public static final String ALLOW = "Accept";
+ public static final String ALLOW_AND_STORE = "Accept and store";
+
+ @Override
+ public boolean isInteractive() {
+ return true;
+ }
+
+ @Override
+ public boolean supports(CredentialItem... items) {
+ for (CredentialItem i : items) {
+ if (i instanceof CredentialItem.YesNoType) {
+ continue;
+ }
+ if (i instanceof CredentialItem.InformationalMessage) {
+ continue;
+ }
+ return false;
+ }
+ return true;
+ }
+
+ @Override
+ public boolean get(URIish uri, CredentialItem... items) throws UnsupportedCredentialItem {
+ List questions = new ArrayList<>();
+ for (CredentialItem item : items) {
+ if (item instanceof CredentialItem.InformationalMessage) {
+ continue;
+ }
+ if (item instanceof CredentialItem.YesNoType) {
+ questions.add((CredentialItem.YesNoType) item);
+ continue;
+ }
+ throw new UnsupportedCredentialItem(uri, item.getClass().getName()
+ + ":" + item.getPromptText()); //$NON-NLS-1$
+ }
+
+ if (questions.isEmpty()) {
+ return true;
+ } else {
+ // We need to prompt the user via a notification;
+ // set up a broadcast receiver for this purpose.
+ Context context = App.getAppContext();
+ final Boolean[] userHasResponded = {false};
+ final BroadcastReceiver broadcastReceiver = new BroadcastReceiver() {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ // Remove the notification
+ NotificationManager notificationManager =
+ (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
+ notificationManager.cancel(SYNC_SSH_REMOTE_HOST_KEY);
+ // Save the user response
+ switch (intent.getAction()) {
+ case ACTION_REJECT_REMOTE_HOST_KEY:
+ questions.get(0).setValue(false);
+ break;
+ case ACTION_ACCEPT_REMOTE_HOST_KEY:
+ questions.get(0).setValue(true);
+ break;
+ case ACTION_ACCEPT_AND_STORE_REMOTE_HOST_KEY:
+ questions.get(0).setValue(true);
+ if (questions.size() == 2) {
+ questions.get(1).setValue(true);
+ }
+ }
+ userHasResponded[0] = true;
+ synchronized (monitor) {
+ monitor.notify();
+ }
+ }
+ };
+ // Create intent filter and register receiver
+ IntentFilter intentFilter = new IntentFilter();
+ intentFilter.addAction(ACTION_REJECT_REMOTE_HOST_KEY);
+ intentFilter.addAction(ACTION_ACCEPT_REMOTE_HOST_KEY);
+ intentFilter.addAction(ACTION_ACCEPT_AND_STORE_REMOTE_HOST_KEY);
+ context.registerReceiver(broadcastReceiver, intentFilter);
+
+ // Send the notification and wait up to 30 seconds for the user to respond
+ Notifications.showSshRemoteHostKeyPrompt(context, uri, items);
+ synchronized (monitor) {
+ if (!userHasResponded[0]) {
+ try {
+ monitor.wait(30000);
+ } catch (InterruptedException e) {
+ e.printStackTrace();
+ if (!userHasResponded[0]) {
+ return false;
+ }
+ }
+ }
+ }
+ // Remove the broadcast receiver and its intent filters
+ context.unregisterReceiver(broadcastReceiver);
+ // Update the original list objects
+ int questionCounter = 0;
+ for (CredentialItem item : items) {
+ if (item instanceof CredentialItem.YesNoType) {
+ ((CredentialItem.YesNoType) item).setValue(questions.get(questionCounter).getValue());
+ questionCounter++;
+ }
+ }
+ return questions.get(0).getValue();
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/orgzly/android/git/SshKey.kt b/app/src/main/java/com/orgzly/android/git/SshKey.kt
new file mode 100644
index 000000000..686962921
--- /dev/null
+++ b/app/src/main/java/com/orgzly/android/git/SshKey.kt
@@ -0,0 +1,340 @@
+package com.orgzly.android.git
+
+import android.content.Context
+import android.content.pm.PackageManager
+import android.os.Build
+import android.security.keystore.KeyGenParameterSpec
+import android.security.keystore.KeyInfo
+import android.security.keystore.KeyProperties
+import android.security.keystore.UserNotAuthenticatedException
+import androidx.core.content.edit
+import androidx.security.crypto.EncryptedFile
+import androidx.security.crypto.MasterKey
+import com.orgzly.R
+import com.orgzly.android.App
+import com.orgzly.android.prefs.AppPreferences
+import com.orgzly.android.util.BiometricAuthenticator
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.withContext
+import net.i2p.crypto.eddsa.EdDSAPrivateKey
+import net.i2p.crypto.eddsa.spec.EdDSANamedCurveTable
+import net.i2p.crypto.eddsa.spec.EdDSAPrivateKeySpec
+import org.apache.sshd.common.config.keys.PublicKeyEntry
+import org.apache.sshd.common.config.keys.PublicKeyEntry.parsePublicKeyEntry
+import java.io.File
+import java.io.IOException
+import java.security.*
+import java.security.interfaces.RSAKey
+import javax.crypto.SecretKey
+import javax.crypto.SecretKeyFactory
+
+private const val PROVIDER_ANDROID_KEY_STORE = "AndroidKeyStore"
+private const val KEYSTORE_ALIAS = "orgzly_sshkey"
+private const val ANDROIDX_SECURITY_KEYSET_PREF_NAME = "orgzly_sshkey_keyset_prefs"
+private const val AUTH_VALIDITY_DURATION = 30
+
+/** Alias to [lazy] with thread safety mode always set to [LazyThreadSafetyMode.NONE]. */
+private fun unsafeLazy(initializer: () -> T) = lazy(LazyThreadSafetyMode.NONE) { initializer.invoke() }
+
+private val androidKeystore: KeyStore by unsafeLazy {
+ KeyStore.getInstance(PROVIDER_ANDROID_KEY_STORE).apply { load(null) }
+}
+
+private val KeyStore.sshPrivateKey
+ get() = getKey(KEYSTORE_ALIAS, null) as? PrivateKey
+
+private val KeyStore.sshPublicKey
+ get() = getCertificate(KEYSTORE_ALIAS)?.publicKey
+
+fun parseSshPublicKey(sshPublicKey: String): PublicKey? {
+ return parsePublicKeyEntry(sshPublicKey).resolvePublicKey(null, null, null)
+}
+
+fun toSshPublicKey(publicKey: PublicKey): String {
+ return PublicKeyEntry.toString(publicKey)
+}
+
+object SshKey {
+ val sshPublicKey
+ get() = if (publicKeyFile.exists()) publicKeyFile.readText() else null
+ val canShowSshPublicKey
+ get() = type in listOf(Type.KeystoreNative, Type.KeystoreWrappedEd25519)
+ val exists
+ get() = type != null
+ private val mustAuthenticate: Boolean
+ get() {
+ return runCatching {
+ if (type !in listOf(Type.KeystoreNative, Type.KeystoreWrappedEd25519)) return false
+ when (val key = androidKeystore.getKey(KEYSTORE_ALIAS, null)) {
+ is PrivateKey -> {
+ val factory =
+ KeyFactory.getInstance(key.algorithm, PROVIDER_ANDROID_KEY_STORE)
+ return factory.getKeySpec(
+ key,
+ KeyInfo::class.java
+ ).isUserAuthenticationRequired
+ }
+ is SecretKey -> {
+ val factory =
+ SecretKeyFactory.getInstance(key.algorithm, PROVIDER_ANDROID_KEY_STORE)
+ (factory.getKeySpec(
+ key,
+ KeyInfo::class.java
+ ) as KeyInfo).isUserAuthenticationRequired
+ }
+ else -> throw IllegalStateException("SSH key does not exist in Keystore")
+ }
+ }
+ .getOrElse {
+ // It is fine to swallow the exception here since it will reappear when the key
+ // is used for SSH authentication and can then be shown in the UI.
+ false
+ }
+ }
+
+ private val context: Context
+ get() = App.getAppContext()
+
+ private val privateKeyFile
+ get() = File(context.filesDir, "ssh_key")
+ private val publicKeyFile
+ get() = File(context.filesDir, "ssh_key.pub")
+
+ private var type: Type?
+ get() = Type.fromValue(AppPreferences.gitSshKeyType(context))
+ set(value) = AppPreferences.gitSshKeyType(context, value?.value)
+
+ private val isStrongBoxSupported by unsafeLazy {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P)
+ context.packageManager.hasSystemFeature(PackageManager.FEATURE_STRONGBOX_KEYSTORE)
+ else false
+ }
+
+ private enum class Type(val value: String) {
+ KeystoreNative("keystore_native"),
+ KeystoreWrappedEd25519("keystore_wrapped_ed25519"),
+ ;
+
+ companion object {
+ fun fromValue(value: String?): Type? = values().associateBy { it.value }[value]
+ }
+ }
+
+ enum class Algorithm(
+ val algorithm: String,
+ val applyToSpec: KeyGenParameterSpec.Builder.() -> Unit
+ ) {
+ Rsa(
+ KeyProperties.KEY_ALGORITHM_RSA,
+ {
+ setKeySize(3072)
+ setSignaturePaddings(KeyProperties.SIGNATURE_PADDING_RSA_PKCS1)
+ setDigests(
+ KeyProperties.DIGEST_SHA1,
+ KeyProperties.DIGEST_SHA256,
+ KeyProperties.DIGEST_SHA512
+ )
+ }
+ ),
+
+ Ecdsa(
+ KeyProperties.KEY_ALGORITHM_EC,
+ {
+ setKeySize(256)
+ setAlgorithmParameterSpec(java.security.spec.ECGenParameterSpec("secp256r1"))
+ setDigests(KeyProperties.DIGEST_SHA256)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+ setIsStrongBoxBacked(isStrongBoxSupported)
+ }
+ }
+ ),
+ }
+
+ private fun delete() {
+ androidKeystore.deleteEntry(KEYSTORE_ALIAS)
+ // Remove Tink key set used by AndroidX's EncryptedFile.
+ context.getSharedPreferences(ANDROIDX_SECURITY_KEYSET_PREF_NAME, Context.MODE_PRIVATE)
+ .edit {
+ clear()
+ }
+ if (privateKeyFile.isFile) {
+ privateKeyFile.delete()
+ }
+ if (publicKeyFile.isFile) {
+ publicKeyFile.delete()
+ }
+ type = null
+ }
+
+ private suspend fun getOrCreateWrappingMasterKey(requireAuthentication: Boolean) =
+ withContext(Dispatchers.IO) {
+ MasterKey.Builder(context, KEYSTORE_ALIAS).run {
+ setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
+ setRequestStrongBoxBacked(true)
+ setUserAuthenticationRequired(requireAuthentication, AUTH_VALIDITY_DURATION)
+ build()
+ }
+ }
+
+ private suspend fun getOrCreateWrappedPrivateKeyFile(requireAuthentication: Boolean) =
+ withContext(Dispatchers.IO) {
+ EncryptedFile.Builder(
+ context,
+ privateKeyFile,
+ getOrCreateWrappingMasterKey(requireAuthentication),
+ EncryptedFile.FileEncryptionScheme.AES256_GCM_HKDF_4KB
+ )
+ .run {
+ setKeysetPrefName(ANDROIDX_SECURITY_KEYSET_PREF_NAME)
+ build()
+ }
+ }
+
+
+ @Suppress("BlockingMethodInNonBlockingContext")
+ suspend fun generateKeystoreWrappedEd25519Key(requireAuthentication: Boolean) =
+ withContext(Dispatchers.IO) {
+ delete()
+
+ val encryptedPrivateKeyFile = getOrCreateWrappedPrivateKeyFile(requireAuthentication)
+ // Generate the ed25519 key pair and encrypt the private key.
+ val keyPair = net.i2p.crypto.eddsa.KeyPairGenerator().generateKeyPair()
+ encryptedPrivateKeyFile.openFileOutput().use { os ->
+ os.write((keyPair.private as EdDSAPrivateKey).seed)
+ }
+
+ // Write public key in SSH format to .ssh_key.pub.
+ publicKeyFile.writeText(toSshPublicKey(keyPair.public))
+
+ type = Type.KeystoreWrappedEd25519
+ }
+
+ fun generateKeystoreNativeKey(algorithm: Algorithm, requireAuthentication: Boolean) {
+ delete()
+
+ // Generate Keystore-backed private key.
+ val parameterSpec =
+ KeyGenParameterSpec.Builder(KEYSTORE_ALIAS, KeyProperties.PURPOSE_SIGN).run {
+ apply(algorithm.applyToSpec)
+ if (requireAuthentication) {
+ setUserAuthenticationRequired(true)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+ setUserAuthenticationParameters(AUTH_VALIDITY_DURATION, KeyProperties.AUTH_BIOMETRIC_STRONG or KeyProperties.AUTH_DEVICE_CREDENTIAL)
+ } else {
+ @Suppress("DEPRECATION") setUserAuthenticationValidityDurationSeconds(
+ AUTH_VALIDITY_DURATION)
+ }
+ }
+ build()
+ }
+ val keyPair =
+ KeyPairGenerator.getInstance(algorithm.algorithm, PROVIDER_ANDROID_KEY_STORE).run {
+ initialize(parameterSpec)
+ generateKeyPair()
+ }
+
+ // Write public key in SSH format to ssh_key.pub
+ publicKeyFile.writeText(toSshPublicKey(keyPair.public))
+
+ type = Type.KeystoreNative
+ }
+
+ fun getKeyPair(): KeyPair {
+ var privateKey: PrivateKey? = null
+ var privateKeyLoadAttempts = 0
+ val publicKey: PublicKey? = when (type) {
+ Type.KeystoreNative -> {
+ kotlin.runCatching { androidKeystore.sshPublicKey }
+ .getOrElse { error ->
+ throw IOException(
+ context.getString(R.string.ssh_key_failed_get_public),
+ error
+ )
+ }
+ }
+ Type.KeystoreWrappedEd25519 -> {
+ runCatching { parseSshPublicKey(sshPublicKey!!) }
+ .getOrElse { error ->
+ throw IOException(context.getString(R.string.ssh_key_failed_get_public), error)
+ }
+ }
+ else -> throw IllegalStateException("SSH key does not exist in Keystore")
+ }
+ while (privateKeyLoadAttempts < 2) {
+ privateKey = when (type) {
+ Type.KeystoreNative -> {
+ runCatching { androidKeystore.sshPrivateKey }
+ .getOrElse { error ->
+ throw IOException(
+ context.getString(R.string.ssh_key_failed_get_private),
+ error
+ )
+ }
+ }
+ Type.KeystoreWrappedEd25519 -> {
+ runCatching {
+ // The current MasterKey API does not allow getting a reference to an existing
+ // one without specifying the KeySpec for a new one. However, the value for
+ // passed here for `requireAuthentication` is not used as the key already exists
+ // at this point.
+ val encryptedPrivateKeyFile = runBlocking {
+ getOrCreateWrappedPrivateKeyFile(false)
+ }
+ val rawPrivateKey =
+ encryptedPrivateKeyFile.openFileInput().use { it.readBytes() }
+ EdDSAPrivateKey(
+ EdDSAPrivateKeySpec(
+ rawPrivateKey, EdDSANamedCurveTable.ED_25519_CURVE_SPEC
+ )
+ )
+ }.getOrElse { error ->
+ throw IOException(context.getString(R.string.ssh_key_failed_get_private), error)
+ }
+ }
+ else -> throw IllegalStateException("SSH key does not exist in Keystore")
+ }
+ try {
+ // Try to sign something to see if the key is unlocked
+ val algorithm: String = if (privateKey is RSAKey) {
+ "SHA256withRSA"
+ } else {
+ "SHA256withECDSA"
+ }
+ Signature.getInstance(algorithm).apply {
+ initSign(privateKey)
+ update("loremipsum".toByteArray())
+ }.sign()
+ // The key is unlocked; exit the loop.
+ break
+ } catch (e: UserNotAuthenticatedException) {
+ if (privateKeyLoadAttempts > 0) {
+ // We expect this exception before trying auth, but after that, this means
+ // we have failed to unlock the SSH key.
+ throw IOException(context.getString(R.string.ssh_key_failed_unlock_private))
+ }
+ } catch (e: Exception) {
+ // Our attempt to use the key for signing may go wrong in many unforeseen ways.
+ // Such failures are unimportant.
+ e.printStackTrace()
+ }
+ if (mustAuthenticate && privateKeyLoadAttempts == 0) {
+ // Time to try biometric auth
+ val currentActivity = App.getCurrentActivity()
+ checkNotNull(currentActivity) {
+ throw IOException(context.getString(R.string.ssh_key_locked_and_no_activity))
+ }
+ val biometricAuthenticator = BiometricAuthenticator(currentActivity)
+ runBlocking(Dispatchers.Main) {
+ biometricAuthenticator.authenticate(
+ context.getString(
+ R.string.biometric_prompt_title_unlock_ssh_key
+ )
+ )
+ }
+ }
+ privateKeyLoadAttempts++
+ }
+ return KeyPair(publicKey, privateKey)
+ }
+}
diff --git a/app/src/main/java/com/orgzly/android/prefs/AppPreferences.java b/app/src/main/java/com/orgzly/android/prefs/AppPreferences.java
index 596702ef1..33486ad45 100644
--- a/app/src/main/java/com/orgzly/android/prefs/AppPreferences.java
+++ b/app/src/main/java/com/orgzly/android/prefs/AppPreferences.java
@@ -902,37 +902,21 @@ public static void dropboxToken(Context context, String value) {
* Git Sync
*/
- public static boolean gitIsEnabled(Context context) {
- return getDefaultSharedPreferences(context).getBoolean(
- context.getResources().getString(R.string.pref_key_git_is_enabled),
- context.getResources().getBoolean(R.bool.pref_default_git_is_enabled));
- }
-
- public static String gitAuthor(Context context) {
- return getStateSharedPreferences(context).getString("pref_key_git_author", null);
- }
-
- public static void gitAuthor(Context context, String value) {
- getStateSharedPreferences(context).edit().putString("pref_key_git_author", value).apply();
- }
-
- public static String gitEmail(Context context) {
- return getStateSharedPreferences(context).getString("pref_key_git_email", null);
- }
-
- public static void gitEmail(Context context, String value) {
- getStateSharedPreferences(context).edit().putString("pref_key_git_email", value).apply();
+ public static String gitSshKeyType(Context context) {
+ return getDefaultSharedPreferences(context).getString(
+ "pref_key_git_ssh_key_type", null);
}
- public static String gitSSHKeyPath(Context context) {
- return getStateSharedPreferences(context).getString("pref_key_git_ssh_key_path", null);
+ public static void gitSshKeyType(Context context, String value) {
+ getDefaultSharedPreferences(context).edit().putString("pref_key_git_ssh_key_type", value).apply();
}
- public static void gitSSHKeyPath(Context context, String value) {
- getStateSharedPreferences(context).edit().putString(
- "pref_key_git_ssh_key_path", value).apply();
+ public static boolean gitIsEnabled(Context context) {
+ return getDefaultSharedPreferences(context).getBoolean(
+ context.getResources().getString(R.string.pref_key_git_is_enabled),
+ context.getResources().getBoolean(R.bool.pref_default_git_is_enabled));
}
-
+
public static String defaultRepositoryStorageDirectory(Context context) {
File path = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS);
return getStringFromSelector(
diff --git a/app/src/main/java/com/orgzly/android/ui/SshKeygenActivity.kt b/app/src/main/java/com/orgzly/android/ui/SshKeygenActivity.kt
new file mode 100644
index 000000000..f2d87f086
--- /dev/null
+++ b/app/src/main/java/com/orgzly/android/ui/SshKeygenActivity.kt
@@ -0,0 +1,149 @@
+package com.orgzly.android.ui
+
+import android.app.KeyguardManager
+import android.os.Build
+import android.os.Bundle
+import android.security.keystore.UserNotAuthenticatedException
+import android.view.MenuItem
+import android.view.View
+import android.view.inputmethod.InputMethodManager
+import androidx.annotation.RequiresApi
+import androidx.core.content.getSystemService
+import androidx.lifecycle.lifecycleScope
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import com.orgzly.R
+import com.orgzly.android.git.SshKey
+import com.orgzly.android.ui.dialogs.ShowSshKeyDialogFragment
+import com.orgzly.android.util.BiometricAuthenticator
+import com.orgzly.databinding.ActivitySshKeygenBinding
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import java.io.IOException
+
+private enum class KeyGenType(val generateKey: suspend (requireAuthentication: Boolean) -> Unit) {
+ Rsa({ requireAuthentication ->
+ SshKey.generateKeystoreNativeKey(SshKey.Algorithm.Rsa, requireAuthentication)
+ }),
+ Ecdsa({ requireAuthentication ->
+ SshKey.generateKeystoreNativeKey(SshKey.Algorithm.Ecdsa, requireAuthentication)
+ }),
+ Ed25519({ requireAuthentication ->
+ SshKey.generateKeystoreWrappedEd25519Key(requireAuthentication)
+ }),
+}
+
+class SshKeygenActivity : CommonActivity() {
+
+ private var keyGenType = KeyGenType.Ecdsa
+ private lateinit var binding: ActivitySshKeygenBinding
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ binding = ActivitySshKeygenBinding.inflate(layoutInflater)
+ setContentView(binding.root)
+ supportActionBar?.setDisplayHomeAsUpEnabled(true)
+ with(binding) {
+ generate.setOnClickListener {
+ if (SshKey.exists) {
+ MaterialAlertDialogBuilder(this@SshKeygenActivity).run {
+ setTitle(R.string.ssh_keygen_existing_title)
+ setMessage(R.string.ssh_keygen_existing_message)
+ setPositiveButton(R.string.ssh_keygen_existing_replace) { _, _ ->
+ lifecycleScope.launch { generate() }
+ }
+ setNegativeButton(R.string.ssh_keygen_existing_keep) { _, _ ->
+ setResult(RESULT_CANCELED)
+ }
+ show()
+ }
+ } else {
+ lifecycleScope.launch { generate() }
+ }
+ }
+ keyTypeGroup.check(R.id.key_type_ecdsa)
+ keyTypeExplanation.setText(R.string.ssh_keygen_explanation_ecdsa)
+ keyTypeGroup.addOnButtonCheckedListener { _, checkedId, isChecked ->
+ if (isChecked) {
+ keyGenType =
+ when (checkedId) {
+ R.id.key_type_ed25519 -> KeyGenType.Ed25519
+ R.id.key_type_ecdsa -> KeyGenType.Ecdsa
+ R.id.key_type_rsa -> KeyGenType.Rsa
+ else -> throw IllegalStateException("Impossible key type selection")
+ }
+ keyTypeExplanation.setText(
+ when (keyGenType) {
+ KeyGenType.Ed25519 -> R.string.ssh_keygen_explanation_ed25519
+ KeyGenType.Ecdsa -> R.string.ssh_keygen_explanation_ecdsa
+ KeyGenType.Rsa -> R.string.ssh_keygen_explanation_rsa
+ }
+ )
+ }
+ }
+ val keyguardManager: KeyguardManager = getSystemService(KEYGUARD_SERVICE) as KeyguardManager
+ keyRequireAuthentication.isEnabled = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
+ false
+ } else {
+ keyguardManager.isDeviceSecure
+ }
+ keyRequireAuthentication.isChecked = keyRequireAuthentication.isEnabled
+ }
+ }
+
+ override fun onOptionsItemSelected(item: MenuItem): Boolean {
+ return when (item.itemId) {
+ android.R.id.home -> {
+ onBackPressedDispatcher.onBackPressed()
+ true
+ }
+ else -> super.onOptionsItemSelected(item)
+ }
+ }
+
+ private suspend fun generate() {
+ binding.generate.apply {
+ text = getString(R.string.ssh_keygen_generating_progress)
+ isEnabled = false
+ }
+ val biometricAuthenticator = BiometricAuthenticator(this)
+ val result: Result = runCatching {
+ val requireAuthentication = binding.keyRequireAuthentication.isChecked
+ if (requireAuthentication) {
+ withContext(Dispatchers.Main) {
+ val result = biometricAuthenticator.authenticate(getString(R.string.biometric_prompt_title_ssh_keygen))
+ if (result != null)
+ throw UserNotAuthenticatedException(result)
+ }
+ }
+ keyGenType.generateKey(requireAuthentication)
+ }
+ binding.generate.apply {
+ text = getString(R.string.ssh_keygen_generate)
+ isEnabled = true
+ }
+ result.fold(
+ onSuccess = { ShowSshKeyDialogFragment().show(supportFragmentManager, "public_key") },
+ onFailure = { e ->
+ e.printStackTrace()
+ MaterialAlertDialogBuilder(this)
+ .setTitle(getString(R.string.error_generate_ssh_key))
+ .setMessage(getString(R.string.ssh_key_error_dialog_text) + e.message)
+ .setPositiveButton(getString(R.string.ok)) { _, _ ->
+ setResult(RESULT_OK)
+ }
+ .show()
+ },
+ )
+ hideKeyboard()
+ }
+
+ private fun hideKeyboard() {
+ val imm = getSystemService() ?: return
+ var view = currentFocus
+ if (view == null) {
+ view = View(this)
+ }
+ imm.hideSoftInputFromWindow(view.windowToken, 0)
+ }
+}
diff --git a/app/src/main/java/com/orgzly/android/ui/dialogs/ShowSshKeyDialogFragment.kt b/app/src/main/java/com/orgzly/android/ui/dialogs/ShowSshKeyDialogFragment.kt
new file mode 100644
index 000000000..bd5eeea75
--- /dev/null
+++ b/app/src/main/java/com/orgzly/android/ui/dialogs/ShowSshKeyDialogFragment.kt
@@ -0,0 +1,35 @@
+package com.orgzly.android.ui.dialogs
+
+import android.app.Dialog
+import android.content.Intent
+import android.os.Bundle
+import androidx.fragment.app.DialogFragment
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import com.orgzly.R
+import com.orgzly.android.git.SshKey.sshPublicKey
+import com.orgzly.android.ui.SshKeygenActivity
+
+class ShowSshKeyDialogFragment : DialogFragment() {
+
+ override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
+ val activity = requireActivity()
+ return MaterialAlertDialogBuilder(activity).run {
+ setMessage(getString(R.string.ssh_keygen_message, sshPublicKey))
+ setTitle(R.string.your_public_key)
+ setNegativeButton(R.string.not_now) { _, _ ->
+ (activity as? SshKeygenActivity)?.finish()
+ }
+ setPositiveButton(R.string.ssh_keygen_share) { _, _ ->
+ val sendIntent =
+ Intent().apply {
+ action = Intent.ACTION_SEND
+ type = "text/plain"
+ putExtra(Intent.EXTRA_TEXT, sshPublicKey)
+ }
+ startActivity(Intent.createChooser(sendIntent, null))
+ (activity as? SshKeygenActivity)?.finish()
+ }
+ create()
+ }
+ }
+}
diff --git a/app/src/main/java/com/orgzly/android/ui/notifications/Notifications.java b/app/src/main/java/com/orgzly/android/ui/notifications/Notifications.java
index 321689b12..794fe9c58 100644
--- a/app/src/main/java/com/orgzly/android/ui/notifications/Notifications.java
+++ b/app/src/main/java/com/orgzly/android/ui/notifications/Notifications.java
@@ -1,7 +1,27 @@
package com.orgzly.android.ui.notifications;
+import static com.orgzly.android.AppIntent.ACTION_ACCEPT_AND_STORE_REMOTE_HOST_KEY;
+import static com.orgzly.android.AppIntent.ACTION_ACCEPT_REMOTE_HOST_KEY;
+import static com.orgzly.android.AppIntent.ACTION_REJECT_REMOTE_HOST_KEY;
import static com.orgzly.android.NewNoteBroadcastReceiver.NOTE_TITLE;
+import static com.orgzly.android.git.SshCredentialsProvider.ALLOW;
+import static com.orgzly.android.git.SshCredentialsProvider.ALLOW_AND_STORE;
+import static com.orgzly.android.git.SshCredentialsProvider.DENY;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+
+import static com.orgzly.android.AppIntent.ACTION_ACCEPT_AND_STORE_REMOTE_HOST_KEY;
+import static com.orgzly.android.AppIntent.ACTION_ACCEPT_REMOTE_HOST_KEY;
+import static com.orgzly.android.AppIntent.ACTION_REJECT_REMOTE_HOST_KEY;
+import static com.orgzly.android.NewNoteBroadcastReceiver.NOTE_TITLE;
+import static com.orgzly.android.git.SshCredentialsProvider.ALLOW;
+import static com.orgzly.android.git.SshCredentialsProvider.ALLOW_AND_STORE;
+import static com.orgzly.android.git.SshCredentialsProvider.DENY;
+
+import android.app.Notification;
+import android.app.NotificationManager;
import android.app.PendingIntent;
import android.content.Context;
import android.content.Intent;
@@ -24,6 +44,10 @@
import com.orgzly.android.ui.util.SystemServices;
import com.orgzly.android.util.LogUtils;
+import org.eclipse.jgit.internal.transport.sshd.SshdText;
+import org.eclipse.jgit.transport.CredentialItem;
+import org.eclipse.jgit.transport.URIish;
+
public class Notifications {
public static final String TAG = Notifications.class.getName();
@@ -32,6 +56,7 @@ public class Notifications {
public static final int REMINDERS_SUMMARY_ID = 3;
public static final int SYNC_IN_PROGRESS_ID = 4;
public static final int SYNC_FAILED_ID = 5;
+ public static final int SYNC_SSH_REMOTE_HOST_KEY = 6;
public static final String REMINDERS_GROUP = "com.orgzly.notification.group.REMINDERS";
@@ -119,4 +144,77 @@ private static int getNotificationPriority(String priority) {
public static void cancelNewNoteNotification(Context context) {
SystemServices.getNotificationManager(context).cancel(ONGOING_NEW_NOTE_ID);
}
-}
\ No newline at end of file
+
+ /*
+ Expandable notification to show when a Git sync repository server
+ presents an unknown or unexpected SSH public key.
+ Presents either two or three choices to the user. The selected action
+ is passed back to the SshCredentialsProvider via a broadcast receiver.
+ */
+ public static void showSshRemoteHostKeyPrompt(Context context, URIish uri, CredentialItem... items) {
+ // Parse CredentialItems
+ List messages = new ArrayList<>();
+ List questions = new ArrayList<>();
+ for (CredentialItem item : items) {
+ messages.add(item.getPromptText());
+ if (item instanceof CredentialItem.YesNoType) {
+ questions.add((CredentialItem.YesNoType) item);
+ }
+ }
+ String bigText = String.join("\n", messages.subList(1, messages.size()));
+
+ // FIXME: The "modified key" prompt is too long to fit into a BigTextStyle notification.
+ // Should we launch a dialog-themed activity from the notification?
+ if (Objects.equals(questions.get(0).getPromptText(), SshdText.get().knownHostsModifiedKeyAcceptPrompt)) {
+ // Remove SHA256 checksums (only show MD5)
+ messages.remove(5);
+ messages.remove(8);
+ bigText = String.join("\n", messages.subList(3, messages.size() - 2));
+ }
+
+ NotificationCompat.Builder builder = new NotificationCompat.Builder(context, NotificationChannels.SYNC_PROMPT)
+ .setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
+ .setSmallIcon(R.drawable.cic_logo_for_notification)
+ .setColor(ContextCompat.getColor(context, R.color.notification))
+ .setContentTitle(String.format("Accept public key for %s?", uri.getHost())) // TODO: strings.xml
+ .setCategory(NotificationCompat.CATEGORY_ERROR)
+ .setContentText(messages.get(0))
+ .setStyle(new NotificationCompat.BigTextStyle()
+ .bigText(bigText));
+
+ if (!questions.isEmpty()) {
+ builder.addAction(
+ R.drawable.cic_logo_for_notification,
+ DENY,
+ PendingIntent.getBroadcast(
+ context,
+ 0,
+ new Intent().setAction(ACTION_REJECT_REMOTE_HOST_KEY),
+ PendingIntent.FLAG_IMMUTABLE));
+ // Middle button is only relevant when there are 2 questions
+ if (questions.size() == 2) {
+ builder.addAction(
+ R.drawable.cic_logo_for_notification,
+ ALLOW,
+ PendingIntent.getBroadcast(
+ context,
+ 0,
+ new Intent().setAction(ACTION_ACCEPT_REMOTE_HOST_KEY),
+ PendingIntent.FLAG_IMMUTABLE));
+ }
+ builder.addAction(
+ R.drawable.cic_logo_for_notification,
+ ALLOW_AND_STORE,
+ PendingIntent.getBroadcast(
+ context,
+ 0,
+ new Intent().setAction(ACTION_ACCEPT_AND_STORE_REMOTE_HOST_KEY),
+ PendingIntent.FLAG_IMMUTABLE));
+ }
+
+ NotificationManager notificationManager =
+ (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
+
+ notificationManager.notify(SYNC_SSH_REMOTE_HOST_KEY, builder.build());
+ }
+}
diff --git a/app/src/main/java/com/orgzly/android/ui/repo/git/GitRepoActivity.kt b/app/src/main/java/com/orgzly/android/ui/repo/git/GitRepoActivity.kt
index b4b330487..06285dd2e 100644
--- a/app/src/main/java/com/orgzly/android/ui/repo/git/GitRepoActivity.kt
+++ b/app/src/main/java/com/orgzly/android/ui/repo/git/GitRepoActivity.kt
@@ -9,6 +9,7 @@ import android.net.Uri
import android.os.AsyncTask
import android.os.Bundle
import android.os.Environment
+import android.provider.Settings
import android.text.TextUtils
import android.view.ContextMenu
import android.view.Menu
@@ -21,7 +22,7 @@ import com.google.android.material.textfield.TextInputLayout
import com.orgzly.R
import com.orgzly.android.App
import com.orgzly.android.git.GitPreferences
-import com.orgzly.android.git.GitSSHKeyTransportSetter
+import com.orgzly.android.git.GitSshKeyTransportSetter
import com.orgzly.android.git.GitTransportSetter
import com.orgzly.android.git.HTTPSTransportSetter
import com.orgzly.android.prefs.AppPreferences
@@ -36,7 +37,7 @@ import com.orgzly.android.ui.showSnackbar
import com.orgzly.android.util.AppPermissions
import com.orgzly.android.util.MiscUtils
import com.orgzly.databinding.ActivityRepoGitBinding
-import org.eclipse.jgit.api.errors.TransportException
+import org.eclipse.jgit.errors.TransportException
import org.eclipse.jgit.errors.NoRemoteRepositoryException
import org.eclipse.jgit.errors.NotSupportedException
import org.eclipse.jgit.lib.ProgressMonitor
@@ -79,10 +80,6 @@ class GitRepoActivity : CommonActivity(), GitPreferences {
R.string.pref_key_git_https_password,
true
),
- Field(
- binding.activityRepoGitSshKey,
- binding.activityRepoGitSshKeyLayout,
- R.string.pref_key_git_ssh_key_path),
Field(
binding.activityRepoGitAuthor,
binding.activityRepoGitAuthorLayout,
@@ -106,24 +103,26 @@ class GitRepoActivity : CommonActivity(), GitPreferences {
startLocalFileBrowser(binding.activityRepoGitDirectory, ACTIVITY_REQUEST_CODE_FOR_DIRECTORY_SELECTION)
}
- binding.activityRepoGitSshKeyBrowse.setOnClickListener {
- startLocalFileBrowser(binding.activityRepoGitSshKey, ACTIVITY_REQUEST_CODE_FOR_SSH_KEY_SELECTION, true)
- }
-
val repoId = intent.getLongExtra(ARG_REPO_ID, 0)
val factory = RepoViewModelFactory.getInstance(dataRepository, repoId)
viewModel = ViewModelProvider(this, factory).get(RepoViewModel::class.java)
- /* Set directory value for existing repository being edited. */
if (repoId != 0L) {
+ /* Set directory value for existing repository being edited. */
dataRepository.getRepo(repoId)?.let { repo ->
binding.activityRepoGitUrl.setText(repo.url)
setFromPreferences()
}
} else {
+ /* Set default values for new repo being added. */
createDefaultRepoFolder()
+ binding.activityRepoGitAuthor.setText("Orgzly")
+ binding.activityRepoGitBranch.setText(R.string.git_default_branch)
+ val deviceName = Settings.Secure.getString(contentResolver, "bluetooth_name")
+ if (deviceName != null) binding.activityRepoGitEmail.setText(String.format("orgzly@%s", deviceName))
+ else binding.activityRepoGitEmail.setText("orgzly@phone")
}
viewModel.finishEvent.observeSingle(this, Observer {
@@ -190,14 +189,10 @@ class GitRepoActivity : CommonActivity(), GitPreferences {
private fun updateAuthVisibility() {
val repoScheme = getRepoScheme()
if ("https" == repoScheme) {
- binding.activityRepoGitSshAuthInfo.visibility = View.GONE
- binding.activityRepoGitSshKeyLayout.visibility = View.GONE
binding.activityRepoGitHttpsAuthInfo.visibility = View.VISIBLE
binding.activityRepoGitHttpsUsernameLayout.visibility = View.VISIBLE
binding.activityRepoGitHttpsPasswordLayout.visibility = View.VISIBLE
} else {
- binding.activityRepoGitSshAuthInfo.visibility = View.VISIBLE
- binding.activityRepoGitSshKeyLayout.visibility = View.VISIBLE
binding.activityRepoGitHttpsAuthInfo.visibility = View.GONE
binding.activityRepoGitHttpsUsernameLayout.visibility = View.GONE
binding.activityRepoGitHttpsPasswordLayout.visibility = View.GONE
@@ -230,7 +225,6 @@ class GitRepoActivity : CommonActivity(), GitPreferences {
private fun setTextFromPrefKey(prefs: RepoPreferences, editText: EditText, prefKey: Int) {
if (editText.length() < 1) {
- val setting = prefs.getStringValue(prefKey, "")
editText.setText(prefs.getStringValue(prefKey, ""))
}
}
@@ -269,8 +263,18 @@ class GitRepoActivity : CommonActivity(), GitPreferences {
private fun saveAndFinish() {
if (validateFields()) {
- // TODO: If this fails we should notify the user in a nice way and mark the git repo field as bad
- RepoCloneTask(this).execute()
+ val repoId = intent.getLongExtra(ARG_REPO_ID, 0)
+ if (repoId != 0L) {
+ save()
+ } else {
+ val targetDirectory = File(binding.activityRepoGitDirectory.text.toString())
+ if (targetDirectory.list()!!.isNotEmpty()) {
+ binding.activityRepoGitDirectoryLayout.error = getString(R.string.git_clone_error_target_not_empty)
+ } else {
+ // TODO: If this fails we should notify the user in a nice way and mark the git repo field as bad
+ RepoCloneTask(this).execute()
+ }
+ }
}
}
@@ -278,17 +282,26 @@ class GitRepoActivity : CommonActivity(), GitPreferences {
if (e == null) {
save()
} else {
- val errorId = when {
- // TODO: show error for invalid username/password when using HTTPS
- e.cause is NoRemoteRepositoryException -> R.string.git_clone_error_invalid_repo
- e.cause is TransportException -> R.string.git_clone_error_ssh_auth
+ val error = when (e.cause) {
+ is NoRemoteRepositoryException -> R.string.git_clone_error_invalid_repo
+ is TransportException -> {
+ // JGit's catch-all "remote hung up unexpectedly" message is not very useful.
+ if (Regex("hung up unexpectedly").containsMatchIn(e.cause!!.message!!)) {
+ String.format(getString(R.string.git_clone_error_ssh), e.cause!!.cause!!.message)
+ } else {
+ String.format(getString(R.string.git_clone_error_ssh), e.cause!!.message)
+ }
+ }
// TODO: This should be checked when the user enters a directory by hand
- e.cause is FileNotFoundException -> R.string.git_clone_error_invalid_target_dir
- e.cause is GitRepo.DirectoryNotEmpty -> R.string.git_clone_error_target_not_empty
- e.cause is NotSupportedException -> R.string.git_clone_error_uri_not_supported
+ is FileNotFoundException -> R.string.git_clone_error_invalid_target_dir
+ is GitRepo.DirectoryNotEmpty -> R.string.git_clone_error_target_not_empty
+ is NotSupportedException -> R.string.git_clone_error_uri_not_supported
else -> R.string.git_clone_error_unknown
}
- showSnackbar(errorId)
+ when (error) {
+ is Int -> { showSnackbar(error) }
+ is String -> { showSnackbar(error) }
+ }
e.printStackTrace()
}
}
@@ -323,8 +336,8 @@ class GitRepoActivity : CommonActivity(), GitPreferences {
}
val targetDirectory = File(binding.activityRepoGitDirectory.text.toString())
- if (!targetDirectory.exists() || targetDirectory.list().isNotEmpty()) {
- binding.activityRepoGitDirectoryLayout.error = getString(R.string.git_clone_error_target_not_empty)
+ if (!targetDirectory.exists()) {
+ binding.activityRepoGitDirectoryLayout.error = getString(R.string.git_clone_error_invalid_target_dir)
}
for (field in fields) {
@@ -368,8 +381,7 @@ class GitRepoActivity : CommonActivity(), GitPreferences {
val password = withDefault(binding.activityRepoGitHttpsPassword.text.toString(), R.string.pref_key_git_https_password)
return HTTPSTransportSetter(username, password)
} else {
- val sshKeyPath = withDefault(binding.activityRepoGitSshKey.text.toString(), R.string.pref_key_git_ssh_key_path)
- return GitSSHKeyTransportSetter(sshKeyPath)
+ return GitSshKeyTransportSetter()
}
}
@@ -429,11 +441,6 @@ class GitRepoActivity : CommonActivity(), GitPreferences {
val uri = data.data
binding.activityRepoGitDirectory.setText(uri?.path)
}
- ACTIVITY_REQUEST_CODE_FOR_SSH_KEY_SELECTION ->
- if (resultCode == Activity.RESULT_OK && data != null) {
- val uri = data.data
- binding.activityRepoGitSshKey.setText(uri?.path)
- }
}
}
@@ -507,7 +514,6 @@ class GitRepoActivity : CommonActivity(), GitPreferences {
private const val ARG_REPO_ID = "repo_id"
const val ACTIVITY_REQUEST_CODE_FOR_DIRECTORY_SELECTION = 0
- const val ACTIVITY_REQUEST_CODE_FOR_SSH_KEY_SELECTION = 1
@JvmStatic
@JvmOverloads
diff --git a/app/src/main/java/com/orgzly/android/ui/settings/SettingsFragment.kt b/app/src/main/java/com/orgzly/android/ui/settings/SettingsFragment.kt
index 960d5dd7b..0681cdf95 100644
--- a/app/src/main/java/com/orgzly/android/ui/settings/SettingsFragment.kt
+++ b/app/src/main/java/com/orgzly/android/ui/settings/SettingsFragment.kt
@@ -12,10 +12,12 @@ import com.orgzly.BuildConfig
import com.orgzly.R
import com.orgzly.android.AppIntent
import com.orgzly.android.SharingShortcutsManager
+import com.orgzly.android.git.SshKey
import com.orgzly.android.prefs.*
import com.orgzly.android.reminders.RemindersScheduler
import com.orgzly.android.ui.CommonActivity
import com.orgzly.android.ui.NoteStates
+import com.orgzly.android.ui.dialogs.ShowSshKeyDialogFragment
import com.orgzly.android.ui.notifications.Notifications
import com.orgzly.android.ui.util.KeyboardUtils
import com.orgzly.android.usecase.NoteReparseStateAndTitles
@@ -97,6 +99,31 @@ class SettingsFragment : PreferenceFragmentCompat(), SharedPreferences.OnSharedP
}
}
+ // Disable Git repos completely on API < 23
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
+ preference(R.string.pref_key_git_is_enabled)?.let {
+ preferenceScreen.removePreference(it)
+ }
+ }
+
+ /* Disable SSH key generation if Git repository type is not enabled */
+ if (!AppPreferences.gitIsEnabled(context)) {
+ preference(R.string.pref_key_ssh_keygen)?.let {
+ preferenceScreen.removePreference(it)
+ }
+ }
+
+ preference(R.string.pref_key_ssh_show_public_key)?.let {
+ if (AppPreferences.gitIsEnabled(context) && SshKey.canShowSshPublicKey) {
+ it.setOnPreferenceClickListener {
+ ShowSshKeyDialogFragment().show(childFragmentManager, "public_key")
+ true
+ }
+ } else {
+ preferenceScreen.removePreference(it)
+ }
+ }
+
/* Update preferences which depend on multiple others. */
updateRemindersScreen()
}
diff --git a/app/src/main/java/com/orgzly/android/util/BiometricAuthenticator.kt b/app/src/main/java/com/orgzly/android/util/BiometricAuthenticator.kt
new file mode 100644
index 000000000..618f4a1dc
--- /dev/null
+++ b/app/src/main/java/com/orgzly/android/util/BiometricAuthenticator.kt
@@ -0,0 +1,63 @@
+package com.orgzly.android.util
+
+import androidx.biometric.BiometricManager
+import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_STRONG
+import androidx.biometric.BiometricManager.Authenticators.DEVICE_CREDENTIAL
+import androidx.biometric.BiometricPrompt
+import androidx.core.content.ContextCompat
+import com.orgzly.R
+import com.orgzly.android.ui.CommonActivity
+import java.io.IOException
+import java.util.concurrent.Executor
+import kotlin.coroutines.resume
+import kotlin.coroutines.suspendCoroutine
+
+class BiometricAuthenticator(private val callingActivity: CommonActivity) {
+ private lateinit var biometricManager: BiometricManager
+ private lateinit var executor: Executor
+ private lateinit var biometricPrompt: BiometricPrompt
+ private lateinit var promptInfo: BiometricPrompt.PromptInfo
+ private val authenticators = BIOMETRIC_STRONG or DEVICE_CREDENTIAL
+
+ suspend fun authenticate(promptText: String) : String? {
+
+ // Initialize BiometricManager for checking biometrics availability
+ biometricManager = BiometricManager.from(callingActivity)
+
+ // Initialize PromptInfo to set title, subtitle, and authenticators of the biometric prompt
+ promptInfo = BiometricPrompt.PromptInfo.Builder()
+ .setTitle(promptText)
+ .setAllowedAuthenticators(authenticators)
+ .build()
+
+ if (biometricManager.canAuthenticate(authenticators) !in listOf(
+ BiometricManager.BIOMETRIC_SUCCESS, BiometricManager.BIOMETRIC_STATUS_UNKNOWN
+ )) {
+ throw IOException(
+ callingActivity.getString(R.string.biometric_auth_not_available)
+ )
+ }
+
+ return suspendCoroutine { continuation ->
+ // Initialize BiometricPrompt to setup success & error callbacks of biometric prompt
+ executor = ContextCompat.getMainExecutor(callingActivity)
+ biometricPrompt =
+ BiometricPrompt(
+ callingActivity, executor,
+ object : BiometricPrompt.AuthenticationCallback() {
+ override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
+ super.onAuthenticationError(errorCode, errString)
+ continuation.resume(errString.toString())
+ }
+ override fun onAuthenticationSucceeded(
+ result: BiometricPrompt.AuthenticationResult
+ ) {
+ super.onAuthenticationSucceeded(result)
+ continuation.resume(null)
+ }
+ },
+ )
+ biometricPrompt.authenticate(promptInfo)
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/res/layout/activity_repo_git.xml b/app/src/main/res/layout/activity_repo_git.xml
index 74d23d968..ff6c4fadd 100644
--- a/app/src/main/res/layout/activity_repo_git.xml
+++ b/app/src/main/res/layout/activity_repo_git.xml
@@ -31,7 +31,7 @@
android:id="@+id/activity_repo_git_url"
android:layout_width="match_parent"
android:layout_height="wrap_content"
- android:inputType="text"
+ android:inputType="textUri"
android:imeOptions="actionNext"
android:hint="@string/git_url_hint"/>
@@ -69,38 +69,6 @@
-
-
-
-
-
-
-
-
-
-
-
-
+ android:inputType="textPersonName" />
@@ -165,7 +133,7 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/git_email_hint"
- android:inputType="text" />
+ android:inputType="textEmailAddress" />
@@ -179,7 +147,6 @@
android:id="@+id/activity_repo_git_branch"
android:layout_width="match_parent"
android:layout_height="wrap_content"
- android:text="@string/git_default_branch"
android:hint="@string/git_branch_hint"
android:selectAllOnFocus="true"
android:inputType="text" />
@@ -206,4 +173,4 @@
android:id="@+id/fab"
style="@style/Fab.Done" />
-
\ No newline at end of file
+
diff --git a/app/src/main/res/layout/activity_ssh_keygen.xml b/app/src/main/res/layout/activity_ssh_keygen.xml
new file mode 100644
index 000000000..4710ab467
--- /dev/null
+++ b/app/src/main/res/layout/activity_ssh_keygen.xml
@@ -0,0 +1,71 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/values-cs-rCZ/strings.xml b/app/src/main/res/values-cs-rCZ/strings.xml
index 96996d0b2..c4d16ef51 100644
--- a/app/src/main/res/values-cs-rCZ/strings.xml
+++ b/app/src/main/res/values-cs-rCZ/strings.xml
@@ -181,7 +181,6 @@
Kontroluji nastavení repozitáře.
Vzdálená adresa (například git@github.com:orgzly / orgzly-android.git)
Umístění slozky (například \"file:/sdcard/orgzly\")
- Umístění SSH klíče (například \"file:/sdcard/id_rsa\")
Uživatelské jméno
Heslo
Autor (např. Jan Novák)
diff --git a/app/src/main/res/values-de-rDE/strings.xml b/app/src/main/res/values-de-rDE/strings.xml
index 652b5b595..20c96d940 100644
--- a/app/src/main/res/values-de-rDE/strings.xml
+++ b/app/src/main/res/values-de-rDE/strings.xml
@@ -175,7 +175,6 @@
Sicherstellen funktionierender Repository-Einstellungen.
Remote-Adresse (z.B. git@github.com:orgzly/orgzly-android.git)
Verzeichnispfad (z.B. \"file:/sdcard/orgzly\")
- Pfad für SSH-Schlüssel (z.B. \"file:/sdcard/id_rsa\")
Benutzername
Passwort
Autor (z.B. Hans Meier)
diff --git a/app/src/main/res/values-es-rES/strings.xml b/app/src/main/res/values-es-rES/strings.xml
index 657aa161c..fbf292bf4 100644
--- a/app/src/main/res/values-es-rES/strings.xml
+++ b/app/src/main/res/values-es-rES/strings.xml
@@ -175,7 +175,6 @@
Asegurando que la configuración del repositorio funciona.
Dirección remota (por ejemplo git@github.com:orgzly/orgzly-android.git)
Ubicación del directorio (por ejemplo, \"file:/sdcard/orgzly\")
- Ubicación de llave SSH (por ejemplo, \"file:/sdcard/id_rsa\")
Nombre de usuario
Contraseña
Autor (por ejemplo, Juan Pérez)
diff --git a/app/src/main/res/values-fr-rFR/strings.xml b/app/src/main/res/values-fr-rFR/strings.xml
index 143e0cb20..ea45ecf70 100644
--- a/app/src/main/res/values-fr-rFR/strings.xml
+++ b/app/src/main/res/values-fr-rFR/strings.xml
@@ -175,7 +175,6 @@
S\'assurer que les paramètres du dépôt fonctionnent.
Adresse distante (par exemple : git@github.com:orgzly/orgzly-android.git)
Emplacement du dossier (par exemple : file:/sdcard/orgzly)
- Emplacement de la clé SSH (par exemple : file:/sdcard/id_rsa)
Nom d\'utilisateur
Mot de passe
Auteur (par exemple : Jean Dupont)
diff --git a/app/src/main/res/values-hu-rHU/strings.xml b/app/src/main/res/values-hu-rHU/strings.xml
index 83d7953bd..664045940 100644
--- a/app/src/main/res/values-hu-rHU/strings.xml
+++ b/app/src/main/res/values-hu-rHU/strings.xml
@@ -175,7 +175,6 @@
Biztosítsa, hogy a beállított tároló működni fog.
Távoli cím (pl. git@github.com:orgzly/orgzly-android.git)
Könyvtár helye (pl. “file:/sdcard/orgzly”)
- SSH kulcs helye (pl. “file:/sdcard/id_rsa”)
Felhasználónév
Jelszó
Szerző (pl. Kovács Jenő)
diff --git a/app/src/main/res/values-in-rID/strings.xml b/app/src/main/res/values-in-rID/strings.xml
index 5b5afb843..358108136 100644
--- a/app/src/main/res/values-in-rID/strings.xml
+++ b/app/src/main/res/values-in-rID/strings.xml
@@ -172,7 +172,6 @@
Ensuring repository settings will work.
Remote URL (e.g. git@github.com:orgzly/orgzly-android.git)
Directory location (e.g. “/sdcard/orgzly”)
- SSH key location (e.g. “/sdcard/id_rsa”)
Username
Password
Author (e.g. John Doe)
diff --git a/app/src/main/res/values-it-rIT/strings.xml b/app/src/main/res/values-it-rIT/strings.xml
index 7b9a1f32a..48e9751cf 100644
--- a/app/src/main/res/values-it-rIT/strings.xml
+++ b/app/src/main/res/values-it-rIT/strings.xml
@@ -175,7 +175,6 @@
Ensuring repository settings will work.
Indirizzo remoto (es. git@github.com:orgzly / orgzly-android.git)
Percorso directory (es. \"file:/sdcard/orgzly\")
- Posizione della chiave SSH (es. “file:/sdcard/id_rsa”)
Username
Password
Autore (es. Sergio Rossi)
diff --git a/app/src/main/res/values-ja-rJP/strings.xml b/app/src/main/res/values-ja-rJP/strings.xml
index 68febfa1f..aa8cb4154 100644
--- a/app/src/main/res/values-ja-rJP/strings.xml
+++ b/app/src/main/res/values-ja-rJP/strings.xml
@@ -172,7 +172,6 @@
リポジトリの設定が動作することを確認します。
リモートアドレス (例: git@github.com:orgzly/orgzly-android.git)
ディレクトリの位置 (例: \"file:/sdcard/orgzly\")
- SSH秘密鍵 (例: \"file:/sdcard/id_rsa\")
ユーザー名
パスワード
Author (例: John Doe)
diff --git a/app/src/main/res/values-ko-rKR/strings.xml b/app/src/main/res/values-ko-rKR/strings.xml
index 1c5aa8cbe..f6a3748e1 100644
--- a/app/src/main/res/values-ko-rKR/strings.xml
+++ b/app/src/main/res/values-ko-rKR/strings.xml
@@ -172,7 +172,6 @@
Ensuring repository settings will work.
외부 주소 (예. git@github.com:orgzly/orgzly-android.git)
디렉터리 위치 (예: \"file:/sdcard/orgzly\")
- SSH 키 위치 (예: \"file:/sdcard/id_rsa\")
Username
Password
저자 (예. 홍길동)
diff --git a/app/src/main/res/values-nl-rNL/strings.xml b/app/src/main/res/values-nl-rNL/strings.xml
index 950f247f7..19a2fd0d1 100644
--- a/app/src/main/res/values-nl-rNL/strings.xml
+++ b/app/src/main/res/values-nl-rNL/strings.xml
@@ -175,7 +175,6 @@
Controleren dat repository instellingen werken.
Extern adres (bijv. git@github.com:orgzly/orgzly-android.git)
Locatie van map (bijv. \"file:/sdcard/orgzly\")
- SSH-sleutellocatie (bijv. “file:/sdcard/id_rsa”)
Gebruikersnaam
Wachtwoord
Auteur (bijv. John Doe)
diff --git a/app/src/main/res/values-pl-rPL/strings.xml b/app/src/main/res/values-pl-rPL/strings.xml
index a996f324d..00f5e7c52 100644
--- a/app/src/main/res/values-pl-rPL/strings.xml
+++ b/app/src/main/res/values-pl-rPL/strings.xml
@@ -181,7 +181,6 @@
Gwarantowanie, że ustawienia repozytorium będą działać.
Adres zdalny (np. git@github.com:orgzly / orgzly-android.git)
Lokalizacja katalogu (np. “file:/sdcard/orgzly”)
- Lokalizacja klucza SSH (np. \"file:/sdcard/id_rsa\")
Nazwa użytkownika
Hasło
Autor (np. John Doe)
diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml
index e4e368923..8605870e0 100644
--- a/app/src/main/res/values-pt-rBR/strings.xml
+++ b/app/src/main/res/values-pt-rBR/strings.xml
@@ -175,7 +175,6 @@
Assegurando que as configurações do repositório irão funcionar.
URL remota (por exemplo git@github.com:orgzly/orgzly-android.git)
Local do diretório (por exemplo, “/sdcard/orgzly”)
- Localização da chave SSH (por exemplo, “/sdcard/id_rsa”)
Nome do Usuário
Senha
Autor (por exemplo, John Doe)
diff --git a/app/src/main/res/values-pt-rPT/strings.xml b/app/src/main/res/values-pt-rPT/strings.xml
index 07dfcadd1..34534fa76 100644
--- a/app/src/main/res/values-pt-rPT/strings.xml
+++ b/app/src/main/res/values-pt-rPT/strings.xml
@@ -175,7 +175,6 @@
Ensuring repository settings will work.
Endereço remoto (ex: git@github.com:orgzly/orgzly-android.git)
Local da pasta (ex: \"file:/sdcard/orgzly\")
- Local da chave SSH (ex: “file:/sdcard/id_rsa”)
Username
Password
Autor (ex: John Doe)
diff --git a/app/src/main/res/values-ru-rRU/strings.xml b/app/src/main/res/values-ru-rRU/strings.xml
index d892b81db..0dc322b56 100644
--- a/app/src/main/res/values-ru-rRU/strings.xml
+++ b/app/src/main/res/values-ru-rRU/strings.xml
@@ -181,7 +181,6 @@
Обеспечение работоспособных настроек репозитория.
Удалённый адрес (например, git@github.com:orgzly/orgzly-android.git)
Путь к каталогу (например, «file:/sdcard/orgzly»)
- Путь к SSH-ключу (например, «file:/sdcard/id_rsa»)
Имя
Пароль
Автор (например, John Doe)
diff --git a/app/src/main/res/values-sv-rSE/strings.xml b/app/src/main/res/values-sv-rSE/strings.xml
index 6b3527d8a..d3d24dc44 100644
--- a/app/src/main/res/values-sv-rSE/strings.xml
+++ b/app/src/main/res/values-sv-rSE/strings.xml
@@ -175,7 +175,6 @@
Kontrollerar att lagringsplatsens inställningar fungerar.
Fjärradress (t.ex. git@github.com:orgzly/orgzly-android.git)
Mappens plats (t.ex. ”file:/sdcard/orgzly”)
- SSH-nyckelns plats (t.ex. ”file:/sdcard/id_rsa”)
Användarnamn
Lösenord
Författare (t.ex. John Doe)
diff --git a/app/src/main/res/values-tr-rTR/strings.xml b/app/src/main/res/values-tr-rTR/strings.xml
index c60d74d16..ec96bf892 100644
--- a/app/src/main/res/values-tr-rTR/strings.xml
+++ b/app/src/main/res/values-tr-rTR/strings.xml
@@ -175,7 +175,6 @@
Ensuring repository settings will work.
Uzak Bağlantı (ör. git@github.com:orgzly/orgzly-android.git)
Dizin konumu (ör. \"/sdcard/orgzly\")
- SSH anahtar konumu (ör. \"/sdcard/id_rsa\")
Kullanıcı adı
Parola
Yazar (ör. Sarı Çizmeli Mehmet)
diff --git a/app/src/main/res/values-uk-rUA/strings.xml b/app/src/main/res/values-uk-rUA/strings.xml
index bf694010f..c186cb7a0 100644
--- a/app/src/main/res/values-uk-rUA/strings.xml
+++ b/app/src/main/res/values-uk-rUA/strings.xml
@@ -181,7 +181,6 @@
Перевірка налаштувань репозиторію.
Віддалена адреса (напр. git@github.com:orgzly/orgzly-android.git)
Розташування каталогу (напр. «file:/sdcard/orgzly»)
- Розташування ключа SSH (напр. «file:/sdcard/id_rsa»)
Імʼя користувача
Пароль
Автор (напр. John Doe)
diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml
index 682b6d010..3f3ba2289 100644
--- a/app/src/main/res/values-zh-rCN/strings.xml
+++ b/app/src/main/res/values-zh-rCN/strings.xml
@@ -172,7 +172,6 @@
确保存储库设置工作。
远程地址(例如 git@github.com:orgzly/orgzly-android.git)
目录位置(例如 “file:/sdcard/orgzly”)
- SSH 密钥位置(例如 “file:/sdcard/id_rsa”)
用户名
密码
作者(例如 John Doe)
diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml
index 733a2cf0f..dc0ce6474 100644
--- a/app/src/main/res/values-zh-rTW/strings.xml
+++ b/app/src/main/res/values-zh-rTW/strings.xml
@@ -172,7 +172,6 @@
確保資料庫設定起作用
Remote 遠端位址 (如 git@github:orgzly/orgzly-android.git)
目錄位址 (如 \"file:/sdcard/orgzly\")
- SSH 金鑰位址 (如 \"file:/sdcard/id_rsa\")
使用者名稱
密碼
作者 (如 無名氏)
diff --git a/app/src/main/res/values/prefs_keys.xml b/app/src/main/res/values/prefs_keys.xml
index f7ff01db5..9ec1cfa57 100644
--- a/app/src/main/res/values/prefs_keys.xml
+++ b/app/src/main/res/values/prefs_keys.xml
@@ -556,11 +556,11 @@
false
+ pref_key_git_ssh_key_type
pref_key_git_author
pref_key_git_email
pref_key_git_https_username
pref_key_git_https_password
- pref_key_git_ssh_key_path
pref_key_git_repository_filepath
pref_key_git_remote_name
pref_key_git_branch_name
@@ -578,6 +578,8 @@
pref_key_repos
+ pref_key_ssh_keygen
+ pref_key_ssh_show_public_key
pref_key_version
pref_key_reload_getting_started
pref_key_clear_database
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 11bb9174e..f7ed372e6 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -85,6 +85,9 @@
Repositories
Location to synchronize your notebooks with
+ SSH key generation
+ Generate key pair for Git repo sync
+ View generated SSH public key
No link set
Note created
Failed to create note
@@ -198,7 +201,8 @@
Ensuring repository settings will work.
Remote URL (e.g. git@github.com:orgzly/orgzly-android.git)
Directory location (e.g. “/sdcard/orgzly”)
- SSH key location (e.g. “/sdcard/id_rsa”)
+ No SSH key pair found. Do you wish to generate one?
+ No SSH key found
Username
Password
Author (e.g. John Doe)
@@ -214,11 +218,13 @@
Failure while cloning, authentication error
+ Failure while cloning: %s
The remote address is not a git repo
Unknown error while cloning
The target directory does not exist
The target directory is not empty
The remote address is not supported
+ Failed to update marker branch
URL (e.g. webdavs://example.com/my-org-files/)
Username
@@ -728,4 +734,31 @@
Clock cancel
Search results
Popup menu buttons
+
+ RSA
+ ECDSA
+ ED25519
+ Protect with screen lock credential
+ Generate
+ SSH key
+ Replace existing SSH key? You might lose access to your server.
+ Replace
+ Keep
+ ECDSA (NIST P-256)\nFast authentication and supported by most servers that are still receiving updates.
+ ED25519\nFast authentication, but only supported by rather modern servers.
+ RSA (3072 bit)\nSupported by older servers.
+ Generating keys…
+ Failed to get SSH public key
+ Failed to get SSH private key
+ Failed to unlock SSH private key
+ Generate SSH key
+ Unlock Orgzly\'s SSH key
+ Biometric authentication is not available
+ Authentication error: Code: %1$d (%2$s)
+ %1$s\n\nProvide this public key to your Git server.
+ Your public key
+ Share
+ Error while trying to generate the SSH key pair
+ Message: \n
+ Error: SSH key can only be unlocked from an activity
diff --git a/app/src/main/res/xml/prefs_screen_sync.xml b/app/src/main/res/xml/prefs_screen_sync.xml
index 3d6205ebe..e5c76f88f 100644
--- a/app/src/main/res/xml/prefs_screen_sync.xml
+++ b/app/src/main/res/xml/prefs_screen_sync.xml
@@ -21,6 +21,21 @@
android:summary="@string/auto_sync_summary">
+
+
+
+
+
+
+