Add Activity to view the Git commit log (#1056)

This commit is contained in:
Nosweh 2020-08-28 17:31:40 +02:00 committed by GitHub
parent 88b1de2b50
commit 0f0d1994e5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 342 additions and 52 deletions

View file

@ -8,6 +8,7 @@ All notable changes to this project will be documented in this file.
- Allow sorting by recently used
- Add [Bromite](https://www.bromite.org/) and [Ungoogled Chromium](https://git.droidware.info/wchen342/ungoogled-chromium-android) to supported browsers list for Autofill
- Add ability to view the Git commit log
### Changed
@ -17,7 +18,6 @@ All notable changes to this project will be documented in this file.
### Fixed
- Password creation UI will scroll if it does not fit on the screen
- Git server protocol and authentication mode are only updated when explicitly saved
- Remember HTTPS password during a sync operation

View file

@ -65,6 +65,10 @@
android:label="@string/title_activity_git_config"
android:windowSoftInputMode="adjustResize" />
<activity
android:name=".git.log.GitLogActivity"
android:label="@string/title_activity_git_log" />
<activity
android:name=".UserPreference"
android:label="@string/action_settings"

View file

@ -48,6 +48,7 @@ import com.zeapo.pwdstore.crypto.BasePgpActivity.Companion.getLongName
import com.zeapo.pwdstore.crypto.DecryptActivity
import com.zeapo.pwdstore.crypto.PasswordCreationActivity
import com.zeapo.pwdstore.git.BaseGitActivity
import com.zeapo.pwdstore.git.log.GitLogActivity
import com.zeapo.pwdstore.git.GitOperationActivity
import com.zeapo.pwdstore.git.GitServerConfigActivity
import com.zeapo.pwdstore.git.config.AuthMode

View file

@ -4,20 +4,24 @@
*/
package com.zeapo.pwdstore.git
import android.content.Intent
import android.os.Bundle
import android.os.Handler
import android.util.Patterns
import androidx.core.os.postDelayed
import androidx.lifecycle.lifecycleScope
import com.github.ajalt.timberkt.e
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
import com.zeapo.pwdstore.R
import com.zeapo.pwdstore.databinding.ActivityGitConfigBinding
import com.zeapo.pwdstore.git.config.GitSettings
import com.zeapo.pwdstore.git.log.GitLogActivity
import com.zeapo.pwdstore.utils.PasswordRepository
import com.zeapo.pwdstore.utils.viewBinding
import kotlinx.coroutines.launch
import org.eclipse.jgit.lib.Constants
import org.eclipse.jgit.lib.Repository
class GitConfigActivity : BaseGitActivity() {
@ -33,23 +37,7 @@ class GitConfigActivity : BaseGitActivity() {
else
binding.gitUserName.setText(GitSettings.authorName)
binding.gitUserEmail.setText(GitSettings.authorEmail)
val repo = PasswordRepository.getRepository(PasswordRepository.getRepositoryDirectory())
if (repo != null) {
try {
val objectId = repo.resolve(Constants.HEAD)
val ref = repo.getRef("refs/heads/${GitSettings.branch}")
val head = if (ref.objectId.equals(objectId)) ref.name else "DETACHED"
binding.gitCommitHash.text = String.format("%s (%s)", objectId.abbreviate(8).name(), head)
// enable the abort button only if we're rebasing
val isRebasing = repo.repositoryState.isRebasing
binding.gitAbortRebase.isEnabled = isRebasing
binding.gitAbortRebase.alpha = if (isRebasing) 1.0f else 0.5f
} catch (ignored: Exception) {
}
}
binding.gitAbortRebase.setOnClickListener { lifecycleScope.launch { launchGitOperation(BREAK_OUT_OF_DETACHED) } }
binding.gitResetToRemote.setOnClickListener { lifecycleScope.launch { launchGitOperation(REQUEST_RESET) } }
setupTools()
binding.saveButton.setOnClickListener {
val email = binding.gitUserEmail.text.toString().trim()
val name = binding.gitUserName.text.toString().trim()
@ -66,4 +54,50 @@ class GitConfigActivity : BaseGitActivity() {
}
}
}
/**
* Sets up the UI components of the tools section.
*/
private fun setupTools() {
val repo = PasswordRepository.getRepository(null)
if (repo != null) {
binding.gitHeadStatus.text = headStatusMsg(repo)
// enable the abort button only if we're rebasing
val isRebasing = repo.repositoryState.isRebasing
binding.gitAbortRebase.isEnabled = isRebasing
binding.gitAbortRebase.alpha = if (isRebasing) 1.0f else 0.5f
}
binding.gitLog.setOnClickListener {
try {
intent = Intent(this, GitLogActivity::class.java)
startActivity(intent)
} catch (ex: Exception) {
e(ex) { "Failed to start GitLogActivity" }
}
}
binding.gitAbortRebase.setOnClickListener { lifecycleScope.launch { launchGitOperation(BREAK_OUT_OF_DETACHED) } }
binding.gitResetToRemote.setOnClickListener { lifecycleScope.launch { launchGitOperation(REQUEST_RESET) } }
}
/**
* Returns a user-friendly message about the current state of HEAD.
*
* The state is recognized to be either pointing to a branch or detached.
*/
private fun headStatusMsg(repo: Repository): String {
return try {
val headRef = repo.getRef(Constants.HEAD)
if (headRef.isSymbolic) {
val branchName = headRef.target.name
val shortBranchName = Repository.shortenRefName(branchName)
getString(R.string.git_head_on_branch, shortBranchName)
} else {
val commitHash = headRef.objectId.abbreviate(8).name()
getString(R.string.git_head_detached, commitHash)
}
} catch (ex: Exception) {
e(ex) { "Error getting HEAD reference" }
getString(R.string.git_head_missing)
}
}
}

View file

@ -0,0 +1,18 @@
/*
* Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
* SPDX-License-Identifier: GPL-3.0-only
*/
package com.zeapo.pwdstore.git.log
import java.util.Date
/**
* Basic information about a git commit.
*
* @property hash full-length hash of the commit object.
* @property shortMessage the commit's short message (i.e. title line).
* @property authorName name of the commit's author without email address.
* @property time time when the commit was created.
*/
data class GitCommit(val hash: String, val shortMessage: String, val authorName: String, val time: Date)

View file

@ -0,0 +1,38 @@
/*
* Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
* SPDX-License-Identifier: GPL-3.0-only
*/
package com.zeapo.pwdstore.git.log
import android.os.Bundle
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import com.zeapo.pwdstore.databinding.ActivityGitLogBinding
import com.zeapo.pwdstore.git.BaseGitActivity
import com.zeapo.pwdstore.utils.viewBinding
/**
* Displays the repository's git commits in git-log fashion.
*
* It provides basic information about each commit by way of a non-interactive RecyclerView.
*/
class GitLogActivity : BaseGitActivity() {
private val binding by viewBinding(ActivityGitLogBinding::inflate)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(binding.root)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
createRecyclerView()
}
private fun createRecyclerView() {
binding.gitLogRecyclerView.apply {
setHasFixedSize(true)
addItemDecoration(DividerItemDecoration(context, LinearLayoutManager.VERTICAL))
adapter = GitLogAdapter()
}
}
}

View file

@ -0,0 +1,56 @@
/*
* Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
* SPDX-License-Identifier: GPL-3.0-only
*/
package com.zeapo.pwdstore.git.log
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.github.ajalt.timberkt.e
import com.zeapo.pwdstore.databinding.GitLogRowLayoutBinding
import java.text.DateFormat
import java.util.Date
private fun shortHash(hash: String): String {
return hash.substring(0 until 8)
}
private fun stringFrom(date: Date): String {
return DateFormat.getDateTimeInstance().format(date)
}
/**
* @see GitLogActivity
*/
class GitLogAdapter : RecyclerView.Adapter<GitLogAdapter.ViewHolder>() {
private val model = GitLogModel()
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val inflater = LayoutInflater.from(parent.context)
val binding = GitLogRowLayoutBinding.inflate(inflater, parent, false)
return ViewHolder(binding)
}
override fun onBindViewHolder(viewHolder: ViewHolder, position: Int) {
val commit = model.get(position)
if (commit == null) {
e { "There is no git commit for view holder at position $position." }
return
}
viewHolder.bind(commit)
}
override fun getItemCount() = model.size
class ViewHolder(private val binding: GitLogRowLayoutBinding) : RecyclerView.ViewHolder(binding.root) {
fun bind(commit: GitCommit) = with(binding) {
gitLogRowMessage.text = commit.shortMessage
gitLogRowHash.text = shortHash(commit.hash)
gitLogRowTime.text = stringFrom(commit.time)
}
}
}

