diff --git a/app/src/main/java/com/itsaky/androidide/di/AppModule.kt b/app/src/main/java/com/itsaky/androidide/di/AppModule.kt index c494be6d58..a601f3178a 100644 --- a/app/src/main/java/com/itsaky/androidide/di/AppModule.kt +++ b/app/src/main/java/com/itsaky/androidide/di/AppModule.kt @@ -6,6 +6,7 @@ import com.itsaky.androidide.agent.GeminiMacroProcessor import com.itsaky.androidide.agent.viewmodel.ChatViewModel import com.itsaky.androidide.analytics.AnalyticsManager import com.itsaky.androidide.analytics.IAnalyticsManager +import com.itsaky.androidide.git.core.GitCredentialsManager import com.itsaky.androidide.roomData.recentproject.RecentProjectRoomDatabase import com.itsaky.androidide.viewmodel.GitBottomSheetViewModel import com.itsaky.androidide.viewmodel.MainViewModel @@ -13,6 +14,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import org.koin.android.ext.koin.androidApplication +import org.koin.android.ext.koin.androidContext import org.koin.dsl.module import org.koin.core.module.dsl.viewModel @@ -28,8 +30,10 @@ val coreModule = ChatViewModel() } viewModel { - GitBottomSheetViewModel() + GitBottomSheetViewModel(get()) } + viewModel { MainViewModel(get()) } + single { CoroutineScope(SupervisorJob() + Dispatchers.IO) @@ -43,5 +47,6 @@ val coreModule = get().recentProjectDao() } - viewModel { MainViewModel(get()) } + single { GitCredentialsManager(get()) } + } diff --git a/app/src/main/java/com/itsaky/androidide/fragments/git/GitBottomSheetFragment.kt b/app/src/main/java/com/itsaky/androidide/fragments/git/GitBottomSheetFragment.kt index 4e034b9cad..12e17b154a 100644 --- a/app/src/main/java/com/itsaky/androidide/fragments/git/GitBottomSheetFragment.kt +++ b/app/src/main/java/com/itsaky/androidide/fragments/git/GitBottomSheetFragment.kt @@ -10,24 +10,28 @@ import android.view.View import android.widget.TextView import androidx.core.widget.doAfterTextChanged import androidx.fragment.app.Fragment -import androidx.fragment.app.activityViewModels import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.LinearLayoutManager import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.itsaky.androidide.R import com.itsaky.androidide.activities.PreferencesActivity +import com.itsaky.androidide.databinding.DialogGitCredentialsBinding import com.itsaky.androidide.databinding.FragmentGitBottomSheetBinding import com.itsaky.androidide.fragments.git.adapter.GitFileChangeAdapter +import com.itsaky.androidide.git.core.GitCredentialsManager import com.itsaky.androidide.preferences.internal.GitPreferences +import com.itsaky.androidide.utils.flashSuccess import com.itsaky.androidide.viewmodel.GitBottomSheetViewModel import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine import kotlinx.coroutines.launch +import org.koin.androidx.viewmodel.ext.android.activityViewModel class GitBottomSheetFragment : Fragment(R.layout.fragment_git_bottom_sheet) { - private val viewModel: GitBottomSheetViewModel by activityViewModels() + private val viewModel: GitBottomSheetViewModel by activityViewModel() private lateinit var fileChangeAdapter: GitFileChangeAdapter + private lateinit var credentialsManager: GitCredentialsManager private var _binding: FragmentGitBottomSheetBinding? = null private val binding get() = _binding!! @@ -35,6 +39,7 @@ class GitBottomSheetFragment : Fragment(R.layout.fragment_git_bottom_sheet) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) _binding = FragmentGitBottomSheetBinding.bind(view) + credentialsManager = GitCredentialsManager(requireContext()) fileChangeAdapter = GitFileChangeAdapter( onFileClicked = { change -> @@ -51,6 +56,17 @@ class GitBottomSheetFragment : Fragment(R.layout.fragment_git_bottom_sheet) { binding.recyclerView.adapter = fileChangeAdapter viewLifecycleOwner.lifecycleScope.launch { + launch { + viewModel.currentBranch.collectLatest { branchName -> + if (branchName != null) { + binding.tvBranchName.visibility = View.VISIBLE + binding.tvBranchName.text = getString(R.string.current_branch_name, branchName) + } else { + binding.tvBranchName.visibility = View.GONE + } + } + } + combine( viewModel.isGitRepository, viewModel.gitStatus @@ -95,6 +111,7 @@ class GitBottomSheetFragment : Fragment(R.layout.fragment_git_bottom_sheet) { dialog.show(childFragmentManager, "CommitHistoryDialog") } + setupPullUI() } override fun onResume() { @@ -187,6 +204,80 @@ class GitBottomSheetFragment : Fragment(R.layout.fragment_git_bottom_sheet) { binding.commitButton.isEnabled = hasSummary && hasSelection && hasAuthor } + private fun setupPullUI() { + viewLifecycleOwner.lifecycleScope.launch { + viewModel.isGitRepository.collectLatest { isRepo -> + binding.btnPull.visibility = if (isRepo) View.VISIBLE else View.GONE + } + } + + viewLifecycleOwner.lifecycleScope.launch { + viewModel.pullState.collectLatest { state -> + when (state) { + is GitBottomSheetViewModel.PullUiState.Idle -> { + binding.btnPull.isEnabled = true + binding.pullProgress.visibility = View.GONE + } + is GitBottomSheetViewModel.PullUiState.Pulling -> { + binding.btnPull.isEnabled = false + binding.pullProgress.visibility = View.VISIBLE + } + is GitBottomSheetViewModel.PullUiState.Success -> { + binding.btnPull.isEnabled = true + binding.pullProgress.visibility = View.GONE + flashSuccess(R.string.pull_successful) + viewModel.resetPullState() + } + is GitBottomSheetViewModel.PullUiState.Error -> { + binding.btnPull.isEnabled = true + binding.pullProgress.visibility = View.GONE + val message = + state.errorResId?.let { getString(it) } ?: state.message + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.pull_failed) + .setMessage(message) + .setPositiveButton(android.R.string.ok, null) + .show() + } + } + } + } + + binding.btnPull.setOnClickListener { + val username = credentialsManager.getUsername() + val token = credentialsManager.getToken() + if (!username.isNullOrBlank() && !token.isNullOrBlank()) { + viewModel.pull(username, token) + } else { + showCredentialsDialog() + } + } + } + + private fun showCredentialsDialog() { + val context = requireContext() + val dialogBinding = DialogGitCredentialsBinding.inflate(layoutInflater) + + dialogBinding.username.setText(credentialsManager.getUsername()) + dialogBinding.token.setText(credentialsManager.getToken()) + + MaterialAlertDialogBuilder(context) + .setTitle(R.string.git_credentials_title) + .setView(dialogBinding.root) + .setPositiveButton(R.string.pull) { _, _ -> + val username = dialogBinding.username.text?.toString()?.trim() + val token = dialogBinding.token.text?.toString()?.trim() + if (!username.isNullOrBlank() && !token.isNullOrBlank()) { + viewModel.pull(username, token) + } + } + .setNeutralButton(R.string.git_credentials_clear) { _, _ -> + credentialsManager.clearCredentials() + } + .setNegativeButton(android.R.string.cancel, null) + .show() + } + override fun onDestroyView() { super.onDestroyView() _binding = null diff --git a/app/src/main/java/com/itsaky/androidide/fragments/git/GitCommitHistoryDialog.kt b/app/src/main/java/com/itsaky/androidide/fragments/git/GitCommitHistoryDialog.kt index a638c40051..824de50c0e 100644 --- a/app/src/main/java/com/itsaky/androidide/fragments/git/GitCommitHistoryDialog.kt +++ b/app/src/main/java/com/itsaky/androidide/fragments/git/GitCommitHistoryDialog.kt @@ -5,24 +5,30 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.fragment.app.DialogFragment -import androidx.fragment.app.activityViewModels import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.LinearLayoutManager +import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.itsaky.androidide.R import com.itsaky.androidide.databinding.DialogGitCommitHistoryBinding +import com.itsaky.androidide.databinding.DialogGitCredentialsBinding import com.itsaky.androidide.fragments.git.adapter.GitCommitHistoryAdapter +import com.itsaky.androidide.git.core.GitCredentialsManager import com.itsaky.androidide.git.core.models.CommitHistoryUiState +import com.itsaky.androidide.utils.flashSuccess import com.itsaky.androidide.viewmodel.GitBottomSheetViewModel import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch +import org.koin.android.ext.android.inject +import org.koin.androidx.viewmodel.ext.android.activityViewModel class GitCommitHistoryDialog : DialogFragment() { private var _binding: DialogGitCommitHistoryBinding? = null private val binding get() = _binding!! - private val viewModel: GitBottomSheetViewModel by activityViewModels() + private val viewModel: GitBottomSheetViewModel by activityViewModel() private lateinit var commitHistoryAdapter: GitCommitHistoryAdapter + private val credentialsManager: GitCredentialsManager by inject() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -83,6 +89,84 @@ class GitCommitHistoryDialog : DialogFragment() { } } } + + setupPushUI() + } + + private fun setupPushUI() { + binding.btnPush.setOnClickListener { + val username = credentialsManager.getUsername() + val token = credentialsManager.getToken() + if (!username.isNullOrBlank() && !token.isNullOrBlank()) { + viewModel.push(username, token) + } else { + showCredentialsDialog() + } + } + + viewLifecycleOwner.lifecycleScope.launch { + viewModel.localCommitsCount.collectLatest { count -> + binding.btnPush.visibility = if (count > 0) View.VISIBLE else View.GONE + } + } + + viewLifecycleOwner.lifecycleScope.launch { + viewModel.pushState.collectLatest { state -> + when (state) { + is GitBottomSheetViewModel.PushUiState.Idle -> { + binding.btnPush.isEnabled = true + binding.btnPush.text = getString(R.string.push) + binding.pushProgress.visibility = View.GONE + } + is GitBottomSheetViewModel.PushUiState.Pushing -> { + binding.btnPush.isEnabled = false + binding.pushProgress.visibility = View.VISIBLE + } + is GitBottomSheetViewModel.PushUiState.Success -> { + binding.btnPush.isEnabled = true + binding.pushProgress.visibility = View.GONE + flashSuccess(R.string.push_successful) + viewModel.resetPushState() + dismiss() + } + is GitBottomSheetViewModel.PushUiState.Error -> { + binding.btnPush.isEnabled = true + binding.pushProgress.visibility = View.GONE + val message = + state.errorResId?.let { getString(it) } ?: state.message + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.push_failed) + .setMessage(message) + .setPositiveButton(android.R.string.ok, null) + .show() + } + } + } + } + + } + + private fun showCredentialsDialog() { + val dialogBinding = DialogGitCredentialsBinding.inflate(layoutInflater) + + dialogBinding.username.setText(credentialsManager.getUsername()) + dialogBinding.token.setText(credentialsManager.getToken()) + + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.git_credentials_title) + .setView(dialogBinding.root) + .setPositiveButton(R.string.push) { _, _ -> + val username = dialogBinding.username.text?.toString()?.trim() + val token = dialogBinding.token.text?.toString()?.trim() + if (!username.isNullOrBlank() && !token.isNullOrBlank()) { + viewModel.push(username, token) + } + } + .setNeutralButton(R.string.git_credentials_clear) { _, _ -> + credentialsManager.clearCredentials() + } + .setNegativeButton(android.R.string.cancel, null) + .show() } override fun onDestroyView() { diff --git a/app/src/main/java/com/itsaky/androidide/fragments/git/GitDiffViewerDialog.kt b/app/src/main/java/com/itsaky/androidide/fragments/git/GitDiffViewerDialog.kt index 197a594527..48ab7bb8de 100644 --- a/app/src/main/java/com/itsaky/androidide/fragments/git/GitDiffViewerDialog.kt +++ b/app/src/main/java/com/itsaky/androidide/fragments/git/GitDiffViewerDialog.kt @@ -12,7 +12,6 @@ import android.view.View import android.view.ViewGroup import androidx.core.content.ContextCompat.getColor import androidx.fragment.app.DialogFragment -import androidx.fragment.app.activityViewModels import androidx.lifecycle.lifecycleScope import com.itsaky.androidide.R import com.itsaky.androidide.databinding.DialogGitDiffBinding @@ -20,11 +19,12 @@ import com.itsaky.androidide.viewmodel.GitBottomSheetViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import org.koin.androidx.viewmodel.ext.android.activityViewModel import java.io.File class GitDiffViewerDialog : DialogFragment() { - private val viewModel: GitBottomSheetViewModel by activityViewModels() + private val viewModel: GitBottomSheetViewModel by activityViewModel() private var filePath: String = "" diff --git a/app/src/main/java/com/itsaky/androidide/fragments/git/adapter/GitCommitHistoryAdapter.kt b/app/src/main/java/com/itsaky/androidide/fragments/git/adapter/GitCommitHistoryAdapter.kt index f597babf5a..55aeb1108c 100644 --- a/app/src/main/java/com/itsaky/androidide/fragments/git/adapter/GitCommitHistoryAdapter.kt +++ b/app/src/main/java/com/itsaky/androidide/fragments/git/adapter/GitCommitHistoryAdapter.kt @@ -38,8 +38,8 @@ class GitCommitHistoryAdapter : tvCommitMessage.text = commit.message tvCommitAuthor.text = commit.authorName tvCommitTime.text = dateFormat.format(Date(commit.timestamp)) - imgNotPushedCommit.setImageResource(if (commit.hasBeenPushed) R.drawable.ic_cloud_done else R.drawable.ic_upload) - imgNotPushedCommit.contentDescription = if (commit.hasBeenPushed) { + imgCommitStatus.setImageResource(if (commit.hasBeenPushed) R.drawable.ic_cloud_done else R.drawable.ic_upload) + imgCommitStatus.contentDescription = if (commit.hasBeenPushed) { binding.root.context.getString(R.string.commit_pushed) } else { binding.root.context.getString(R.string.commit_not_pushed) diff --git a/app/src/main/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModel.kt b/app/src/main/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModel.kt index 03324a7646..40bed1d722 100644 --- a/app/src/main/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModel.kt +++ b/app/src/main/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModel.kt @@ -2,33 +2,46 @@ package com.itsaky.androidide.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.blankj.utilcode.util.NetworkUtils import com.itsaky.androidide.eventbus.events.editor.DocumentSaveEvent import com.itsaky.androidide.eventbus.events.file.FileCreationEvent import com.itsaky.androidide.eventbus.events.file.FileDeletionEvent import com.itsaky.androidide.eventbus.events.file.FileRenameEvent import com.itsaky.androidide.events.ListProjectFilesRequestEvent +import com.itsaky.androidide.git.core.GitCredentialsManager import com.itsaky.androidide.git.core.GitRepository import com.itsaky.androidide.git.core.GitRepositoryManager import com.itsaky.androidide.git.core.models.CommitHistoryUiState import com.itsaky.androidide.git.core.models.GitStatus import com.itsaky.androidide.preferences.internal.GitPreferences import com.itsaky.androidide.projects.IProjectManager +import com.itsaky.androidide.resources.R +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import org.eclipse.jgit.api.PullResult +import org.eclipse.jgit.errors.NoRemoteRepositoryException +import org.eclipse.jgit.transport.RemoteRefUpdate +import org.eclipse.jgit.transport.UsernamePasswordCredentialsProvider import org.greenrobot.eventbus.EventBus import org.greenrobot.eventbus.Subscribe import org.greenrobot.eventbus.ThreadMode import org.slf4j.LoggerFactory import java.io.File -class GitBottomSheetViewModel : ViewModel() { +class GitBottomSheetViewModel(private val credentialsManager: GitCredentialsManager) : ViewModel() { private val log = LoggerFactory.getLogger(GitBottomSheetViewModel::class.java) private val _gitStatus = MutableStateFlow(GitStatus.EMPTY) val gitStatus: StateFlow = _gitStatus.asStateFlow() + + private val _currentBranch = MutableStateFlow(null) + val currentBranch: StateFlow = _currentBranch.asStateFlow() + private val _commitHistory = MutableStateFlow(CommitHistoryUiState.Loading) val commitHistory: StateFlow = _commitHistory.asStateFlow() @@ -36,6 +49,18 @@ class GitBottomSheetViewModel : ViewModel() { private val _isGitRepository = MutableStateFlow(false) val isGitRepository: StateFlow = _isGitRepository.asStateFlow() + private val _localCommitsCount = MutableStateFlow(0) + val localCommitsCount: StateFlow = _localCommitsCount.asStateFlow() + + private val _pullState = MutableStateFlow(PullUiState.Idle) + val pullState: StateFlow = _pullState.asStateFlow() + + private val _pushState = MutableStateFlow(PushUiState.Idle) + val pushState: StateFlow = _pushState.asStateFlow() + + private var pullResetJob: Job? = null + private var pushResetJob: Job? = null + var currentRepository: GitRepository? = null private set @@ -74,16 +99,26 @@ class GitBottomSheetViewModel : ViewModel() { currentRepository?.let { repo -> val status = repo.getStatus() _gitStatus.value = status + _currentBranch.value = repo.getCurrentBranch()?.name + getLocalCommitsCount() } ?: run { _gitStatus.value = GitStatus.EMPTY + _currentBranch.value = null + _localCommitsCount.value = 0 } } catch (e: Exception) { log.error("Failed to refresh git status", e) _gitStatus.value = GitStatus.EMPTY + _currentBranch.value = null + _localCommitsCount.value = 0 } } } + suspend fun getLocalCommitsCount() { + _localCommitsCount.value = currentRepository?.getLocalCommitsCount() ?: 0 + } + fun commitChanges( summary: String, description: String? = null, @@ -128,6 +163,7 @@ class GitBottomSheetViewModel : ViewModel() { } else { _commitHistory.value = CommitHistoryUiState.Success(history) } + getLocalCommitsCount() } catch (e: Exception) { log.error("Failed to fetch commit history", e) _commitHistory.value = CommitHistoryUiState.Error(e.message) @@ -135,6 +171,147 @@ class GitBottomSheetViewModel : ViewModel() { } } + fun push(username: String?, token: String?) { + pushResetJob?.cancel() + + if (!NetworkUtils.isConnected()){ + _pushState.value = PushUiState.Error(errorResId = R.string.no_internet_connection) + return + } + + viewModelScope.launch { + _pushState.value = PushUiState.Pushing + try { + val repository = currentRepository ?: return@launch + val credentials = buildCredentials(username, token) + val results = repository.push(credentialsProvider = credentials) + val error = results.flatMap { it.remoteUpdates } + .firstOrNull { + it.status != RemoteRefUpdate.Status.OK && + it.status != RemoteRefUpdate.Status.UP_TO_DATE + } + + if (error != null) { + handlePushError(error) + return@launch + } + + handlePushSuccess(username, token) + } catch (e: Exception) { + if (e.message?.contains("not authorized", ignoreCase = true) == true) { + credentialsManager.clearCredentials() + _pushState.value = PushUiState.Error(errorResId = R.string.repo_authorization_error) + return@launch + } + _pushState.value = PushUiState.Error(e.message) + } finally { + pushResetJob = viewModelScope.launch { + delay(3000) + _pushState.value = PushUiState.Idle + } + } + } + } + + private fun buildCredentials(username: String?, token: String?) = + if (!username.isNullOrBlank() && !token.isNullOrBlank()) { + UsernamePasswordCredentialsProvider(username, token) + } else null + + private fun handlePushError(update: RemoteRefUpdate) { + _pushState.value = PushUiState.Error( + update.message ?: update.status.name + ) + } + + private suspend fun handlePushSuccess( + username: String?, + token: String?, + ) { + _pushState.value = PushUiState.Success + credentialsManager.saveCredentialsIfNeeded(username, token) + refreshStatus() + getLocalCommitsCount() + getCommitHistoryList() + } + + fun pull(username: String?, token: String?) { + pullResetJob?.cancel() + + if (!NetworkUtils.isConnected()){ + _pullState.value = PullUiState.Error(errorResId = R.string.no_internet_connection) + return + } + + viewModelScope.launch { + _pullState.value = PullUiState.Pulling + try { + val repository = currentRepository ?: return@launch + val credentials = buildCredentials(username, token) + val result = repository.pull(credentialsProvider = credentials) + + if (!result.isSuccessful) { + handlePullError(result) + return@launch + } + + handlePullSuccess(username, token) + } catch (e: Exception) { + log.error("Pull failed", e) + if (e.message?.contains("not authorized", ignoreCase = true) == true) { + credentialsManager.clearCredentials() + _pullState.value = PullUiState.Error(errorResId = R.string.repo_authorization_error) + return@launch + } + _pullState.value = PullUiState.Error(e.message) + } finally { + pullResetJob = viewModelScope.launch { + delay(3000) + _pullState.value = PullUiState.Idle + } + } + } + } + + private fun handlePullError(result: PullResult) { + val status = result.mergeResult?.mergeStatus?.name ?: "Unknown error" + _pullState.value = PullUiState.Error("Pull failed: $status") + } + + private fun handlePullSuccess( + username: String?, + token: String?, + ) { + _pullState.value = PullUiState.Success + credentialsManager.saveCredentialsIfNeeded(username, token) + refreshStatus() + getCommitHistoryList() + } + + fun resetPullState() { + pullResetJob?.cancel() + _pullState.value = PullUiState.Idle + } + + fun resetPushState() { + pushResetJob?.cancel() + _pushState.value = PushUiState.Idle + } + + sealed class PullUiState { + object Idle : PullUiState() + object Pulling : PullUiState() + object Success : PullUiState() + data class Error(val message: String? = null, val errorResId: Int? = R.string.unknown_error) : PullUiState() + } + + sealed class PushUiState { + object Idle : PushUiState() + object Pushing : PushUiState() + object Success : PushUiState() + data class Error(val message: String? = null, val errorResId: Int? = R.string.unknown_error) : PushUiState() + } + @Subscribe(threadMode = ThreadMode.MAIN) fun onDocumentSaved(event: DocumentSaveEvent) { refreshStatus() @@ -159,4 +336,5 @@ class GitBottomSheetViewModel : ViewModel() { fun onFileRenamed(event: FileRenameEvent) { refreshStatus() } + } diff --git a/app/src/main/res/layout/dialog_git_commit_history.xml b/app/src/main/res/layout/dialog_git_commit_history.xml index bf39edbef9..f6b619de32 100644 --- a/app/src/main/res/layout/dialog_git_commit_history.xml +++ b/app/src/main/res/layout/dialog_git_commit_history.xml @@ -9,13 +9,38 @@ + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_git_bottom_sheet.xml b/app/src/main/res/layout/fragment_git_bottom_sheet.xml index 3f93bc6a05..dda3b4bbad 100644 --- a/app/src/main/res/layout/fragment_git_bottom_sheet.xml +++ b/app/src/main/res/layout/fragment_git_bottom_sheet.xml @@ -6,6 +6,44 @@ android:orientation="vertical" android:padding="16dp"> + + + + + + + app:layout_constraintTop_toBottomOf="@id/tv_branch_name" /> + app:layout_constraintTop_toBottomOf="@id/tv_branch_name" /> + app:constraint_referenced_ids="img_commit_status" /> { + val cipher = Cipher.getInstance(ALGORITHM) + cipher.init(Cipher.ENCRYPT_MODE, getSecretKey()) + val ciphertext = cipher.doFinal(data.toByteArray(Charsets.UTF_8)) + return cipher.iv to ciphertext + } + + /** + * Decrypts the [ciphertext] using the provided [iv] and returns the original string. + */ + fun decrypt(iv: ByteArray, ciphertext: ByteArray): String { + val cipher = Cipher.getInstance(ALGORITHM) + val spec = GCMParameterSpec(128, iv) + cipher.init(Cipher.DECRYPT_MODE, getSecretKey(), spec) + return String(cipher.doFinal(ciphertext), Charsets.UTF_8) + } +} diff --git a/git-core/src/main/java/com/itsaky/androidide/git/core/GitCredentialsManager.kt b/git-core/src/main/java/com/itsaky/androidide/git/core/GitCredentialsManager.kt new file mode 100644 index 0000000000..9bd71da2c4 --- /dev/null +++ b/git-core/src/main/java/com/itsaky/androidide/git/core/GitCredentialsManager.kt @@ -0,0 +1,88 @@ +package com.itsaky.androidide.git.core + +import android.content.Context +import android.content.SharedPreferences +import android.util.Base64 +import androidx.core.content.edit +import org.slf4j.LoggerFactory + +/** + * Manages Git credentials securely using standard SharedPreferences + CryptoManager. + */ +class GitCredentialsManager( + private val context: Context +) { + + companion object { + private const val PREF_FILE_NAME = "git_credentials_prefs" + private const val KEY_USERNAME_IV = "git_username_iv" + private const val KEY_USERNAME_DATA = "git_username_data" + private const val KEY_TOKEN_IV = "git_token_iv" + private const val KEY_TOKEN_DATA = "git_token_data" + } + + private val log = LoggerFactory.getLogger(GitCredentialsManager::class.java) + + private var cachedPrefs: SharedPreferences? = null + + private fun getPrefs(): SharedPreferences { + if (cachedPrefs == null) { + cachedPrefs = context.getSharedPreferences(PREF_FILE_NAME, Context.MODE_PRIVATE) + } + return cachedPrefs!! + } + + fun saveCredentials(username: String, token: String) { + try { + val (usernameIv, usernameData) = CryptoManager.encrypt(username) + val (tokenIv, tokenData) = CryptoManager.encrypt(token) + + getPrefs().edit { + putString(KEY_USERNAME_IV, Base64.encodeToString(usernameIv, Base64.NO_WRAP)) + putString(KEY_USERNAME_DATA, Base64.encodeToString(usernameData, Base64.NO_WRAP)) + putString(KEY_TOKEN_IV, Base64.encodeToString(tokenIv, Base64.NO_WRAP)) + putString(KEY_TOKEN_DATA, Base64.encodeToString(tokenData, Base64.NO_WRAP)) + } + } catch (e: Exception) { + log.error("Failed to save credentials", e) + } + } + + fun getUsername(): String? = decrypt(KEY_USERNAME_IV, KEY_USERNAME_DATA) + fun getToken(): String? = decrypt(KEY_TOKEN_IV, KEY_TOKEN_DATA) + + fun hasCredentials(): Boolean { + val prefs = getPrefs() + return prefs.contains(KEY_USERNAME_DATA) && prefs.contains(KEY_TOKEN_DATA) + } + + private fun decrypt(ivKey: String, dataKey: String): String? { + val prefs = getPrefs() + val ivBase64 = prefs.getString(ivKey, null) ?: return null + val dataBase64 = prefs.getString(dataKey, null) ?: return null + + return try { + val iv = Base64.decode(ivBase64, Base64.NO_WRAP) + val data = Base64.decode(dataBase64, Base64.NO_WRAP) + CryptoManager.decrypt(iv, data) + } catch (e: Exception) { + log.error("Failed to decrypt field: $dataKey", e) + null + } + } + + fun saveCredentialsIfNeeded(username: String?, token: String?) { + if (!username.isNullOrBlank() && !token.isNullOrBlank()) { + saveCredentials(username, token) + } + } + + fun clearCredentials() { + getPrefs().edit { + remove(KEY_USERNAME_IV) + remove(KEY_USERNAME_DATA) + remove(KEY_TOKEN_IV) + remove(KEY_TOKEN_DATA) + } + } +} diff --git a/git-core/src/main/java/com/itsaky/androidide/git/core/GitRepository.kt b/git-core/src/main/java/com/itsaky/androidide/git/core/GitRepository.kt index f69d3be6f5..917eab54ab 100644 --- a/git-core/src/main/java/com/itsaky/androidide/git/core/GitRepository.kt +++ b/git-core/src/main/java/com/itsaky/androidide/git/core/GitRepository.kt @@ -3,6 +3,10 @@ package com.itsaky.androidide.git.core import com.itsaky.androidide.git.core.models.GitBranch import com.itsaky.androidide.git.core.models.GitCommit import com.itsaky.androidide.git.core.models.GitStatus +import org.eclipse.jgit.api.PullResult +import org.eclipse.jgit.lib.ProgressMonitor +import org.eclipse.jgit.transport.CredentialsProvider +import org.eclipse.jgit.transport.PushResult import java.io.File import java.io.Closeable @@ -22,4 +26,19 @@ interface GitRepository : Closeable { // Commit Operations suspend fun stageFiles(files: List) suspend fun commit(message: String, authorName: String? = null, authorEmail: String? = null): GitCommit? + + // Push Operations + suspend fun push( + remote: String = "origin", + credentialsProvider: CredentialsProvider? = null, + progressMonitor: ProgressMonitor? = null + ): Iterable + + suspend fun getLocalCommitsCount(): Int + + suspend fun pull( + remote: String = "origin", + credentialsProvider: CredentialsProvider? = null, + progressMonitor: ProgressMonitor? = null + ): PullResult } diff --git a/git-core/src/main/java/com/itsaky/androidide/git/core/JGitRepository.kt b/git-core/src/main/java/com/itsaky/androidide/git/core/JGitRepository.kt index e60c21a221..0891e0a1af 100644 --- a/git-core/src/main/java/com/itsaky/androidide/git/core/JGitRepository.kt +++ b/git-core/src/main/java/com/itsaky/androidide/git/core/JGitRepository.kt @@ -9,21 +9,25 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.eclipse.jgit.api.Git import org.eclipse.jgit.api.ListBranchCommand.ListMode -import org.eclipse.jgit.api.errors.NoHeadException import org.eclipse.jgit.diff.DiffFormatter import org.eclipse.jgit.dircache.DirCacheIterator import org.eclipse.jgit.lib.BranchConfig import org.eclipse.jgit.lib.Constants import org.eclipse.jgit.lib.PersonIdent +import org.eclipse.jgit.lib.ProgressMonitor import org.eclipse.jgit.lib.Repository import org.eclipse.jgit.revwalk.RevCommit import org.eclipse.jgit.revwalk.RevWalk import org.eclipse.jgit.storage.file.FileRepositoryBuilder +import org.eclipse.jgit.transport.CredentialsProvider +import org.eclipse.jgit.transport.PushResult +import org.eclipse.jgit.api.PullResult import org.eclipse.jgit.treewalk.AbstractTreeIterator import org.eclipse.jgit.treewalk.CanonicalTreeParser import org.eclipse.jgit.treewalk.EmptyTreeIterator import org.eclipse.jgit.treewalk.FileTreeIterator import org.eclipse.jgit.treewalk.filter.PathFilter +import org.slf4j.LoggerFactory import java.io.ByteArrayOutputStream import java.io.File @@ -32,6 +36,8 @@ import java.io.File */ class JGitRepository(override val rootDir: File) : GitRepository { + private val log = LoggerFactory.getLogger(JGitRepository::class.java) + private val repository: Repository = FileRepositoryBuilder() .setWorkTree(rootDir) .findGitDir(rootDir) @@ -121,7 +127,8 @@ class JGitRepository(override val rootDir: File) : GitRepository { commit.toGitCommit(isPushed) } } - } catch (_: NoHeadException) { + } catch (e: Exception) { + log.error("Error fetching commit history", e) emptyList() } } @@ -200,6 +207,71 @@ class JGitRepository(override val rootDir: File) : GitRepository { ) } + override suspend fun push( + remote: String, + credentialsProvider: CredentialsProvider?, + progressMonitor: ProgressMonitor? + ): Iterable = withContext(Dispatchers.IO) { + val pushCommand = git.push().setRemote(remote) + + if (credentialsProvider != null) { + pushCommand.setCredentialsProvider(credentialsProvider) + } + + if (progressMonitor != null) { + pushCommand.setProgressMonitor(progressMonitor) + } + + pushCommand.call() + } + + override suspend fun getLocalCommitsCount(): Int = withContext(Dispatchers.IO) { + try { + val branchName = repository.branch ?: return@withContext 0 + val branch = repository.resolve(Constants.HEAD) ?: return@withContext 0 + val config = BranchConfig(repository.config, branchName) + val trackingBranch = config.trackingBranch + val remoteBranch = trackingBranch?.let { repository.resolve(it) } + + RevWalk(repository).use { walk -> + val localCommit = walk.parseCommit(branch) + walk.markStart(localCommit) + + if (remoteBranch != null) { + val remoteCommit = walk.parseCommit(remoteBranch) + walk.markUninteresting(remoteCommit) + } + + var count = 0 + walk.forEach { _ -> + count++ + } + count + } + } catch (e: Exception) { + log.error("Error fetching local commits", e) + 0 + } + } + + override suspend fun pull( + remote: String, + credentialsProvider: CredentialsProvider?, + progressMonitor: ProgressMonitor? + ): PullResult = withContext(Dispatchers.IO) { + val pullCommand = git.pull().setRemote(remote) + + if (credentialsProvider != null) { + pullCommand.setCredentialsProvider(credentialsProvider) + } + + if (progressMonitor != null) { + pullCommand.setProgressMonitor(progressMonitor) + } + + pullCommand.call() + } + override fun close() { repository.close() git.close() diff --git a/resources/src/main/res/values/strings.xml b/resources/src/main/res/values/strings.xml index fd1202270e..0f5f508f3c 100644 --- a/resources/src/main/res/values/strings.xml +++ b/resources/src/main/res/values/strings.xml @@ -1249,4 +1249,17 @@ You can update your Git configuration in Preferences. Not set This project is not a Git repository + Current Branch: %1$s + Push + Pushing… + Push successful! + Push failed + Pull + Pulling… + Pull successful! + Pull failed + Git Credentials + Enter your credentials to sync changes with the remote repository. + Clear Credentials + Authentication failed. Ensure your Personal Access Token has \'repo\' permissions, or try clearing and re-entering credentials.