aboutsummaryrefslogtreecommitdiffstats
path: root/app/src/main/java/dev/msfjarvis/aps/ui/git
diff options
context:
space:
mode:
Diffstat (limited to 'app/src/main/java/dev/msfjarvis/aps/ui/git')
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/ui/git/base/BaseGitActivity.kt146
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/ui/git/config/GitConfigActivity.kt153
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/ui/git/config/GitServerConfigActivity.kt267
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/ui/git/log/GitLogActivity.kt49
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/ui/git/log/GitLogAdapter.kt58
5 files changed, 673 insertions, 0 deletions
diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/git/base/BaseGitActivity.kt b/app/src/main/java/dev/msfjarvis/aps/ui/git/base/BaseGitActivity.kt
new file mode 100644
index 00000000..89455c90
--- /dev/null
+++ b/app/src/main/java/dev/msfjarvis/aps/ui/git/base/BaseGitActivity.kt
@@ -0,0 +1,146 @@
+/*
+ * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+package dev.msfjarvis.aps.ui.git.base
+
+import androidx.appcompat.app.AppCompatActivity
+import androidx.core.content.edit
+import com.github.ajalt.timberkt.d
+import com.github.michaelbull.result.Err
+import com.github.michaelbull.result.Result
+import com.github.michaelbull.result.andThen
+import com.github.michaelbull.result.mapError
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import dev.msfjarvis.aps.R
+import dev.msfjarvis.aps.util.settings.GitSettings
+import dev.msfjarvis.aps.util.git.operation.BreakOutOfDetached
+import dev.msfjarvis.aps.util.git.operation.CloneOperation
+import dev.msfjarvis.aps.util.git.operation.PullOperation
+import dev.msfjarvis.aps.util.git.operation.PushOperation
+import dev.msfjarvis.aps.util.git.operation.ResetToRemoteOperation
+import dev.msfjarvis.aps.util.git.operation.SyncOperation
+import dev.msfjarvis.aps.util.git.sshj.ContinuationContainerActivity
+import dev.msfjarvis.aps.util.settings.PreferenceKeys
+import dev.msfjarvis.aps.util.extensions.getEncryptedGitPrefs
+import dev.msfjarvis.aps.util.extensions.sharedPrefs
+import dev.msfjarvis.aps.util.git.ErrorMessages
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import net.schmizz.sshj.common.DisconnectReason
+import net.schmizz.sshj.common.SSHException
+import net.schmizz.sshj.userauth.UserAuthException
+
+/**
+ * Abstract [AppCompatActivity] that holds some information that is commonly shared across git-related
+ * tasks and makes sense to be held here.
+ */
+abstract class BaseGitActivity : ContinuationContainerActivity() {
+
+ /**
+ * Enum of possible Git operations than can be run through [launchGitOperation].
+ */
+ enum class GitOp {
+
+ BREAK_OUT_OF_DETACHED,
+ CLONE,
+ PULL,
+ PUSH,
+ RESET,
+ SYNC,
+ }
+
+ /**
+ * Attempt to launch the requested Git operation.
+ * @param operation The type of git operation to launch
+ */
+ suspend fun launchGitOperation(operation: GitOp): Result<Unit, Throwable> {
+ if (GitSettings.url == null) {
+ return Err(IllegalStateException("Git url is not set!"))
+ }
+ if (operation == GitOp.SYNC && !GitSettings.useMultiplexing) {
+ // If the server does not support multiple SSH channels per connection, we cannot run
+ // a sync operation without reconnecting and thus break sync into its two parts.
+ return launchGitOperation(GitOp.PULL).andThen { launchGitOperation(GitOp.PUSH) }
+ }
+ val op = when (operation) {
+ GitOp.CLONE -> CloneOperation(this, GitSettings.url!!)
+ GitOp.PULL -> PullOperation(this)
+ GitOp.PUSH -> PushOperation(this)
+ GitOp.SYNC -> SyncOperation(this)
+ GitOp.BREAK_OUT_OF_DETACHED -> BreakOutOfDetached(this)
+ GitOp.RESET -> ResetToRemoteOperation(this)
+ }
+ return op.executeAfterAuthentication(GitSettings.authMode).mapError { throwable ->
+ val err = rootCauseException(throwable)
+ if (err.message?.contains("cannot open additional channels") == true) {
+ GitSettings.useMultiplexing = false
+ SSHException(DisconnectReason.TOO_MANY_CONNECTIONS, "The server does not support multiple Git operations per SSH session. Please try again, a slower fallback mode will be used.")
+ } else {
+ err
+ }
+ }
+ }
+
+ fun finishOnSuccessHandler(@Suppress("UNUSED_PARAMETER") nothing: Unit) {
+ finish()
+ }
+
+ suspend fun promptOnErrorHandler(err: Throwable, onPromptDone: () -> Unit = {}) {
+ val error = rootCauseException(err)
+ if (!isExplicitlyUserInitiatedError(error)) {
+ getEncryptedGitPrefs().edit {
+ remove(PreferenceKeys.HTTPS_PASSWORD)
+ }
+ sharedPrefs.edit { remove(PreferenceKeys.SSH_OPENKEYSTORE_KEYID) }
+ d(error)
+ withContext(Dispatchers.Main) {
+ MaterialAlertDialogBuilder(this@BaseGitActivity).run {
+ setTitle(resources.getString(R.string.jgit_error_dialog_title))
+ setMessage(ErrorMessages[error])
+ setPositiveButton(resources.getString(R.string.dialog_ok)) { _, _ -> }
+ setOnDismissListener {
+ onPromptDone()
+ }
+ show()
+ }
+ }
+ } else {
+ onPromptDone()
+ }
+ }
+
+ /**
+ * Check if a given [Throwable] is the result of an error caused by the user cancelling the
+ * operation.
+ */
+ private fun isExplicitlyUserInitiatedError(throwable: Throwable): Boolean {
+ var cause: Throwable? = throwable
+ while (cause != null) {
+ if (cause is SSHException &&
+ cause.disconnectReason == DisconnectReason.AUTH_CANCELLED_BY_USER)
+ return true
+ cause = cause.cause
+ }
+ return false
+ }
+
+ /**
+ * Get the real root cause of a [Throwable] by traversing until known wrapping exceptions are no
+ * longer found.
+ */
+ private fun rootCauseException(throwable: Throwable): Throwable {
+ var rootCause = throwable
+ // JGit's InvalidRemoteException and TransportException hide the more helpful SSHJ exceptions.
+ // Also, SSHJ's UserAuthException about exhausting available authentication methods hides
+ // more useful exceptions.
+ while ((rootCause is org.eclipse.jgit.errors.TransportException ||
+ rootCause is org.eclipse.jgit.api.errors.TransportException ||
+ rootCause is org.eclipse.jgit.api.errors.InvalidRemoteException ||
+ (rootCause is UserAuthException &&
+ rootCause.message == "Exhausted available authentication methods"))) {
+ rootCause = rootCause.cause ?: break
+ }
+ return rootCause
+ }
+}
diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/git/config/GitConfigActivity.kt b/app/src/main/java/dev/msfjarvis/aps/ui/git/config/GitConfigActivity.kt
new file mode 100644
index 00000000..4067fda9
--- /dev/null
+++ b/app/src/main/java/dev/msfjarvis/aps/ui/git/config/GitConfigActivity.kt
@@ -0,0 +1,153 @@
+/*
+ * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+package dev.msfjarvis.aps.ui.git.config
+
+import android.content.Intent
+import android.os.Bundle
+import android.os.Handler
+import android.os.Looper
+import android.util.Patterns
+import android.view.MenuItem
+import androidx.core.os.postDelayed
+import androidx.lifecycle.lifecycleScope
+import com.github.ajalt.timberkt.e
+import com.github.michaelbull.result.fold
+import com.github.michaelbull.result.getOrElse
+import com.github.michaelbull.result.onFailure
+import com.github.michaelbull.result.runCatching
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import com.google.android.material.snackbar.Snackbar
+import dev.msfjarvis.aps.R
+import dev.msfjarvis.aps.databinding.ActivityGitConfigBinding
+import dev.msfjarvis.aps.util.settings.GitSettings
+import dev.msfjarvis.aps.ui.git.log.GitLogActivity
+import dev.msfjarvis.aps.data.repo.PasswordRepository
+import dev.msfjarvis.aps.ui.git.base.BaseGitActivity
+import dev.msfjarvis.aps.util.extensions.viewBinding
+import kotlinx.coroutines.launch
+import org.eclipse.jgit.lib.Constants
+import org.eclipse.jgit.lib.Repository
+
+class GitConfigActivity : BaseGitActivity() {
+
+ private val binding by viewBinding(ActivityGitConfigBinding::inflate)
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContentView(binding.root)
+ supportActionBar?.setDisplayHomeAsUpEnabled(true)
+
+ if (GitSettings.authorName.isEmpty())
+ binding.gitUserName.requestFocus()
+ else
+ binding.gitUserName.setText(GitSettings.authorName)
+ binding.gitUserEmail.setText(GitSettings.authorEmail)
+ setupTools()
+ binding.saveButton.setOnClickListener {
+ val email = binding.gitUserEmail.text.toString().trim()
+ val name = binding.gitUserName.text.toString().trim()
+ if (!email.matches(Patterns.EMAIL_ADDRESS.toRegex())) {
+ MaterialAlertDialogBuilder(this)
+ .setMessage(getString(R.string.invalid_email_dialog_text))
+ .setPositiveButton(getString(R.string.dialog_ok), null)
+ .show()
+ } else {
+ GitSettings.authorEmail = email
+ GitSettings.authorName = name
+ Snackbar.make(binding.root, getString(R.string.git_server_config_save_success), Snackbar.LENGTH_SHORT).show()
+ Handler(Looper.getMainLooper()).postDelayed(500) { finish() }
+ }
+ }
+ }
+
+ override fun onOptionsItemSelected(item: MenuItem): Boolean {
+ return when (item.itemId) {
+ android.R.id.home -> {
+ finish()
+ true
+ }
+ else -> super.onOptionsItemSelected(item)
+ }
+ }
+
+ /**
+ * 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 {
+ runCatching {
+ startActivity(Intent(this, GitLogActivity::class.java))
+ }.onFailure { ex ->
+ e(ex) { "Failed to start GitLogActivity" }
+ }
+ }
+ binding.gitAbortRebase.setOnClickListener {
+ lifecycleScope.launch {
+ launchGitOperation(GitOp.BREAK_OUT_OF_DETACHED).fold(
+ success = {
+ MaterialAlertDialogBuilder(this@GitConfigActivity).run {
+ setTitle(resources.getString(R.string.git_abort_and_push_title))
+ setMessage(resources.getString(
+ R.string.git_break_out_of_detached_success,
+ GitSettings.branch,
+ "conflicting-${GitSettings.branch}-...",
+ ))
+ setOnDismissListener { finish() }
+ setPositiveButton(resources.getString(R.string.dialog_ok)) { _, _ -> }
+ show()
+ }
+ },
+ failure = { err ->
+ promptOnErrorHandler(err) {
+ finish()
+ }
+ },
+ )
+ }
+ }
+ binding.gitResetToRemote.setOnClickListener {
+ lifecycleScope.launch {
+ launchGitOperation(GitOp.RESET).fold(
+ success = ::finishOnSuccessHandler,
+ failure = { err ->
+ promptOnErrorHandler(err) {
+ finish()
+ }
+ },
+ )
+ }
+ }
+ }
+
+ /**
+ * 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 runCatching {
+ 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)
+ }
+ }.getOrElse { ex ->
+ e(ex) { "Error getting HEAD reference" }
+ getString(R.string.git_head_missing)
+ }
+ }
+}
diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/git/config/GitServerConfigActivity.kt b/app/src/main/java/dev/msfjarvis/aps/ui/git/config/GitServerConfigActivity.kt
new file mode 100644
index 00000000..a0750eeb
--- /dev/null
+++ b/app/src/main/java/dev/msfjarvis/aps/ui/git/config/GitServerConfigActivity.kt
@@ -0,0 +1,267 @@
+/*
+ * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+package dev.msfjarvis.aps.ui.git.config
+
+import android.content.Context
+import android.content.Intent
+import android.os.Bundle
+import android.os.Handler
+import android.os.Looper
+import android.view.MenuItem
+import android.view.View
+import androidx.core.os.postDelayed
+import androidx.core.view.isVisible
+import androidx.core.widget.doOnTextChanged
+import androidx.lifecycle.lifecycleScope
+import com.github.ajalt.timberkt.e
+import com.github.michaelbull.result.fold
+import com.github.michaelbull.result.onFailure
+import com.github.michaelbull.result.runCatching
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import com.google.android.material.snackbar.Snackbar
+import dev.msfjarvis.aps.R
+import dev.msfjarvis.aps.databinding.ActivityGitCloneBinding
+import dev.msfjarvis.aps.util.settings.AuthMode
+import dev.msfjarvis.aps.util.settings.GitSettings
+import dev.msfjarvis.aps.util.settings.Protocol
+import dev.msfjarvis.aps.ui.dialogs.BasicBottomSheet
+import dev.msfjarvis.aps.data.repo.PasswordRepository
+import dev.msfjarvis.aps.ui.git.base.BaseGitActivity
+import dev.msfjarvis.aps.util.extensions.snackbar
+import dev.msfjarvis.aps.util.extensions.viewBinding
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+
+/**
+ * Activity that encompasses both the initial clone as well as editing the server config for future
+ * changes.
+ */
+class GitServerConfigActivity : BaseGitActivity() {
+
+ private val binding by viewBinding(ActivityGitCloneBinding::inflate)
+
+ private lateinit var newAuthMode: AuthMode
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ val isClone = intent?.extras?.getBoolean("cloning") ?: false
+ if (isClone) {
+ binding.saveButton.text = getString(R.string.clone_button)
+ }
+ setContentView(binding.root)
+ supportActionBar?.setDisplayHomeAsUpEnabled(true)
+
+ newAuthMode = GitSettings.authMode
+
+ binding.authModeGroup.apply {
+ when (newAuthMode) {
+ AuthMode.SshKey -> check(binding.authModeSshKey.id)
+ AuthMode.Password -> check(binding.authModePassword.id)
+ AuthMode.OpenKeychain -> check(binding.authModeOpenKeychain.id)
+ AuthMode.None -> check(View.NO_ID)
+ }
+ addOnButtonCheckedListener { _, _, _ ->
+ when (checkedButtonId) {
+ binding.authModeSshKey.id -> newAuthMode = AuthMode.SshKey
+ binding.authModeOpenKeychain.id -> newAuthMode = AuthMode.OpenKeychain
+ binding.authModePassword.id -> newAuthMode = AuthMode.Password
+ View.NO_ID -> newAuthMode = AuthMode.None
+ }
+ }
+ }
+
+ binding.serverUrl.setText(GitSettings.url.also {
+ if (it.isNullOrEmpty()) return@also
+ setAuthModes(it.startsWith("http://") || it.startsWith("https://"))
+ })
+ binding.serverBranch.setText(GitSettings.branch)
+
+ binding.serverUrl.doOnTextChanged { text, _, _, _ ->
+ if (text.isNullOrEmpty()) return@doOnTextChanged
+ setAuthModes(text.startsWith("http://") || text.startsWith("https://"))
+ }
+
+ binding.saveButton.setOnClickListener {
+ val newUrl = binding.serverUrl.text.toString().trim()
+ // If url is of type john_doe@example.org:12435/path/to/repo, then not adding `ssh://`
+ // in the beginning will cause the port to be seen as part of the path. Let users know
+ // about it and offer a quickfix.
+ if (newUrl.contains(PORT_REGEX)) {
+ if (newUrl.startsWith("https://")) {
+ BasicBottomSheet.Builder(this)
+ .setTitleRes(R.string.https_scheme_with_port_title)
+ .setMessageRes(R.string.https_scheme_with_port_message)
+ .setPositiveButtonClickListener {
+ binding.serverUrl.setText(newUrl.replace(PORT_REGEX, "/"))
+ }
+ .build()
+ .show(supportFragmentManager, "SSH_SCHEME_WARNING")
+ return@setOnClickListener
+ } else if (!newUrl.startsWith("ssh://")) {
+ BasicBottomSheet.Builder(this)
+ .setTitleRes(R.string.ssh_scheme_needed_title)
+ .setMessageRes(R.string.ssh_scheme_needed_message)
+ .setPositiveButtonClickListener {
+ @Suppress("SetTextI18n")
+ binding.serverUrl.setText("ssh://$newUrl")
+ }
+ .build()
+ .show(supportFragmentManager, "SSH_SCHEME_WARNING")
+ return@setOnClickListener
+ }
+ }
+ when (val updateResult = GitSettings.updateConnectionSettingsIfValid(
+ newAuthMode = newAuthMode,
+ newUrl = binding.serverUrl.text.toString().trim(),
+ newBranch = binding.serverBranch.text.toString().trim())) {
+ GitSettings.UpdateConnectionSettingsResult.FailedToParseUrl -> {
+ Snackbar.make(binding.root, getString(R.string.git_server_config_save_error), Snackbar.LENGTH_LONG).show()
+ }
+
+ is GitSettings.UpdateConnectionSettingsResult.MissingUsername -> {
+ when (updateResult.newProtocol) {
+ Protocol.Https ->
+ BasicBottomSheet.Builder(this)
+ .setTitleRes(R.string.ssh_scheme_needed_title)
+ .setMessageRes(R.string.git_server_config_save_missing_username_https)
+ .setPositiveButtonClickListener {
+ }
+ .build()
+ .show(supportFragmentManager, "HTTPS_MISSING_USERNAME")
+ Protocol.Ssh ->
+ BasicBottomSheet.Builder(this)
+ .setTitleRes(R.string.ssh_scheme_needed_title)
+ .setMessageRes(R.string.git_server_config_save_missing_username_ssh)
+ .setPositiveButtonClickListener {
+ }
+ .build()
+ .show(supportFragmentManager, "SSH_MISSING_USERNAME")
+ }
+ }
+ GitSettings.UpdateConnectionSettingsResult.Valid -> {
+ if (isClone && PasswordRepository.getRepository(null) == null)
+ PasswordRepository.initialize()
+ if (!isClone) {
+ Snackbar.make(binding.root, getString(R.string.git_server_config_save_success), Snackbar.LENGTH_SHORT).show()
+ Handler(Looper.getMainLooper()).postDelayed(500) { finish() }
+ } else {
+ cloneRepository()
+ }
+ }
+ is GitSettings.UpdateConnectionSettingsResult.AuthModeMismatch -> {
+ val message = getString(
+ R.string.git_server_config_save_auth_mode_mismatch,
+ updateResult.newProtocol,
+ updateResult.validModes.joinToString(", "),
+ )
+ Snackbar.make(binding.root, message, Snackbar.LENGTH_LONG).show()
+ }
+ }
+ }
+ }
+
+ override fun onOptionsItemSelected(item: MenuItem): Boolean {
+ return when (item.itemId) {
+ android.R.id.home -> {
+ finish()
+ true
+ }
+ else -> super.onOptionsItemSelected(item)
+ }
+ }
+
+ private fun setAuthModes(isHttps: Boolean) = with(binding) {
+ if (isHttps) {
+ authModeSshKey.isVisible = false
+ authModeOpenKeychain.isVisible = false
+ authModePassword.isVisible = true
+ if (authModeGroup.checkedButtonId != authModePassword.id)
+ authModeGroup.check(View.NO_ID)
+ } else {
+ authModeSshKey.isVisible = true
+ authModeOpenKeychain.isVisible = true
+ authModePassword.isVisible = true
+ if (authModeGroup.checkedButtonId == View.NO_ID)
+ authModeGroup.check(authModeSshKey.id)
+ }
+ }
+
+ /**
+ * Clones the repository, the directory exists, deletes it
+ */
+ private fun cloneRepository() {
+ val localDir = requireNotNull(PasswordRepository.getRepositoryDirectory())
+ val localDirFiles = localDir.listFiles() ?: emptyArray()
+ // Warn if non-empty folder unless it's a just-initialized store that has just a .git folder
+ if (localDir.exists() && localDirFiles.isNotEmpty() &&
+ !(localDirFiles.size == 1 && localDirFiles[0].name == ".git")) {
+ MaterialAlertDialogBuilder(this)
+ .setTitle(R.string.dialog_delete_title)
+ .setMessage(resources.getString(R.string.dialog_delete_msg, localDir.toString()))
+ .setCancelable(false)
+ .setPositiveButton(R.string.dialog_delete) { dialog, _ ->
+ runCatching {
+ lifecycleScope.launch {
+ val snackbar = snackbar(message = getString(R.string.delete_directory_progress_text), length = Snackbar.LENGTH_INDEFINITE)
+ withContext(Dispatchers.IO) {
+ localDir.deleteRecursively()
+ }
+ snackbar.dismiss()
+ launchGitOperation(GitOp.CLONE).fold(
+ success = {
+ setResult(RESULT_OK)
+ finish()
+ },
+ failure = { err ->
+ promptOnErrorHandler(err) {
+ finish()
+ }
+ }
+ )
+ }
+ }.onFailure { e ->
+ e.printStackTrace()
+ MaterialAlertDialogBuilder(this).setMessage(e.message).show()
+ }
+ dialog.cancel()
+ }
+ .setNegativeButton(R.string.dialog_do_not_delete) { dialog, _ ->
+ dialog.cancel()
+ }
+ .show()
+ } else {
+ runCatching {
+ // Silently delete & replace the lone .git folder if it exists
+ if (localDir.exists() && localDirFiles.size == 1 && localDirFiles[0].name == ".git") {
+ localDir.deleteRecursively()
+ }
+ }.onFailure { e ->
+ e(e)
+ MaterialAlertDialogBuilder(this).setMessage(e.message).show()
+ }
+ lifecycleScope.launch {
+ launchGitOperation(GitOp.CLONE).fold(
+ success = {
+ setResult(RESULT_OK)
+ finish()
+ },
+ failure = { promptOnErrorHandler(it) },
+ )
+ }
+ }
+ }
+
+ companion object {
+
+ private val PORT_REGEX = ":[0-9]{1,5}/".toRegex()
+
+ fun createCloneIntent(context: Context): Intent {
+ return Intent(context, GitServerConfigActivity::class.java).apply {
+ putExtra("cloning", true)
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/git/log/GitLogActivity.kt b/app/src/main/java/dev/msfjarvis/aps/ui/git/log/GitLogActivity.kt
new file mode 100644
index 00000000..b02733fd
--- /dev/null
+++ b/app/src/main/java/dev/msfjarvis/aps/ui/git/log/GitLogActivity.kt
@@ -0,0 +1,49 @@
+/*
+ * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package dev.msfjarvis.aps.ui.git.log
+
+import android.os.Bundle
+import android.view.MenuItem
+import androidx.recyclerview.widget.DividerItemDecoration
+import androidx.recyclerview.widget.LinearLayoutManager
+import dev.msfjarvis.aps.databinding.ActivityGitLogBinding
+import dev.msfjarvis.aps.ui.git.base.BaseGitActivity
+import dev.msfjarvis.aps.util.extensions.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()
+ }
+
+ override fun onOptionsItemSelected(item: MenuItem): Boolean {
+ return when (item.itemId) {
+ android.R.id.home -> {
+ finish()
+ true
+ }
+ else -> super.onOptionsItemSelected(item)
+ }
+ }
+
+ private fun createRecyclerView() {
+ binding.gitLogRecyclerView.apply {
+ setHasFixedSize(true)
+ addItemDecoration(DividerItemDecoration(context, LinearLayoutManager.VERTICAL))
+ adapter = GitLogAdapter()
+ }
+ }
+}
diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/git/log/GitLogAdapter.kt b/app/src/main/java/dev/msfjarvis/aps/ui/git/log/GitLogAdapter.kt
new file mode 100644
index 00000000..bccbe3b4
--- /dev/null
+++ b/app/src/main/java/dev/msfjarvis/aps/ui/git/log/GitLogAdapter.kt
@@ -0,0 +1,58 @@
+/*
+ * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package dev.msfjarvis.aps.ui.git.log
+
+import android.view.LayoutInflater
+import android.view.ViewGroup
+import androidx.recyclerview.widget.RecyclerView
+import com.github.ajalt.timberkt.e
+import dev.msfjarvis.aps.databinding.GitLogRowLayoutBinding
+import dev.msfjarvis.aps.util.git.GitCommit
+import dev.msfjarvis.aps.util.git.GitLogModel
+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)
+ }
+ }
+}