View file

@ -0,0 +1,53 @@
/*
* Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
* SPDX-License-Identifier: GPL-3.0-only
*/
package com.zeapo.pwdstore.git.log
import com.github.ajalt.timberkt.e
import com.zeapo.pwdstore.utils.PasswordRepository
import com.zeapo.pwdstore.utils.hash
import com.zeapo.pwdstore.utils.time
import org.eclipse.jgit.api.Git
import org.eclipse.jgit.revwalk.RevCommit
private fun commits(): Iterable<RevCommit> {
val repo = PasswordRepository.getRepository(null)
if (repo == null) {
e { "Could not access git repository" }
return listOf()
}
return try {
Git(repo).log().call()
} catch (exc: Exception) {
e(exc) { "Failed to obtain git commits" }
listOf()
}
}
/**
* Provides [GitCommit]s from a git-log of the password git repository.
*
* All commits are acquired on the first request to this object.
*/
class GitLogModel {
// All commits are acquired here at once. Acquiring the commits in batches would not have been
// entirely sensible because the amount of computation required to obtain commit number n from
// the log includes the amount of computation required to obtain commit number n-1 from the log.
// This is because the commit graph is walked from HEAD to the last commit to obtain.
// Additionally, tests with 1000 commits in the log have not produced a significant delay in the
// user experience.
private val cache: MutableList<GitCommit> by lazy {
commits().map {
GitCommit(it.hash, it.shortMessage, it.authorIdent.name, it.time)
}.toMutableList()
}
val size = cache.size
fun get(index: Int): GitCommit? {
if (index >= size) e { "Cannot get git commit with index $index. There are only $size." }
return cache.getOrNull(index)
}
}

