diff options
| author | Harsh Shandilya <me@msfjarvis.dev> | 2020-12-05 06:07:18 +0530 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2020-12-05 06:07:18 +0530 |
| commit | 5e66d99c852ea67a88b650c03b0e8d55e83eccde (patch) | |
| tree | aee323a08253fe7b863976eff92a1bd412bd1430 /app/src/main/java/dev/msfjarvis/aps/ui/git | |
| parent | 8eb55f18a11d6b2f155c7c9d48ca833313ee13db (diff) | |
| download | HardenedPass-5e66d99c852ea67a88b650c03b0e8d55e83eccde.tar.gz HardenedPass-5e66d99c852ea67a88b650c03b0e8d55e83eccde.zip | |
Refactor package structure (#1233)
* idea: default test runner to Gradle
* Kick off package structure revamp
* Reparent all classes under dev.msfjarvis.aps
Signed-off-by: Harsh Shandilya <me@msfjarvis.dev>
Diffstat (limited to 'app/src/main/java/dev/msfjarvis/aps/ui/git')
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) + } + } +} |