View file

@ -28,6 +28,9 @@ import com.zeapo.pwdstore.git.GitCommandExecutor
import com.zeapo.pwdstore.git.operation.GitOperation
import com.zeapo.pwdstore.utils.PasswordRepository.Companion.getRepositoryDirectory
import java.io.File
import java.util.Date
import org.eclipse.jgit.lib.ObjectId
import org.eclipse.jgit.revwalk.RevCommit
const val OPENPGP_PROVIDER = "org.sufficientlysecure.keychain"
@ -162,3 +165,23 @@ val Context.autofillManager: AutofillManager?
fun File.isInsideRepository(): Boolean {
return canonicalPath.contains(getRepositoryDirectory().canonicalPath)
}
/**
* Unique SHA-1 hash of this commit as hexadecimal string.
*
* @see RevCommit.id
*/
val RevCommit.hash: String
get() = ObjectId.toString(id)
/**
* Time this commit was made with second precision.
*
* @see RevCommit.commitTime
*/
val RevCommit.time: Date
get() {
val epochSeconds = commitTime.toLong()
val epochMilliseconds = epochSeconds * 1000
return Date(epochMilliseconds)
}

View file

@ -17,7 +17,7 @@
android:id="@+id/username_input_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="8dp"
android:layout_margin="@dimen/normal_margin"
android:hint="@string/git_user_name_hint"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
@ -35,7 +35,7 @@
android:id="@+id/email_input_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="8dp"
android:layout_margin="@dimen/normal_margin"
android:hint="@string/git_user_email"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/username_input_layout">
@ -52,7 +52,7 @@
android:id="@+id/save_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="8dp"
android:layout_margin="@dimen/normal_margin"
android:text="@string/crypto_save"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/email_input_layout" />
@ -62,45 +62,43 @@
style="@style/TextAppearance.MaterialComponents.Headline5"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="8dp"
android:text="@string/hackish_tools"
android:layout_margin="@dimen/normal_margin"
android:text="@string/git_tools"
android:textSize="24sp"
android:textStyle="bold"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/save_button" />
<androidx.appcompat.widget.AppCompatTextView
android:id="@+id/commit_hash_label"
android:id="@+id/git_head_status"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="8dp"
android:text="@string/commit_hash"
android:layout_margin="@dimen/normal_margin"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/git_tools_title" />
<androidx.appcompat.widget.AppCompatTextView
android:id="@+id/git_commit_hash"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="8dp"
android:textStyle="bold"
app:layout_constraintStart_toEndOf="@id/commit_hash_label"
app:layout_constraintTop_toBottomOf="@id/git_tools_title"
tools:text="HASH" />
tools:text="HEAD status" />
<com.google.android.material.button.MaterialButton
android:id="@+id/git_log"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="@dimen/normal_margin"
android:text="@string/git_log"
app:layout_constraintTop_toBottomOf="@+id/git_head_status" />
<com.google.android.material.button.MaterialButton
android:id="@+id/git_abort_rebase"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="8dp"
android:layout_margin="@dimen/normal_margin"
android:text="@string/abort_rebase"
app:layout_constraintTop_toBottomOf="@id/commit_hash_label" />
app:layout_constraintTop_toBottomOf="@+id/git_log" />
<com.google.android.material.button.MaterialButton
android:id="@+id/git_reset_to_remote"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="8dp"
android:layout_margin="@dimen/normal_margin"
android:text="@string/reset_to_remote"
app:layout_constraintTop_toBottomOf="@id/git_abort_rebase" />

View file

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
~ SPDX-License-Identifier: GPL-3.0-only
-->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/git_log_recycler_view"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:scrollbars="vertical"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
tools:context="com.zeapo.pwdstore.git.log.GitLogActivity"
tools:listitem="@layout/git_log_row_layout" />
</LinearLayout>

View file

@ -0,0 +1,45 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
~ SPDX-License-Identifier: GPL-3.0-only
-->
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingHorizontal="@dimen/activity_vertical_margin"
android:paddingVertical="@dimen/activity_horizontal_margin">
<androidx.appcompat.widget.AppCompatTextView
android:id="@+id/git_log_row_message"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/normal_margin"
android:ellipsize="end"
android:maxLines="2"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/git_log_row_hash"
tools:text="Commit message" />
<androidx.appcompat.widget.AppCompatTextView
android:id="@+id/git_log_row_hash"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="?attr/colorSecondary"
android:textStyle="bold"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="Hash" />
<androidx.appcompat.widget.AppCompatTextView
android:id="@+id/git_log_row_time"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:alpha="0.5"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="Time" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -11,6 +11,7 @@
<string name="dialog_delete">Ordner löschen</string>
<string name="dialog_do_not_delete">Abbruch</string>
<string name="title_activity_git_clone">Repository Informationen</string>
<string name="title_activity_git_log">Commit-Log</string>
<!-- Password Store -->
<string name="creation_dialog_text">Bitte klone oder erstelle ein neues Repository, bevor du versuchst ein Passwort hinzuzufügen oder jegliche Synchronisation-Operation durchführst.</string>
<string name="move">Verschieben</string>
@ -121,6 +122,7 @@
<string name="git_sync">Synchronisiere Repository</string>
<string name="git_pull">Git Pull</string>
<string name="git_push">Git Push</string>
<string name="git_log">Commit-Log anzeigen</string>
<string name="show_password_pref_title">Zeige das Password</string>
<string name="show_password_pref_summary">Soll das entschlüsselte Passwort sichtbar sein? Dies deaktiviert nicht das Kopieren.</string>
<string name="show_extra_content_pref_title">Zeige weiteren Inhalt</string>

View file

@ -178,7 +178,5 @@
<string name="git_push_generic_error">El envío fue rechazado por el servidor, la razón:</string>
<string name="jgit_error_push_dialog_text">Ocurrió un error durante el envío:</string>
<string name="git_operation_remember_passphrase">Recordar contraseñagit (inseguro)</string>
<string name="hackish_tools">Hackish tools</string>
<string name="abort_rebase">Abortar rebase</string>
<string name="commit_hash">Hash del commit</string>
</resources>

View file

@ -178,6 +178,4 @@
<string name="git_push_other_error">Pousser au dépôt distant sans avance rapide rejetée. Vérifiez la variable receive.denyNonFastForwards dans le fichier de configuration du répertoire de destination.</string>
<string name="jgit_error_push_dialog_text">Une erreur s\'est produite lors de l\'opération de poussée:</string>
<string name="git_operation_remember_passphrase">Se rappeler de la phrase secrète dans la configuration de l\'application (peu sûr)</string>
<string name="hackish_tools">Outils de hack</string>
<string name="commit_hash">Commettre la clé</string>
</resources>

View file

@ -248,10 +248,8 @@
<string name="clear_saved_passphrase_ssh">Limpar a frase secreta salva para chave SSH local</string>
<string name="clear_saved_passphrase_https">Limpar senha HTTPS salva</string>
<string name="git_operation_remember_passphrase">Lembrar senha da chave</string>
<string name="hackish_tools">Ferramentas de hackers</string>
<string name="abort_rebase">Abortar rebase e realizar push do novo branch</string>
<string name="reset_to_remote">Hard reset no branch remoto</string>
<string name="commit_hash">Commit hash</string>
<string name="openkeychain_ssh_api_connect_fail">Falha ao conectar ao serviço de API SSH do OpenKeychain.</string>
<string name="no_ssh_api_provider">Nenhum provedor de API SSH encontrado. O OpenKeychain está instalado?</string>
<string name="ssh_api_pending_intent_failed">SSH API pendente falhou</string>

View file

@ -233,10 +233,8 @@
<string name="git_push_other_error">Удаленный репозиторий отклонил запись изменений без быстрой перемотки вперед. Проверьте переменную receive.denyNonFastForwards в файле конфигурации репозитория назначения.</string>
<string name="jgit_error_push_dialog_text">В хоте операции записи изменений возникла ошибка:</string>
<string name="git_operation_remember_passphrase">Заполнить парольную фразу в конфигурации приложнеия (небезопасно)</string>
<string name="hackish_tools">Костыльные инструменты</string>
<string name="abort_rebase">Прервать перебазирование и записать изменения в новую ветку</string>
<string name="reset_to_remote">Полный сброс до состояния удаленной ветки</string>
<string name="commit_hash">Хэш-сумма изменений</string>
<string name="openkeychain_ssh_api_connect_fail">Ошибка при подключении к сервису OpenKeychain SSH API</string>
<string name="no_ssh_api_provider">Не найдено SSH API провайдеров. OpenKeychain установлен?</string>
<string name="ssh_api_pending_intent_failed">Ожидаемое намерение SSH API не удалось</string>

View file

@ -20,6 +20,7 @@
<string name="dialog_do_not_delete">Cancel</string>
<string name="title_activity_git_clone">Repository information</string>
<string name="title_activity_git_config" translatable="false">Git configuration</string>
<string name="title_activity_git_log">Commit log</string>
<!-- Password Store -->
<string name="creation_dialog_text">Please clone or create a new repository below before trying to add a password or running any synchronization operation.</string>
@ -117,8 +118,8 @@
<!-- Preferences -->
<string name="pref_repository_title">Repository</string>
<string name="pref_edit_server_info">Edit git server settings</string>
<string name="pref_edit_git_config">Git utils</string>
<string name="pref_edit_server_info">Edit Git server settings</string>
<string name="pref_edit_git_config">Local Git config &amp; utilities</string>
<string name="pref_ssh_title">Import SSH key</string>
<string name="pref_ssh_keygen_title">Generate SSH key pair</string>
<string name="pref_ssh_see_key_title">View generated public SSH key</string>
@ -217,6 +218,7 @@
<string name="git_pull">Pull from remote</string>
<string name="git_push">Push to remote</string>
<string name="git_push_up_to_date">Everything up-to-date</string>
<string name="git_log">Show commit log</string>
<string name="show_password_pref_title">Show the password</string>
<string name="show_password_pref_summary">Control the visibility of the passwords once decrypted. This does not disable copying to clipboard.</string>
<string name="show_extra_content_pref_title">Show extra content</string>
@ -281,10 +283,12 @@
<string name="clear_saved_passphrase_ssh">Clear saved passphrase for local SSH key</string>
<string name="clear_saved_passphrase_https">Clear saved HTTPS password</string>
<string name="git_operation_remember_passphrase">Remember key passphrase</string>
<string name="hackish_tools">Hackish tools</string>
<string name="git_tools">Utilities</string>
<string name="abort_rebase">Abort rebase and push new branch</string>
<string name="reset_to_remote">Hard reset to remote branch</string>
<string name="commit_hash">Commit hash</string>
<string name="git_head_on_branch">On branch %1$s</string>
<string name="git_head_detached">HEAD detached at %1$s</string>
<string name="git_head_missing">Unable to locate HEAD</string>
<string name="openkeychain_ssh_api_connect_fail">Failed to connect to OpenKeychain SSH API service.</string>
<string name="no_ssh_api_provider">No SSH API provider found. Is OpenKeychain installed?</string>
<string name="ssh_api_pending_intent_failed">SSH API pending intent failed</string>
@ -393,6 +397,6 @@
<!-- GPG key selection in folder creation -->
<string name="folder_creation_err_file_exists">A file by that name already exists</string>
<string name="folder_creation_err_folder_exists">A folder by that name already exists</string>
<string name="xkpwgen_extrachars_label" >Digits/Symbols (d/s)</string>
<string name="xkpwgen_extrachars_label">Digits/Symbols (d/s)</string>
<string name="xk_numbers_symbols_append_default">ds</string>
</resources>