From 860e0619ab28066bf7f82b38599a176e674df926 Mon Sep 17 00:00:00 2001 From: Mirurin Date: Sun, 26 Apr 2026 16:30:39 +0800 Subject: [PATCH 01/24] fix(jgit): implement safety checks for force-with-lease and push results --- .../fuwagit/data/jgit/JGitRemoteDataSource.kt | 41 +++++++++++++++++-- 1 file changed, 38 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/jamgmilk/fuwagit/data/jgit/JGitRemoteDataSource.kt b/app/src/main/java/jamgmilk/fuwagit/data/jgit/JGitRemoteDataSource.kt index a3e34cf..332deaa 100644 --- a/app/src/main/java/jamgmilk/fuwagit/data/jgit/JGitRemoteDataSource.kt +++ b/app/src/main/java/jamgmilk/fuwagit/data/jgit/JGitRemoteDataSource.kt @@ -258,13 +258,33 @@ class JGitRemoteDataSource @Inject constructor( } } } else if (localRef != null) { - // localRef exists, remoteRef doesn't - normal for new branch + // No remote ref yet, proceed with push } else { val headRevision = try { git.repository.resolve("HEAD") } catch (_: Exception) { null } if (headRevision == null) { throw Exception("Repository has no commits. Make at least one commit before pushing.") } } + } else if (options.forceWithLease && !options.pushAllBranches) { + val localBranch = git.repository.branch + val trackingRef = git.repository.exactRef("refs/remotes/$targetRemote/$localBranch") + if (trackingRef != null) { + val currentRef = git.repository.exactRef("refs/heads/$localBranch") + if (currentRef != null) { + org.eclipse.jgit.revwalk.RevWalk(git.repository).use { revWalk -> + val trackingCommit = revWalk.parseCommit(trackingRef.objectId) + val currentCommit = revWalk.parseCommit(currentRef.objectId) + val isAncestor = revWalk.isMergedInto(currentCommit, trackingCommit) + if (!isAncestor) { + throw Exception( + "Force-with-lease denied: remote '$targetRemote/$localBranch' has new commits " + + "that you don't have locally. Fetch them first to get the latest changes, " + + "or use regular force push if you're sure you want to overwrite." + ) + } + } + } + } } val pushCommand = git.push().setRemote(options.remote) @@ -277,12 +297,27 @@ class JGitRemoteDataSource @Inject constructor( if (options.pushTags) pushCommand.setPushTags() - if (options.forceWithLease || options.forcePush) { + if (options.forcePush) { + pushCommand.setForce(true) + } else if (options.forceWithLease && !options.pushAllBranches) { pushCommand.setForce(true) } core.configureCredentials(pushCommand, credentials) - pushCommand.call() + val results = pushCommand.call() + + for (result in results) { + for (update in result.remoteUpdates) { + val status = update.status + if (status != org.eclipse.jgit.transport.RemoteRefUpdate.Status.OK && + status != org.eclipse.jgit.transport.RemoteRefUpdate.Status.UP_TO_DATE && + status != org.eclipse.jgit.transport.RemoteRefUpdate.Status.NON_EXISTING) { + throw Exception("Push rejected for ${update.remoteName}: ${status.name}. " + + "This may indicate that the remote branch was modified by someone else " + + "after your last fetch. Try fetching again.") + } + } + } if (options.setUpstreamOnPush) { val currentBranch = git.repository.branch From 9457aa5b45cf63ca3a06c5063dd5aee47f63eaf2 Mon Sep 17 00:00:00 2001 From: Mirurin Date: Sun, 26 Apr 2026 16:37:54 +0800 Subject: [PATCH 02/24] fix(jgit): use ResetType.MERGE to preserve uncommitted changes --- .../main/java/jamgmilk/fuwagit/data/jgit/JGitMergeDataSource.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/jamgmilk/fuwagit/data/jgit/JGitMergeDataSource.kt b/app/src/main/java/jamgmilk/fuwagit/data/jgit/JGitMergeDataSource.kt index 8629fc4..79a432b 100644 --- a/app/src/main/java/jamgmilk/fuwagit/data/jgit/JGitMergeDataSource.kt +++ b/app/src/main/java/jamgmilk/fuwagit/data/jgit/JGitMergeDataSource.kt @@ -176,7 +176,7 @@ class JGitMergeDataSource @Inject constructor( if (!java.io.File(gitDir, "MERGE_HEAD").exists()) { throw Exception("No merge in progress to abort") } - git.reset().setMode(org.eclipse.jgit.api.ResetCommand.ResetType.HARD).call() + git.reset().setMode(org.eclipse.jgit.api.ResetCommand.ResetType.MERGE).call() "Merge aborted" } From 50ed258ca0c928971c1397121102796377581bd9 Mon Sep 17 00:00:00 2001 From: Mirurin Date: Sun, 26 Apr 2026 16:47:07 +0800 Subject: [PATCH 03/24] fix(jgit): auto-stash uncommitted changes before hard reset --- .../fuwagit/data/jgit/JGitCommitDataSource.kt | 33 ++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/jamgmilk/fuwagit/data/jgit/JGitCommitDataSource.kt b/app/src/main/java/jamgmilk/fuwagit/data/jgit/JGitCommitDataSource.kt index 1dcef7a..e3db38a 100644 --- a/app/src/main/java/jamgmilk/fuwagit/data/jgit/JGitCommitDataSource.kt +++ b/app/src/main/java/jamgmilk/fuwagit/data/jgit/JGitCommitDataSource.kt @@ -164,10 +164,41 @@ class JGitCommitDataSource @Inject constructor( override fun reset(repoPath: String, commitHash: String, mode: GitResetMode): Result { return try { core.withGit(repoPath) { git -> - // 验证 commitHash 是否存在 git.repository.resolve(commitHash) ?: throw Exception("Commit not found: $commitHash") + val status = git.status().call() + val hasUncommittedChanges = status.hasUncommittedChanges() + + if (mode == GitResetMode.HARD && hasUncommittedChanges) { + val workingDirectoryChanges = status.modified.isNotEmpty() || + status.added.isNotEmpty() || + status.removed.isNotEmpty() || + status.changed.isNotEmpty() + + if (workingDirectoryChanges) { + val stashResult = try { + git.stashCreate() + .setIncludeUntracked(true) + .call() + } catch (_: Exception) { + null + } + + if (stashResult != null) { + val resetCommand = git.reset().setRef(commitHash) + .setMode(org.eclipse.jgit.api.ResetCommand.ResetType.HARD) + resetCommand.call() + return@withGit "Reset to $commitHash (hard): All changes auto-stashed (stash@{0} = ${stashResult.name}). Use 'git stash pop' to restore." + } else { + throw Exception( + "Cannot reset: there are uncommitted changes that could not be auto-stashed. " + + "Please commit or stash your changes before performing a hard reset." + ) + } + } + } + val resetCommand = git.reset().setRef(commitHash) when (mode) { GitResetMode.SOFT -> resetCommand.setMode(org.eclipse.jgit.api.ResetCommand.ResetType.SOFT) From c38b620529e365dcede3e143f2a6233bccf6a81a Mon Sep 17 00:00:00 2001 From: Mirurin Date: Sun, 26 Apr 2026 16:52:35 +0800 Subject: [PATCH 04/24] fix(jgit): add per-repository locks to prevent concurrent git operations --- .../fuwagit/data/jgit/JGitCoreDataSource.kt | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/jamgmilk/fuwagit/data/jgit/JGitCoreDataSource.kt b/app/src/main/java/jamgmilk/fuwagit/data/jgit/JGitCoreDataSource.kt index eaea209..f5d06d9 100644 --- a/app/src/main/java/jamgmilk/fuwagit/data/jgit/JGitCoreDataSource.kt +++ b/app/src/main/java/jamgmilk/fuwagit/data/jgit/JGitCoreDataSource.kt @@ -11,6 +11,8 @@ import org.eclipse.jgit.api.Git import org.eclipse.jgit.storage.file.FileRepositoryBuilder import java.io.File import java.security.Security +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.locks.ReentrantLock import javax.inject.Inject import javax.inject.Singleton @@ -23,9 +25,16 @@ class JGitCoreDataSource @Inject constructor( companion object { private const val TAG = "JGitCoreDataSource" + private val repoLocks = ConcurrentHashMap() + } + + private fun getLockForRepo(repoPath: String): ReentrantLock { + return repoLocks.computeIfAbsent(repoPath) { ReentrantLock() } } override fun withGit(repoPath: String, block: (Git) -> T): Result { + val lock = getLockForRepo(repoPath) + lock.lock() return try { Git.open(File(repoPath)).use { git -> Result.success(block(git)) @@ -33,6 +42,8 @@ class JGitCoreDataSource @Inject constructor( } catch (e: Exception) { Log.e(TAG, "Git operation failed for $repoPath", e) Result.failure(e) + } finally { + lock.unlock() } } @@ -74,7 +85,7 @@ class JGitCoreDataSource @Inject constructor( return try { val gitDir = File(path, ".git") gitDir.exists() && gitDir.isDirectory - } catch (e: Exception) { + } catch (_: Exception) { false } } @@ -87,7 +98,7 @@ class JGitCoreDataSource @Inject constructor( .build().use { repository -> repository.isBare || repository.directory.exists() } - } catch (e: Exception) { + } catch (_: Exception) { false } } From e440f2b9091497abe1e042d87650018c57a21b56 Mon Sep 17 00:00:00 2001 From: Mirurin Date: Sun, 26 Apr 2026 16:55:01 +0800 Subject: [PATCH 05/24] refactor(security): remove HMAC integrity protection from credential store --- .../local/security/SecureCredentialStore.kt | 71 ++----------------- 1 file changed, 4 insertions(+), 67 deletions(-) diff --git a/app/src/main/java/jamgmilk/fuwagit/data/local/security/SecureCredentialStore.kt b/app/src/main/java/jamgmilk/fuwagit/data/local/security/SecureCredentialStore.kt index 3b95f32..126be20 100644 --- a/app/src/main/java/jamgmilk/fuwagit/data/local/security/SecureCredentialStore.kt +++ b/app/src/main/java/jamgmilk/fuwagit/data/local/security/SecureCredentialStore.kt @@ -14,8 +14,6 @@ import kotlinx.serialization.json.Json import java.io.File import java.nio.charset.StandardCharsets import javax.crypto.Cipher -import javax.crypto.KeyGenerator -import javax.crypto.Mac import javax.crypto.SecretKey import javax.crypto.spec.GCMParameterSpec import javax.inject.Inject @@ -39,9 +37,6 @@ class SecureCredentialStore @Inject constructor( companion object { private const val DATA_FILE = "credential_data.json" private const val ENCRYPTED_MARKER = "ENC:AES_GCM:" - private const val HMAC_ALGORITHM = "HmacSHA256" - private const val HMAC_KEY_ALIAS = "fuwagit_credential_hmac_key" - private const val HMAC_KEY_SIZE = 32 private const val GCM_TAG_LENGTH = 128 private const val GCM_IV_LENGTH = 12 } @@ -62,52 +57,6 @@ class SecureCredentialStore @Inject constructor( private val sessionLock = Any() private val fileLock = Any() - private val hmacKey: SecretKey by lazy { - getOrCreateHmacKey() - } - - private fun getOrCreateHmacKey(): SecretKey { - val keyStore = java.security.KeyStore.getInstance("AndroidKeyStore").apply { load(null) } - return if (keyStore.containsAlias(HMAC_KEY_ALIAS)) { - keyStore.getKey(HMAC_KEY_ALIAS, null) as SecretKey - } else { - val keyGenerator = KeyGenerator.getInstance( - android.security.keystore.KeyProperties.KEY_ALGORITHM_HMAC_SHA256, - "AndroidKeyStore" - ) - val spec = android.security.keystore.KeyGenParameterSpec.Builder( - HMAC_KEY_ALIAS, - android.security.keystore.KeyProperties.PURPOSE_SIGN or android.security.keystore.KeyProperties.PURPOSE_VERIFY - ) - .setKeySize(HMAC_KEY_SIZE * 8) - .build() - keyGenerator.init(spec) - keyGenerator.generateKey() - } - } - - private fun computeHmac(data: String): String { - val mac = Mac.getInstance(HMAC_ALGORITHM) - mac.init(hmacKey) - val hmacBytes = mac.doFinal(data.toByteArray(StandardCharsets.UTF_8)) - return Base64.encodeToString(hmacBytes, Base64.NO_WRAP) - } - - private fun verifyHmac(data: String, expectedHmac: String): Boolean { - return try { - val computed = computeHmac(data) - computed == expectedHmac - } catch (_: Exception) { - false - } - } - - /** - * Gets the session timeout in milliseconds from user preferences. - * Returns 0 if auto-lock is disabled, or the configured timeout value otherwise. - * Negative values are treated as disabled (0). - * Minimum timeout is 30 seconds, maximum is 24 hours. - */ private suspend fun getSessionTimeoutMillis(): Long { val timeoutSeconds = appPreferencesStore.preferencesFlow .first { true } @@ -135,22 +84,11 @@ class SecureCredentialStore @Inject constructor( if (content.isBlank()) { CredentialData() } else { - val lines = content.lines() - if (lines.size >= 2 && lines[0].length == 44) { - val hmac = lines[0] - val jsonContent = lines.drop(1).joinToString("\n") - if (verifyHmac(jsonContent, hmac)) { - json.decodeFromString(jsonContent) - } else { - CredentialData() - } - } else { - json.decodeFromString(content) - } + json.decodeFromString(content) } } - } catch (_: Exception) { - CredentialData() + } catch (e: Exception) { + throw SecurityException("Failed to load credential data: ${e.message}", e) } } } @@ -159,11 +97,10 @@ class SecureCredentialStore @Inject constructor( synchronized(fileLock) { val updatedData = data.copy(updatedAt = System.currentTimeMillis()) val jsonString = json.encodeToString(updatedData) - val hmac = computeHmac(jsonString) val tempFile = File(context.filesDir, "$DATA_FILE.tmp") try { - tempFile.writeText("$hmac\n$jsonString") + tempFile.writeText(jsonString) if (!tempFile.renameTo(dataFile)) { tempFile.copyTo(dataFile, overwrite = true) tempFile.delete() From 0eba30951c1edbf5f6412f6cfb70e475b7654922 Mon Sep 17 00:00:00 2001 From: Mirurin Date: Sun, 26 Apr 2026 17:28:45 +0800 Subject: [PATCH 06/24] fix(security): unify auto-lock timeout for password and biometric sessions --- .../fuwagit/data/local/prefs/AppPreferencesStore.kt | 2 +- .../fuwagit/data/local/security/SecureCredentialStore.kt | 8 +++----- .../java/jamgmilk/fuwagit/domain/model/AppPreferences.kt | 2 +- .../jamgmilk/fuwagit/ui/screen/settings/SettingsScreen.kt | 2 +- .../fuwagit/ui/screen/settings/SettingsViewModel.kt | 2 +- app/src/main/res/values-zh/strings.xml | 4 ++-- app/src/main/res/values/strings.xml | 4 ++-- 7 files changed, 11 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/jamgmilk/fuwagit/data/local/prefs/AppPreferencesStore.kt b/app/src/main/java/jamgmilk/fuwagit/data/local/prefs/AppPreferencesStore.kt index 1d80b0d..c0e13e3 100644 --- a/app/src/main/java/jamgmilk/fuwagit/data/local/prefs/AppPreferencesStore.kt +++ b/app/src/main/java/jamgmilk/fuwagit/data/local/prefs/AppPreferencesStore.kt @@ -46,7 +46,7 @@ class AppPreferencesStore @Inject constructor( darkMode = prefs[PreferencesKeys.DARK_MODE] ?: "system", language = prefs[PreferencesKeys.LANGUAGE] ?: "system", dynamicColor = prefs[PreferencesKeys.DYNAMIC_COLOR] ?: true, - autoLockTimeout = prefs[PreferencesKeys.AUTO_LOCK_TIMEOUT] ?: "300", + autoLockTimeout = prefs[PreferencesKeys.AUTO_LOCK_TIMEOUT] ?: "600", isFirstRun = prefs[PreferencesKeys.IS_FIRST_RUN] ?: true ) } diff --git a/app/src/main/java/jamgmilk/fuwagit/data/local/security/SecureCredentialStore.kt b/app/src/main/java/jamgmilk/fuwagit/data/local/security/SecureCredentialStore.kt index 126be20..0578b21 100644 --- a/app/src/main/java/jamgmilk/fuwagit/data/local/security/SecureCredentialStore.kt +++ b/app/src/main/java/jamgmilk/fuwagit/data/local/security/SecureCredentialStore.kt @@ -61,7 +61,7 @@ class SecureCredentialStore @Inject constructor( val timeoutSeconds = appPreferencesStore.preferencesFlow .first { true } .autoLockTimeout - .toLongOrNull() ?: 300L + .toLongOrNull() ?: 600L val validTimeout = when { timeoutSeconds < 0 -> 0L @@ -157,14 +157,12 @@ class SecureCredentialStore @Inject constructor( return@synchronized null } - val effectiveTimeout = if (isBiometricSession) sessionTimeout else 0L - - if (effectiveTimeout == 0L) { + if (sessionTimeout == 0L) { return@synchronized key } val elapsed = System.currentTimeMillis() - lastUnlockTime - if (elapsed < effectiveTimeout) { + if (elapsed < sessionTimeout) { return@synchronized key } diff --git a/app/src/main/java/jamgmilk/fuwagit/domain/model/AppPreferences.kt b/app/src/main/java/jamgmilk/fuwagit/domain/model/AppPreferences.kt index 4ea517d..74a5110 100644 --- a/app/src/main/java/jamgmilk/fuwagit/domain/model/AppPreferences.kt +++ b/app/src/main/java/jamgmilk/fuwagit/domain/model/AppPreferences.kt @@ -8,6 +8,6 @@ data class AppPreferences( val darkMode: String = "system", val language: String = "system", val dynamicColor: Boolean = true, - val autoLockTimeout: String = "300", + val autoLockTimeout: String = "600", val isFirstRun: Boolean = true ) diff --git a/app/src/main/java/jamgmilk/fuwagit/ui/screen/settings/SettingsScreen.kt b/app/src/main/java/jamgmilk/fuwagit/ui/screen/settings/SettingsScreen.kt index 9d5ac3f..963a313 100644 --- a/app/src/main/java/jamgmilk/fuwagit/ui/screen/settings/SettingsScreen.kt +++ b/app/src/main/java/jamgmilk/fuwagit/ui/screen/settings/SettingsScreen.kt @@ -517,7 +517,7 @@ private fun SecuritySettingsCard( isDecryptionUnlocked: Boolean = false, isMasterPasswordSet: Boolean = false, onBiometricEnabledChange: ((Boolean) -> Unit)? = null, - autoLockTimeout: String = "300", + autoLockTimeout: String = "600", onAutoLockTimeoutChange: (String) -> Unit = {} ) { val colors = MaterialTheme.colorScheme diff --git a/app/src/main/java/jamgmilk/fuwagit/ui/screen/settings/SettingsViewModel.kt b/app/src/main/java/jamgmilk/fuwagit/ui/screen/settings/SettingsViewModel.kt index b914301..28eef02 100644 --- a/app/src/main/java/jamgmilk/fuwagit/ui/screen/settings/SettingsViewModel.kt +++ b/app/src/main/java/jamgmilk/fuwagit/ui/screen/settings/SettingsViewModel.kt @@ -36,7 +36,7 @@ data class SettingsUiState( val globalUserName: String? = null, val globalUserEmail: String? = null, val applyResult: ApplyConfigResult? = null, - val autoLockTimeout: String = "300", + val autoLockTimeout: String = "600", val isFirstRun: Boolean = true ) diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index d51faeb..cd65004 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -83,7 +83,7 @@ 30 分钟 1 小时 %1$d 秒 - 设置凭据自动锁定前的超时时间(秒)。设为 0 可禁用自动锁定。 + 设置超时时间(秒)。凭据将在此空闲期后自动锁定。输入 0 可禁用自动锁定(永不过期)。 配置 @@ -124,7 +124,7 @@ 打开文件选择器并显示所选路径 测试文件选择器 超时时间(秒) - 300 + 600 重置引导 立即显示引导屏幕 这将重置首次运行标记。引导屏幕将立即显示。 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index dbc3b27..139acae 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -83,7 +83,7 @@ 30 minutes 1 hour %1$d seconds - Set the timeout duration in seconds after which the credentials will be automatically locked. Set to 0 to disable auto-lock. + Set the timeout duration in seconds. Credentials will auto-lock after this period of inactivity. Enter 0 to disable auto-lock (never expires). Configuration @@ -124,7 +124,7 @@ Open file picker and show selected path Test File Picker Timeout (seconds) - 300 + 600 Reset Onboarding Show onboarding screen immediately This will reset the first-run flag. The onboarding screen will appear immediately. From 65e030a267c0d737e4cefb0534570c2ef90437c4 Mon Sep 17 00:00:00 2001 From: Mirurin Date: Sun, 26 Apr 2026 17:38:48 +0800 Subject: [PATCH 07/24] refactor(repo): remove duplicate getSavedRepos function --- .../fuwagit/data/local/prefs/RepoDataStore.kt | 12 +++++------- .../fuwagit/data/repository/RepoRepositoryImpl.kt | 6 +++--- .../fuwagit/domain/repository/RepoRepository.kt | 4 ++-- .../fuwagit/ui/screen/myrepos/MyReposViewModel.kt | 10 +++++----- .../fuwagit/ui/screen/settings/SettingsViewModel.kt | 4 ++-- 5 files changed, 17 insertions(+), 19 deletions(-) diff --git a/app/src/main/java/jamgmilk/fuwagit/data/local/prefs/RepoDataStore.kt b/app/src/main/java/jamgmilk/fuwagit/data/local/prefs/RepoDataStore.kt index db3b3ed..3d7d5c6 100644 --- a/app/src/main/java/jamgmilk/fuwagit/data/local/prefs/RepoDataStore.kt +++ b/app/src/main/java/jamgmilk/fuwagit/data/local/prefs/RepoDataStore.kt @@ -35,7 +35,7 @@ class RepoDataStore @Inject constructor( private val _reposFlow = MutableStateFlow>(emptyList()) val reposFlow: Flow> = _reposFlow.asStateFlow() - fun getSavedReposFlow(): Flow> = reposFlow + fun getAllReposFlow(): Flow> = reposFlow private var cachedWrapper: RepoListWrapper = loadFromFile() @@ -55,7 +55,7 @@ class RepoDataStore @Inject constructor( json.decodeFromString(content) } } - } catch (e: Exception) { + } catch (_: Exception) { RepoListWrapper() } } @@ -77,14 +77,12 @@ class RepoDataStore @Inject constructor( private fun persistAndNotify(repos: List, currentPath: String?): List { val wrapper = RepoListWrapper(repos, currentPath) - cachedWrapper = wrapper saveToFile(wrapper) + cachedWrapper = wrapper _reposFlow.value = repos return repos } - fun getSavedRepos(): List = cachedWrapper.repos - fun getAllRepos(): List = cachedWrapper.repos fun setCurrentRepo(path: String?) { @@ -100,7 +98,7 @@ class RepoDataStore @Inject constructor( currentList.add(repo) persistAndNotify(currentList, cachedWrapper.currentRepoPath) true - } catch (e: Exception) { + } catch (_: Exception) { false } } @@ -112,7 +110,7 @@ class RepoDataStore @Inject constructor( val newCurrentPath = if (cachedWrapper.currentRepoPath == path) null else cachedWrapper.currentRepoPath persistAndNotify(currentList, newCurrentPath) true - } catch (e: Exception) { + } catch (_: Exception) { false } } diff --git a/app/src/main/java/jamgmilk/fuwagit/data/repository/RepoRepositoryImpl.kt b/app/src/main/java/jamgmilk/fuwagit/data/repository/RepoRepositoryImpl.kt index 09127c4..1eb487e 100644 --- a/app/src/main/java/jamgmilk/fuwagit/data/repository/RepoRepositoryImpl.kt +++ b/app/src/main/java/jamgmilk/fuwagit/data/repository/RepoRepositoryImpl.kt @@ -12,12 +12,12 @@ class RepoRepositoryImpl @Inject constructor( private val repoDataStore: RepoDataStore ) : RepoRepository { - override fun getSavedReposFlow(): Flow> { - return repoDataStore.getSavedReposFlow() + override fun getAllReposFlow(): Flow> { + return repoDataStore.getAllReposFlow() } override suspend fun getAllRepos(): List { - return repoDataStore.getSavedRepos() + return repoDataStore.getAllRepos() } override suspend fun setCurrentRepo(path: String?) { diff --git a/app/src/main/java/jamgmilk/fuwagit/domain/repository/RepoRepository.kt b/app/src/main/java/jamgmilk/fuwagit/domain/repository/RepoRepository.kt index 4d889b2..dd66934 100644 --- a/app/src/main/java/jamgmilk/fuwagit/domain/repository/RepoRepository.kt +++ b/app/src/main/java/jamgmilk/fuwagit/domain/repository/RepoRepository.kt @@ -9,9 +9,9 @@ import kotlinx.coroutines.flow.Flow */ interface RepoRepository { /** - * Get the list of saved repositories as a Flow. + * Get the list of repositories as a Flow. */ - fun getSavedReposFlow(): Flow> + fun getAllReposFlow(): Flow> /** * Get the list of saved repositories (one-time). diff --git a/app/src/main/java/jamgmilk/fuwagit/ui/screen/myrepos/MyReposViewModel.kt b/app/src/main/java/jamgmilk/fuwagit/ui/screen/myrepos/MyReposViewModel.kt index f2774f0..0670564 100644 --- a/app/src/main/java/jamgmilk/fuwagit/ui/screen/myrepos/MyReposViewModel.kt +++ b/app/src/main/java/jamgmilk/fuwagit/ui/screen/myrepos/MyReposViewModel.kt @@ -60,7 +60,7 @@ class MyReposViewModel @Inject constructor( } } size - } catch (e: Exception) { + } catch (_: Exception) { 0L } } @@ -81,7 +81,7 @@ class MyReposViewModel @Inject constructor( init { viewModelScope.launch { - repoDataStore.getSavedReposFlow().collectLatest { repos -> + repoDataStore.getAllReposFlow().collectLatest { repos -> val currentPath = currentRepoManager.getRepoPath() val currentSizes = _uiState.value.repoSizes val items = buildRepoItems(repos, currentPath, currentSizes) @@ -126,7 +126,7 @@ class MyReposViewModel @Inject constructor( } } - suspend fun addRepo(path: String, alias: String? = null, credentialId: String? = null, showSnackbar: Boolean = true): Boolean { + fun addRepo(path: String, alias: String? = null, credentialId: String? = null, showSnackbar: Boolean = true): Boolean { val repo = RepoData(path = path, alias = alias, credentialId = credentialId) val result = repoDataStore.addRepo(repo) if (result && currentRepoManager.getRepoPath() == null) { @@ -138,7 +138,7 @@ class MyReposViewModel @Inject constructor( return result } - suspend fun removeRepo(path: String): Boolean { + fun removeRepo(path: String): Boolean { val result = repoDataStore.removeRepo(path) if (result && currentRepoManager.getRepoPath() == path) { currentRepoManager.clearRepo() @@ -146,7 +146,7 @@ class MyReposViewModel @Inject constructor( return result } - suspend fun setCurrentRepo(path: String?) { + fun setCurrentRepo(path: String?) { currentRepoManager.setRepoPath(path) } diff --git a/app/src/main/java/jamgmilk/fuwagit/ui/screen/settings/SettingsViewModel.kt b/app/src/main/java/jamgmilk/fuwagit/ui/screen/settings/SettingsViewModel.kt index 28eef02..0ceee12 100644 --- a/app/src/main/java/jamgmilk/fuwagit/ui/screen/settings/SettingsViewModel.kt +++ b/app/src/main/java/jamgmilk/fuwagit/ui/screen/settings/SettingsViewModel.kt @@ -76,7 +76,7 @@ class SettingsViewModel @Inject constructor( private fun observeRepositories() { viewModelScope.launch { launch { - repoRepository.getSavedReposFlow().collect { repos -> + repoRepository.getAllReposFlow().collect { repos -> _uiState.update { it.copy(savedReposCount = repos.size) } } } @@ -228,7 +228,7 @@ class SettingsViewModel @Inject constructor( } } - suspend fun getCurrentRepoPath(): String? { + fun getCurrentRepoPath(): String? { return repoStateManager.getRepoPath() } } From a2d0ec2378c5e2776efa45afb540cbc99271627e Mon Sep 17 00:00:00 2001 From: Mirurin Date: Sun, 26 Apr 2026 17:46:41 +0800 Subject: [PATCH 08/24] fix: call MyReposViewModel.onCredentialUnlocked() after credential unlock --- .../main/java/jamgmilk/fuwagit/ui/screen/main/AppNavHost.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/jamgmilk/fuwagit/ui/screen/main/AppNavHost.kt b/app/src/main/java/jamgmilk/fuwagit/ui/screen/main/AppNavHost.kt index 9123318..0c218d3 100644 --- a/app/src/main/java/jamgmilk/fuwagit/ui/screen/main/AppNavHost.kt +++ b/app/src/main/java/jamgmilk/fuwagit/ui/screen/main/AppNavHost.kt @@ -351,7 +351,10 @@ fun MainScreen( LaunchedEffect(Unit) { credentialStoreViewModel.events.collect { event -> when (event) { - is CredentialStoreEvent.UnlockSuccess -> statusViewModel.onCredentialUnlocked() + is CredentialStoreEvent.UnlockSuccess -> { + statusViewModel.onCredentialUnlocked() + myReposViewModel.onCredentialUnlocked() + } else -> { } } } From 23b0752b91de6b74986213774a762324821f9966 Mon Sep 17 00:00:00 2001 From: Mirurin Date: Sun, 26 Apr 2026 18:02:57 +0800 Subject: [PATCH 09/24] fix(credential): use exact or subdomain match for host credential resolution --- .../ResolveCloneCredentialUseCase.kt | 22 +++++++++++++------ 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/jamgmilk/fuwagit/domain/usecase/credential/ResolveCloneCredentialUseCase.kt b/app/src/main/java/jamgmilk/fuwagit/domain/usecase/credential/ResolveCloneCredentialUseCase.kt index 024696d..56307a8 100644 --- a/app/src/main/java/jamgmilk/fuwagit/domain/usecase/credential/ResolveCloneCredentialUseCase.kt +++ b/app/src/main/java/jamgmilk/fuwagit/domain/usecase/credential/ResolveCloneCredentialUseCase.kt @@ -31,7 +31,7 @@ class ResolveCloneCredentialUseCase @Inject constructor( resolveHttpsCredential(selectedCredentialUuid, httpsCredentials) } selectedSshKeyUuid != null -> { - resolveSshCredential(selectedSshKeyUuid, sshKeys) + resolveSshCredential(selectedSshKeyUuid) } else -> { resolveAutoSelectCredential(httpsCredentials, sshKeys, remoteUrl) @@ -49,8 +49,7 @@ class ResolveCloneCredentialUseCase @Inject constructor( } private suspend fun resolveSshCredential( - uuid: String, - sshKeys: List + uuid: String ): CloneCredential? { val privateKey = getSshPrivateKeyUseCase(uuid).getOrNull() ?: return null val passphrase = getSshPassphraseUseCase(uuid).getOrNull() @@ -64,9 +63,11 @@ class ResolveCloneCredentialUseCase @Inject constructor( ): CloneCredential? { if (httpsCredentials.isNotEmpty()) { if (remoteUrl != null) { - val host = UrlUtils.extractHost(remoteUrl) - if (host != null) { - val matched = httpsCredentials.find { it.host.contains(host, ignoreCase = true) } + val remoteHost = UrlUtils.extractHost(remoteUrl) + if (remoteHost != null) { + val matched = httpsCredentials.find { cred -> + isHostMatch(cred.host, remoteHost) + } if (matched != null) { return resolveHttpsCredential(matched.uuid, httpsCredentials) } @@ -76,9 +77,16 @@ class ResolveCloneCredentialUseCase @Inject constructor( } if (sshKeys.isNotEmpty()) { - return resolveSshCredential(sshKeys.first().uuid, sshKeys) + return resolveSshCredential(sshKeys.first().uuid) } return null } + + private fun isHostMatch(credHost: String, remoteHost: String): Boolean { + val normalizedCredHost = credHost.lowercase() + val normalizedRemoteHost = remoteHost.lowercase() + return normalizedCredHost == normalizedRemoteHost || + normalizedRemoteHost.endsWith(".$normalizedCredHost") + } } From d49dbf0a159618bcb166522868612a68fe2ecad5 Mon Sep 17 00:00:00 2001 From: Mirurin Date: Sun, 26 Apr 2026 18:06:39 +0800 Subject: [PATCH 10/24] fix(jgit): change clean to respect .gitignore settings --- .../main/java/jamgmilk/fuwagit/data/jgit/JGitMergeDataSource.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/jamgmilk/fuwagit/data/jgit/JGitMergeDataSource.kt b/app/src/main/java/jamgmilk/fuwagit/data/jgit/JGitMergeDataSource.kt index 79a432b..21808db 100644 --- a/app/src/main/java/jamgmilk/fuwagit/data/jgit/JGitMergeDataSource.kt +++ b/app/src/main/java/jamgmilk/fuwagit/data/jgit/JGitMergeDataSource.kt @@ -184,7 +184,7 @@ class JGitMergeDataSource @Inject constructor( core.withGit(repoPath) { git -> val cleanedPaths = git.clean() .setCleanDirectories(true) - .setIgnore(false) + .setIgnore(true) .setDryRun(dryRun) .call() CleanResult(files = cleanedPaths.toList(), isDryRun = dryRun) From d4b84f2f1b36aa2d50ba463733eb7750f58f4953 Mon Sep 17 00:00:00 2001 From: Mirurin Date: Sun, 26 Apr 2026 18:14:54 +0800 Subject: [PATCH 11/24] fix(security): clear password from memory after master password setup --- .../fuwagit/ui/screen/onboarding/OnboardingViewModel.kt | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/app/src/main/java/jamgmilk/fuwagit/ui/screen/onboarding/OnboardingViewModel.kt b/app/src/main/java/jamgmilk/fuwagit/ui/screen/onboarding/OnboardingViewModel.kt index df81c0a..0696f7d 100644 --- a/app/src/main/java/jamgmilk/fuwagit/ui/screen/onboarding/OnboardingViewModel.kt +++ b/app/src/main/java/jamgmilk/fuwagit/ui/screen/onboarding/OnboardingViewModel.kt @@ -116,6 +116,7 @@ class OnboardingViewModel @Inject constructor( viewModelScope.launch { setupMasterPasswordUseCase(state.password, state.confirmPassword, state.passwordHint.ifBlank { null }) .onSuccess { + clearSensitiveData() if (state.enableBiometric && activity != null) { enableBiometricUseCase(activity) { result -> when (result) { @@ -143,6 +144,12 @@ class OnboardingViewModel @Inject constructor( } } + private fun clearSensitiveData() { + _uiState.update { + it.copy(password = "", confirmPassword = "") + } + } + fun skipPassword() { nextStep() } From 8048b878b582409bcae86292e9175c788605ec1c Mon Sep 17 00:00:00 2001 From: Mirurin Date: Sun, 26 Apr 2026 18:16:55 +0800 Subject: [PATCH 12/24] fix(jgit): discardChanges now restores from HEAD instead of index --- .../java/jamgmilk/fuwagit/data/jgit/JGitStatusDataSource.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/jamgmilk/fuwagit/data/jgit/JGitStatusDataSource.kt b/app/src/main/java/jamgmilk/fuwagit/data/jgit/JGitStatusDataSource.kt index 2b0f286..6cffd8e 100644 --- a/app/src/main/java/jamgmilk/fuwagit/data/jgit/JGitStatusDataSource.kt +++ b/app/src/main/java/jamgmilk/fuwagit/data/jgit/JGitStatusDataSource.kt @@ -129,7 +129,7 @@ class JGitStatusDataSource @Inject constructor( /** * Discards changes to a specific file. - * Handles both modified files (checkout) and untracked files (clean). + * Handles both modified files (checkout from HEAD) and untracked files (clean). */ override fun discardChanges(repoPath: String, filePath: String): Result = core.withGit(repoPath) { git -> val status = git.status().call() @@ -147,7 +147,7 @@ class JGitStatusDataSource @Inject constructor( git.clean().setPaths(setOf(filePath)).call() } if (isModified) { - git.checkout().addPath(filePath).call() + git.checkout().setStartPoint("HEAD").addPath(filePath).call() } } catch (e: Exception) { throw Exception("Failed to discard changes: ${e.message}. Make sure the file is not locked by another process.") From 9b506d0c12129c728aab7ed651f86b19adfde6c4 Mon Sep 17 00:00:00 2001 From: Mirurin Date: Sun, 26 Apr 2026 18:18:52 +0800 Subject: [PATCH 13/24] fix(jgit): properly return Result from unstageFile method --- .../fuwagit/data/jgit/JGitStatusDataSource.kt | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/jamgmilk/fuwagit/data/jgit/JGitStatusDataSource.kt b/app/src/main/java/jamgmilk/fuwagit/data/jgit/JGitStatusDataSource.kt index 6cffd8e..2caa61e 100644 --- a/app/src/main/java/jamgmilk/fuwagit/data/jgit/JGitStatusDataSource.kt +++ b/app/src/main/java/jamgmilk/fuwagit/data/jgit/JGitStatusDataSource.kt @@ -118,13 +118,17 @@ class JGitStatusDataSource @Inject constructor( * Unstages a specific file. */ override fun unstageFile(repoPath: String, filePath: String): Result = core.withGit(repoPath) { git -> - val headRef = git.repository.resolve("HEAD") - if (headRef == null) { - git.rm().setCached(true).addFilepattern(filePath).call() - } else { - git.reset().setRef("HEAD").addPath(filePath).call() + try { + val headRef = git.repository.resolve("HEAD") + if (headRef == null) { + git.rm().setCached(true).addFilepattern(filePath).call() + } else { + git.reset().setRef("HEAD").addPath(filePath).call() + } + Result.success(Unit) + } catch (e: Exception) { + Result.failure(e) } - Unit } /** From 67c3a826b7a5cca22e6f8a1e960b408acd6d190a Mon Sep 17 00:00:00 2001 From: Mirurin Date: Sun, 26 Apr 2026 18:37:07 +0800 Subject: [PATCH 14/24] fix: resolve multiple security and performance issues --- .../data/local/security/MasterKeyManager.kt | 7 +-- .../ui/screen/status/StatusViewModel.kt | 5 +- .../fuwagit/ui/state/RepoStateManager.kt | 46 ++++++++++--------- .../jamgmilk/fuwagit/util/CrashLogManager.kt | 4 +- 4 files changed, 35 insertions(+), 27 deletions(-) diff --git a/app/src/main/java/jamgmilk/fuwagit/data/local/security/MasterKeyManager.kt b/app/src/main/java/jamgmilk/fuwagit/data/local/security/MasterKeyManager.kt index ce26b6c..147aae8 100644 --- a/app/src/main/java/jamgmilk/fuwagit/data/local/security/MasterKeyManager.kt +++ b/app/src/main/java/jamgmilk/fuwagit/data/local/security/MasterKeyManager.kt @@ -89,9 +89,10 @@ class MasterKeyManager @Inject constructor( Base64.encodeToString(salt, Base64.NO_WRAP)) } - val result = SecretKeySpec(masterKey.encoded, "AES") + val encodedBytes = masterKey.encoded + val result = SecretKeySpec(encodedBytes.copyOf(), "AES") + java.util.Arrays.fill(encodedBytes, 0.toByte()) derivedKey.secureZero() - masterKey.encoded?.let { java.util.Arrays.fill(it, 0.toByte()) } result } } @@ -197,7 +198,7 @@ class MasterKeyManager @Inject constructor( putString(KEY_BIOMETRIC_IV, Base64.encodeToString(actualIv, Base64.NO_WRAP)) putBoolean(KEY_BIOMETRIC_ENABLED, true) - apply() + commit() } if (BuildConfig.DEBUG) Log.d(TAG, "enableBiometric: success saved to prefs with IV size ${actualIv?.size}") onSuccess() diff --git a/app/src/main/java/jamgmilk/fuwagit/ui/screen/status/StatusViewModel.kt b/app/src/main/java/jamgmilk/fuwagit/ui/screen/status/StatusViewModel.kt index 47a351c..3c289d8 100644 --- a/app/src/main/java/jamgmilk/fuwagit/ui/screen/status/StatusViewModel.kt +++ b/app/src/main/java/jamgmilk/fuwagit/ui/screen/status/StatusViewModel.kt @@ -17,6 +17,7 @@ import jamgmilk.fuwagit.domain.usecase.git.MergeUseCase import jamgmilk.fuwagit.ui.components.DangerousOperationType import jamgmilk.fuwagit.ui.state.RepoStateManager import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow @@ -98,6 +99,7 @@ class StatusViewModel @Inject constructor( val events: SharedFlow = _events.asSharedFlow() private var currentRepoPath: String? = null + private var refreshJob: Job? = null init { viewModelScope.launch { @@ -181,7 +183,8 @@ class StatusViewModel @Inject constructor( fun refreshWorkspace() { val path = currentRepoPath ?: return - viewModelScope.launch { + refreshJob?.cancel() + refreshJob = viewModelScope.launch { _uiState.update { it.copy(isLoading = true) } val filesResult = withContext(Dispatchers.IO) { gitStatus.getDetailedStatus(path) } diff --git a/app/src/main/java/jamgmilk/fuwagit/ui/state/RepoStateManager.kt b/app/src/main/java/jamgmilk/fuwagit/ui/state/RepoStateManager.kt index 8ef25b1..3a47f56 100644 --- a/app/src/main/java/jamgmilk/fuwagit/ui/state/RepoStateManager.kt +++ b/app/src/main/java/jamgmilk/fuwagit/ui/state/RepoStateManager.kt @@ -75,30 +75,32 @@ class RepoStateManager @Inject constructor( isLoading = true ) - val result = when { - !file.exists() -> { - repoDataStore.setCurrentRepo(null) - ValidationResult.Error("Path does not exist") + scope.launch(Dispatchers.IO) { + val result = when { + !file.exists() -> { + repoDataStore.setCurrentRepo(null) + ValidationResult.Error("Path does not exist") + } + !File(file, ".git").exists() -> { + repoDataStore.setCurrentRepo(null) + ValidationResult.Error("Not a git repository") + } + else -> { + repoDataStore.setCurrentRepo(path) + repoDataStore.updateLastAccessed(path) + ValidationResult.Success(path, name) + } } - !File(file, ".git").exists() -> { - repoDataStore.setCurrentRepo(null) - ValidationResult.Error("Not a git repository") - } - else -> { - repoDataStore.setCurrentRepo(path) - repoDataStore.updateLastAccessed(path) - ValidationResult.Success(path, name) - } - } - _repoInfo.value = when (result) { - is ValidationResult.Success -> RepoInfo( - repoPath = result.path, - repoName = result.name - ) - is ValidationResult.Error -> RepoInfo( - error = result.message - ) + _repoInfo.value = when (result) { + is ValidationResult.Success -> RepoInfo( + repoPath = result.path, + repoName = result.name + ) + is ValidationResult.Error -> RepoInfo( + error = result.message + ) + } } } diff --git a/app/src/main/java/jamgmilk/fuwagit/util/CrashLogManager.kt b/app/src/main/java/jamgmilk/fuwagit/util/CrashLogManager.kt index 35c8814..dd44be0 100644 --- a/app/src/main/java/jamgmilk/fuwagit/util/CrashLogManager.kt +++ b/app/src/main/java/jamgmilk/fuwagit/util/CrashLogManager.kt @@ -43,7 +43,7 @@ object CrashLogManager { val versionCode = packageInfo.versionCode val versionName = packageInfo.versionName ?: "Unknown" "$versionName ($versionCode)" - } catch (e: Exception) { + } catch (_: Exception) { "Unknown" } @@ -69,6 +69,7 @@ object CrashLogManager { try { writeCrashLog(logFile, thread, throwable) Log.i(TAG, "Crash log written to: ${logFile.absolutePath}") + cleanupOldLogsSync() } catch (e: Exception) { Log.e(TAG, "Failed to write crash log", e) } @@ -180,6 +181,7 @@ object CrashLogManager { writer.println("=== End of Manual Error Log ===") } Log.d(TAG, "Manual error log written to: ${logFile.absolutePath}") + cleanupOldLogsSync() } catch (e: Exception) { Log.e(TAG, "Failed to write manual error log", e) } From 3b0ac161dc5cfff080bdba6e36607b6414039108 Mon Sep 17 00:00:00 2001 From: Mirurin Date: Mon, 27 Apr 2026 15:33:32 +0800 Subject: [PATCH 15/24] feat(security): migrate to biometric-compose --- app/build.gradle.kts | 2 +- .../data/biometric/BiometricAuthManager.kt | 80 +----- .../local/security/BiometricKeyManager.kt | 226 +++++++++++++++ .../data/local/security/MasterKeyManager.kt | 231 +++------------- .../repository/BiometricRepositoryImpl.kt | 70 +++-- .../repository/CredentialRepositoryImpl.kt | 9 + .../domain/repository/BiometricRepository.kt | 29 +- .../domain/repository/CredentialRepository.kt | 2 + .../credential/CredentialStoreFacade.kt | 261 ++++++------------ .../credential/EnableBiometricUseCase.kt | 22 +- .../credential/UnlockWithBiometricUseCase.kt | 28 +- .../biometric/BiometricAuthenticator.kt | 107 +++++++ .../ui/screen/credentials/CredentialScreen.kt | 14 +- .../credentials/CredentialStoreViewModel.kt | 56 ++-- .../credentials/MasterPasswordScreen.kt | 42 ++- .../credentials/MasterPasswordViewModel.kt | 142 ++++------ .../ui/screen/credentials/PasswordDialogs.kt | 10 +- .../fuwagit/ui/screen/main/AppNavHost.kt | 11 +- .../fuwagit/ui/screen/myrepos/CloneContent.kt | 11 +- .../ui/screen/onboarding/OnboardingScreen.kt | 6 +- .../screen/onboarding/OnboardingViewModel.kt | 66 +++-- .../ui/screen/settings/SettingsScreen.kt | 49 ++-- .../ui/screen/settings/SettingsViewModel.kt | 6 + gradle/libs.versions.toml | 14 +- 24 files changed, 805 insertions(+), 689 deletions(-) create mode 100644 app/src/main/java/jamgmilk/fuwagit/data/local/security/BiometricKeyManager.kt create mode 100644 app/src/main/java/jamgmilk/fuwagit/ui/components/biometric/BiometricAuthenticator.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 818e9a0..b5ed792 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -104,7 +104,7 @@ dependencies { implementation(libs.androidx.security.crypto) implementation(libs.androidx.navigation.compose) implementation(libs.androidx.navigation.runtime.ktx) - implementation(libs.androidx.biometric) + implementation(libs.androidx.biometric.compose) implementation(libs.androidx.appcompat) implementation(libs.androidx.datastore.preferences) diff --git a/app/src/main/java/jamgmilk/fuwagit/data/biometric/BiometricAuthManager.kt b/app/src/main/java/jamgmilk/fuwagit/data/biometric/BiometricAuthManager.kt index 9c20cc5..acfc0f8 100644 --- a/app/src/main/java/jamgmilk/fuwagit/data/biometric/BiometricAuthManager.kt +++ b/app/src/main/java/jamgmilk/fuwagit/data/biometric/BiometricAuthManager.kt @@ -1,79 +1,25 @@ package jamgmilk.fuwagit.data.biometric -import android.util.Log -import androidx.biometric.BiometricPrompt -import androidx.core.content.ContextCompat -import androidx.fragment.app.FragmentActivity +import android.content.Context +import androidx.biometric.BiometricManager import javax.inject.Inject import javax.inject.Singleton @Singleton -class BiometricAuthManager @Inject constructor( -) { - companion object { - private const val TAG = "BiometricAuthManager" - } - sealed class AuthResult { - data object Cancelled : AuthResult() - data class Error(val code: Int, val message: String) : AuthResult() - data class SuccessWithCrypto(val result: BiometricPrompt.AuthenticationResult) : AuthResult() - } +class BiometricAuthManager @Inject constructor() { - enum class AuthAction { - ENABLE, - UNLOCK + sealed class AuthAvailability { + data object Available : AuthAvailability() + data object NotAvailable : AuthAvailability() + data object NotEnrolled : AuthAvailability() } - fun authenticateWithCrypto( - activity: FragmentActivity, - action: AuthAction, - cryptoObject: BiometricPrompt.CryptoObject?, - onResult: (AuthResult) -> Unit - ) { - val executor = ContextCompat.getMainExecutor(activity) - - val callback = object : BiometricPrompt.AuthenticationCallback() { - override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { - onResult(AuthResult.SuccessWithCrypto(result)) - } - - override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { - when { - errorCode == BiometricPrompt.ERROR_USER_CANCELED || - errorCode == BiometricPrompt.ERROR_NEGATIVE_BUTTON -> { - onResult(AuthResult.Cancelled) - } - else -> { - onResult(AuthResult.Error(errorCode, errString.toString())) - } - } - } - - override fun onAuthenticationFailed() { - Log.w(TAG, "Biometric authentication failed") - } - } - - val biometricPrompt = BiometricPrompt(activity, executor, callback) - - val promptInfo = when (action) { - AuthAction.ENABLE -> BiometricPrompt.PromptInfo.Builder() - .setTitle("Enable Biometric Unlock") - .setSubtitle("Use your fingerprint to quickly access credentials") - .setNegativeButtonText("Cancel") - .build() - - AuthAction.UNLOCK -> BiometricPrompt.PromptInfo.Builder() - .setTitle("Unlock Credentials") - .setSubtitle("Use your fingerprint to access credentials") - .setNegativeButtonText("Use Password") - .build() - } - - if (cryptoObject != null) { - biometricPrompt.authenticate(promptInfo, cryptoObject) - } else { - biometricPrompt.authenticate(promptInfo) + fun canAuthenticate(context: Context): AuthAvailability { + val biometricManager = BiometricManager.from(context) + return when (biometricManager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG)) { + BiometricManager.BIOMETRIC_SUCCESS -> AuthAvailability.Available + BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> AuthAvailability.NotEnrolled + else -> AuthAvailability.NotAvailable } } } diff --git a/app/src/main/java/jamgmilk/fuwagit/data/local/security/BiometricKeyManager.kt b/app/src/main/java/jamgmilk/fuwagit/data/local/security/BiometricKeyManager.kt new file mode 100644 index 0000000..94f21fd --- /dev/null +++ b/app/src/main/java/jamgmilk/fuwagit/data/local/security/BiometricKeyManager.kt @@ -0,0 +1,226 @@ +package jamgmilk.fuwagit.data.local.security + +import android.content.Context +import android.util.Base64 +import androidx.biometric.BiometricManager +import androidx.biometric.BiometricPrompt +import androidx.core.content.ContextCompat +import androidx.fragment.app.FragmentActivity +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext +import java.security.KeyStore +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +@Singleton +class BiometricKeyManager @Inject constructor( + @ApplicationContext private val context: Context +) { + companion object { + private const val KEYSTORE_PROVIDER = "AndroidKeyStore" + private const val KEY_NAME = "fuwa_git_biometric_key" + const val GCM_TAG_LENGTH = 128 + const val GCM_IV_LENGTH = 12 + } + + private val keyStore: KeyStore = KeyStore.getInstance(KEYSTORE_PROVIDER).apply { + load(null) + } + + fun canAuthenticate(): Int { + val biometricManager = BiometricManager.from(context) + return biometricManager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG) + } + + suspend fun createBiometricKey(): Result = withContext(Dispatchers.IO) { + runCatching { + if (keyStore.containsAlias(KEY_NAME)) { + keyStore.deleteEntry(KEY_NAME) + } + + val keyGenerator = KeyGenerator.getInstance( + android.security.keystore.KeyProperties.KEY_ALGORITHM_AES, + KEYSTORE_PROVIDER + ) + + val builder = android.security.keystore.KeyGenParameterSpec.Builder( + KEY_NAME, + android.security.keystore.KeyProperties.PURPOSE_ENCRYPT or android.security.keystore.KeyProperties.PURPOSE_DECRYPT + ) + .setBlockModes(android.security.keystore.KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(android.security.keystore.KeyProperties.ENCRYPTION_PADDING_NONE) + .setKeySize(256) + .setUserAuthenticationRequired(true) + .setInvalidatedByBiometricEnrollment(true) + .setUserAuthenticationValidityDurationSeconds(-1) + + keyGenerator.init(builder.build()) + keyGenerator.generateKey() + Unit + } + } + + suspend fun encryptMasterKey( + activity: FragmentActivity, + masterKey: SecretKey, + title: String, + subtitle: String, + negativeButtonText: String + ): Result = suspendCancellableCoroutine { continuation -> + val executor = ContextCompat.getMainExecutor(activity) + + val callback = object : BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { + try { + val cipher = result.cryptoObject?.cipher + if (cipher != null) { + val encrypted = cipher.doFinal(masterKey.encoded) + val iv = cipher.iv + val combined = iv + encrypted + val encoded = Base64.encodeToString(combined, Base64.NO_WRAP) + continuation.resume(Result.success(encoded)) + } else { + continuation.resumeWithException(Exception("Cipher not available")) + } + } catch (e: Exception) { + continuation.resumeWithException(e) + } + } + + override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { + continuation.resumeWithException(BiometricError(errorCode, errString.toString())) + } + + override fun onAuthenticationFailed() { + // Don't resume - let user retry + } + } + + val biometricPrompt = BiometricPrompt(activity, executor, callback) + + val cipher = try { + val key = keyStore.getKey(KEY_NAME, null) as? SecretKey + ?: throw Exception("Key not found") + val cipherInstance = Cipher.getInstance("AES/GCM/NoPadding") + cipherInstance.init(Cipher.ENCRYPT_MODE, key) + cipherInstance + } catch (e: Exception) { + continuation.resumeWithException(e) + return@suspendCancellableCoroutine + } + + val promptInfo = BiometricPrompt.PromptInfo.Builder() + .setTitle(title) + .setSubtitle(subtitle) + .setNegativeButtonText(negativeButtonText) + .setAllowedAuthenticators(BiometricManager.Authenticators.BIOMETRIC_STRONG) + .build() + + biometricPrompt.authenticate(promptInfo, BiometricPrompt.CryptoObject(cipher)) + + continuation.invokeOnCancellation { + biometricPrompt.cancelAuthentication() + } + } + + suspend fun decryptMasterKey( + activity: FragmentActivity, + encryptedMasterKey: String, + title: String, + subtitle: String, + negativeButtonText: String + ): Result = suspendCancellableCoroutine { continuation -> + val executor = ContextCompat.getMainExecutor(activity) + + val combined = Base64.decode(encryptedMasterKey, Base64.NO_WRAP) + val iv = combined.copyOfRange(0, GCM_IV_LENGTH) + val encrypted = combined.copyOfRange(GCM_IV_LENGTH, combined.size) + + val callback = object : BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { + try { + val cipher = result.cryptoObject?.cipher + if (cipher != null) { + val decrypted = cipher.doFinal(encrypted) + val secretKey = javax.crypto.spec.SecretKeySpec(decrypted, "AES") + continuation.resume(Result.success(secretKey)) + } else { + continuation.resumeWithException(Exception("Cipher not available")) + } + } catch (e: Exception) { + continuation.resumeWithException(e) + } + } + + override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { + continuation.resumeWithException(BiometricError(errorCode, errString.toString())) + } + + override fun onAuthenticationFailed() { + // Don't resume - let user retry + } + } + + val biometricPrompt = BiometricPrompt(activity, executor, callback) + + val cipher = try { + val key = keyStore.getKey(KEY_NAME, null) as? SecretKey + ?: throw Exception("Key not found") + val cipherInstance = Cipher.getInstance("AES/GCM/NoPadding") + cipherInstance.init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(GCM_TAG_LENGTH, iv)) + cipherInstance + } catch (e: Exception) { + continuation.resumeWithException(e) + return@suspendCancellableCoroutine + } + + val promptInfo = BiometricPrompt.PromptInfo.Builder() + .setTitle(title) + .setSubtitle(subtitle) + .setNegativeButtonText(negativeButtonText) + .setAllowedAuthenticators(BiometricManager.Authenticators.BIOMETRIC_STRONG) + .build() + + biometricPrompt.authenticate(promptInfo, BiometricPrompt.CryptoObject(cipher)) + + continuation.invokeOnCancellation { + biometricPrompt.cancelAuthentication() + } + } + + fun getKey(): SecretKey? { + return try { + keyStore.getKey(KEY_NAME, null) as? SecretKey + } catch (e: Exception) { + null + } + } + + fun hasBiometricKey(): Boolean { + return try { + keyStore.containsAlias(KEY_NAME) + } catch (e: Exception) { + false + } + } + + suspend fun deleteBiometricKey() = withContext(Dispatchers.IO) { + try { + if (keyStore.containsAlias(KEY_NAME)) { + keyStore.deleteEntry(KEY_NAME) + } + } catch (e: Exception) { + // Ignore + } + } +} + +class BiometricError(val errorCode: Int, override val message: String) : Exception(message) diff --git a/app/src/main/java/jamgmilk/fuwagit/data/local/security/MasterKeyManager.kt b/app/src/main/java/jamgmilk/fuwagit/data/local/security/MasterKeyManager.kt index 147aae8..d70c2d5 100644 --- a/app/src/main/java/jamgmilk/fuwagit/data/local/security/MasterKeyManager.kt +++ b/app/src/main/java/jamgmilk/fuwagit/data/local/security/MasterKeyManager.kt @@ -6,12 +6,9 @@ import android.security.keystore.KeyGenParameterSpec import android.security.keystore.KeyProperties import android.util.Base64 import android.util.Log -import androidx.biometric.BiometricPrompt import androidx.core.content.edit -import androidx.fragment.app.FragmentActivity import dagger.hilt.android.qualifiers.ApplicationContext import jamgmilk.fuwagit.BuildConfig -import jamgmilk.fuwagit.data.biometric.BiometricAuthManager import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import java.security.KeyStore @@ -39,29 +36,23 @@ private class SecureDerivedKey(private val spec: PBEKeySpec, private val keyByte @Singleton class MasterKeyManager @Inject constructor( @ApplicationContext private val context: Context, - private val biometricAuthManager: BiometricAuthManager + private val secureCredentialStore: SecureCredentialStore ) { companion object { private const val TAG = "MasterKeyManager" - private const val KEYSTORE_BIOMETRIC_ALIAS = "fuwagit_biometric_key" private const val PREFS_NAME = "credential_key_store" private const val KEY_ENCRYPTED_MASTER = "encrypted_master_key" private const val KEY_SALT = "salt" private const val KEY_BIOMETRIC_ENABLED = "biometric_enabled" - private const val KEY_BIOMETRIC_ENCRYPTED_MASTER = "biometric_encrypted_master" - private const val KEY_BIOMETRIC_IV = "biometric_iv" - private const val PBKDF2_ITERATIONS = 100000 private const val KEY_PASSWORD_HINT = "password_hint" + private const val PBKDF2_ITERATIONS = 100000 + private const val KEY_ENCRYPTED_BIOMETRIC_MASTER = "encrypted_biometric_master_key" private const val KEY_LENGTH = 256 private const val GCM_TAG_LENGTH = 128 private const val GCM_IV_LENGTH = 12 } - private val keyStore: KeyStore by lazy { - KeyStore.getInstance("AndroidKeyStore").apply { load(null) } - } - private val prefs: SharedPreferences by lazy { context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) } @@ -161,153 +152,38 @@ class MasterKeyManager @Inject constructor( } } - fun enableBiometric( - activity: FragmentActivity, - masterKey: SecretKey, - onSuccess: () -> Unit, - onError: (String) -> Unit - ) { - if (BuildConfig.DEBUG) Log.d(TAG, "enableBiometric: starting") - try { - if (keyStore.containsAlias(KEYSTORE_BIOMETRIC_ALIAS)) { - if (BuildConfig.DEBUG) Log.d(TAG, "enableBiometric: deleting existing key") - keyStore.deleteEntry(KEYSTORE_BIOMETRIC_ALIAS) - } - - if (BuildConfig.DEBUG) Log.d(TAG, "enableBiometric: creating biometric key") - createBiometricKey() - - if (BuildConfig.DEBUG) Log.d(TAG, "enableBiometric: creating cipher") - val cipher = createBiometricCipher() - - biometricAuthManager.authenticateWithCrypto( - activity = activity, - action = BiometricAuthManager.AuthAction.ENABLE, - cryptoObject = BiometricPrompt.CryptoObject(cipher), - onResult = { result -> - when (result) { - is BiometricAuthManager.AuthResult.SuccessWithCrypto -> { - if (BuildConfig.DEBUG) Log.d(TAG, "enableBiometric: onAuthenticationSucceeded") - result.result.cryptoObject?.cipher?.let { c -> - try { - val encrypted = c.doFinal(masterKey.encoded) - val actualIv = c.iv - prefs.edit { - putString(KEY_BIOMETRIC_ENCRYPTED_MASTER, - Base64.encodeToString(encrypted, Base64.NO_WRAP)) - putString(KEY_BIOMETRIC_IV, - Base64.encodeToString(actualIv, Base64.NO_WRAP)) - putBoolean(KEY_BIOMETRIC_ENABLED, true) - commit() - } - if (BuildConfig.DEBUG) Log.d(TAG, "enableBiometric: success saved to prefs with IV size ${actualIv?.size}") - onSuccess() - } catch (e: Exception) { - Log.e(TAG, "enableBiometric: doFinal failed: ${e.message}") - onError("Encryption failed: ${e.message}") - } - } ?: run { - Log.e(TAG, "enableBiometric: Cipher is null") - onError("Cipher is null") - } - } - is BiometricAuthManager.AuthResult.Error -> { - Log.e(TAG, "enableBiometric: onAuthenticationError: ${result.code} - ${result.message}") - onError(result.message) - } - is BiometricAuthManager.AuthResult.Cancelled -> { - if (BuildConfig.DEBUG) Log.d(TAG, "enableBiometric: cancelled") - } - } + suspend fun enableBiometric(masterKey: SecretKey): Result { + return withContext(Dispatchers.IO) { + runCatching { + secureCredentialStore.cacheMasterKey(masterKey) + prefs.edit { + putBoolean(KEY_BIOMETRIC_ENABLED, true) } - ) - } catch (e: Exception) { - Log.e(TAG, "enableBiometric: exception: ${e.message}", e) - onError(e.message ?: "Failed to enable biometric") + } } } - fun unlockWithBiometric( - activity: FragmentActivity, - onSuccess: (SecretKey) -> Unit, - onError: (String) -> Unit - ) { - if (BuildConfig.DEBUG) Log.d(TAG, "unlockWithBiometric: starting") - try { - val ivBase64 = prefs.getString(KEY_BIOMETRIC_IV, null) - if (ivBase64 == null) { - Log.e(TAG, "unlockWithBiometric: Biometric not set up") - onError("Biometric not set up") - return - } - - val iv = Base64.decode(ivBase64, Base64.NO_WRAP) - val cipher = createBiometricCipherForDecrypt(iv) - - biometricAuthManager.authenticateWithCrypto( - activity = activity, - action = BiometricAuthManager.AuthAction.UNLOCK, - cryptoObject = BiometricPrompt.CryptoObject(cipher), - onResult = { result -> - when (result) { - is BiometricAuthManager.AuthResult.SuccessWithCrypto -> { - if (BuildConfig.DEBUG) Log.d(TAG, "unlockWithBiometric: onAuthenticationSucceeded") - result.result.cryptoObject?.cipher?.let { c -> - val encryptedBase64 = prefs.getString(KEY_BIOMETRIC_ENCRYPTED_MASTER, null) - if (encryptedBase64 != null) { - try { - val encrypted = Base64.decode(encryptedBase64, Base64.NO_WRAP) - val masterKeyBytes = c.doFinal(encrypted) - if (BuildConfig.DEBUG) Log.d(TAG, "unlockWithBiometric: success") - onSuccess(SecretKeySpec(masterKeyBytes, "AES")) - } catch (e: Exception) { - Log.e(TAG, "unlockWithBiometric: doFinal failed: ${e.message}") - if (e is javax.crypto.AEADBadTagException) { - Log.w(TAG, "unlockWithBiometric: AEADBadTagException, disabling biometric") - disableBiometric() - onError("Biometric data corrupted. Please re-enable in settings.") - } else { - onError("Decryption failed: ${e.message}") - } - } - } else { - Log.e(TAG, "unlockWithBiometric: No biometric data found") - onError("No biometric data found") - } - } ?: run { - Log.e(TAG, "unlockWithBiometric: Cipher is null") - onError("Cipher is null") - } - } - is BiometricAuthManager.AuthResult.Error -> { - Log.e(TAG, "unlockWithBiometric: onAuthenticationError: ${result.code} - ${result.message}") - onError(result.message) - } - is BiometricAuthManager.AuthResult.Cancelled -> { - if (BuildConfig.DEBUG) Log.d(TAG, "unlockWithBiometric: cancelled") - } - } + suspend fun unlockWithBiometric(): Result { + return withContext(Dispatchers.IO) { + try { + val cachedKey = secureCredentialStore.getCachedMasterKey() + if (cachedKey != null) { + Result.success(cachedKey) + } else { + Result.failure(Exception("Biometric session expired. Please enter your password.")) } - ) - } catch (e: Exception) { - Log.e(TAG, "unlockWithBiometric: exception: ${e.message}", e) - onError(e.message ?: "Failed to unlock with biometric") + } catch (e: Exception) { + Result.failure(e) + } } } - fun disableBiometric() { - prefs.edit { - remove(KEY_BIOMETRIC_ENCRYPTED_MASTER) - remove(KEY_BIOMETRIC_IV) - putBoolean(KEY_BIOMETRIC_ENABLED, false) - apply() - } - try { - if (keyStore.containsAlias(KEYSTORE_BIOMETRIC_ALIAS)) { - keyStore.deleteEntry(KEYSTORE_BIOMETRIC_ALIAS) + suspend fun disableBiometric() { + withContext(Dispatchers.IO) { + secureCredentialStore.clearCachedMasterKey() + prefs.edit { + putBoolean(KEY_BIOMETRIC_ENABLED, false) } - } catch (e: Exception) { - Log.e(TAG, "disableBiometric: failed to delete key entry", e) } } @@ -319,6 +195,26 @@ class MasterKeyManager @Inject constructor( return prefs.getString(KEY_PASSWORD_HINT, null) } + fun hasEncryptedMasterKey(): Boolean { + return prefs.contains(KEY_ENCRYPTED_BIOMETRIC_MASTER) + } + + fun getEncryptedMasterKey(): String? { + return prefs.getString(KEY_ENCRYPTED_BIOMETRIC_MASTER, null) + } + + fun saveEncryptedMasterKey(encryptedKey: String) { + prefs.edit { putString(KEY_ENCRYPTED_BIOMETRIC_MASTER, encryptedKey) } + } + + fun clearEncryptedMasterKey() { + prefs.edit { remove(KEY_ENCRYPTED_BIOMETRIC_MASTER) } + } + + fun setBiometricEnabledInternal(enabled: Boolean) { + prefs.edit { putBoolean(KEY_BIOMETRIC_ENABLED, enabled) } + } + private fun generateRandomKey(): SecretKey { val keyGenerator = KeyGenerator.getInstance("AES") keyGenerator.init(KEY_LENGTH, SecureRandom()) @@ -339,39 +235,4 @@ class MasterKeyManager @Inject constructor( cipher.init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(GCM_TAG_LENGTH, iv)) return cipher.doFinal(encrypted) } - - private fun createBiometricKey() { - val keyGenerator = KeyGenerator.getInstance( - KeyProperties.KEY_ALGORITHM_AES, - "AndroidKeyStore" - ) - - val spec = KeyGenParameterSpec.Builder( - KEYSTORE_BIOMETRIC_ALIAS, - KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT - ) - .setBlockModes(KeyProperties.BLOCK_MODE_GCM) - .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) - .setKeySize(KEY_LENGTH) - .setUserAuthenticationRequired(true) - .setInvalidatedByBiometricEnrollment(true) - .build() - - keyGenerator.init(spec) - keyGenerator.generateKey() - } - - private fun createBiometricCipher(): Cipher { - val secretKey = keyStore.getKey(KEYSTORE_BIOMETRIC_ALIAS, null) as SecretKey - val cipher = Cipher.getInstance("AES/GCM/NoPadding") - cipher.init(Cipher.ENCRYPT_MODE, secretKey) - return cipher - } - - private fun createBiometricCipherForDecrypt(iv: ByteArray): Cipher { - val secretKey = keyStore.getKey(KEYSTORE_BIOMETRIC_ALIAS, null) as SecretKey - val cipher = Cipher.getInstance("AES/GCM/NoPadding") - cipher.init(Cipher.DECRYPT_MODE, secretKey, GCMParameterSpec(GCM_TAG_LENGTH, iv)) - return cipher - } } diff --git a/app/src/main/java/jamgmilk/fuwagit/data/repository/BiometricRepositoryImpl.kt b/app/src/main/java/jamgmilk/fuwagit/data/repository/BiometricRepositoryImpl.kt index 0715abc..d5b56b8 100644 --- a/app/src/main/java/jamgmilk/fuwagit/data/repository/BiometricRepositoryImpl.kt +++ b/app/src/main/java/jamgmilk/fuwagit/data/repository/BiometricRepositoryImpl.kt @@ -1,7 +1,9 @@ package jamgmilk.fuwagit.data.repository +import androidx.biometric.BiometricManager import androidx.fragment.app.FragmentActivity import jamgmilk.fuwagit.core.result.AppResult +import jamgmilk.fuwagit.data.local.security.BiometricKeyManager import jamgmilk.fuwagit.data.local.security.MasterKeyManager import jamgmilk.fuwagit.domain.repository.BiometricRepository import javax.crypto.SecretKey @@ -10,42 +12,66 @@ import javax.inject.Singleton @Singleton class BiometricRepositoryImpl @Inject constructor( - private val masterKeyManager: MasterKeyManager + private val masterKeyManager: MasterKeyManager, + private val biometricKeyManager: BiometricKeyManager ) : BiometricRepository { - override fun enableBiometric( + override fun canAuthenticate(): Boolean { + return biometricKeyManager.canAuthenticate() == BiometricManager.BIOMETRIC_SUCCESS + } + + override suspend fun enableBiometric( activity: FragmentActivity, masterKey: SecretKey, - onSuccess: () -> Unit, - onError: (String) -> Unit - ) { - masterKeyManager.enableBiometric( - activity = activity, - masterKey = masterKey, - onSuccess = onSuccess, - onError = onError - ) + title: String, + subtitle: String, + negativeButtonText: String + ): AppResult { + return AppResult.catching { + biometricKeyManager.createBiometricKey().getOrThrow() + + val encryptedKey = biometricKeyManager.encryptMasterKey( + activity = activity, + masterKey = masterKey, + title = title, + subtitle = subtitle, + negativeButtonText = negativeButtonText + ).getOrThrow() + + masterKeyManager.saveEncryptedMasterKey(encryptedKey) + masterKeyManager.setBiometricEnabledInternal(true) + } } - override fun unlockWithBiometric( + override suspend fun unlockWithBiometric( activity: FragmentActivity, - onSuccess: (SecretKey) -> Unit, - onError: (String) -> Unit - ) { - masterKeyManager.unlockWithBiometric( - activity = activity, - onSuccess = onSuccess, - onError = onError - ) + title: String, + subtitle: String, + negativeButtonText: String + ): AppResult { + val encryptedKey = masterKeyManager.getEncryptedMasterKey() + ?: return AppResult.Error(jamgmilk.fuwagit.core.result.AppException.BiometricError("No encrypted key found")) + + return AppResult.catching { + biometricKeyManager.decryptMasterKey( + activity = activity, + encryptedMasterKey = encryptedKey, + title = title, + subtitle = subtitle, + negativeButtonText = negativeButtonText + ).getOrThrow() + } } override fun isBiometricEnabled(): Boolean { return masterKeyManager.isBiometricEnabled() } - override fun disableBiometric(): AppResult { + override suspend fun disableBiometric(): AppResult { return AppResult.catching { - masterKeyManager.disableBiometric() + biometricKeyManager.deleteBiometricKey() + masterKeyManager.clearEncryptedMasterKey() + masterKeyManager.setBiometricEnabledInternal(false) } } diff --git a/app/src/main/java/jamgmilk/fuwagit/data/repository/CredentialRepositoryImpl.kt b/app/src/main/java/jamgmilk/fuwagit/data/repository/CredentialRepositoryImpl.kt index d798dc5..20524d8 100644 --- a/app/src/main/java/jamgmilk/fuwagit/data/repository/CredentialRepositoryImpl.kt +++ b/app/src/main/java/jamgmilk/fuwagit/data/repository/CredentialRepositoryImpl.kt @@ -178,4 +178,13 @@ class CredentialRepositoryImpl @Inject constructor( masterKeyManager.disableBiometric() } } + + override suspend fun changeMasterPassword(oldPassword: String, newPassword: String, hint: String?): AppResult { + return AppResult.catching { + masterKeyManager.changeMasterPassword(oldPassword, newPassword).getOrThrow() + if (hint != null) { + masterKeyManager.setPasswordHint(hint) + } + } + } } diff --git a/app/src/main/java/jamgmilk/fuwagit/domain/repository/BiometricRepository.kt b/app/src/main/java/jamgmilk/fuwagit/domain/repository/BiometricRepository.kt index 6c2b6f4..a0feb1a 100644 --- a/app/src/main/java/jamgmilk/fuwagit/domain/repository/BiometricRepository.kt +++ b/app/src/main/java/jamgmilk/fuwagit/domain/repository/BiometricRepository.kt @@ -10,23 +10,32 @@ import javax.crypto.SecretKey */ interface BiometricRepository { /** - * Enable biometric authentication by encrypting the master key. + * Check if biometric authentication can be used. */ - fun enableBiometric( + fun canAuthenticate(): Boolean + + /** + * Enable biometric authentication by encrypting and storing the master key. + * Uses BiometricPrompt to secure the encryption. + */ + suspend fun enableBiometric( activity: FragmentActivity, masterKey: SecretKey, - onSuccess: () -> Unit, - onError: (String) -> Unit - ) + title: String, + subtitle: String, + negativeButtonText: String + ): AppResult /** * Unlock using biometric authentication, returning the decrypted master key. + * Uses BiometricPrompt to secure the decryption. */ - fun unlockWithBiometric( + suspend fun unlockWithBiometric( activity: FragmentActivity, - onSuccess: (SecretKey) -> Unit, - onError: (String) -> Unit - ) + title: String, + subtitle: String, + negativeButtonText: String + ): AppResult /** * Check if biometric authentication is enabled. @@ -36,7 +45,7 @@ interface BiometricRepository { /** * Disable biometric authentication. */ - fun disableBiometric(): AppResult + suspend fun disableBiometric(): AppResult /** * Check if master password is set. diff --git a/app/src/main/java/jamgmilk/fuwagit/domain/repository/CredentialRepository.kt b/app/src/main/java/jamgmilk/fuwagit/domain/repository/CredentialRepository.kt index 8f0dd9b..0700d51 100644 --- a/app/src/main/java/jamgmilk/fuwagit/domain/repository/CredentialRepository.kt +++ b/app/src/main/java/jamgmilk/fuwagit/domain/repository/CredentialRepository.kt @@ -59,4 +59,6 @@ interface CredentialRepository { suspend fun importCredentials(jsonData: String): AppResult suspend fun disableBiometric(): AppResult + + suspend fun changeMasterPassword(oldPassword: String, newPassword: String, hint: String?): AppResult } diff --git a/app/src/main/java/jamgmilk/fuwagit/domain/usecase/credential/CredentialStoreFacade.kt b/app/src/main/java/jamgmilk/fuwagit/domain/usecase/credential/CredentialStoreFacade.kt index cdd3a4c..b3b11be 100644 --- a/app/src/main/java/jamgmilk/fuwagit/domain/usecase/credential/CredentialStoreFacade.kt +++ b/app/src/main/java/jamgmilk/fuwagit/domain/usecase/credential/CredentialStoreFacade.kt @@ -2,218 +2,111 @@ package jamgmilk.fuwagit.domain.usecase.credential import androidx.fragment.app.FragmentActivity import jamgmilk.fuwagit.core.result.AppResult -import jamgmilk.fuwagit.core.result.AppException import jamgmilk.fuwagit.domain.model.credential.HttpsCredential import jamgmilk.fuwagit.domain.model.credential.SshKey +import jamgmilk.fuwagit.domain.repository.CredentialRepository import javax.inject.Inject class CredentialStoreFacade @Inject constructor( - private val sessionFacade: CredentialSessionFacade, - private val masterPasswordFacade: MasterPasswordFacade, - private val biometricAuthFacade: BiometricAuthFacade, - private val credentialRepository: jamgmilk.fuwagit.domain.repository.CredentialRepository + private val credentialRepository: CredentialRepository, + private val enableBiometricUseCase: EnableBiometricUseCase, + private val unlockWithBiometricUseCase: UnlockWithBiometricUseCase ) { + suspend fun enableBiometric( + activity: FragmentActivity, + title: String, + subtitle: String, + negativeButtonText: String + ): AppResult { + return enableBiometricUseCase(activity, title, subtitle, negativeButtonText) + } - // ========== 会话状态管理 ========== - - fun isMasterPasswordSet(): Boolean = credentialRepository.isMasterPasswordSet() - - fun isBiometricEnabled(): Boolean = credentialRepository.isBiometricEnabled() - - suspend fun isUnlocked(): Boolean = credentialRepository.isUnlocked() - - fun getMasterPasswordHint(): String? = credentialRepository.getMasterPasswordHint() - - fun lock() = credentialRepository.lock() - - // ========== 密码管理 ========== - - suspend fun setupMasterPassword( - password: String, - confirmPassword: String, - hint: String? - ): AppResult = masterPasswordFacade.setupMasterPassword(password, confirmPassword, hint) - - suspend fun unlockWithPassword(password: String): AppResult = - masterPasswordFacade.unlockWithPassword(password) - - suspend fun changeMasterPassword( - oldPassword: String, - newPassword: String, - hint: String? - ): AppResult = masterPasswordFacade.changePassword(oldPassword, newPassword, hint) - - // ========== HTTPS 凭证管理 ========== - - suspend fun getHttpsCredentials(): AppResult> = - sessionFacade.getHttpsCredentials() - - suspend fun addHttpsCredential(host: String, username: String, password: String): AppResult = - sessionFacade.addHttpsCredential(host, username, password) - - suspend fun updateHttpsCredential( - uuid: String, - host: String?, - username: String?, - password: String? - ): AppResult = sessionFacade.updateHttpsCredential(uuid, host, username, password) - - suspend fun deleteHttpsCredential(uuid: String): AppResult = - sessionFacade.deleteHttpsCredential(uuid) - - suspend fun getHttpsPassword(uuid: String): AppResult = - sessionFacade.getHttpsPassword(uuid) - - // ========== SSH 密钥管理 ========== - - suspend fun getSshKeys(): AppResult> = sessionFacade.getSshKeys() - - suspend fun addSshKey( - name: String, - type: String, - publicKey: String, - privateKey: String, - passphrase: String?, - fingerprint: String - ): AppResult = sessionFacade.addSshKey(name, type, publicKey, privateKey, passphrase, fingerprint) - - suspend fun deleteSshKey(uuid: String): AppResult = sessionFacade.deleteSshKey(uuid) - - suspend fun getSshPrivateKey(uuid: String): AppResult = sessionFacade.getSshPrivateKey(uuid) - - suspend fun getSshPassphrase(uuid: String): AppResult = sessionFacade.getSshPassphrase(uuid) - - // ========== 导入/导出 ========== - - suspend fun exportCredentials(): AppResult = sessionFacade.exportCredentials() + suspend fun unlockWithBiometric( + activity: FragmentActivity, + title: String, + subtitle: String, + negativeButtonText: String + ): AppResult { + return unlockWithBiometricUseCase(activity, title, subtitle, negativeButtonText) + } - suspend fun importCredentials(jsonData: String): AppResult = sessionFacade.importCredentials(jsonData) + suspend fun setupMasterPassword(password: String, confirmPassword: String, hint: String?): AppResult { + return credentialRepository.setupMasterPassword(password, hint) + } - // ========== 生物识别认证 ========== + suspend fun changeMasterPassword(oldPassword: String, newPassword: String, hint: String?): AppResult { + return credentialRepository.changeMasterPassword(oldPassword, newPassword, hint) + } - suspend fun enableBiometric( - activity: FragmentActivity, - onResult: (AppResult) -> Unit - ) = biometricAuthFacade.enableBiometric(activity, onResult) + fun isMasterPasswordSet(): Boolean { + return credentialRepository.isMasterPasswordSet() + } - fun unlockWithBiometric( - activity: FragmentActivity, - onResult: (AppResult) -> Unit - ) = biometricAuthFacade.unlockWithBiometric(activity, onResult) + fun isBiometricEnabled(): Boolean { + return credentialRepository.isBiometricEnabled() + } - suspend fun disableBiometric(): AppResult = - credentialRepository.disableBiometric() -} + fun getMasterPasswordHint(): String? { + return credentialRepository.getMasterPasswordHint() + } -/** - * Facade for credential session operations (HTTPS + SSH + Import/Export). - * 整合 HTTPS、SSH 和导入/导出相关的用例。 - */ -class CredentialSessionFacade @Inject constructor( - private val getHttpsCredentialsUseCase: GetHttpsCredentialsUseCase, - private val addHttpsCredentialUseCase: AddHttpsCredentialUseCase, - private val updateHttpsCredentialUseCase: UpdateHttpsCredentialUseCase, - private val deleteHttpsCredentialUseCase: DeleteHttpsCredentialUseCase, - private val getHttpsPasswordUseCase: GetHttpsPasswordUseCase, - private val getSshKeysUseCase: GetSshKeysUseCase, - private val addSshKeyUseCase: AddSshKeyUseCase, - private val deleteSshKeyUseCase: DeleteSshKeyUseCase, - private val getSshPrivateKeyUseCase: GetSshPrivateKeyUseCase, - private val getSshPassphraseUseCase: GetSshPassphraseUseCase, - private val exportCredentialsUseCase: ExportCredentialsUseCase, - private val importCredentialsUseCase: ImportCredentialsUseCase -) { - suspend fun getHttpsCredentials(): AppResult> = getHttpsCredentialsUseCase() + suspend fun disableBiometric(): AppResult { + return credentialRepository.disableBiometric() + } - suspend fun addHttpsCredential(host: String, username: String, password: String): AppResult = - addHttpsCredentialUseCase(host, username, password) + suspend fun unlockWithPassword(password: String): AppResult { + return credentialRepository.unlockWithPassword(password) + } - suspend fun updateHttpsCredential( - uuid: String, - host: String?, - username: String?, - password: String? - ): AppResult = updateHttpsCredentialUseCase(uuid, host, username, password) + suspend fun isUnlocked(): Boolean { + return credentialRepository.isUnlocked() + } - suspend fun deleteHttpsCredential(uuid: String): AppResult = deleteHttpsCredentialUseCase(uuid) + fun lock() { + credentialRepository.lock() + } - suspend fun getHttpsPassword(uuid: String): AppResult = getHttpsPasswordUseCase(uuid) + suspend fun getHttpsCredentials(): AppResult> { + return credentialRepository.getAllHttpsCredentials() + } - suspend fun getSshKeys(): AppResult> = getSshKeysUseCase() + suspend fun addHttpsCredential(host: String, username: String, password: String): AppResult { + return credentialRepository.addHttpsCredential(host, username, password).map { } + } - suspend fun addSshKey( - name: String, - type: String, - publicKey: String, - privateKey: String, - passphrase: String?, - fingerprint: String - ): AppResult = addSshKeyUseCase(name, type, publicKey, privateKey, passphrase, fingerprint) + suspend fun deleteHttpsCredential(uuid: String): AppResult { + return credentialRepository.deleteHttpsCredential(uuid) + } - suspend fun deleteSshKey(uuid: String): AppResult = deleteSshKeyUseCase(uuid) + suspend fun getSshKeys(): AppResult> { + return credentialRepository.getAllSshKeys() + } - suspend fun getSshPrivateKey(uuid: String): AppResult = getSshPrivateKeyUseCase(uuid) + suspend fun addSshKey(name: String, type: String, publicKey: String, privateKey: String, passphrase: String?, fingerprint: String): AppResult { + return credentialRepository.addSshKey(name, type, publicKey, privateKey, passphrase, fingerprint).map { } + } - suspend fun getSshPassphrase(uuid: String): AppResult = getSshPassphraseUseCase(uuid) + suspend fun deleteSshKey(uuid: String): AppResult { + return credentialRepository.deleteSshKey(uuid) + } - suspend fun exportCredentials(): AppResult = exportCredentialsUseCase() + suspend fun exportCredentials(): AppResult { + return credentialRepository.exportCredentials() + } - suspend fun importCredentials(jsonData: String): AppResult = importCredentialsUseCase(jsonData) -} + suspend fun importCredentials(jsonData: String): AppResult { + return credentialRepository.importCredentials(jsonData) + } -/** - * Facade for master password operations. - * 整合主密码设置、解锁和修改相关的用例。 - */ -class MasterPasswordFacade @Inject constructor( - private val setupMasterPasswordUseCase: SetupMasterPasswordUseCase, - private val unlockWithPasswordUseCase: UnlockWithPasswordUseCase, - private val masterKeyManager: jamgmilk.fuwagit.data.local.security.MasterKeyManager -) { - suspend fun setupMasterPassword( - password: String, - confirmPassword: String, - hint: String? - ): AppResult = setupMasterPasswordUseCase(password, confirmPassword, hint) - - suspend fun unlockWithPassword(password: String): AppResult = unlockWithPasswordUseCase(password) - - suspend fun changePassword( - oldPassword: String, - newPassword: String, - hint: String? - ): AppResult { - val result = masterKeyManager.changeMasterPassword(oldPassword, newPassword) - return if (result.isSuccess) { - if (hint != null) { - masterKeyManager.setPasswordHint(hint) - } - AppResult.Success(Unit) - } else { - AppResult.Error(AppException.Unknown(result.exceptionOrNull()?.message ?: "Unknown error")) - } + suspend fun getHttpsPassword(uuid: String): AppResult { + return credentialRepository.getHttpsPassword(uuid) } -} -/** - * Facade for biometric authentication operations. - * 整合生物识别认证相关的用例。 - */ -class BiometricAuthFacade @Inject constructor( - private val enableBiometricUseCase: EnableBiometricUseCase, - private val unlockWithBiometricUseCase: UnlockWithBiometricUseCase -) { - suspend fun enableBiometric( - activity: FragmentActivity, - onResult: (AppResult) -> Unit - ) { - enableBiometricUseCase(activity, onResult) + suspend fun getSshPrivateKey(uuid: String): AppResult { + return credentialRepository.getSshPrivateKey(uuid) } - fun unlockWithBiometric( - activity: FragmentActivity, - onResult: (AppResult) -> Unit - ) { - unlockWithBiometricUseCase(activity, onResult) + suspend fun getSshPassphrase(uuid: String): AppResult { + return credentialRepository.getSshPassphrase(uuid) } } diff --git a/app/src/main/java/jamgmilk/fuwagit/domain/usecase/credential/EnableBiometricUseCase.kt b/app/src/main/java/jamgmilk/fuwagit/domain/usecase/credential/EnableBiometricUseCase.kt index 00e1453..0ff3278 100644 --- a/app/src/main/java/jamgmilk/fuwagit/domain/usecase/credential/EnableBiometricUseCase.kt +++ b/app/src/main/java/jamgmilk/fuwagit/domain/usecase/credential/EnableBiometricUseCase.kt @@ -13,23 +13,19 @@ class EnableBiometricUseCase @Inject constructor( ) { suspend operator fun invoke( activity: FragmentActivity, - onResult: (AppResult) -> Unit - ) { + title: String, + subtitle: String, + negativeButtonText: String + ): AppResult { val masterKey = credentialRepository.getCachedMasterKey() - ?: run { - onResult(AppResult.Error(AppException.MasterKeyNotUnlocked())) - return - } + ?: return AppResult.Error(AppException.MasterKeyNotUnlocked()) - biometricRepository.enableBiometric( + return biometricRepository.enableBiometric( activity = activity, masterKey = masterKey, - onSuccess = { - onResult(AppResult.Success(Unit)) - }, - onError = { message -> - onResult(AppResult.Error(AppException.BiometricError(message))) - } + title = title, + subtitle = subtitle, + negativeButtonText = negativeButtonText ) } } diff --git a/app/src/main/java/jamgmilk/fuwagit/domain/usecase/credential/UnlockWithBiometricUseCase.kt b/app/src/main/java/jamgmilk/fuwagit/domain/usecase/credential/UnlockWithBiometricUseCase.kt index b968636..8ae4ffb 100644 --- a/app/src/main/java/jamgmilk/fuwagit/domain/usecase/credential/UnlockWithBiometricUseCase.kt +++ b/app/src/main/java/jamgmilk/fuwagit/domain/usecase/credential/UnlockWithBiometricUseCase.kt @@ -11,16 +11,26 @@ class UnlockWithBiometricUseCase @Inject constructor( private val credentialRepository: CredentialRepository, private val biometricRepository: BiometricRepository ) { - operator fun invoke(activity: FragmentActivity, onResult: (AppResult) -> Unit) { - biometricRepository.unlockWithBiometric( + suspend operator fun invoke( + activity: FragmentActivity, + title: String, + subtitle: String, + negativeButtonText: String + ): AppResult { + val result = biometricRepository.unlockWithBiometric( activity = activity, - onSuccess = { key -> - credentialRepository.setMasterKeyFromBiometric(key) - onResult(AppResult.Success(Unit)) - }, - onError = { message -> - onResult(AppResult.Error(AppException.BiometricError(message))) - } + title = title, + subtitle = subtitle, + negativeButtonText = negativeButtonText ) + return when (result) { + is AppResult.Success -> { + credentialRepository.setMasterKeyFromBiometric(result.data) + AppResult.Success(Unit) + } + is AppResult.Error -> { + AppResult.Error(AppException.BiometricError(result.message ?: "Biometric error")) + } + } } } diff --git a/app/src/main/java/jamgmilk/fuwagit/ui/components/biometric/BiometricAuthenticator.kt b/app/src/main/java/jamgmilk/fuwagit/ui/components/biometric/BiometricAuthenticator.kt new file mode 100644 index 0000000..967ccfd --- /dev/null +++ b/app/src/main/java/jamgmilk/fuwagit/ui/components/biometric/BiometricAuthenticator.kt @@ -0,0 +1,107 @@ +package jamgmilk.fuwagit.ui.components.biometric + +import androidx.biometric.AuthenticationRequest +import androidx.biometric.AuthenticationResult +import androidx.biometric.AuthenticationResultCallback +import androidx.biometric.BiometricManager +import androidx.biometric.compose.rememberAuthenticationLauncher +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import jamgmilk.fuwagit.R + +private object BiometricErrorCodes { + const val USER_CANCELED = 10 + const val NO_BIOMETRICS = 7 + const val LOCKOUT = 5 +} + +@Composable +fun BiometricAuthenticator( + title: String, + subtitle: String, + negativeButtonText: String, + onSuccess: () -> Unit, + onError: (String) -> Unit, + onCancelled: () -> Unit, + isEnabled: Boolean = true +) { + var authState by remember { mutableStateOf(false) } + val context = LocalContext.current + + val resultCallback = AuthenticationResultCallback { result -> + when { + result is AuthenticationResult.Success -> onSuccess() + result is AuthenticationResult.Error -> { + when (result.errorCode) { + BiometricErrorCodes.USER_CANCELED, + BiometricErrorCodes.NO_BIOMETRICS, + BiometricErrorCodes.LOCKOUT -> onCancelled() + else -> onError(result.errString.toString()) + } + } + else -> { } + } + } + + val authLauncher = rememberAuthenticationLauncher(resultCallback) + + LaunchedEffect(isEnabled, authState) { + if (isEnabled && !authState) { + val biometricManager = BiometricManager.from(context) + val canAuth = biometricManager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG) + + if (canAuth == BiometricManager.BIOMETRIC_SUCCESS) { + authState = true + val request = AuthenticationRequest.Biometric.Builder( + title = title, + authFallbacks = arrayOf() + ).build() + authLauncher.launch(request) + } else { + onError(context.getString(R.string.biometric_not_available)) + } + } + } +} + +@Composable +fun BiometricEnableAuthenticator( + onSuccess: () -> Unit, + onError: (String) -> Unit, + onCancelled: () -> Unit, + isEnabled: Boolean = true +) { + BiometricAuthenticator( + title = stringResource(R.string.biometric_enable_title), + subtitle = stringResource(R.string.biometric_enable_subtitle), + negativeButtonText = stringResource(R.string.settings_biometric_cancel), + onSuccess = onSuccess, + onError = onError, + onCancelled = onCancelled, + isEnabled = isEnabled + ) +} + +@Composable +fun BiometricUnlockAuthenticator( + onSuccess: () -> Unit, + onError: (String) -> Unit, + onCancelled: () -> Unit, + isEnabled: Boolean = true +) { + BiometricAuthenticator( + title = stringResource(R.string.biometric_unlock_title), + subtitle = stringResource(R.string.credentials_unlock_biometric_subtitle), + negativeButtonText = stringResource(R.string.credentials_use_password), + onSuccess = onSuccess, + onError = onError, + onCancelled = onCancelled, + isEnabled = isEnabled + ) +} diff --git a/app/src/main/java/jamgmilk/fuwagit/ui/screen/credentials/CredentialScreen.kt b/app/src/main/java/jamgmilk/fuwagit/ui/screen/credentials/CredentialScreen.kt index d6b328c..79fe05e 100644 --- a/app/src/main/java/jamgmilk/fuwagit/ui/screen/credentials/CredentialScreen.kt +++ b/app/src/main/java/jamgmilk/fuwagit/ui/screen/credentials/CredentialScreen.kt @@ -44,7 +44,6 @@ fun CredentialScreen( modifier: Modifier = Modifier ) { val context = LocalContext.current - val activity = context as? FragmentActivity val scope = rememberCoroutineScope() val snackbarHostState = remember { androidx.compose.material3.SnackbarHostState() } @@ -124,6 +123,9 @@ fun CredentialScreen( } if (uiState.showUnlockDialog) { + val biometricUnlockTitle = stringResource(R.string.biometric_unlock_title) + val biometricUnlockSubtitle = stringResource(R.string.credentials_unlock_biometric_subtitle) + val biometricUsePasswordText = stringResource(R.string.credentials_use_password) UnlockDialog( onDismiss = { viewModel.dismissUnlockDialog() }, onUnlock = { password -> @@ -131,7 +133,15 @@ fun CredentialScreen( }, biometricEnabled = uiState.isBiometricEnabled, onUnlockWithBiometric = { - activity?.let { viewModel.unlockWithBiometric(it) } + val activity = context as? FragmentActivity + activity?.let { + viewModel.unlockWithBiometric( + it, + biometricUnlockTitle, + biometricUnlockSubtitle, + biometricUsePasswordText + ) + } }, passwordHint = uiState.passwordHint, error = uiState.error, diff --git a/app/src/main/java/jamgmilk/fuwagit/ui/screen/credentials/CredentialStoreViewModel.kt b/app/src/main/java/jamgmilk/fuwagit/ui/screen/credentials/CredentialStoreViewModel.kt index 02d9198..6caa41b 100644 --- a/app/src/main/java/jamgmilk/fuwagit/ui/screen/credentials/CredentialStoreViewModel.kt +++ b/app/src/main/java/jamgmilk/fuwagit/ui/screen/credentials/CredentialStoreViewModel.kt @@ -197,27 +197,34 @@ class CredentialStoreViewModel @Inject constructor( } } - fun enableBiometric(activity: FragmentActivity) { + fun enableBiometric( + activity: FragmentActivity, + title: String, + subtitle: String, + negativeButtonText: String + ) { viewModelScope.launch { - credentialFacade.enableBiometric(activity) { result -> - when (result) { - is AppResult.Success -> { - _uiState.update { it.copy(isBiometricEnabled = true) } - viewModelScope.launch { _events.emit(CredentialStoreEvent.BiometricEnabled) } - } - is AppResult.Error -> { - _uiState.update { it.copy(error = result.message ?: "Biometric error") } - viewModelScope.launch { _events.emit(CredentialStoreEvent.Error(result.message ?: "Biometric error")) } - } + credentialFacade.enableBiometric(activity, title, subtitle, negativeButtonText) + .onSuccess { + _uiState.update { it.copy(isBiometricEnabled = true) } + _events.emit(CredentialStoreEvent.BiometricEnabled) + } + .onError { e -> + _uiState.update { it.copy(error = e.message ?: "Biometric error") } + _events.emit(CredentialStoreEvent.Error(e.message ?: "Biometric error")) } - } } } - fun unlockWithBiometric(activity: FragmentActivity) { - credentialFacade.unlockWithBiometric(activity) { result -> - when (result) { - is AppResult.Success -> { + fun unlockWithBiometric( + activity: FragmentActivity, + title: String, + subtitle: String, + negativeButtonText: String + ) { + viewModelScope.launch { + credentialFacade.unlockWithBiometric(activity, title, subtitle, negativeButtonText) + .onSuccess { _uiState.update { it.copy( isDecryptionUnlocked = true, @@ -225,14 +232,13 @@ class CredentialStoreViewModel @Inject constructor( showUnlockDialog = false ) } - viewModelScope.launch { _events.emit(CredentialStoreEvent.UnlockSuccess) } + _events.emit(CredentialStoreEvent.UnlockSuccess) loadCredentials() } - is AppResult.Error -> { - _uiState.update { it.copy(error = result.message ?: "Biometric error") } - viewModelScope.launch { _events.emit(CredentialStoreEvent.Error(result.message ?: "Biometric error")) } + .onError { e -> + _uiState.update { it.copy(error = e.message ?: "Biometric error") } + _events.emit(CredentialStoreEvent.Error(e.message ?: "Biometric error")) } - } } } @@ -341,14 +347,6 @@ class CredentialStoreViewModel @Inject constructor( } } - /** - * Test SSH connection with the given host and key UUID. - * Retrieves the decrypted private key from the vault (requires unlock). - * - * @param host The SSH host (e.g., "git@github.com") - * @param sshKeyUuid The UUID of the SSH key to test - * @param onResult Callback to receive the test result - */ fun testSshConnection( host: String, sshKeyUuid: String, diff --git a/app/src/main/java/jamgmilk/fuwagit/ui/screen/credentials/MasterPasswordScreen.kt b/app/src/main/java/jamgmilk/fuwagit/ui/screen/credentials/MasterPasswordScreen.kt index 950e350..2522a44 100644 --- a/app/src/main/java/jamgmilk/fuwagit/ui/screen/credentials/MasterPasswordScreen.kt +++ b/app/src/main/java/jamgmilk/fuwagit/ui/screen/credentials/MasterPasswordScreen.kt @@ -97,27 +97,47 @@ fun MasterPasswordScreen( .fillMaxWidth() .verticalScroll(rememberScrollState()) ) { + val context = LocalContext.current + val activity = context as? FragmentActivity + + val biometricTitle = stringResource(R.string.biometric_enable_title) + val biometricSubtitle = stringResource(R.string.biometric_enable_subtitle) + val biometricNegativeButtonText = stringResource(R.string.settings_biometric_cancel) + MasterPasswordContent( mode = mode, passwordHint = uiState.passwordHint, isBiometricEnabled = uiState.isBiometricEnabled, error = uiState.error, isLoading = uiState.isLoading, + biometricTitle = biometricTitle, + biometricSubtitle = biometricSubtitle, + biometricNegativeButtonText = biometricNegativeButtonText, onSetup = { password, confirmPassword, hint -> - viewModel.setupMasterPassword(password, confirmPassword, hint) + viewModel.setupPasswordAndContinue(password, confirmPassword, hint) }, - onChange = { activity, oldPassword, newPassword, confirmPassword, hint, biometricEnabled -> + onChange = { oldPassword, newPassword, confirmPassword, hint, biometricEnabled -> viewModel.changeMasterPassword( - activity = activity, oldPassword = oldPassword, newPassword = newPassword, confirmPassword = confirmPassword, hint = hint, - biometricEnabled = biometricEnabled + biometricEnabled = biometricEnabled, + activity = activity!!, + biometricTitle = biometricTitle, + biometricSubtitle = biometricSubtitle, + biometricNegativeButtonText = biometricNegativeButtonText ) }, - onEnableBiometric = { activity -> - viewModel.enableBiometric(activity) + onEnableBiometric = { + activity?.let { + viewModel.enableBiometric( + it, + biometricTitle, + biometricSubtitle, + biometricNegativeButtonText + ) + } }, onDisableBiometric = { viewModel.disableBiometric() @@ -138,23 +158,24 @@ private fun MasterPasswordContent( isBiometricEnabled: Boolean, error: String?, isLoading: Boolean, + biometricTitle: String, + biometricSubtitle: String, + biometricNegativeButtonText: String, onSetup: (password: String, confirmPassword: String, hint: String?) -> Unit, onChange: ( - activity: FragmentActivity?, oldPassword: String, newPassword: String, confirmPassword: String, hint: String?, biometricEnabled: Boolean ) -> Unit, - onEnableBiometric: (FragmentActivity) -> Unit, + onEnableBiometric: () -> Unit, onDisableBiometric: () -> Unit, onClearError: () -> Unit, onComplete: () -> Unit ) { val colors = MaterialTheme.colorScheme val context = LocalContext.current - val activity = context as? FragmentActivity var oldPassword by remember { mutableStateOf("") } var password by remember { mutableStateOf("") } @@ -389,7 +410,7 @@ private fun MasterPasswordContent( onCheckedChange = { enabled -> if (isSetupMode) { if (enabled) { - activity?.let { onEnableBiometric(it) } + onEnableBiometric() } else { onDisableBiometric() } @@ -407,7 +428,6 @@ private fun MasterPasswordContent( onSetup(password, confirmPassword, hint.ifBlank { null }) } else { onChange( - activity, oldPassword, password, confirmPassword, diff --git a/app/src/main/java/jamgmilk/fuwagit/ui/screen/credentials/MasterPasswordViewModel.kt b/app/src/main/java/jamgmilk/fuwagit/ui/screen/credentials/MasterPasswordViewModel.kt index 76f9967..e2cb74c 100644 --- a/app/src/main/java/jamgmilk/fuwagit/ui/screen/credentials/MasterPasswordViewModel.kt +++ b/app/src/main/java/jamgmilk/fuwagit/ui/screen/credentials/MasterPasswordViewModel.kt @@ -5,7 +5,6 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import jamgmilk.fuwagit.core.result.AppException -import jamgmilk.fuwagit.core.result.AppResult import jamgmilk.fuwagit.domain.usecase.credential.CredentialStoreFacade import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -62,7 +61,7 @@ class MasterPasswordViewModel @Inject constructor( } } - fun setupMasterPassword(password: String, confirmPassword: String, hint: String?) { + fun setupPasswordAndContinue(password: String, confirmPassword: String, hint: String?) { if (password != confirmPassword) { _uiState.update { it.copy(error = "Passwords do not match") } return @@ -98,13 +97,34 @@ class MasterPasswordViewModel @Inject constructor( } } + fun enableBiometric( + activity: FragmentActivity, + title: String, + subtitle: String, + negativeButtonText: String + ) { + viewModelScope.launch { + credentialFacade.enableBiometric(activity, title, subtitle, negativeButtonText) + .onSuccess { + _uiState.update { it.copy(isBiometricEnabled = true) } + _events.emit(MasterPasswordEvent.BiometricEnabled) + } + .onError { e -> + _events.emit(MasterPasswordEvent.BiometricError(e.message ?: "Biometric error")) + } + } + } + fun changeMasterPassword( - activity: FragmentActivity?, oldPassword: String, newPassword: String, confirmPassword: String, hint: String?, - biometricEnabled: Boolean + biometricEnabled: Boolean, + activity: FragmentActivity, + biometricTitle: String, + biometricSubtitle: String, + biometricNegativeButtonText: String ) { if (newPassword != confirmPassword) { _uiState.update { it.copy(error = "Passwords do not match") } @@ -121,74 +141,29 @@ class MasterPasswordViewModel @Inject constructor( credentialFacade.changeMasterPassword(oldPassword, newPassword, hint) .onSuccess { - when { - wasBiometricEnabled && !biometricEnabled -> { - credentialFacade.disableBiometric() - viewModelScope.launch { - finishPasswordChange(hint = hint, biometricEnabled = false) + if (wasBiometricEnabled && !biometricEnabled) { + credentialFacade.disableBiometric() + finishPasswordChange(hint = hint, biometricEnabled = false) + } else if (biometricEnabled) { + credentialFacade.enableBiometric( + activity = activity, + title = biometricTitle, + subtitle = biometricSubtitle, + negativeButtonText = biometricNegativeButtonText + ) + .onSuccess { + finishPasswordChange(hint = hint, biometricEnabled = true) } - } - - !wasBiometricEnabled && biometricEnabled -> { - credentialFacade.unlockWithPassword(newPassword) - .onSuccess { - if (activity == null) { - val message = AppException.MasterKeyNotUnlocked().message - _uiState.update { - it.copy( - isLoading = false, - error = message - ) - } - _events.emit(MasterPasswordEvent.Error(message)) - return@onSuccess - } - - credentialFacade.enableBiometric(activity) { result -> - when (result) { - is AppResult.Success -> { - viewModelScope.launch { - finishPasswordChange(hint = hint, biometricEnabled = true) - } - } - - is AppResult.Error -> { - val message = result.message ?: "Biometric error" - viewModelScope.launch { - _uiState.update { - it.copy( - isLoading = false, - error = message - ) - } - _events.emit(MasterPasswordEvent.Error(message)) - } - } - } - } - } - .onError { e -> - val message = e.message ?: AppException.MasterKeyNotUnlocked().message - viewModelScope.launch { - _uiState.update { - it.copy( - isLoading = false, - error = message - ) - } - _events.emit(MasterPasswordEvent.Error(message)) - } + .onError { e -> + _uiState.update { + it.copy( + isLoading = false, + error = e.message ?: "Biometric setup failed" + ) } - } - - else -> { - viewModelScope.launch { - finishPasswordChange( - hint = hint, - biometricEnabled = wasBiometricEnabled - ) } - } + } else { + finishPasswordChange(hint = hint, biometricEnabled = wasBiometricEnabled) } } .onError { e -> @@ -204,31 +179,6 @@ class MasterPasswordViewModel @Inject constructor( } } - fun clearError() { - _uiState.update { it.copy(error = null) } - } - - fun enableBiometric(activity: FragmentActivity) { - viewModelScope.launch { - credentialFacade.enableBiometric(activity) { result -> - when (result) { - is AppResult.Success -> { - viewModelScope.launch { - _uiState.update { it.copy(isBiometricEnabled = true) } - _events.emit(MasterPasswordEvent.BiometricEnabled) - } - } - is AppResult.Error -> { - viewModelScope.launch { - _uiState.update { it.copy(error = result.message ?: "Biometric error") } - _events.emit(MasterPasswordEvent.BiometricError(result.message ?: "Biometric error")) - } - } - } - } - } - } - fun disableBiometric() { viewModelScope.launch { credentialFacade.disableBiometric() @@ -236,6 +186,10 @@ class MasterPasswordViewModel @Inject constructor( } } + fun clearError() { + _uiState.update { it.copy(error = null) } + } + private suspend fun finishPasswordChange(hint: String?, biometricEnabled: Boolean) { _uiState.update { it.copy( diff --git a/app/src/main/java/jamgmilk/fuwagit/ui/screen/credentials/PasswordDialogs.kt b/app/src/main/java/jamgmilk/fuwagit/ui/screen/credentials/PasswordDialogs.kt index 18e3696..73cb37a 100644 --- a/app/src/main/java/jamgmilk/fuwagit/ui/screen/credentials/PasswordDialogs.kt +++ b/app/src/main/java/jamgmilk/fuwagit/ui/screen/credentials/PasswordDialogs.kt @@ -44,6 +44,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType @@ -52,6 +53,7 @@ import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.fragment.app.FragmentActivity import jamgmilk.fuwagit.R @Composable @@ -175,8 +177,9 @@ fun UnlockDialog( ) { var password by remember { mutableStateOf("") } var showPassword by remember { mutableStateOf(false) } + var biometricError by remember { mutableStateOf(null) } - LaunchedEffect(Unit) { + LaunchedEffect(biometricEnabled) { if (biometricEnabled) { onUnlockWithBiometric() } @@ -196,7 +199,10 @@ fun UnlockDialog( style = MaterialTheme.typography.titleLarge ) if (biometricEnabled) { - IconButton(onClick = onUnlockWithBiometric) { + IconButton(onClick = { + biometricError = null + onUnlockWithBiometric() + }) { Icon( imageVector = Icons.Default.Fingerprint, contentDescription = stringResource(R.string.credentials_unlock_with_biometric), diff --git a/app/src/main/java/jamgmilk/fuwagit/ui/screen/main/AppNavHost.kt b/app/src/main/java/jamgmilk/fuwagit/ui/screen/main/AppNavHost.kt index 0c218d3..93fe19e 100644 --- a/app/src/main/java/jamgmilk/fuwagit/ui/screen/main/AppNavHost.kt +++ b/app/src/main/java/jamgmilk/fuwagit/ui/screen/main/AppNavHost.kt @@ -37,6 +37,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import androidx.fragment.app.FragmentActivity import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavHostController @@ -44,11 +45,11 @@ import androidx.navigation.NavType import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.navArgument -import androidx.fragment.app.FragmentActivity import android.content.Context import android.content.res.Configuration import java.net.URLEncoder import java.nio.charset.StandardCharsets +import jamgmilk.fuwagit.R import jamgmilk.fuwagit.ui.navigation.AddRepoTab import jamgmilk.fuwagit.ui.navigation.DiffType import jamgmilk.fuwagit.ui.navigation.NavRoutes @@ -227,7 +228,6 @@ fun AppNavHost(navController: NavHostController, startDestination: String = NavR } composable(NavRoutes.PERMISSIONS) { - val activity = LocalContext.current.requireActivity() val credentialUiState by credentialStoreViewModel.uiState.collectAsStateWithLifecycle() var sshTestResult by remember { mutableStateOf(SshTestResult.Idle) } @@ -254,7 +254,12 @@ fun AppNavHost(navController: NavHostController, startDestination: String = NavR }, biometricEnabled = credentialUiState.isBiometricEnabled, onUnlockWithBiometric = { - credentialStoreViewModel.unlockWithBiometric(activity) + credentialStoreViewModel.unlockWithBiometric( + activity, + context.getString(R.string.biometric_unlock_title), + context.getString(R.string.credentials_unlock_biometric_subtitle), + context.getString(R.string.credentials_use_password) + ) } ) } diff --git a/app/src/main/java/jamgmilk/fuwagit/ui/screen/myrepos/CloneContent.kt b/app/src/main/java/jamgmilk/fuwagit/ui/screen/myrepos/CloneContent.kt index 29fcd27..b2743d7 100644 --- a/app/src/main/java/jamgmilk/fuwagit/ui/screen/myrepos/CloneContent.kt +++ b/app/src/main/java/jamgmilk/fuwagit/ui/screen/myrepos/CloneContent.kt @@ -92,7 +92,6 @@ internal fun CloneContent( onCloneComplete: (String) -> Unit ) { val context = LocalContext.current - val activity = context as? FragmentActivity val scope = rememberCoroutineScope() val credentialsUiState by credentialsViewModel.uiState.collectAsStateWithLifecycle() @@ -386,6 +385,7 @@ internal fun CloneContent( } if (showUnlockDialog) { + val activity = context as? FragmentActivity UnlockDialog( onDismiss = { showUnlockDialog = false }, onUnlock = { password -> @@ -393,7 +393,14 @@ internal fun CloneContent( }, biometricEnabled = credentialsUiState.isBiometricEnabled, onUnlockWithBiometric = { - activity?.let { credentialsViewModel.unlockWithBiometric(it) } + activity?.let { + credentialsViewModel.unlockWithBiometric( + it, + context.getString(R.string.biometric_unlock_title), + context.getString(R.string.credentials_unlock_biometric_subtitle), + context.getString(R.string.credentials_use_password) + ) + } }, passwordHint = credentialsUiState.passwordHint, error = credentialsUiState.error, diff --git a/app/src/main/java/jamgmilk/fuwagit/ui/screen/onboarding/OnboardingScreen.kt b/app/src/main/java/jamgmilk/fuwagit/ui/screen/onboarding/OnboardingScreen.kt index 64c8c8b..992ec25 100644 --- a/app/src/main/java/jamgmilk/fuwagit/ui/screen/onboarding/OnboardingScreen.kt +++ b/app/src/main/java/jamgmilk/fuwagit/ui/screen/onboarding/OnboardingScreen.kt @@ -72,6 +72,7 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.res.painterResource +import androidx.fragment.app.FragmentActivity import androidx.compose.ui.res.stringResource import androidx.compose.ui.graphics.painter.BitmapPainter import androidx.compose.ui.graphics.painter.Painter @@ -83,7 +84,6 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat import androidx.core.net.toUri -import androidx.fragment.app.FragmentActivity import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver @@ -105,7 +105,7 @@ fun OnboardingScreen( val steps = OnboardingStep.entries val pagerState = rememberPagerState(pageCount = { steps.size }) val context = LocalContext.current - val fragmentActivity = context as? FragmentActivity + val activity = context as? FragmentActivity MaterialTheme.colorScheme var isPermissionGranted by remember { mutableStateOf(false) } @@ -172,7 +172,7 @@ fun OnboardingScreen( isGitConfigValid = uiState.userName.isNotBlank() && uiState.userEmail.isNotBlank() && uiState.defaultBranch.isNotBlank(), onNext = viewModel::nextStep, onSkipPassword = viewModel::skipPassword, - onSetupPassword = { viewModel.setupPasswordAndContinue(fragmentActivity) }, + onSetupPassword = { activity?.let { viewModel.setupPasswordAndContinue(it) } }, onSaveConfig = viewModel::saveConfigAndContinue, onAddRepository = onAddRepository, onComplete = { diff --git a/app/src/main/java/jamgmilk/fuwagit/ui/screen/onboarding/OnboardingViewModel.kt b/app/src/main/java/jamgmilk/fuwagit/ui/screen/onboarding/OnboardingViewModel.kt index 0696f7d..cf121bc 100644 --- a/app/src/main/java/jamgmilk/fuwagit/ui/screen/onboarding/OnboardingViewModel.kt +++ b/app/src/main/java/jamgmilk/fuwagit/ui/screen/onboarding/OnboardingViewModel.kt @@ -4,16 +4,15 @@ import androidx.fragment.app.FragmentActivity import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel -import jamgmilk.fuwagit.core.result.AppResult import jamgmilk.fuwagit.domain.repository.SettingsRepository -import jamgmilk.fuwagit.domain.usecase.credential.EnableBiometricUseCase -import jamgmilk.fuwagit.domain.usecase.credential.SetupMasterPasswordUseCase +import jamgmilk.fuwagit.domain.usecase.credential.CredentialStoreFacade import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import javax.inject.Inject + import androidx.compose.runtime.Stable enum class OnboardingStep { @@ -37,14 +36,15 @@ data class OnboardingUiState( val enableBiometric: Boolean = false, val isSettingPassword: Boolean = false, val passwordError: String? = null, - val isSavingConfig: Boolean = false + val isSavingConfig: Boolean = false, + val isBiometricSetupComplete: Boolean = false, + val biometricSetupError: String? = null ) @HiltViewModel class OnboardingViewModel @Inject constructor( private val settingsRepository: SettingsRepository, - private val setupMasterPasswordUseCase: SetupMasterPasswordUseCase, - private val enableBiometricUseCase: EnableBiometricUseCase + private val credentialFacade: CredentialStoreFacade ) : ViewModel() { private val _uiState = MutableStateFlow(OnboardingUiState()) @@ -89,6 +89,23 @@ class OnboardingViewModel @Inject constructor( _uiState.update { it.copy(enableBiometric = enable) } } + fun enableBiometricIfNeeded(activity: FragmentActivity) { + if (!_uiState.value.enableBiometric) return + + viewModelScope.launch { + credentialFacade.enableBiometric( + activity = activity, + title = "Enable Biometric Unlock", + subtitle = "Use your fingerprint to quickly access credentials", + negativeButtonText = "Cancel" + ).onSuccess { + _uiState.update { it.copy(isBiometricSetupComplete = true) } + }.onError { e -> + _uiState.update { it.copy(biometricSetupError = e.message) } + } + } + } + fun updateUserName(name: String) { _uiState.update { it.copy(userName = name) } } @@ -101,7 +118,7 @@ class OnboardingViewModel @Inject constructor( _uiState.update { it.copy(defaultBranch = branch) } } - fun setupPasswordAndContinue(activity: FragmentActivity?) { + fun setupPasswordAndContinue(activity: FragmentActivity) { val state = _uiState.value if (state.password.length < 6) { _uiState.update { it.copy(passwordError = "Password must be at least 6 characters") } @@ -114,27 +131,13 @@ class OnboardingViewModel @Inject constructor( _uiState.update { it.copy(isSettingPassword = true, passwordError = null) } viewModelScope.launch { - setupMasterPasswordUseCase(state.password, state.confirmPassword, state.passwordHint.ifBlank { null }) + credentialFacade.setupMasterPassword(state.password, state.confirmPassword, state.passwordHint.ifBlank { null }) .onSuccess { clearSensitiveData() - if (state.enableBiometric && activity != null) { - enableBiometricUseCase(activity) { result -> - when (result) { - is AppResult.Success -> { - nextStep() - } - is AppResult.Error -> { - _uiState.update { - it.copy( - isSettingPassword = false, - passwordError = result.message ?: "Biometric setup failed" - ) - } - } - } - } + _uiState.update { it.copy(isSettingPassword = false) } + if (state.enableBiometric) { + enableBiometricIfNeeded(activity) } else { - _uiState.update { it.copy(isSettingPassword = false) } nextStep() } } @@ -144,6 +147,19 @@ class OnboardingViewModel @Inject constructor( } } + fun onBiometricSetupComplete() { + _uiState.update { it.copy(isBiometricSetupComplete = true) } + nextStep() + } + + fun onBiometricSetupError(error: String) { + _uiState.update { it.copy(biometricSetupError = error) } + } + + fun completePasswordSetup() { + nextStep() + } + private fun clearSensitiveData() { _uiState.update { it.copy(password = "", confirmPassword = "") diff --git a/app/src/main/java/jamgmilk/fuwagit/ui/screen/settings/SettingsScreen.kt b/app/src/main/java/jamgmilk/fuwagit/ui/screen/settings/SettingsScreen.kt index 963a313..90aa7fd 100644 --- a/app/src/main/java/jamgmilk/fuwagit/ui/screen/settings/SettingsScreen.kt +++ b/app/src/main/java/jamgmilk/fuwagit/ui/screen/settings/SettingsScreen.kt @@ -3,9 +3,8 @@ package jamgmilk.fuwagit.ui.screen.settings import android.content.Intent import android.os.Build import android.util.Log -import androidx.appcompat.app.AppCompatDelegate -import androidx.core.os.LocaleListCompat import jamgmilk.fuwagit.BuildConfig +import jamgmilk.fuwagit.util.LanguageManager import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.expandVertically import androidx.compose.animation.fadeIn @@ -134,8 +133,8 @@ fun SettingsScreen( credentialsViewModel: CredentialStoreViewModel = hiltViewModel() ) { val context = LocalContext.current - val resources = LocalResources.current val activity = context as? FragmentActivity + val resources = LocalResources.current val settingsUiState by settingsViewModel.uiState.collectAsStateWithLifecycle() val credentialsUiState by credentialsViewModel.uiState.collectAsStateWithLifecycle() val applyResult = settingsUiState.applyResult @@ -175,12 +174,7 @@ fun SettingsScreen( settingsViewModel.events.collect { event -> when (event) { is SettingsEvent.LanguageChanged -> { - val localeList = when (event.language) { - "zh" -> LocaleListCompat.forLanguageTags("zh") - "en" -> LocaleListCompat.forLanguageTags("en") - else -> LocaleListCompat.getEmptyLocaleList() - } - AppCompatDelegate.setApplicationLocales(localeList) + LanguageManager.setLanguage(event.language) } } } @@ -209,14 +203,15 @@ fun SettingsScreen( Pair(credentialsUiState.isDecryptionUnlocked, pendingBiometricEnable) }.collectLatest { (isUnlocked, pendingEnable) -> if (BuildConfig.DEBUG) Log.d(TAG, "snapshotFlow: isDecryptionUnlocked=$isUnlocked, pendingBiometricEnable=$pendingEnable") - if (isUnlocked && pendingEnable) { - if (BuildConfig.DEBUG) Log.d(TAG, "snapshotFlow: calling enableBiometric, activity=$activity") - if (activity == null) { - Log.e(TAG, "snapshotFlow: activity is NULL, cannot enable biometric") - } + if (isUnlocked && pendingEnable && activity != null) { delay(100) pendingBiometricEnable = false - activity?.let { credentialsViewModel.enableBiometric(it) } + credentialsViewModel.enableBiometric( + activity = activity, + title = context.getString(R.string.biometric_enable_title), + subtitle = context.getString(R.string.biometric_enable_subtitle), + negativeButtonText = context.getString(R.string.settings_biometric_cancel) + ) } } } @@ -272,7 +267,7 @@ fun SettingsScreen( isDecryptionUnlocked = credentialsUiState.isDecryptionUnlocked, isMasterPasswordSet = credentialsUiState.isMasterPasswordSet, onBiometricEnabledChange = { enabled -> - if (BuildConfig.DEBUG) Log.d(TAG, "Switch toggled: enabled=$enabled, isDecryptionUnlocked=${credentialsUiState.isDecryptionUnlocked}, activity=$activity") + if (BuildConfig.DEBUG) Log.d(TAG, "Switch toggled: enabled=$enabled, isDecryptionUnlocked=${credentialsUiState.isDecryptionUnlocked}") if (enabled) { if (!credentialsUiState.isDecryptionUnlocked) { if (BuildConfig.DEBUG) Log.d(TAG, "Enabling biometric but locked, showing unlock dialog") @@ -280,7 +275,14 @@ fun SettingsScreen( credentialsViewModel.showUnlockDialog() } else { if (BuildConfig.DEBUG) Log.d(TAG, "Calling enableBiometric directly") - activity?.let { credentialsViewModel.enableBiometric(it) } + activity?.let { + credentialsViewModel.enableBiometric( + activity = it, + title = context.getString(R.string.biometric_enable_title), + subtitle = context.getString(R.string.biometric_enable_subtitle), + negativeButtonText = context.getString(R.string.settings_biometric_cancel) + ) + } } } else { if (!credentialsUiState.isDecryptionUnlocked && credentialsUiState.isBiometricEnabled) { @@ -380,7 +382,14 @@ fun SettingsScreen( }, biometricEnabled = credentialsUiState.isBiometricEnabled, onUnlockWithBiometric = { - activity?.let { credentialsViewModel.unlockWithBiometric(it) } + activity?.let { + credentialsViewModel.unlockWithBiometric( + it, + context.getString(R.string.biometric_unlock_title), + context.getString(R.string.credentials_unlock_biometric_subtitle), + context.getString(R.string.credentials_use_password) + ) + } }, passwordHint = credentialsUiState.passwordHint, error = credentialsUiState.error, @@ -1931,7 +1940,7 @@ private fun AppearanceSettingsCard( } val languageLabel = when (language) { - "zh" -> stringResource(R.string.settings_language_zh_cn) + "zh-Hans" -> stringResource(R.string.settings_language_zh_cn) "en" -> stringResource(R.string.settings_language_en) else -> stringResource(R.string.settings_language_system) } @@ -2027,7 +2036,7 @@ private fun AppearanceSettingsCard( ) { val languageOptions = listOf( "system" to stringResource(R.string.settings_language_system), - "zh" to stringResource(R.string.settings_language_zh_cn), + "zh-Hans" to stringResource(R.string.settings_language_zh_cn), "en" to stringResource(R.string.settings_language_en) ) diff --git a/app/src/main/java/jamgmilk/fuwagit/ui/screen/settings/SettingsViewModel.kt b/app/src/main/java/jamgmilk/fuwagit/ui/screen/settings/SettingsViewModel.kt index 0ceee12..437ea09 100644 --- a/app/src/main/java/jamgmilk/fuwagit/ui/screen/settings/SettingsViewModel.kt +++ b/app/src/main/java/jamgmilk/fuwagit/ui/screen/settings/SettingsViewModel.kt @@ -9,6 +9,7 @@ import jamgmilk.fuwagit.domain.repository.RepoRepository import jamgmilk.fuwagit.domain.repository.SettingsRepository import jamgmilk.fuwagit.domain.usecase.git.ApplyGitConfigToAllRepos import jamgmilk.fuwagit.ui.state.RepoStateManager +import jamgmilk.fuwagit.util.LanguageManager import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow @@ -93,6 +94,7 @@ class SettingsViewModel @Inject constructor( } } launch { + var isFirstEmit = true settingsRepository.preferencesFlow().collect { prefs -> _uiState.update { it.copy( @@ -107,6 +109,10 @@ class SettingsViewModel @Inject constructor( isFirstRun = prefs.isFirstRun ) } + if (isFirstEmit) { + LanguageManager.setLanguage(prefs.language) + isFirstEmit = false + } } } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c410739..e7f9fc3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -8,22 +8,22 @@ uiautomator = "2.3.0" lifecycleRuntimeKtx = "2.10.0" activityCompose = "1.13.0" kotlin = "2.3.20" -composeBom = "2026.03.01" +composeBom = "2026.04.01" orgEclipseJgit = "6.8.0.202311291450-r" documentfile = "1.1.0" jsch = "2.28.0" securityCrypto = "1.1.0" -bouncycastle = "1.83" -navigation = "2.9.7" +bouncycastle = "1.84" +navigation = "2.9.8" kotlinxSerialization = "1.11.0" -biometric = "1.2.0-alpha05" +biometric-compose = "1.4.0-alpha07" datastore = "1.2.1" appcompat = "1.7.1" hilt = "2.59.2" hiltNavigationCompose = "1.3.0" ksp = "2.3.6" -uiGraphics = "1.10.6" -foundationLayout = "1.10.6" +uiGraphics = "1.11.0" +foundationLayout = "1.11.0" [libraries] # Core & Lifecycle @@ -47,7 +47,7 @@ androidx-documentfile = { group = "androidx.documentfile", name = "documentfile" androidx-security-crypto = { group = "androidx.security", name = "security-crypto", version.ref = "securityCrypto" } androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigation" } androidx-navigation-runtime-ktx = { group = "androidx.navigation", name = "navigation-runtime-ktx", version.ref = "navigation" } -androidx-biometric = { group = "androidx.biometric", name = "biometric", version.ref = "biometric" } +androidx-biometric-compose = { group = "androidx.biometric", name = "biometric-compose", version.ref = "biometric-compose" } androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" } androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } From da5ed86408e747a596c2709519c00176260eee09 Mon Sep 17 00:00:00 2001 From: Mirurin Date: Mon, 27 Apr 2026 15:35:13 +0800 Subject: [PATCH 16/24] feat(language): implement unified language switching --- .../jamgmilk/fuwagit/util/CrashLogManager.kt | 4 ++- .../jamgmilk/fuwagit/util/LanguageManager.kt | 26 +++++++++++++++++++ .../strings.xml | 0 app/src/main/res/values/strings.xml | 8 ++++++ app/src/main/res/xml/locales_config.xml | 2 +- 5 files changed, 38 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/jamgmilk/fuwagit/util/LanguageManager.kt rename app/src/main/res/{values-zh => values-b+zh+Hans}/strings.xml (100%) diff --git a/app/src/main/java/jamgmilk/fuwagit/util/CrashLogManager.kt b/app/src/main/java/jamgmilk/fuwagit/util/CrashLogManager.kt index dd44be0..43f1d44 100644 --- a/app/src/main/java/jamgmilk/fuwagit/util/CrashLogManager.kt +++ b/app/src/main/java/jamgmilk/fuwagit/util/CrashLogManager.kt @@ -2,8 +2,10 @@ package jamgmilk.fuwagit.util import android.content.Context import android.content.Intent +import android.content.pm.PackageInfo import android.os.Build import android.util.Log +import androidx.core.content.pm.PackageInfoCompat import java.io.File import java.io.FileWriter import java.io.PrintWriter @@ -40,7 +42,7 @@ object CrashLogManager { appVersion = try { val packageInfo = context.packageManager.getPackageInfo(context.packageName, 0) - val versionCode = packageInfo.versionCode + val versionCode = PackageInfoCompat.getLongVersionCode(packageInfo) val versionName = packageInfo.versionName ?: "Unknown" "$versionName ($versionCode)" } catch (_: Exception) { diff --git a/app/src/main/java/jamgmilk/fuwagit/util/LanguageManager.kt b/app/src/main/java/jamgmilk/fuwagit/util/LanguageManager.kt new file mode 100644 index 0000000..d359f2f --- /dev/null +++ b/app/src/main/java/jamgmilk/fuwagit/util/LanguageManager.kt @@ -0,0 +1,26 @@ +package jamgmilk.fuwagit.util + +import androidx.appcompat.app.AppCompatDelegate +import androidx.core.os.LocaleListCompat + +object LanguageManager { + /** + * Sets the application language. + * + * @param languageTag BCP-47 language tag. Supported values: + * - "system": Use system language (clears Per-App Language setting) + * - "en": English + * - "zh-Hans": Simplified Chinese + * + * On API 33+, this uses LocaleManager internally. + * On API 26-32, this uses AppCompatDelegate with auto-persistence. + */ + fun setLanguage(languageTag: String) { + if (languageTag == "system") { + AppCompatDelegate.setApplicationLocales(LocaleListCompat.getEmptyLocaleList()) + } else { + val appLocales = LocaleListCompat.forLanguageTags(languageTag) + AppCompatDelegate.setApplicationLocales(appLocales) + } + } +} diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-b+zh+Hans/strings.xml similarity index 100% rename from app/src/main/res/values-zh/strings.xml rename to app/src/main/res/values-b+zh+Hans/strings.xml diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 139acae..00d770f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -74,6 +74,7 @@ Tap to unlock credentials first Enabled Use fingerprint to unlock + Cancel Auto-Lock Timeout Session expires after %1$s Never @@ -260,6 +261,7 @@ Passwords do not match At least 6 characters Incorrect password + Cancel Set Password Setting… Changing… @@ -270,6 +272,12 @@ Unlocking… Unlock Unlock with biometric + Use your fingerprint to access credentials + Use Password + Enable Biometric Unlock + Use your fingerprint to quickly access credentials + Unlock Credentials + Biometric authentication not available Add HTTPS Credential Host github.com diff --git a/app/src/main/res/xml/locales_config.xml b/app/src/main/res/xml/locales_config.xml index d5cc522..9bb66e4 100644 --- a/app/src/main/res/xml/locales_config.xml +++ b/app/src/main/res/xml/locales_config.xml @@ -1,5 +1,5 @@ - + From bdd2ad8387e56b8a0bacf3802ddc5ab234c7979f Mon Sep 17 00:00:00 2001 From: Mirurin Date: Mon, 27 Apr 2026 18:37:57 +0800 Subject: [PATCH 17/24] refactor(biometric): remove duplicate auth implementations --- .../data/biometric/BiometricAuthManager.kt | 25 ---- .../repository/CredentialRepositoryImpl.kt | 6 - .../domain/repository/CredentialRepository.kt | 2 - .../credential/CredentialStoreFacade.kt | 8 +- .../biometric/BiometricAuthenticator.kt | 107 ------------------ .../credentials/CredentialInputDialogs.kt | 1 - .../credentials/CredentialSelectDialog.kt | 4 +- .../credentials/CredentialStoreViewModel.kt | 95 +--------------- .../credentials/MasterPasswordViewModel.kt | 2 +- .../ui/screen/onboarding/OnboardingScreen.kt | 26 +++-- .../screen/onboarding/OnboardingViewModel.kt | 37 +++--- app/src/main/res/values-b+zh+Hans/strings.xml | 23 ++++ app/src/main/res/values/strings.xml | 13 +++ 13 files changed, 80 insertions(+), 269 deletions(-) delete mode 100644 app/src/main/java/jamgmilk/fuwagit/data/biometric/BiometricAuthManager.kt delete mode 100644 app/src/main/java/jamgmilk/fuwagit/ui/components/biometric/BiometricAuthenticator.kt diff --git a/app/src/main/java/jamgmilk/fuwagit/data/biometric/BiometricAuthManager.kt b/app/src/main/java/jamgmilk/fuwagit/data/biometric/BiometricAuthManager.kt deleted file mode 100644 index acfc0f8..0000000 --- a/app/src/main/java/jamgmilk/fuwagit/data/biometric/BiometricAuthManager.kt +++ /dev/null @@ -1,25 +0,0 @@ -package jamgmilk.fuwagit.data.biometric - -import android.content.Context -import androidx.biometric.BiometricManager -import javax.inject.Inject -import javax.inject.Singleton - -@Singleton -class BiometricAuthManager @Inject constructor() { - - sealed class AuthAvailability { - data object Available : AuthAvailability() - data object NotAvailable : AuthAvailability() - data object NotEnrolled : AuthAvailability() - } - - fun canAuthenticate(context: Context): AuthAvailability { - val biometricManager = BiometricManager.from(context) - return when (biometricManager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG)) { - BiometricManager.BIOMETRIC_SUCCESS -> AuthAvailability.Available - BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> AuthAvailability.NotEnrolled - else -> AuthAvailability.NotAvailable - } - } -} diff --git a/app/src/main/java/jamgmilk/fuwagit/data/repository/CredentialRepositoryImpl.kt b/app/src/main/java/jamgmilk/fuwagit/data/repository/CredentialRepositoryImpl.kt index 20524d8..820c1c3 100644 --- a/app/src/main/java/jamgmilk/fuwagit/data/repository/CredentialRepositoryImpl.kt +++ b/app/src/main/java/jamgmilk/fuwagit/data/repository/CredentialRepositoryImpl.kt @@ -173,12 +173,6 @@ class CredentialRepositoryImpl @Inject constructor( } } - override suspend fun disableBiometric(): AppResult { - return AppResult.catching { - masterKeyManager.disableBiometric() - } - } - override suspend fun changeMasterPassword(oldPassword: String, newPassword: String, hint: String?): AppResult { return AppResult.catching { masterKeyManager.changeMasterPassword(oldPassword, newPassword).getOrThrow() diff --git a/app/src/main/java/jamgmilk/fuwagit/domain/repository/CredentialRepository.kt b/app/src/main/java/jamgmilk/fuwagit/domain/repository/CredentialRepository.kt index 0700d51..d603147 100644 --- a/app/src/main/java/jamgmilk/fuwagit/domain/repository/CredentialRepository.kt +++ b/app/src/main/java/jamgmilk/fuwagit/domain/repository/CredentialRepository.kt @@ -58,7 +58,5 @@ interface CredentialRepository { suspend fun importCredentials(jsonData: String): AppResult - suspend fun disableBiometric(): AppResult - suspend fun changeMasterPassword(oldPassword: String, newPassword: String, hint: String?): AppResult } diff --git a/app/src/main/java/jamgmilk/fuwagit/domain/usecase/credential/CredentialStoreFacade.kt b/app/src/main/java/jamgmilk/fuwagit/domain/usecase/credential/CredentialStoreFacade.kt index b3b11be..2607df9 100644 --- a/app/src/main/java/jamgmilk/fuwagit/domain/usecase/credential/CredentialStoreFacade.kt +++ b/app/src/main/java/jamgmilk/fuwagit/domain/usecase/credential/CredentialStoreFacade.kt @@ -4,11 +4,13 @@ import androidx.fragment.app.FragmentActivity import jamgmilk.fuwagit.core.result.AppResult import jamgmilk.fuwagit.domain.model.credential.HttpsCredential import jamgmilk.fuwagit.domain.model.credential.SshKey +import jamgmilk.fuwagit.domain.repository.BiometricRepository import jamgmilk.fuwagit.domain.repository.CredentialRepository import javax.inject.Inject class CredentialStoreFacade @Inject constructor( private val credentialRepository: CredentialRepository, + private val biometricRepository: BiometricRepository, private val enableBiometricUseCase: EnableBiometricUseCase, private val unlockWithBiometricUseCase: UnlockWithBiometricUseCase ) { @@ -30,7 +32,7 @@ class CredentialStoreFacade @Inject constructor( return unlockWithBiometricUseCase(activity, title, subtitle, negativeButtonText) } - suspend fun setupMasterPassword(password: String, confirmPassword: String, hint: String?): AppResult { + suspend fun setupMasterPassword(password: String, hint: String?): AppResult { return credentialRepository.setupMasterPassword(password, hint) } @@ -43,7 +45,7 @@ class CredentialStoreFacade @Inject constructor( } fun isBiometricEnabled(): Boolean { - return credentialRepository.isBiometricEnabled() + return biometricRepository.isBiometricEnabled() } fun getMasterPasswordHint(): String? { @@ -51,7 +53,7 @@ class CredentialStoreFacade @Inject constructor( } suspend fun disableBiometric(): AppResult { - return credentialRepository.disableBiometric() + return biometricRepository.disableBiometric() } suspend fun unlockWithPassword(password: String): AppResult { diff --git a/app/src/main/java/jamgmilk/fuwagit/ui/components/biometric/BiometricAuthenticator.kt b/app/src/main/java/jamgmilk/fuwagit/ui/components/biometric/BiometricAuthenticator.kt deleted file mode 100644 index 967ccfd..0000000 --- a/app/src/main/java/jamgmilk/fuwagit/ui/components/biometric/BiometricAuthenticator.kt +++ /dev/null @@ -1,107 +0,0 @@ -package jamgmilk.fuwagit.ui.components.biometric - -import androidx.biometric.AuthenticationRequest -import androidx.biometric.AuthenticationResult -import androidx.biometric.AuthenticationResultCallback -import androidx.biometric.BiometricManager -import androidx.biometric.compose.rememberAuthenticationLauncher -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import jamgmilk.fuwagit.R - -private object BiometricErrorCodes { - const val USER_CANCELED = 10 - const val NO_BIOMETRICS = 7 - const val LOCKOUT = 5 -} - -@Composable -fun BiometricAuthenticator( - title: String, - subtitle: String, - negativeButtonText: String, - onSuccess: () -> Unit, - onError: (String) -> Unit, - onCancelled: () -> Unit, - isEnabled: Boolean = true -) { - var authState by remember { mutableStateOf(false) } - val context = LocalContext.current - - val resultCallback = AuthenticationResultCallback { result -> - when { - result is AuthenticationResult.Success -> onSuccess() - result is AuthenticationResult.Error -> { - when (result.errorCode) { - BiometricErrorCodes.USER_CANCELED, - BiometricErrorCodes.NO_BIOMETRICS, - BiometricErrorCodes.LOCKOUT -> onCancelled() - else -> onError(result.errString.toString()) - } - } - else -> { } - } - } - - val authLauncher = rememberAuthenticationLauncher(resultCallback) - - LaunchedEffect(isEnabled, authState) { - if (isEnabled && !authState) { - val biometricManager = BiometricManager.from(context) - val canAuth = biometricManager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG) - - if (canAuth == BiometricManager.BIOMETRIC_SUCCESS) { - authState = true - val request = AuthenticationRequest.Biometric.Builder( - title = title, - authFallbacks = arrayOf() - ).build() - authLauncher.launch(request) - } else { - onError(context.getString(R.string.biometric_not_available)) - } - } - } -} - -@Composable -fun BiometricEnableAuthenticator( - onSuccess: () -> Unit, - onError: (String) -> Unit, - onCancelled: () -> Unit, - isEnabled: Boolean = true -) { - BiometricAuthenticator( - title = stringResource(R.string.biometric_enable_title), - subtitle = stringResource(R.string.biometric_enable_subtitle), - negativeButtonText = stringResource(R.string.settings_biometric_cancel), - onSuccess = onSuccess, - onError = onError, - onCancelled = onCancelled, - isEnabled = isEnabled - ) -} - -@Composable -fun BiometricUnlockAuthenticator( - onSuccess: () -> Unit, - onError: (String) -> Unit, - onCancelled: () -> Unit, - isEnabled: Boolean = true -) { - BiometricAuthenticator( - title = stringResource(R.string.biometric_unlock_title), - subtitle = stringResource(R.string.credentials_unlock_biometric_subtitle), - negativeButtonText = stringResource(R.string.credentials_use_password), - onSuccess = onSuccess, - onError = onError, - onCancelled = onCancelled, - isEnabled = isEnabled - ) -} diff --git a/app/src/main/java/jamgmilk/fuwagit/ui/screen/credentials/CredentialInputDialogs.kt b/app/src/main/java/jamgmilk/fuwagit/ui/screen/credentials/CredentialInputDialogs.kt index b5ef003..71683dc 100644 --- a/app/src/main/java/jamgmilk/fuwagit/ui/screen/credentials/CredentialInputDialogs.kt +++ b/app/src/main/java/jamgmilk/fuwagit/ui/screen/credentials/CredentialInputDialogs.kt @@ -51,7 +51,6 @@ import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.VisualTransformation diff --git a/app/src/main/java/jamgmilk/fuwagit/ui/screen/credentials/CredentialSelectDialog.kt b/app/src/main/java/jamgmilk/fuwagit/ui/screen/credentials/CredentialSelectDialog.kt index de9d8af..2bf1119 100644 --- a/app/src/main/java/jamgmilk/fuwagit/ui/screen/credentials/CredentialSelectDialog.kt +++ b/app/src/main/java/jamgmilk/fuwagit/ui/screen/credentials/CredentialSelectDialog.kt @@ -44,6 +44,8 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.compose.ui.res.stringResource +import jamgmilk.fuwagit.R import jamgmilk.fuwagit.domain.model.credential.HttpsCredential import jamgmilk.fuwagit.domain.model.credential.SshKey import jamgmilk.fuwagit.ui.theme.AppShapes @@ -127,7 +129,7 @@ fun CredentialSelectDialog( ) { if (currentItems.isEmpty()) { Box(Modifier.fillMaxWidth().padding(24.dp), contentAlignment = Alignment.Center) { - Text("No credentials found", style = MaterialTheme.typography.bodyMedium, color = colors.onSurfaceVariant) + Text(stringResource(R.string.credentials_not_found), style = MaterialTheme.typography.bodyMedium, color = colors.onSurfaceVariant) } } else { currentItems.forEachIndexed { index, item -> diff --git a/app/src/main/java/jamgmilk/fuwagit/ui/screen/credentials/CredentialStoreViewModel.kt b/app/src/main/java/jamgmilk/fuwagit/ui/screen/credentials/CredentialStoreViewModel.kt index 6caa41b..575f770 100644 --- a/app/src/main/java/jamgmilk/fuwagit/ui/screen/credentials/CredentialStoreViewModel.kt +++ b/app/src/main/java/jamgmilk/fuwagit/ui/screen/credentials/CredentialStoreViewModel.kt @@ -37,21 +37,16 @@ data class CredentialsStoreUiState( val isBiometricEnabled: Boolean = false, val isDecryptionUnlocked: Boolean = false, val showUnlockDialog: Boolean = false, - val showChangePasswordDialog: Boolean = false, - val changePasswordError: String? = null, val passwordHint: String? = null, val httpsCredentials: List = emptyList(), val sshKeys: List = emptyList(), val isLoading: Boolean = false, val error: String? = null, - val exportedData: String? = null, - val showExportDialog: Boolean = false, - val showImportDialog: Boolean = false, - val importSuccess: Boolean = false + val exportedData: String? = null ) @HiltViewModel -class CredentialStoreViewModel @Inject constructor( + class CredentialStoreViewModel @Inject constructor( private val credentialFacade: CredentialStoreFacade, private val testSshConnectionUseCase: TestSshConnectionUseCase ) : ViewModel() { @@ -77,21 +72,6 @@ class CredentialStoreViewModel @Inject constructor( loadCredentials() } - fun setupMasterPassword(password: String, confirmPassword: String, hint: String?) { - executeWithLoading { - credentialFacade.setupMasterPassword(password, confirmPassword, hint) - .onSuccess { - _uiState.update { - it.copy( - isMasterPasswordSet = true, - showUnlockDialog = false - ) - } - loadCredentials() - } - } - } - fun unlockWithPassword(password: String) { executeWithLoading { credentialFacade.unlockWithPassword(password) @@ -171,10 +151,7 @@ class CredentialStoreViewModel @Inject constructor( credentialFacade.exportCredentials() .onSuccess { data -> _uiState.update { - it.copy( - exportedData = data, - showExportDialog = true - ) + it.copy(exportedData = data) } _events.emit(CredentialStoreEvent.CredentialExported) } @@ -185,12 +162,6 @@ class CredentialStoreViewModel @Inject constructor( executeWithLoading { credentialFacade.importCredentials(jsonData) .onSuccess { - _uiState.update { - it.copy( - showImportDialog = false, - importSuccess = true - ) - } _events.emit(CredentialStoreEvent.CredentialImported) loadCredentials() } @@ -264,66 +235,6 @@ class CredentialStoreViewModel @Inject constructor( _uiState.update { it.copy(showUnlockDialog = false) } } - fun showExportDialog() { - _uiState.update { it.copy(showExportDialog = true) } - } - - fun dismissExportDialog() { - _uiState.update { it.copy(showExportDialog = false, exportedData = null) } - } - - fun showImportDialog() { - _uiState.update { it.copy(showImportDialog = true) } - } - - fun dismissImportDialog() { - _uiState.update { it.copy(showImportDialog = false) } - } - - fun showChangePasswordDialog() { - _uiState.update { it.copy(showChangePasswordDialog = true, changePasswordError = null) } - } - - fun dismissChangePasswordDialog() { - _uiState.update { it.copy(showChangePasswordDialog = false, changePasswordError = null) } - } - - fun changeMasterPassword(oldPassword: String, newPassword: String, confirmPassword: String, hint: String?) { - if (newPassword != confirmPassword) { - _uiState.update { it.copy(changePasswordError = "Passwords do not match") } - return - } - if (newPassword.length < 6) { - _uiState.update { it.copy(changePasswordError = "Password must be at least 6 characters") } - return - } - - viewModelScope.launch { - _uiState.update { it.copy(isLoading = true, changePasswordError = null) } - - credentialFacade.changeMasterPassword(oldPassword, newPassword, hint) - .onSuccess { - _uiState.update { - it.copy( - isLoading = false, - showChangePasswordDialog = false, - changePasswordError = null, - passwordHint = hint, - isBiometricEnabled = false - ) - } - } - .onError { - _uiState.update { - it.copy( - isLoading = false, - changePasswordError = "Incorrect old password" - ) - } - } - } - } - fun clearError() { _uiState.update { it.copy(error = null) } } diff --git a/app/src/main/java/jamgmilk/fuwagit/ui/screen/credentials/MasterPasswordViewModel.kt b/app/src/main/java/jamgmilk/fuwagit/ui/screen/credentials/MasterPasswordViewModel.kt index e2cb74c..8f1583d 100644 --- a/app/src/main/java/jamgmilk/fuwagit/ui/screen/credentials/MasterPasswordViewModel.kt +++ b/app/src/main/java/jamgmilk/fuwagit/ui/screen/credentials/MasterPasswordViewModel.kt @@ -74,7 +74,7 @@ class MasterPasswordViewModel @Inject constructor( viewModelScope.launch { _uiState.update { it.copy(isLoading = true, error = null) } - credentialFacade.setupMasterPassword(password, confirmPassword, hint) + credentialFacade.setupMasterPassword(password, hint) .onSuccess { _uiState.update { it.copy( diff --git a/app/src/main/java/jamgmilk/fuwagit/ui/screen/onboarding/OnboardingScreen.kt b/app/src/main/java/jamgmilk/fuwagit/ui/screen/onboarding/OnboardingScreen.kt index 992ec25..70ff783 100644 --- a/app/src/main/java/jamgmilk/fuwagit/ui/screen/onboarding/OnboardingScreen.kt +++ b/app/src/main/java/jamgmilk/fuwagit/ui/screen/onboarding/OnboardingScreen.kt @@ -6,9 +6,6 @@ import android.content.pm.PackageManager import android.os.Build import android.os.Environment import android.provider.Settings -import android.graphics.Bitmap -import android.graphics.Canvas -import android.graphics.drawable.Drawable import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.ExperimentalFoundationApi @@ -32,7 +29,6 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.AccountTree import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.CloudDownload import androidx.compose.material.icons.filled.CreateNewFolder @@ -67,15 +63,11 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.res.painterResource -import androidx.fragment.app.FragmentActivity import androidx.compose.ui.res.stringResource -import androidx.compose.ui.graphics.painter.BitmapPainter -import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType @@ -84,6 +76,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat import androidx.core.net.toUri +import androidx.fragment.app.FragmentActivity import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver @@ -92,7 +85,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import jamgmilk.fuwagit.R import jamgmilk.fuwagit.ui.navigation.AddRepoTab import jamgmilk.fuwagit.ui.theme.AppShapes -import androidx.core.graphics.createBitmap @OptIn(ExperimentalFoundationApi::class) @Composable @@ -106,6 +98,9 @@ fun OnboardingScreen( val pagerState = rememberPagerState(pageCount = { steps.size }) val context = LocalContext.current val activity = context as? FragmentActivity + val biometricTitle = stringResource(R.string.biometric_enable_title) + val biometricSubtitle = stringResource(R.string.biometric_enable_subtitle) + val biometricCancelText = stringResource(R.string.settings_biometric_cancel) MaterialTheme.colorScheme var isPermissionGranted by remember { mutableStateOf(false) } @@ -172,7 +167,16 @@ fun OnboardingScreen( isGitConfigValid = uiState.userName.isNotBlank() && uiState.userEmail.isNotBlank() && uiState.defaultBranch.isNotBlank(), onNext = viewModel::nextStep, onSkipPassword = viewModel::skipPassword, - onSetupPassword = { activity?.let { viewModel.setupPasswordAndContinue(it) } }, + onSetupPassword = { + activity?.let { + viewModel.setupPasswordAndContinue( + it, + biometricTitle, + biometricSubtitle, + biometricCancelText + ) + } + }, onSaveConfig = viewModel::saveConfigAndContinue, onAddRepository = onAddRepository, onComplete = { @@ -198,7 +202,7 @@ private fun StepIndicator( horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically ) { - OnboardingStep.entries.forEachIndexed { index, step -> + OnboardingStep.entries.forEachIndexed { index, _ -> val isActive = index <= currentStep.ordinal val isCurrent = index == currentStep.ordinal diff --git a/app/src/main/java/jamgmilk/fuwagit/ui/screen/onboarding/OnboardingViewModel.kt b/app/src/main/java/jamgmilk/fuwagit/ui/screen/onboarding/OnboardingViewModel.kt index cf121bc..aa2c15a 100644 --- a/app/src/main/java/jamgmilk/fuwagit/ui/screen/onboarding/OnboardingViewModel.kt +++ b/app/src/main/java/jamgmilk/fuwagit/ui/screen/onboarding/OnboardingViewModel.kt @@ -89,15 +89,20 @@ class OnboardingViewModel @Inject constructor( _uiState.update { it.copy(enableBiometric = enable) } } - fun enableBiometricIfNeeded(activity: FragmentActivity) { + fun enableBiometricIfNeeded( + activity: FragmentActivity, + title: String, + subtitle: String, + negativeButtonText: String + ) { if (!_uiState.value.enableBiometric) return viewModelScope.launch { credentialFacade.enableBiometric( activity = activity, - title = "Enable Biometric Unlock", - subtitle = "Use your fingerprint to quickly access credentials", - negativeButtonText = "Cancel" + title = title, + subtitle = subtitle, + negativeButtonText = negativeButtonText ).onSuccess { _uiState.update { it.copy(isBiometricSetupComplete = true) } }.onError { e -> @@ -118,7 +123,12 @@ class OnboardingViewModel @Inject constructor( _uiState.update { it.copy(defaultBranch = branch) } } - fun setupPasswordAndContinue(activity: FragmentActivity) { + fun setupPasswordAndContinue( + activity: FragmentActivity, + biometricTitle: String, + biometricSubtitle: String, + biometricNegativeButtonText: String + ) { val state = _uiState.value if (state.password.length < 6) { _uiState.update { it.copy(passwordError = "Password must be at least 6 characters") } @@ -131,12 +141,12 @@ class OnboardingViewModel @Inject constructor( _uiState.update { it.copy(isSettingPassword = true, passwordError = null) } viewModelScope.launch { - credentialFacade.setupMasterPassword(state.password, state.confirmPassword, state.passwordHint.ifBlank { null }) + credentialFacade.setupMasterPassword(state.password, state.passwordHint.ifBlank { null }) .onSuccess { clearSensitiveData() _uiState.update { it.copy(isSettingPassword = false) } if (state.enableBiometric) { - enableBiometricIfNeeded(activity) + enableBiometricIfNeeded(activity, biometricTitle, biometricSubtitle, biometricNegativeButtonText) } else { nextStep() } @@ -147,19 +157,6 @@ class OnboardingViewModel @Inject constructor( } } - fun onBiometricSetupComplete() { - _uiState.update { it.copy(isBiometricSetupComplete = true) } - nextStep() - } - - fun onBiometricSetupError(error: String) { - _uiState.update { it.copy(biometricSetupError = error) } - } - - fun completePasswordSetup() { - nextStep() - } - private fun clearSensitiveData() { _uiState.update { it.copy(password = "", confirmPassword = "") diff --git a/app/src/main/res/values-b+zh+Hans/strings.xml b/app/src/main/res/values-b+zh+Hans/strings.xml index cd65004..27fa562 100644 --- a/app/src/main/res/values-b+zh+Hans/strings.xml +++ b/app/src/main/res/values-b+zh+Hans/strings.xml @@ -74,6 +74,11 @@ 请先点击解锁凭据 已启用 使用指纹解锁 + 取消 + 启用生物识别解锁 + 使用指纹快速访问凭据 + 解锁凭据 + 生物识别验证不可用 自动锁定超时 会话将在%1$s后过期 从不 @@ -138,6 +143,7 @@ 清除 COMMIT_EDITMSG 当前仓库中没有 COMMIT_EDITMSG 文件 COMMIT_EDITMSG 文件已删除 + COMMIT_EDITMSG 文件已删除 未找到 COMMIT_EDITMSG 文件 未选择仓库 导出日志 @@ -268,6 +274,9 @@ 正在解锁… 解锁 使用生物识别解锁 + 使用指纹快速解锁 + 使用密码 + 取消 正在更改… 更改密码 主密码已设置 @@ -344,6 +353,7 @@ 在此粘贴JSON数据… 当前提示:%1$s 已复制到剪贴板! + 已复制到剪贴板! 安全警告 正在导入… @@ -718,4 +728,17 @@ 添加 要开始了哟~ + + 密码不匹配 + 密码必须至少 6 个字符 + 生物识别错误 + 生物识别设置失败 + 未找到凭据 + 分支包含未合并的提交 + 使用强制删除来移除它 + 无法删除当前检出的分支 + 请先切换到另一个分支 + 当前分支已与目标分支同步。 + 分支包含未合并的提交。这是合并操作的预期行为。 + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 00d770f..2027604 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -727,4 +727,17 @@ Add You\'re All Set! + + Passwords do not match + Password must be at least 6 characters + Biometric error + Biometric setup failed + No credentials found + The branch contains commits that have not been merged + Use force delete to remove it anyway + Cannot delete the currently checked out branch + Switch to another branch first + The current branch is already up to date with the target branch. + The branch contains unmerged commits. This is expected for a merge operation. + From 9e1316d23fb56a2673531edbce47517ed3c609f2 Mon Sep 17 00:00:00 2001 From: Mirurin Date: Mon, 27 Apr 2026 19:13:06 +0800 Subject: [PATCH 18/24] refactor(security): unify SSH fingerprint computation --- .../fuwagit/core/util/SshFingerprintUtils.kt | 37 ++++++++++++++++++ .../jamgmilk/fuwagit/core/util/SshKeyUtils.kt | 22 +++-------- .../fuwagit/data/jgit/HostKeyAskHelper.kt | 39 +++++++------------ .../fuwagit/ui/screen/main/AppNavHost.kt | 7 +--- 4 files changed, 57 insertions(+), 48 deletions(-) create mode 100644 app/src/main/java/jamgmilk/fuwagit/core/util/SshFingerprintUtils.kt diff --git a/app/src/main/java/jamgmilk/fuwagit/core/util/SshFingerprintUtils.kt b/app/src/main/java/jamgmilk/fuwagit/core/util/SshFingerprintUtils.kt new file mode 100644 index 0000000..d213a45 --- /dev/null +++ b/app/src/main/java/jamgmilk/fuwagit/core/util/SshFingerprintUtils.kt @@ -0,0 +1,37 @@ +package jamgmilk.fuwagit.core.util + +import jamgmilk.fuwagit.BuildConfig +import java.security.MessageDigest +import java.util.Base64 + +object SshFingerprintUtils { + private const val TAG = "SshFingerprintUtils" + + fun computePublicKeyFingerprint(publicKey: String): String { + if (BuildConfig.DEBUG) { + android.util.Log.d(TAG, "Computing fingerprint for publicKey: ${publicKey.take(50)}...") + } + + val keyPart = publicKey.substringAfter(" ").substringBefore(" ") + if (BuildConfig.DEBUG) { + android.util.Log.d(TAG, "Key part for fingerprint: ${keyPart.take(20)}...") + } + + val keyBytes = Base64.getDecoder().decode(keyPart) + val fingerprint = computeSha256Fingerprint(keyBytes) + + if (BuildConfig.DEBUG) { + android.util.Log.d(TAG, "Fingerprint calculated: $fingerprint") + } + return fingerprint + } + + fun computeHostKeyFingerprint(keyBytes: ByteArray): String { + return computeSha256Fingerprint(keyBytes) + } + + private fun computeSha256Fingerprint(keyBytes: ByteArray): String { + val digest = MessageDigest.getInstance("SHA-256").digest(keyBytes) + return "SHA256:${Base64.getEncoder().withoutPadding().encodeToString(digest)}" + } +} \ No newline at end of file diff --git a/app/src/main/java/jamgmilk/fuwagit/core/util/SshKeyUtils.kt b/app/src/main/java/jamgmilk/fuwagit/core/util/SshKeyUtils.kt index c8e2529..d57e2a7 100644 --- a/app/src/main/java/jamgmilk/fuwagit/core/util/SshKeyUtils.kt +++ b/app/src/main/java/jamgmilk/fuwagit/core/util/SshKeyUtils.kt @@ -8,7 +8,6 @@ import java.io.StringReader import java.io.StringWriter import java.math.BigInteger import java.security.KeyPairGenerator -import java.security.MessageDigest import java.security.SecureRandom import java.security.interfaces.RSAPrivateCrtKey import java.util.Base64 @@ -63,7 +62,7 @@ private fun generateRsaKeyPair(comment: String = ""): Pair { val publicKeyEncoded = encodeRsaPublicKey(publicKey, comment) if (BuildConfig.DEBUG) Log.d(SSH_KEY_LOG_TAG, "generateRsaKeyPair: Public key encoded: ${publicKeyEncoded.take(50)}...") - val privateKey = encodeRsaPrivateKey(keyPair.private as RSAPrivateCrtKey, comment) + val privateKey = encodeRsaPrivateKey(keyPair.private as RSAPrivateCrtKey) if (BuildConfig.DEBUG) Log.d(SSH_KEY_LOG_TAG, "generateRsaKeyPair: Private key encoded (PKCS#1), length: ${privateKey.length}") return Pair(publicKeyEncoded, privateKey) @@ -99,7 +98,7 @@ private fun encodeRsaPublicKey(publicKey: java.security.interfaces.RSAPublicKey, return if (comment.isNotBlank()) "ssh-rsa $base64Key $comment" else "ssh-rsa $base64Key" } -private fun encodeRsaPrivateKey(privateKey: RSAPrivateCrtKey, comment: String = ""): String { +private fun encodeRsaPrivateKey(privateKey: RSAPrivateCrtKey): String { val vector = ASN1EncodableVector() vector.add(ASN1Integer(BigInteger.ZERO)) vector.add(ASN1Integer(privateKey.modulus)) @@ -223,18 +222,7 @@ private fun writeOpenSshString(dos: DataOutputStream, bytes: ByteArray) { } fun calculateFingerprint(publicKey: String): String { - if (BuildConfig.DEBUG) Log.d(SSH_KEY_LOG_TAG, "Calculating fingerprint for publicKey: ${publicKey.take(50)}...") - - val keyPart = publicKey.substringAfter(" ").substringBefore(" ") - if (BuildConfig.DEBUG) Log.d(SSH_KEY_LOG_TAG, "Key part for fingerprint: ${keyPart.take(20)}...") - - val keyBytes = Base64.getDecoder().decode(keyPart) - val md = MessageDigest.getInstance("SHA-256") - val digest = md.digest(keyBytes) - val fingerprint = "SHA256:${Base64.getEncoder().withoutPadding().encodeToString(digest)}" - - if (BuildConfig.DEBUG) Log.d(SSH_KEY_LOG_TAG, "Fingerprint calculated: $fingerprint") - return fingerprint + return SshFingerprintUtils.computePublicKeyFingerprint(publicKey) } fun detectSshKeyType(privateKey: String): String { @@ -283,8 +271,8 @@ private fun detectOpenSshKeyType(keyContent: ByteArray): String { return "Unknown" } - val kdfName = readString(dis) - val kdfOptions = readString(dis) + readString(dis) // kdfName + readString(dis) // kdfOptions dis.readInt() diff --git a/app/src/main/java/jamgmilk/fuwagit/data/jgit/HostKeyAskHelper.kt b/app/src/main/java/jamgmilk/fuwagit/data/jgit/HostKeyAskHelper.kt index 26950a9..60d3774 100644 --- a/app/src/main/java/jamgmilk/fuwagit/data/jgit/HostKeyAskHelper.kt +++ b/app/src/main/java/jamgmilk/fuwagit/data/jgit/HostKeyAskHelper.kt @@ -4,10 +4,10 @@ import android.util.Base64 import android.util.Log import com.jcraft.jsch.HostKey import com.jcraft.jsch.HostKeyRepository +import jamgmilk.fuwagit.core.util.SshFingerprintUtils import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.asSharedFlow -import java.security.MessageDigest import java.util.Collections import java.util.WeakHashMap import java.util.concurrent.CompletableFuture @@ -55,32 +55,26 @@ object HostKeyAskHelper { fun inferKeyTypeInfo(key: ByteArray): KeyTypeInfo { if (key.size < 8) return KeyTypeInfo("ssh-rsa", 0) - val typeStr = extractString(key, 0) ?: return KeyTypeInfo("ssh-rsa", 0) + val typeStr = extractString(key) ?: return KeyTypeInfo("ssh-rsa", 0) val typeCode = keyStringToType(typeStr) val canonicalStr = KEY_TYPE_TO_STRING[typeCode] ?: "ssh-rsa" return KeyTypeInfo(canonicalStr, typeCode) } - private fun extractString(data: ByteArray, offset: Int): String? { - if (offset + 4 > data.size) return null - val length = ((data[offset].toInt() and 0xFF) shl 24) or - ((data[offset + 1].toInt() and 0xFF) shl 16) or - ((data[offset + 2].toInt() and 0xFF) shl 8) or - (data[offset + 3].toInt() and 0xFF) - if (offset + 4 + length > data.size) return null - return String(data, offset + 4, length, Charsets.UTF_8) + private fun extractString(data: ByteArray): String? { + if (4 > data.size) return null + val length = ((data[0].toInt() and 0xFF) shl 24) or + ((data[1].toInt() and 0xFF) shl 16) or + ((data[2].toInt() and 0xFF) shl 8) or + (data[3].toInt() and 0xFF) + if (4 + length > data.size) return null + return String(data, 4, length, Charsets.UTF_8) } fun computeFingerprint(key: ByteArray): String { return try { - val digest = MessageDigest.getInstance("SHA-256").digest(key) - val sb = StringBuilder(digest.size * 3 - 1) - digest.forEachIndexed { i, b -> - if (i > 0) sb.append(':') - sb.append("%02x".format(b)) - } - sb.toString() + SshFingerprintUtils.computeHostKeyFingerprint(key) } catch (e: Exception) { Log.e(TAG, "Failed to compute fingerprint", e) "unknown" @@ -117,7 +111,7 @@ class FuwaHostKeyRepositoryImpl( decodedKeyCache[hostKey] ?: run { try { Base64.decode(hostKey.key, Base64.NO_WRAP).also { decodedKeyCache[hostKey] = it } - } catch (e: Exception) { + } catch (_: Exception) { null } } @@ -150,7 +144,7 @@ class FuwaHostKeyRepositoryImpl( val keyBytes = try { Base64.decode(keyBase64, Base64.NO_WRAP) - } catch (e: Exception) { + } catch (_: Exception) { return null } @@ -247,7 +241,7 @@ class FuwaHostKeyRepositoryImpl( Log.i(TAG, "User rejected new host key for $host") return HOST_KEY_NOT_FOUND } - } catch (e: java.util.concurrent.TimeoutException) { + } catch (_: java.util.concurrent.TimeoutException) { Log.w(TAG, "Host key ask timed out for $host") future.complete(false) return HOST_KEY_NOT_FOUND @@ -281,11 +275,6 @@ class FuwaHostKeyRepositoryImpl( } } - fun addHostKeyDirectly(host: String, keyType: Int, key: ByteArray) { - hostKeys.add(HostKey(host, keyType, key)) - saveToFile() - } - companion object { private const val TAG = "FuwaHostKeyRepository" private const val HOST_KEY_CHANGED = -1 diff --git a/app/src/main/java/jamgmilk/fuwagit/ui/screen/main/AppNavHost.kt b/app/src/main/java/jamgmilk/fuwagit/ui/screen/main/AppNavHost.kt index 93fe19e..7930d94 100644 --- a/app/src/main/java/jamgmilk/fuwagit/ui/screen/main/AppNavHost.kt +++ b/app/src/main/java/jamgmilk/fuwagit/ui/screen/main/AppNavHost.kt @@ -180,9 +180,6 @@ fun AppNavHost(navController: NavHostController, startDestination: String = NavR popUpTo(NavRoutes.MAIN) { saveState = true } } }, - onMasterPasswordSuccess = { - navController.popBackStack() - }, onViewFileDiff = { filePath, diffType -> val encodedPath = URLEncoder.encode(filePath, StandardCharsets.UTF_8.name()) navController.navigate("${NavRoutes.FILE_DIFF}?filePath=$encodedPath&diffType=${diffType.name}") @@ -328,7 +325,6 @@ fun MainScreen( onNavigateToPermissions: () -> Unit, onNavigateToCredentials: () -> Unit, onNavigateToMasterPassword: () -> Unit, - onMasterPasswordSuccess: () -> Unit = {}, onViewFileDiff: ((String, DiffType) -> Unit)? = null, onViewCommitDiff: ((DiffViewRequest) -> Unit)? = null ) { @@ -434,8 +430,7 @@ fun MainScreen( modifier = Modifier.fillMaxSize(), onNavigateToPermissions = onNavigateToPermissions, onNavigateToCredentials = onNavigateToCredentials, - onNavigateToMasterPassword = onNavigateToMasterPassword, - onMasterPasswordSuccess = onMasterPasswordSuccess + onNavigateToMasterPassword = onNavigateToMasterPassword ) null -> { } } From 0bb02152815be52a5a6e53a6438f1505ba3faf19 Mon Sep 17 00:00:00 2001 From: Mirurin Date: Mon, 27 Apr 2026 19:24:33 +0800 Subject: [PATCH 19/24] fix(credentials): resolve import credentials button not working --- .../ui/screen/credentials/CredentialScreen.kt | 1 - .../screen/credentials/ImportExportDialogs.kt | 20 +- .../ui/screen/settings/SettingsScreen.kt | 385 +++--------------- 3 files changed, 77 insertions(+), 329 deletions(-) diff --git a/app/src/main/java/jamgmilk/fuwagit/ui/screen/credentials/CredentialScreen.kt b/app/src/main/java/jamgmilk/fuwagit/ui/screen/credentials/CredentialScreen.kt index 79fe05e..dc0c168 100644 --- a/app/src/main/java/jamgmilk/fuwagit/ui/screen/credentials/CredentialScreen.kt +++ b/app/src/main/java/jamgmilk/fuwagit/ui/screen/credentials/CredentialScreen.kt @@ -262,7 +262,6 @@ fun CredentialScreen( is CredentialDialogState.ImportCredentials -> { ImportCredentialsDialog( viewModel = viewModel, - snackbarHostState = snackbarHostState, onDismiss = { dialogState = CredentialDialogState.None } ) } diff --git a/app/src/main/java/jamgmilk/fuwagit/ui/screen/credentials/ImportExportDialogs.kt b/app/src/main/java/jamgmilk/fuwagit/ui/screen/credentials/ImportExportDialogs.kt index 2b68f7d..8d9514a 100644 --- a/app/src/main/java/jamgmilk/fuwagit/ui/screen/credentials/ImportExportDialogs.kt +++ b/app/src/main/java/jamgmilk/fuwagit/ui/screen/credentials/ImportExportDialogs.kt @@ -135,13 +135,18 @@ fun ExportCredentialsDialog( @Composable fun ImportCredentialsDialog( viewModel: CredentialStoreViewModel, - snackbarHostState: SnackbarHostState, onDismiss: () -> Unit ) { val colors = MaterialTheme.colorScheme - val scope = rememberCoroutineScope() + val uiState by viewModel.uiState.collectAsStateWithLifecycle() var importData by remember { mutableStateOf("") } - var isLoading by remember { mutableStateOf(false) } + var hasStartedImport by remember { mutableStateOf(false) } + + LaunchedEffect(uiState.isLoading) { + if (hasStartedImport && !uiState.isLoading) { + onDismiss() + } + } AlertDialog( onDismissRequest = onDismiss, @@ -178,7 +183,7 @@ fun ImportCredentialsDialog( modifier = Modifier.fillMaxWidth() ) - if (isLoading) { + if (uiState.isLoading) { Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) { CircularProgressIndicator(modifier = Modifier.size(24.dp), color = colors.primary) Spacer(Modifier.width(8.dp)) @@ -191,16 +196,15 @@ fun ImportCredentialsDialog( Button( onClick = { if (importData.isNotBlank()) { - isLoading = true + hasStartedImport = true viewModel.importCredentials(importData) - isLoading = false } }, - enabled = importData.isNotBlank() && !isLoading, + enabled = importData.isNotBlank() && !uiState.isLoading, colors = ButtonDefaults.buttonColors(containerColor = colors.primary), shape = RoundedCornerShape(12.dp) ) { - if (isLoading) { + if (uiState.isLoading) { CircularProgressIndicator(modifier = Modifier.size(18.dp), color = Color.White, strokeWidth = 2.dp) } else { Text(stringResource(R.string.action_import)) diff --git a/app/src/main/java/jamgmilk/fuwagit/ui/screen/settings/SettingsScreen.kt b/app/src/main/java/jamgmilk/fuwagit/ui/screen/settings/SettingsScreen.kt index 90aa7fd..d8ff675 100644 --- a/app/src/main/java/jamgmilk/fuwagit/ui/screen/settings/SettingsScreen.kt +++ b/app/src/main/java/jamgmilk/fuwagit/ui/screen/settings/SettingsScreen.kt @@ -3,8 +3,6 @@ package jamgmilk.fuwagit.ui.screen.settings import android.content.Intent import android.os.Build import android.util.Log -import jamgmilk.fuwagit.BuildConfig -import jamgmilk.fuwagit.util.LanguageManager import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.expandVertically import androidx.compose.animation.fadeIn @@ -26,10 +24,10 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.filled.AccountTree @@ -37,21 +35,17 @@ import androidx.compose.material.icons.filled.AutoAwesome import androidx.compose.material.icons.filled.Backup import androidx.compose.material.icons.filled.BugReport import androidx.compose.material.icons.filled.Build -import androidx.compose.material.icons.filled.Business -import androidx.compose.material.icons.filled.Check -import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.CloudSync import androidx.compose.material.icons.filled.Code import androidx.compose.material.icons.filled.CreditCard import androidx.compose.material.icons.filled.DarkMode -import androidx.compose.material.icons.filled.Email import androidx.compose.material.icons.filled.Fingerprint import androidx.compose.material.icons.filled.FolderOpen import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.Key import androidx.compose.material.icons.filled.Language import androidx.compose.material.icons.filled.Lock -import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.filled.Palette import androidx.compose.material.icons.filled.Schedule import androidx.compose.material.icons.filled.Security import androidx.compose.material.icons.filled.Shield @@ -59,25 +53,23 @@ import androidx.compose.material.icons.filled.Storage import androidx.compose.material.icons.filled.Terminal import androidx.compose.material.icons.filled.Warning import androidx.compose.material.icons.outlined.Info -import androidx.compose.material.icons.filled.Palette import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CardDefaults -import androidx.compose.material3.Checkbox import androidx.compose.material3.ElevatedCard import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.RadioButton +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Surface import androidx.compose.material3.Switch import androidx.compose.material3.SwitchDefaults import androidx.compose.material3.Text import androidx.compose.material3.TextButton -import androidx.compose.material3.SnackbarDuration -import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect @@ -88,6 +80,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -95,8 +88,6 @@ import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.lerp import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalLifecycleOwner -import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight @@ -109,17 +100,19 @@ import androidx.core.content.pm.PackageInfoCompat import androidx.core.net.toUri import androidx.fragment.app.FragmentActivity import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle +import jamgmilk.fuwagit.BuildConfig import jamgmilk.fuwagit.R import jamgmilk.fuwagit.ui.components.FilePickerDialog import jamgmilk.fuwagit.ui.components.ScreenTemplate import jamgmilk.fuwagit.ui.screen.credentials.CredentialStoreViewModel import jamgmilk.fuwagit.ui.screen.credentials.UnlockDialog import jamgmilk.fuwagit.util.CrashLogManager +import jamgmilk.fuwagit.util.LanguageManager import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch -import androidx.compose.runtime.snapshotFlow private const val TAG = "SettingsScreen" @Composable @@ -128,16 +121,13 @@ fun SettingsScreen( onNavigateToPermissions: () -> Unit = {}, onNavigateToCredentials: () -> Unit = {}, onNavigateToMasterPassword: () -> Unit = {}, - onMasterPasswordSuccess: () -> Unit = {}, settingsViewModel: SettingsViewModel = hiltViewModel(), credentialsViewModel: CredentialStoreViewModel = hiltViewModel() ) { val context = LocalContext.current val activity = context as? FragmentActivity - val resources = LocalResources.current val settingsUiState by settingsViewModel.uiState.collectAsStateWithLifecycle() val credentialsUiState by credentialsViewModel.uiState.collectAsStateWithLifecycle() - val applyResult = settingsUiState.applyResult var showFilePicker by rememberSaveable { mutableStateOf(false) } var pendingBiometricEnable by rememberSaveable { mutableStateOf(false) } @@ -158,11 +148,6 @@ fun SettingsScreen( } } - LaunchedEffect(applyResult) { - applyResult?.let { - } - } - LaunchedEffect(credentialsUiState.error) { credentialsUiState.error?.let { errorMessage -> snackbarHostState.showSnackbar(errorMessage, duration = SnackbarDuration.Long) @@ -236,15 +221,10 @@ fun SettingsScreen( userEmail = settingsUiState.userEmail, defaultBranch = settingsUiState.defaultBranch, setUpstreamOnPush = settingsUiState.setUpstreamOnPush, - applyResult = applyResult, onUserConfigSave = { name, email -> settingsViewModel.saveUserConfig(name, email) }, onDefaultBranchSave = { settingsViewModel.saveDefaultBranch(it) }, onSetUpstreamOnPushChange = { settingsViewModel.saveSetUpstreamOnPush(it) }, onReload = { settingsViewModel.reloadUserConfig() }, - onApplyToAllRepos = { name, email, alsoToGlobal -> - settingsViewModel.applyConfigToAllRepos(name, email, alsoToGlobal) - }, - onClearApplyResult = { settingsViewModel.clearApplyResult() }, modifier = Modifier.fillMaxWidth() ) @@ -345,7 +325,7 @@ fun SettingsScreen( } } }, - onExportLogsComplete = { hasLogs, message -> + onExportLogsComplete = { _, message -> scope.launch { snackbarHostState.showSnackbar(message) } @@ -654,61 +634,61 @@ private fun SecuritySettingsCard( } } -@Composable -private fun SyncSettingsCard( - autoSync: Boolean, - onAutoSyncChange: (Boolean) -> Unit, - conflictSafeMode: Boolean, - onConflictSafeModeChange: (Boolean) -> Unit, - backupBeforeSync: Boolean, - onBackupBeforeSyncChange: (Boolean) -> Unit, - modifier: Modifier = Modifier -) { - val colors = MaterialTheme.colorScheme - - ElevatedCard( - modifier = modifier.border(1.dp, colors.outlineVariant, RoundedCornerShape(24.dp)), - shape = RoundedCornerShape(24.dp), - colors = CardDefaults.elevatedCardColors(containerColor = colors.surfaceContainerLow), - elevation = CardDefaults.elevatedCardElevation(0.dp) - ) { - Column(modifier = Modifier.fillMaxWidth()) { - SettingsSectionHeader( - title = stringResource(R.string.settings_sync_backup), - icon = Icons.Default.CloudSync, - color = colors.secondary - ) - - SettingsSwitchItem( - title = stringResource(R.string.settings_auto_sync), - subtitle = stringResource(R.string.settings_auto_sync_subtitle), - icon = Icons.Default.Schedule, - checked = autoSync, - onCheckedChange = onAutoSyncChange - ) - - HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp)) - - SettingsSwitchItem( - title = stringResource(R.string.settings_conflict_safe_mode), - subtitle = stringResource(R.string.settings_conflict_safe_mode_subtitle), - icon = Icons.Default.Shield, - checked = conflictSafeMode, - onCheckedChange = onConflictSafeModeChange - ) - - HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp)) - - SettingsSwitchItem( - title = stringResource(R.string.settings_backup_before_sync), - subtitle = stringResource(R.string.settings_backup_before_sync_subtitle), - icon = Icons.Default.Backup, - checked = backupBeforeSync, - onCheckedChange = onBackupBeforeSyncChange - ) - } - } -} +//@Composable +//private fun SyncSettingsCard( +// autoSync: Boolean, +// onAutoSyncChange: (Boolean) -> Unit, +// conflictSafeMode: Boolean, +// onConflictSafeModeChange: (Boolean) -> Unit, +// backupBeforeSync: Boolean, +// onBackupBeforeSyncChange: (Boolean) -> Unit, +// modifier: Modifier = Modifier +//) { +// val colors = MaterialTheme.colorScheme +// +// ElevatedCard( +// modifier = modifier.border(1.dp, colors.outlineVariant, RoundedCornerShape(24.dp)), +// shape = RoundedCornerShape(24.dp), +// colors = CardDefaults.elevatedCardColors(containerColor = colors.surfaceContainerLow), +// elevation = CardDefaults.elevatedCardElevation(0.dp) +// ) { +// Column(modifier = Modifier.fillMaxWidth()) { +// SettingsSectionHeader( +// title = stringResource(R.string.settings_sync_backup), +// icon = Icons.Default.CloudSync, +// color = colors.secondary +// ) +// +// SettingsSwitchItem( +// title = stringResource(R.string.settings_auto_sync), +// subtitle = stringResource(R.string.settings_auto_sync_subtitle), +// icon = Icons.Default.Schedule, +// checked = autoSync, +// onCheckedChange = onAutoSyncChange +// ) +// +// HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp)) +// +// SettingsSwitchItem( +// title = stringResource(R.string.settings_conflict_safe_mode), +// subtitle = stringResource(R.string.settings_conflict_safe_mode_subtitle), +// icon = Icons.Default.Shield, +// checked = conflictSafeMode, +// onCheckedChange = onConflictSafeModeChange +// ) +// +// HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp)) +// +// SettingsSwitchItem( +// title = stringResource(R.string.settings_backup_before_sync), +// subtitle = stringResource(R.string.settings_backup_before_sync_subtitle), +// icon = Icons.Default.Backup, +// checked = backupBeforeSync, +// onCheckedChange = onBackupBeforeSyncChange +// ) +// } +// } +//} @Composable private fun DeveloperOptionsCard( @@ -1032,7 +1012,7 @@ private fun AboutCard( val packageInfo = remember(context) { try { context.packageManager.getPackageInfo(context.packageName, 0) - } catch (e: Exception) { + } catch (_: Exception) { null } } @@ -1100,13 +1080,10 @@ private fun GlobalConfigCard( userEmail: String, defaultBranch: String, setUpstreamOnPush: Boolean, - applyResult: ApplyConfigResult?, onUserConfigSave: (String, String) -> Unit, onDefaultBranchSave: (String) -> Unit, onSetUpstreamOnPushChange: (Boolean) -> Unit, onReload: suspend () -> Unit, - onApplyToAllRepos: (String, String, Boolean) -> Unit, - onClearApplyResult: () -> Unit, modifier: Modifier = Modifier ) { val colors = MaterialTheme.colorScheme @@ -1234,14 +1211,6 @@ private fun GlobalConfigCard( } } - // Show application configuration result dialog - if (applyResult != null) { - ApplyConfigResultDialog( - result = applyResult, - onDismiss = { onClearApplyResult() } - ) - } - HorizontalDivider( modifier = Modifier.padding(horizontal = 16.dp), color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f) @@ -1412,231 +1381,7 @@ private fun SettingsNavigationItem( } } -/** - * 应用到所有仓库对话框 - */ @Composable -private fun ApplyToAllReposDialog( - name: String, - email: String, - onApply: (Boolean) -> Unit, - onDismiss: () -> Unit -) { - var applyToGlobal by remember { mutableStateOf(false) } - val colors = MaterialTheme.colorScheme - - AlertDialog( - onDismissRequest = onDismiss, - icon = { - Box( - modifier = Modifier - .size(56.dp) - .background(colors.secondary.copy(alpha = 0.15f), CircleShape), - contentAlignment = Alignment.Center - ) { - Icon( - Icons.Default.Business, - contentDescription = null, - tint = colors.secondary, - modifier = Modifier.size(28.dp) - ) - } - }, - title = { - Text( - text = stringResource(R.string.apply_to_all_repos_title), - fontWeight = FontWeight.Bold, - style = MaterialTheme.typography.titleLarge - ) - }, - text = { - Column( - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - Text( - text = stringResource(R.string.apply_to_all_repos_description), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - - Surface( - shape = RoundedCornerShape(8.dp), - color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f), - modifier = Modifier.fillMaxWidth() - ) { - Column( - modifier = Modifier.padding(12.dp), - verticalArrangement = Arrangement.spacedBy(6.dp) - ) { - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - Icons.Default.Person, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(16.dp) - ) - Text( - text = stringResource(R.string.settings_config_name_format, name), - style = MaterialTheme.typography.bodySmall, - fontFamily = FontFamily.Monospace - ) - } - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - Icons.Default.Email, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(16.dp) - ) - Text( - text = stringResource(R.string.settings_config_email_format, email), - style = MaterialTheme.typography.bodySmall, - fontFamily = FontFamily.Monospace - ) - } - } - } - - HorizontalDivider() - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Column(modifier = Modifier.weight(1f)) { - Text( - text = stringResource(R.string.apply_to_all_repos_also_global), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface - ) - Text( - text = stringResource(R.string.apply_to_all_repos_also_global_subtitle), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - Checkbox( - checked = applyToGlobal, - onCheckedChange = { applyToGlobal = it } - ) - } - } - }, - confirmButton = { - Button( - onClick = { onApply(applyToGlobal) }, - shape = RoundedCornerShape(12.dp) - ) { - Icon( - Icons.Default.Check, - contentDescription = null, - modifier = Modifier.size(18.dp) - ) - Spacer(Modifier.width(6.dp)) - Text(stringResource(R.string.action_apply)) - } - }, - dismissButton = { - TextButton(onClick = onDismiss) { - Text(stringResource(R.string.action_cancel)) - } - }, - shape = RoundedCornerShape(24.dp) - ) -} - -/** - * 应用配置结果对话框 - */ -@Composable -private fun ApplyConfigResultDialog( - result: ApplyConfigResult, - onDismiss: () -> Unit -) { - val colors = MaterialTheme.colorScheme - - AlertDialog( - onDismissRequest = onDismiss, - icon = { - Box( - modifier = Modifier - .size(56.dp) - .background( - (if (result.allSuccess) colors.primary else colors.tertiary).copy(alpha = 0.15f), - CircleShape - ), - contentAlignment = Alignment.Center - ) { - Icon( - if (result.allSuccess) Icons.Default.CheckCircle else Icons.Default.Warning, - contentDescription = null, - tint = if (result.allSuccess) colors.primary else colors.tertiary, - modifier = Modifier.size(28.dp) - ) - } - }, - title = { - Text( - text = if (result.allSuccess) stringResource(R.string.apply_config_result_success) else stringResource(R.string.apply_config_result_issues), - fontWeight = FontWeight.Bold, - style = MaterialTheme.typography.titleLarge - ) - }, - text = { - Column( - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - Text( - text = stringResource(R.string.apply_config_result_success_format, result.successCount, result.totalCount), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - - if (result.failures.isNotEmpty()) { - Text( - text = stringResource(R.string.apply_config_result_failed_repos), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.error - ) - - Column( - verticalArrangement = Arrangement.spacedBy(4.dp) - ) { - result.failures.forEach { (path, error) -> - Surface( - shape = RoundedCornerShape(6.dp), - color = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.2f) - ) { - Text( - text = stringResource(R.string.settings_error_format, path, error), - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.padding(8.dp), - fontFamily = FontFamily.Monospace - ) - } - } - } - } - } - }, - confirmButton = { - Button( - onClick = onDismiss, - shape = RoundedCornerShape(12.dp) - ) { - Text(stringResource(R.string.action_ok)) - } - }, - shape = RoundedCornerShape(24.dp) - ) -}@Composable private fun SettingsSwitchItem( title: String, subtitle: String, From 254f8aecdffec2be1a0813fbcff3dfe180d3dcdf Mon Sep 17 00:00:00 2001 From: Mirurin Date: Mon, 27 Apr 2026 23:12:18 +0800 Subject: [PATCH 20/24] refactor(security): centralize credential session and auto-lock management --- .../local/security/SecureCredentialStore.kt | 61 ++++--- .../repository/BiometricRepositoryImpl.kt | 14 +- .../repository/CredentialRepositoryImpl.kt | 4 +- .../domain/repository/BiometricRepository.kt | 35 +--- .../credential/CredentialStoreFacade.kt | 4 +- .../ui/screen/credentials/CredentialScreen.kt | 28 ---- .../credentials/CredentialStoreViewModel.kt | 78 +++++---- .../screen/credentials/ImportExportDialogs.kt | 11 +- .../ui/state/CredentialSessionManager.kt | 151 ++++++++++++++++++ 9 files changed, 247 insertions(+), 139 deletions(-) create mode 100644 app/src/main/java/jamgmilk/fuwagit/ui/state/CredentialSessionManager.kt diff --git a/app/src/main/java/jamgmilk/fuwagit/data/local/security/SecureCredentialStore.kt b/app/src/main/java/jamgmilk/fuwagit/data/local/security/SecureCredentialStore.kt index 0578b21..cb1f2b1 100644 --- a/app/src/main/java/jamgmilk/fuwagit/data/local/security/SecureCredentialStore.kt +++ b/app/src/main/java/jamgmilk/fuwagit/data/local/security/SecureCredentialStore.kt @@ -8,7 +8,9 @@ import jamgmilk.fuwagit.data.local.credential.ExportData import jamgmilk.fuwagit.data.local.credential.HttpsCredential import jamgmilk.fuwagit.data.local.credential.SshKey import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json import java.io.File @@ -28,10 +30,14 @@ private class SecureSecretKey(private val keyBytes: ByteArray, private val algor } } +sealed class VaultStateEvent { + data object Unlocked : VaultStateEvent() + data object Locked : VaultStateEvent() +} + @Singleton class SecureCredentialStore @Inject constructor( - @ApplicationContext private val context: Context, - private val appPreferencesStore: jamgmilk.fuwagit.data.local.prefs.AppPreferencesStore + @ApplicationContext private val context: Context ) { companion object { @@ -57,22 +63,8 @@ class SecureCredentialStore @Inject constructor( private val sessionLock = Any() private val fileLock = Any() - private suspend fun getSessionTimeoutMillis(): Long { - val timeoutSeconds = appPreferencesStore.preferencesFlow - .first { true } - .autoLockTimeout - .toLongOrNull() ?: 600L - - val validTimeout = when { - timeoutSeconds < 0 -> 0L - timeoutSeconds == 0L -> 0L - timeoutSeconds < 30 -> 30L - timeoutSeconds > 86400 -> 86400L - else -> timeoutSeconds - } - - return if (validTimeout == 0L) 0L else validTimeout * 1000L - } + private val _vaultStateEvents = MutableSharedFlow(extraBufferCapacity = 1) + val vaultStateEvents: SharedFlow = _vaultStateEvents.asSharedFlow() fun loadCredentialData(): CredentialData { synchronized(fileLock) { @@ -121,6 +113,7 @@ class SecureCredentialStore @Inject constructor( } fun cacheMasterKey(key: SecretKey) { + val wasLocked = cachedMasterKey == null synchronized(sessionLock) { secureClearCachedKey() val secureKey = SecureSecretKey(key.encoded, key.algorithm) @@ -128,9 +121,13 @@ class SecureCredentialStore @Inject constructor( lastUnlockTime = System.currentTimeMillis() isBiometricSession = false } + if (wasLocked) { + _vaultStateEvents.tryEmit(VaultStateEvent.Unlocked) + } } fun cacheMasterKeyFromBiometric(key: SecretKey) { + val wasLocked = cachedMasterKey == null synchronized(sessionLock) { secureClearCachedKey() val secureKey = SecureSecretKey(key.encoded, key.algorithm) @@ -138,6 +135,9 @@ class SecureCredentialStore @Inject constructor( lastUnlockTime = System.currentTimeMillis() isBiometricSession = true } + if (wasLocked) { + _vaultStateEvents.tryEmit(VaultStateEvent.Unlocked) + } } private fun secureClearCachedKey() { @@ -145,10 +145,12 @@ class SecureCredentialStore @Inject constructor( cachedMasterKey = null } - suspend fun getCachedMasterKey(): SecretKey? { - val sessionTimeout = getSessionTimeoutMillis() + fun getCachedMasterKey(): SecretKey? = getCachedMasterKey(0L) + + fun getCachedMasterKey(timeoutMillis: Long): SecretKey? { + var didTimeoutExpire = false - return synchronized(sessionLock) { + val key = synchronized(sessionLock) { val key = cachedMasterKey if (key == null) { @@ -157,28 +159,39 @@ class SecureCredentialStore @Inject constructor( return@synchronized null } - if (sessionTimeout == 0L) { + if (timeoutMillis == 0L) { return@synchronized key } val elapsed = System.currentTimeMillis() - lastUnlockTime - if (elapsed < sessionTimeout) { + if (elapsed < timeoutMillis) { return@synchronized key } + didTimeoutExpire = true secureClearCachedKey() lastUnlockTime = 0 isBiometricSession = false null } + + if (didTimeoutExpire) { + _vaultStateEvents.tryEmit(VaultStateEvent.Locked) + } + + return key } fun clearCachedMasterKey() { + val wasUnlocked = cachedMasterKey != null synchronized(sessionLock) { secureClearCachedKey() lastUnlockTime = 0 isBiometricSession = false } + if (wasUnlocked) { + _vaultStateEvents.tryEmit(VaultStateEvent.Locked) + } } suspend fun exportAllCredentials(masterKey: SecretKey): String { diff --git a/app/src/main/java/jamgmilk/fuwagit/data/repository/BiometricRepositoryImpl.kt b/app/src/main/java/jamgmilk/fuwagit/data/repository/BiometricRepositoryImpl.kt index d5b56b8..7d96489 100644 --- a/app/src/main/java/jamgmilk/fuwagit/data/repository/BiometricRepositoryImpl.kt +++ b/app/src/main/java/jamgmilk/fuwagit/data/repository/BiometricRepositoryImpl.kt @@ -63,10 +63,6 @@ class BiometricRepositoryImpl @Inject constructor( } } - override fun isBiometricEnabled(): Boolean { - return masterKeyManager.isBiometricEnabled() - } - override suspend fun disableBiometric(): AppResult { return AppResult.catching { biometricKeyManager.deleteBiometricKey() @@ -74,12 +70,4 @@ class BiometricRepositoryImpl @Inject constructor( masterKeyManager.setBiometricEnabledInternal(false) } } - - override fun isMasterPasswordSet(): Boolean { - return masterKeyManager.isMasterPasswordSet() - } - - override fun getMasterPasswordHint(): String? { - return masterKeyManager.getPasswordHint() - } -} +} \ No newline at end of file diff --git a/app/src/main/java/jamgmilk/fuwagit/data/repository/CredentialRepositoryImpl.kt b/app/src/main/java/jamgmilk/fuwagit/data/repository/CredentialRepositoryImpl.kt index 820c1c3..4b6b553 100644 --- a/app/src/main/java/jamgmilk/fuwagit/data/repository/CredentialRepositoryImpl.kt +++ b/app/src/main/java/jamgmilk/fuwagit/data/repository/CredentialRepositoryImpl.kt @@ -50,7 +50,7 @@ class CredentialRepositoryImpl @Inject constructor( override fun getMasterPasswordHint(): String? = masterKeyManager.getPasswordHint() - override suspend fun isUnlocked(): Boolean = secureStore.getCachedMasterKey() != null + override suspend fun isUnlocked(): Boolean = getCachedMasterKey() != null override fun lock() = secureStore.clearCachedMasterKey() @@ -60,7 +60,7 @@ class CredentialRepositoryImpl @Inject constructor( override fun setMasterKeyFromBiometric(key: SecretKey) = secureStore.cacheMasterKeyFromBiometric(key) - private suspend fun getMasterKey(): SecretKey = + private fun getMasterKey(): SecretKey = secureStore.getCachedMasterKey() ?: throw AppException.MasterKeyNotUnlocked() private suspend fun getCredentialOrThrow( diff --git a/app/src/main/java/jamgmilk/fuwagit/domain/repository/BiometricRepository.kt b/app/src/main/java/jamgmilk/fuwagit/domain/repository/BiometricRepository.kt index a0feb1a..ac754c5 100644 --- a/app/src/main/java/jamgmilk/fuwagit/domain/repository/BiometricRepository.kt +++ b/app/src/main/java/jamgmilk/fuwagit/domain/repository/BiometricRepository.kt @@ -4,20 +4,9 @@ import androidx.fragment.app.FragmentActivity import jamgmilk.fuwagit.core.result.AppResult import javax.crypto.SecretKey -/** - * Domain interface for biometric authentication operations. - * Extracted to prevent Domain → Data layer dependency violations. - */ interface BiometricRepository { - /** - * Check if biometric authentication can be used. - */ fun canAuthenticate(): Boolean - /** - * Enable biometric authentication by encrypting and storing the master key. - * Uses BiometricPrompt to secure the encryption. - */ suspend fun enableBiometric( activity: FragmentActivity, masterKey: SecretKey, @@ -26,10 +15,6 @@ interface BiometricRepository { negativeButtonText: String ): AppResult - /** - * Unlock using biometric authentication, returning the decrypted master key. - * Uses BiometricPrompt to secure the decryption. - */ suspend fun unlockWithBiometric( activity: FragmentActivity, title: String, @@ -37,23 +22,5 @@ interface BiometricRepository { negativeButtonText: String ): AppResult - /** - * Check if biometric authentication is enabled. - */ - fun isBiometricEnabled(): Boolean - - /** - * Disable biometric authentication. - */ suspend fun disableBiometric(): AppResult - - /** - * Check if master password is set. - */ - fun isMasterPasswordSet(): Boolean - - /** - * Get master password hint. - */ - fun getMasterPasswordHint(): String? -} +} \ No newline at end of file diff --git a/app/src/main/java/jamgmilk/fuwagit/domain/usecase/credential/CredentialStoreFacade.kt b/app/src/main/java/jamgmilk/fuwagit/domain/usecase/credential/CredentialStoreFacade.kt index 2607df9..0ae7cad 100644 --- a/app/src/main/java/jamgmilk/fuwagit/domain/usecase/credential/CredentialStoreFacade.kt +++ b/app/src/main/java/jamgmilk/fuwagit/domain/usecase/credential/CredentialStoreFacade.kt @@ -45,7 +45,7 @@ class CredentialStoreFacade @Inject constructor( } fun isBiometricEnabled(): Boolean { - return biometricRepository.isBiometricEnabled() + return credentialRepository.isBiometricEnabled() } fun getMasterPasswordHint(): String? { @@ -111,4 +111,4 @@ class CredentialStoreFacade @Inject constructor( suspend fun getSshPassphrase(uuid: String): AppResult { return credentialRepository.getSshPassphrase(uuid) } -} +} \ No newline at end of file diff --git a/app/src/main/java/jamgmilk/fuwagit/ui/screen/credentials/CredentialScreen.kt b/app/src/main/java/jamgmilk/fuwagit/ui/screen/credentials/CredentialScreen.kt index dc0c168..1b9cfb6 100644 --- a/app/src/main/java/jamgmilk/fuwagit/ui/screen/credentials/CredentialScreen.kt +++ b/app/src/main/java/jamgmilk/fuwagit/ui/screen/credentials/CredentialScreen.kt @@ -15,7 +15,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import androidx.fragment.app.FragmentActivity import androidx.lifecycle.compose.collectAsStateWithLifecycle import jamgmilk.fuwagit.R import jamgmilk.fuwagit.core.util.calculateFingerprint @@ -122,33 +121,6 @@ fun CredentialScreen( } } - if (uiState.showUnlockDialog) { - val biometricUnlockTitle = stringResource(R.string.biometric_unlock_title) - val biometricUnlockSubtitle = stringResource(R.string.credentials_unlock_biometric_subtitle) - val biometricUsePasswordText = stringResource(R.string.credentials_use_password) - UnlockDialog( - onDismiss = { viewModel.dismissUnlockDialog() }, - onUnlock = { password -> - viewModel.unlockWithPassword(password) - }, - biometricEnabled = uiState.isBiometricEnabled, - onUnlockWithBiometric = { - val activity = context as? FragmentActivity - activity?.let { - viewModel.unlockWithBiometric( - it, - biometricUnlockTitle, - biometricUnlockSubtitle, - biometricUsePasswordText - ) - } - }, - passwordHint = uiState.passwordHint, - error = uiState.error, - isLoading = uiState.isLoading - ) - } - when (val state = dialogState) { is CredentialDialogState.AddHttps -> { AddHttpsCredentialDialog( diff --git a/app/src/main/java/jamgmilk/fuwagit/ui/screen/credentials/CredentialStoreViewModel.kt b/app/src/main/java/jamgmilk/fuwagit/ui/screen/credentials/CredentialStoreViewModel.kt index 575f770..2b0b713 100644 --- a/app/src/main/java/jamgmilk/fuwagit/ui/screen/credentials/CredentialStoreViewModel.kt +++ b/app/src/main/java/jamgmilk/fuwagit/ui/screen/credentials/CredentialStoreViewModel.kt @@ -11,6 +11,7 @@ import jamgmilk.fuwagit.domain.model.credential.SshKey import jamgmilk.fuwagit.domain.usecase.credential.CredentialStoreFacade import jamgmilk.fuwagit.domain.usecase.git.TestSshConnectionUseCase import jamgmilk.fuwagit.ui.screen.permissions.SshTestResult +import jamgmilk.fuwagit.ui.state.CredentialSessionManager import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow @@ -48,7 +49,8 @@ data class CredentialsStoreUiState( @HiltViewModel class CredentialStoreViewModel @Inject constructor( private val credentialFacade: CredentialStoreFacade, - private val testSshConnectionUseCase: TestSshConnectionUseCase + private val testSshConnectionUseCase: TestSshConnectionUseCase, + private val sessionManager: CredentialSessionManager ) : ViewModel() { private val _uiState = MutableStateFlow(CredentialsStoreUiState()) @@ -58,17 +60,28 @@ data class CredentialsStoreUiState( val events: SharedFlow = _events.asSharedFlow() init { + observeGlobalSession() initialize() } - fun initialize() { - _uiState.update { - it.copy( - isMasterPasswordSet = credentialFacade.isMasterPasswordSet(), - isBiometricEnabled = credentialFacade.isBiometricEnabled(), - passwordHint = credentialFacade.getMasterPasswordHint() - ) + private fun observeGlobalSession() { + viewModelScope.launch { + sessionManager.sessionState.collect { session -> + _uiState.update { + it.copy( + isDecryptionUnlocked = session.isUnlocked, + isMasterPasswordSet = session.isMasterPasswordSet, + isBiometricEnabled = session.isBiometricEnabled, + passwordHint = session.passwordHint, + showUnlockDialog = session.showUnlockDialog + ) + } + } } + } + + fun initialize() { + sessionManager.refreshSessionState() loadCredentials() } @@ -76,12 +89,6 @@ data class CredentialsStoreUiState( executeWithLoading { credentialFacade.unlockWithPassword(password) .onSuccess { - _uiState.update { - it.copy( - isDecryptionUnlocked = true, - showUnlockDialog = false - ) - } viewModelScope.launch { _events.emit(CredentialStoreEvent.UnlockSuccess) } loadCredentials() } @@ -90,10 +97,6 @@ data class CredentialsStoreUiState( private fun loadCredentials() { viewModelScope.launch { - _uiState.update { - it.copy(isDecryptionUnlocked = credentialFacade.isUnlocked()) - } - credentialFacade.getHttpsCredentials() .onSuccess { credentials -> _uiState.update { it.copy(httpsCredentials = credentials) } @@ -147,14 +150,29 @@ data class CredentialsStoreUiState( } fun exportCredentials() { - executeWithLoading { - credentialFacade.exportCredentials() + viewModelScope.launch { + if (!credentialFacade.isUnlocked()) { + _uiState.update { + it.copy(isDecryptionUnlocked = false) + } + _events.emit(CredentialStoreEvent.Error("Vault is locked. Please unlock first.")) + return@launch + } + _uiState.update { it.copy(isLoading = true, error = null) } + val result = credentialFacade.exportCredentials() + result .onSuccess { data -> _uiState.update { it.copy(exportedData = data) } - _events.emit(CredentialStoreEvent.CredentialExported) } + .onError { e -> + _uiState.update { it.copy(error = e.message ?: "Unknown error") } + } + _uiState.update { it.copy(isLoading = false) } + result.onSuccess { + _events.emit(CredentialStoreEvent.CredentialExported) + } } } @@ -177,7 +195,7 @@ data class CredentialsStoreUiState( viewModelScope.launch { credentialFacade.enableBiometric(activity, title, subtitle, negativeButtonText) .onSuccess { - _uiState.update { it.copy(isBiometricEnabled = true) } + sessionManager.reloadConfig() _events.emit(CredentialStoreEvent.BiometricEnabled) } .onError { e -> @@ -196,13 +214,6 @@ data class CredentialsStoreUiState( viewModelScope.launch { credentialFacade.unlockWithBiometric(activity, title, subtitle, negativeButtonText) .onSuccess { - _uiState.update { - it.copy( - isDecryptionUnlocked = true, - isBiometricEnabled = true, - showUnlockDialog = false - ) - } _events.emit(CredentialStoreEvent.UnlockSuccess) loadCredentials() } @@ -216,23 +227,20 @@ data class CredentialsStoreUiState( fun disableBiometric() { viewModelScope.launch { credentialFacade.disableBiometric() - _uiState.update { it.copy(isBiometricEnabled = false) } + sessionManager.reloadConfig() } } fun lock() { credentialFacade.lock() - _uiState.update { - it.copy(isDecryptionUnlocked = false) - } } fun showUnlockDialog() { - _uiState.update { it.copy(showUnlockDialog = true) } + sessionManager.showUnlockDialog() } fun dismissUnlockDialog() { - _uiState.update { it.copy(showUnlockDialog = false) } + sessionManager.dismissUnlockDialog() } fun clearError() { diff --git a/app/src/main/java/jamgmilk/fuwagit/ui/screen/credentials/ImportExportDialogs.kt b/app/src/main/java/jamgmilk/fuwagit/ui/screen/credentials/ImportExportDialogs.kt index 8d9514a..1d2f598 100644 --- a/app/src/main/java/jamgmilk/fuwagit/ui/screen/credentials/ImportExportDialogs.kt +++ b/app/src/main/java/jamgmilk/fuwagit/ui/screen/credentials/ImportExportDialogs.kt @@ -64,15 +64,24 @@ fun ExportCredentialsDialog( val scope = rememberCoroutineScope() val clipboardManager = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val exportedData = uiState.exportedData var isLoading by remember { mutableStateOf(false) } LaunchedEffect(Unit) { + if (!uiState.isDecryptionUnlocked) { + onDismiss() + return@LaunchedEffect + } isLoading = true viewModel.exportCredentials() isLoading = false } - val exportedData = uiState.exportedData + LaunchedEffect(uiState.isDecryptionUnlocked, uiState.error) { + if (!uiState.isDecryptionUnlocked && exportedData == null && !isLoading) { + onDismiss() + } + } AlertDialog( onDismissRequest = onDismiss, diff --git a/app/src/main/java/jamgmilk/fuwagit/ui/state/CredentialSessionManager.kt b/app/src/main/java/jamgmilk/fuwagit/ui/state/CredentialSessionManager.kt new file mode 100644 index 0000000..da1b32a --- /dev/null +++ b/app/src/main/java/jamgmilk/fuwagit/ui/state/CredentialSessionManager.kt @@ -0,0 +1,151 @@ +package jamgmilk.fuwagit.ui.state + +import jamgmilk.fuwagit.data.local.prefs.AppPreferencesStore +import jamgmilk.fuwagit.data.local.security.MasterKeyManager +import jamgmilk.fuwagit.data.local.security.SecureCredentialStore +import jamgmilk.fuwagit.data.local.security.VaultStateEvent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import javax.inject.Inject +import javax.inject.Singleton + +data class CredentialSessionState( + val isUnlocked: Boolean = false, + val isMasterPasswordSet: Boolean = false, + val isBiometricEnabled: Boolean = false, + val passwordHint: String? = null, + val showUnlockDialog: Boolean = false +) + +@Singleton +class CredentialSessionManager @Inject constructor( + private val secureCredentialStore: SecureCredentialStore, + private val masterKeyManager: MasterKeyManager, + private val appPreferencesStore: AppPreferencesStore +) { + private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob()) + + private var cachedIsMasterPasswordSet: Boolean = false + private var cachedIsBiometricEnabled: Boolean = false + private var cachedPasswordHint: String? = null + + private val _sessionState = MutableStateFlow(CredentialSessionState()) + val sessionState: StateFlow = _sessionState.asStateFlow() + + private var autoLockJob: Job? = null + + init { + loadCachedConfig() + refreshSessionState() + observeVaultState() + } + + private fun observeVaultState() { + scope.launch { + secureCredentialStore.vaultStateEvents.collect { event -> + when (event) { + is VaultStateEvent.Unlocked -> onVaultUnlocked() + is VaultStateEvent.Locked -> onVaultLocked() + } + } + } + } + + private fun loadCachedConfig() { + cachedIsMasterPasswordSet = masterKeyManager.isMasterPasswordSet() + cachedIsBiometricEnabled = masterKeyManager.isBiometricEnabled() + cachedPasswordHint = masterKeyManager.getPasswordHint() + } + + fun refreshSessionState() { + scope.launch { + val isUnlocked = isVaultUnlockedInternal() + _sessionState.value = _sessionState.value.copy( + isUnlocked = isUnlocked, + isMasterPasswordSet = cachedIsMasterPasswordSet, + isBiometricEnabled = cachedIsBiometricEnabled, + passwordHint = cachedPasswordHint + ) + } + } + + fun reloadConfig() { + loadCachedConfig() + refreshSessionState() + } + + private suspend fun getSessionTimeoutMillis(): Long { + val timeoutSeconds = appPreferencesStore.preferencesFlow + .first { true } + .autoLockTimeout + .toLongOrNull() ?: 600L + + return when { + timeoutSeconds < 0 -> 0L + timeoutSeconds == 0L -> 0L + timeoutSeconds < 30 -> 30L + timeoutSeconds > 86400 -> 86400L + else -> timeoutSeconds + } * 1000L + } + + private suspend fun isVaultUnlockedInternal(): Boolean { + val timeout = getSessionTimeoutMillis() + return secureCredentialStore.getCachedMasterKey(timeout) != null + } + + @Suppress("UNUSED") + suspend fun isVaultUnlocked(): Boolean { + return isVaultUnlockedInternal() + } + + private fun onVaultUnlocked() { + _sessionState.value = _sessionState.value.copy( + showUnlockDialog = false, + isUnlocked = true + ) + startAutoLockTimer() + } + + private fun onVaultLocked() { + cancelAutoLockTimer() + _sessionState.value = _sessionState.value.copy( + showUnlockDialog = false, + isUnlocked = false + ) + } + + private fun startAutoLockTimer() { + cancelAutoLockTimer() + scope.launch { + val timeout = getSessionTimeoutMillis() + if (timeout > 0) { + autoLockJob = scope.launch { + delay(timeout) + secureCredentialStore.clearCachedMasterKey() + } + } + } + } + + private fun cancelAutoLockTimer() { + autoLockJob?.cancel() + autoLockJob = null + } + + fun showUnlockDialog() { + _sessionState.value = _sessionState.value.copy(showUnlockDialog = true) + } + + fun dismissUnlockDialog() { + _sessionState.value = _sessionState.value.copy(showUnlockDialog = false) + } +} \ No newline at end of file From 8c25dbfbd29d52f6d0b964417faff7dfc1d4cdb5 Mon Sep 17 00:00:00 2001 From: Mirurin Date: Mon, 27 Apr 2026 23:33:07 +0800 Subject: [PATCH 21/24] chore(deps): remove unused dependencies --- .idea/.gitignore | 1 + app/build.gradle.kts | 4 ---- gradle/libs.versions.toml | 9 ++------- gradle/wrapper/gradle-wrapper.properties | 4 ++-- 4 files changed, 5 insertions(+), 13 deletions(-) diff --git a/.idea/.gitignore b/.idea/.gitignore index 1bcf9a5..8ca85cb 100644 --- a/.idea/.gitignore +++ b/.idea/.gitignore @@ -18,6 +18,7 @@ compiler.xml vcs.xml markdown.xml .name +planningMode.xml # --- Project Structure --- libraries/ diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b5ed792..8f48ee1 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -97,7 +97,6 @@ dependencies { implementation(libs.androidx.compose.foundation) implementation(libs.androidx.compose.material3) implementation(libs.androidx.compose.material.icons.extended) - implementation(libs.androidx.compose.foundation.layout) // Other AndroidX implementation(libs.androidx.documentfile) @@ -121,16 +120,13 @@ dependencies { // Hilt implementation(libs.hilt.android) implementation(libs.hilt.navigation.compose) - implementation(libs.androidx.uiautomator) implementation(libs.androidx.compose.ui.graphics) - implementation(libs.androidx.foundation.layout) ksp(libs.hilt.compiler) // Test Dependencies testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) - androidTestImplementation(libs.androidx.uiautomator) androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(libs.androidx.compose.ui.test.junit4) debugImplementation(libs.androidx.compose.ui.tooling) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e7f9fc3..ed6d5e3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -4,10 +4,9 @@ coreKtx = "1.18.0" junit = "4.13.2" junitVersion = "1.3.0" espressoCore = "3.7.0" -uiautomator = "2.3.0" lifecycleRuntimeKtx = "2.10.0" activityCompose = "1.13.0" -kotlin = "2.3.20" +kotlin = "2.3.21" composeBom = "2026.04.01" orgEclipseJgit = "6.8.0.202311291450-r" documentfile = "1.1.0" @@ -21,9 +20,8 @@ datastore = "1.2.1" appcompat = "1.7.1" hilt = "2.59.2" hiltNavigationCompose = "1.3.0" -ksp = "2.3.6" +ksp = "2.3.7" uiGraphics = "1.11.0" -foundationLayout = "1.11.0" [libraries] # Core & Lifecycle @@ -40,7 +38,6 @@ androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-te androidx-compose-foundation = { group = "androidx.compose.foundation", name = "foundation" } androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" } androidx-compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" } -androidx-compose-foundation-layout = { group = "androidx.compose.foundation", name = "foundation-layout" } # Other AndroidX androidx-documentfile = { group = "androidx.documentfile", name = "documentfile", version.ref = "documentfile" } @@ -68,9 +65,7 @@ hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-com junit = { group = "junit", name = "junit", version.ref = "junit" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } -androidx-uiautomator = { group = "androidx.test.uiautomator", name = "uiautomator", version.ref = "uiautomator" } androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics", version.ref = "uiGraphics" } -androidx-foundation-layout = { group = "androidx.compose.foundation", name = "foundation-layout", version.ref = "foundationLayout" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 1afdcd9..b394cd2 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,8 +1,8 @@ #Thu Mar 19 10:29:42 CST 2026 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionSha256Sum=f0effd0ab258b23da888a94cae8602447f8824c65a61dc14e4014f9acdb09950 -distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.0-rc-2-bin.zip +distributionSha256Sum=8bf1cd7dcadb6d5de70aecf4548465c05973a2a3db8063c72db667314405bee3 +distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.0-milestone-1-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME From 8c3b705c11f8922d2cde8d7dbf2679aea65bdb87 Mon Sep 17 00:00:00 2001 From: Mirurin Date: Mon, 27 Apr 2026 23:50:29 +0800 Subject: [PATCH 22/24] fix(ui): show unlock dialog directly in StatusScreen and CredentialScreen --- .../fuwagit/ui/screen/main/AppNavHost.kt | 30 +++++++++++--- .../fuwagit/ui/screen/status/StatusScreen.kt | 40 +++++++++++++++---- app/src/main/res/values-b+zh+Hans/strings.xml | 1 - app/src/main/res/values/strings.xml | 1 - 4 files changed, 58 insertions(+), 14 deletions(-) diff --git a/app/src/main/java/jamgmilk/fuwagit/ui/screen/main/AppNavHost.kt b/app/src/main/java/jamgmilk/fuwagit/ui/screen/main/AppNavHost.kt index 7930d94..ef35e8f 100644 --- a/app/src/main/java/jamgmilk/fuwagit/ui/screen/main/AppNavHost.kt +++ b/app/src/main/java/jamgmilk/fuwagit/ui/screen/main/AppNavHost.kt @@ -263,14 +263,33 @@ fun AppNavHost(navController: NavHostController, startDestination: String = NavR } composable(NavRoutes.CREDENTIALS) { - val activity = LocalContext.current.requireActivity() - val credentialsViewModel: CredentialStoreViewModel = hiltViewModel( - viewModelStoreOwner = activity - ) + val credentialUiState by credentialStoreViewModel.uiState.collectAsStateWithLifecycle() + CredentialScreen( - viewModel = credentialsViewModel, + viewModel = credentialStoreViewModel, onBack = { navController.popBackStack() } ) + + if (credentialUiState.showUnlockDialog) { + UnlockDialog( + onDismiss = { credentialStoreViewModel.dismissUnlockDialog() }, + onUnlock = { password -> + credentialStoreViewModel.unlockWithPassword(password) + }, + biometricEnabled = credentialUiState.isBiometricEnabled, + onUnlockWithBiometric = { + credentialStoreViewModel.unlockWithBiometric( + activity, + context.getString(R.string.biometric_unlock_title), + context.getString(R.string.credentials_unlock_biometric_subtitle), + context.getString(R.string.credentials_use_password) + ) + }, + passwordHint = credentialUiState.passwordHint, + error = credentialUiState.error, + isLoading = credentialUiState.isLoading + ) + } } composable(NavRoutes.MASTER_PASSWORD) { @@ -398,6 +417,7 @@ fun MainScreen( when (MainPage.entries.getOrNull(page)) { MainPage.Status -> StatusScreen( statusViewModel = statusViewModel, + credentialStoreViewModel = credentialStoreViewModel, modifier = Modifier.fillMaxSize(), onViewDiff = { filePath, isStaged -> val diffType = if (isStaged) DiffType.STAGED else DiffType.WORKING_TREE diff --git a/app/src/main/java/jamgmilk/fuwagit/ui/screen/status/StatusScreen.kt b/app/src/main/java/jamgmilk/fuwagit/ui/screen/status/StatusScreen.kt index 7ed0288..5f4c1e2 100644 --- a/app/src/main/java/jamgmilk/fuwagit/ui/screen/status/StatusScreen.kt +++ b/app/src/main/java/jamgmilk/fuwagit/ui/screen/status/StatusScreen.kt @@ -18,7 +18,6 @@ import androidx.compose.material3.FilledTonalIconButton import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.SnackbarDuration import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf @@ -33,6 +32,7 @@ import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import androidx.fragment.app.FragmentActivity import androidx.lifecycle.compose.collectAsStateWithLifecycle import jamgmilk.fuwagit.R import jamgmilk.fuwagit.domain.model.git.GitChangeType @@ -40,6 +40,8 @@ import jamgmilk.fuwagit.domain.model.git.GitFileStatus import jamgmilk.fuwagit.ui.components.DangerousOperationType import jamgmilk.fuwagit.ui.components.ScreenTemplate import jamgmilk.fuwagit.ui.components.TwoStepConfirmDialog +import jamgmilk.fuwagit.ui.screen.credentials.CredentialStoreViewModel +import jamgmilk.fuwagit.ui.screen.credentials.UnlockDialog import kotlinx.coroutines.launch data class StatusStats( @@ -55,10 +57,12 @@ data class StatusStats( @Composable fun StatusScreen( statusViewModel: StatusViewModel, + credentialStoreViewModel: CredentialStoreViewModel, modifier: Modifier = Modifier, onViewDiff: ((String, Boolean) -> Unit)? = null ) { val uiState by statusViewModel.uiState.collectAsStateWithLifecycle() + val credentialUiState by credentialStoreViewModel.uiState.collectAsStateWithLifecycle() val files = uiState.workspaceFiles val staged by remember(files) { derivedStateOf { files.filter { it.isStaged } } } val workspace by remember(files) { derivedStateOf { files.filter { !it.isStaged } } } @@ -69,6 +73,7 @@ fun StatusScreen( val currentBranch = uiState.currentBranch val colors = MaterialTheme.colorScheme val context = LocalContext.current + val activity = context as FragmentActivity val scope = rememberCoroutineScope() val snackbarHostState = remember { SnackbarHostState() } @@ -85,35 +90,35 @@ fun StatusScreen( scope.launch { snackbarHostState.showSnackbar(event.message) } } is StatusEvent.PushError -> { - scope.launch { snackbarHostState.showSnackbar(context.getString(R.string.status_push_failed, event.message), duration = SnackbarDuration.Long) } + scope.launch { snackbarHostState.showSnackbar(context.getString(R.string.status_push_failed, event.message)) } } is StatusEvent.PullSuccess -> { scope.launch { snackbarHostState.showSnackbar(event.message) } } is StatusEvent.PullError -> { - scope.launch { snackbarHostState.showSnackbar(context.getString(R.string.status_pull_failed, event.message), duration = SnackbarDuration.Long) } + scope.launch { snackbarHostState.showSnackbar(context.getString(R.string.status_pull_failed, event.message)) } } is StatusEvent.FetchSuccess -> { scope.launch { snackbarHostState.showSnackbar(event.message) } } is StatusEvent.FetchError -> { - scope.launch { snackbarHostState.showSnackbar(context.getString(R.string.status_fetch_failed, event.message), duration = SnackbarDuration.Long) } + scope.launch { snackbarHostState.showSnackbar(context.getString(R.string.status_fetch_failed, event.message)) } } is StatusEvent.CredentialUnlockRequired -> { - scope.launch { snackbarHostState.showSnackbar(context.getString(R.string.status_credential_unlock_required)) } + credentialStoreViewModel.showUnlockDialog() } is StatusEvent.DiscardChangesSuccess -> { scope.launch { snackbarHostState.showSnackbar(context.getString(R.string.vm_changes_discarded, event.fileName)) } } is StatusEvent.DiscardChangesError -> { val message = "${event.reason}\n${event.suggestion}" - scope.launch { snackbarHostState.showSnackbar(message, duration = SnackbarDuration.Long) } + scope.launch { snackbarHostState.showSnackbar(message) } } is StatusEvent.ConflictResolved -> { scope.launch { snackbarHostState.showSnackbar(event.message) } } is StatusEvent.ConflictError -> { - scope.launch { snackbarHostState.showSnackbar(event.message, duration = SnackbarDuration.Long) } + scope.launch { snackbarHostState.showSnackbar(event.message) } } is StatusEvent.AbortRebaseSuccess -> { scope.launch { snackbarHostState.showSnackbar(event.message) } @@ -339,6 +344,27 @@ fun StatusScreen( onDismiss = { statusViewModel.cancelConflictResolution() } ) } + + if (credentialUiState.showUnlockDialog) { + UnlockDialog( + onDismiss = { credentialStoreViewModel.dismissUnlockDialog() }, + onUnlock = { password -> + credentialStoreViewModel.unlockWithPassword(password) + }, + biometricEnabled = credentialUiState.isBiometricEnabled, + onUnlockWithBiometric = { + credentialStoreViewModel.unlockWithBiometric( + activity, + context.getString(R.string.biometric_unlock_title), + context.getString(R.string.credentials_unlock_biometric_subtitle), + context.getString(R.string.credentials_use_password) + ) + }, + passwordHint = credentialUiState.passwordHint, + error = credentialUiState.error, + isLoading = credentialUiState.isLoading + ) + } } @Composable diff --git a/app/src/main/res/values-b+zh+Hans/strings.xml b/app/src/main/res/values-b+zh+Hans/strings.xml index 27fa562..b2debaf 100644 --- a/app/src/main/res/values-b+zh+Hans/strings.xml +++ b/app/src/main/res/values-b+zh+Hans/strings.xml @@ -200,7 +200,6 @@ Push 失败:%1$s Pull 失败:%1$s Fetch 失败:%1$s - 请先在设置中解锁凭据保险库 刷新 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2027604..74aaa6f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -192,7 +192,6 @@ Push failed: %1$s Pull failed: %1$s Fetch failed: %1$s - Please unlock credential vault in Settings first Push Fetch Stage All From 170be8a70e890a338adaceb3366910da375208fc Mon Sep 17 00:00:00 2001 From: Mirurin Date: Tue, 28 Apr 2026 12:02:56 +0800 Subject: [PATCH 23/24] refactor(ui): hoist string resources to composable scope --- .../ui/screen/branches/BranchesScreen.kt | 33 +++++++------- .../credentials/CredentialInfoDialogs.kt | 29 +++++++++---- .../ui/screen/credentials/CredentialScreen.kt | 12 +++--- .../screen/credentials/ImportExportDialogs.kt | 3 +- .../fuwagit/ui/screen/myrepos/CloneContent.kt | 17 +++++--- .../ui/screen/myrepos/MyReposScreen.kt | 9 ++-- .../ui/screen/settings/SettingsScreen.kt | 43 ++++++++++++------- .../fuwagit/ui/screen/status/StatusScreen.kt | 22 +++++++--- .../fuwagit/ui/screen/tags/TagsScreen.kt | 27 ++++++------ 9 files changed, 115 insertions(+), 80 deletions(-) diff --git a/app/src/main/java/jamgmilk/fuwagit/ui/screen/branches/BranchesScreen.kt b/app/src/main/java/jamgmilk/fuwagit/ui/screen/branches/BranchesScreen.kt index 8db61b3..c807f8c 100644 --- a/app/src/main/java/jamgmilk/fuwagit/ui/screen/branches/BranchesScreen.kt +++ b/app/src/main/java/jamgmilk/fuwagit/ui/screen/branches/BranchesScreen.kt @@ -85,7 +85,6 @@ import jamgmilk.fuwagit.ui.screen.tags.TagsViewModel import jamgmilk.fuwagit.ui.theme.AppShapes import kotlinx.coroutines.launch - @Composable fun BranchesScreen( branchesViewModel: BranchesViewModel, @@ -200,7 +199,6 @@ fun BranchesScreen( } } - // Tags 对话框(当在 BranchesScreen 中显示 Tags 时) if (showTagsView && tagsViewModel != null) { TagsDialogs(tagsViewModel = tagsViewModel) } @@ -304,15 +302,19 @@ fun BranchesScreen( ) } - // Event handling for snackbar - val context = LocalContext.current val scope = rememberCoroutineScope() + val vmBranchDeletedSuccess = stringResource(R.string.vm_branch_deleted_success) + val vmMergeSuccess = stringResource(R.string.vm_merge_success) + val vmRebaseSuccess = stringResource(R.string.vm_rebase_success) + val vmCheckoutSuccess = stringResource(R.string.vm_checkout_success) + val vmBranchCreatedSuccess = stringResource(R.string.vm_branch_created_success) + val vmBranchRenamedSuccess = stringResource(R.string.vm_branch_renamed_success) LaunchedEffect(Unit) { branchesViewModel.events.collect { event -> when (event) { is BranchUiEvent.DeleteSuccess -> { - scope.launch { snackbarHostState.showSnackbar(context.getString(R.string.vm_branch_deleted_success, event.branchName)) } + scope.launch { snackbarHostState.showSnackbar(String.format(vmBranchDeletedSuccess, event.branchName)) } } is BranchUiEvent.DeleteError -> { val message = if (event.suggestion != null) { @@ -323,7 +325,7 @@ fun BranchesScreen( scope.launch { snackbarHostState.showSnackbar(message, duration = SnackbarDuration.Long) } } is BranchUiEvent.MergeSuccess -> { - scope.launch { snackbarHostState.showSnackbar(context.getString(R.string.vm_merge_success, event.branchName)) } + scope.launch { snackbarHostState.showSnackbar(String.format(vmMergeSuccess, event.branchName)) } } is BranchUiEvent.MergeConflict -> { scope.launch { snackbarHostState.showSnackbar(event.hint, duration = SnackbarDuration.Long) } @@ -332,7 +334,7 @@ fun BranchesScreen( scope.launch { snackbarHostState.showSnackbar(event.suggestion, duration = SnackbarDuration.Long) } } is BranchUiEvent.RebaseSuccess -> { - scope.launch { snackbarHostState.showSnackbar(context.getString(R.string.vm_rebase_success, event.branchName)) } + scope.launch { snackbarHostState.showSnackbar(String.format(vmRebaseSuccess, event.branchName)) } } is BranchUiEvent.RebaseConflict -> { scope.launch { snackbarHostState.showSnackbar(event.hint, duration = SnackbarDuration.Long) } @@ -341,13 +343,13 @@ fun BranchesScreen( scope.launch { snackbarHostState.showSnackbar(event.suggestion, duration = SnackbarDuration.Long) } } is BranchUiEvent.CheckoutSuccess -> { - scope.launch { snackbarHostState.showSnackbar(context.getString(R.string.vm_checkout_success, event.branchName)) } + scope.launch { snackbarHostState.showSnackbar(String.format(vmCheckoutSuccess, event.branchName)) } } is BranchUiEvent.CreateBranchSuccess -> { - scope.launch { snackbarHostState.showSnackbar(context.getString(R.string.vm_branch_created_success, event.branchName)) } + scope.launch { snackbarHostState.showSnackbar(String.format(vmBranchCreatedSuccess, event.branchName)) } } is BranchUiEvent.RenameSuccess -> { - scope.launch { snackbarHostState.showSnackbar(context.getString(R.string.vm_branch_renamed_success, event.newName)) } + scope.launch { snackbarHostState.showSnackbar(String.format(vmBranchRenamedSuccess, event.newName)) } } is BranchUiEvent.ConflictResolved -> { scope.launch { snackbarHostState.showSnackbar(event.message) } @@ -407,7 +409,6 @@ fun BranchesScreen( } } - // ConflictResolutionDialog if (conflictResult != null && uiState.isResolvingConflict) { val isRebase = conflictResult.operationType == "REBASE" val allResolved = conflictResult.allResolved @@ -761,7 +762,7 @@ private fun BranchItem( onClick = { val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager clipboard.setPrimaryClip(ClipData.newPlainText(strings.branchNameClipboard, branch.name)) - scope.launch { snackbarHostState.showSnackbar(context.getString(R.string.branches_name_copied)) } + scope.launch { snackbarHostState.showSnackbar(strings.nameCopied) } showMenu = false }, leadingIcon = { @@ -790,7 +791,7 @@ private fun BranchItem( text = { Text(stringResource(R.string.branches_merge_into_current)) }, onClick = { onMerge?.invoke() ?: run { - scope.launch { snackbarHostState.showSnackbar(context.getString(R.string.branches_merge_only_local)) } + scope.launch { snackbarHostState.showSnackbar(strings.mergeOnlyLocal) } } showMenu = false }, @@ -803,7 +804,7 @@ private fun BranchItem( text = { Text(stringResource(R.string.branches_rebase_onto_current)) }, onClick = { onRebase?.invoke() ?: run { - scope.launch { snackbarHostState.showSnackbar(context.getString(R.string.branches_rebase_only_local)) } + scope.launch { snackbarHostState.showSnackbar(strings.rebaseOnlyLocal) } } showMenu = false }, @@ -816,7 +817,7 @@ private fun BranchItem( text = { Text(stringResource(R.string.branches_rename)) }, onClick = { onRename?.invoke() ?: run { - scope.launch { snackbarHostState.showSnackbar(context.getString(R.string.branches_rename_only_local)) } + scope.launch { snackbarHostState.showSnackbar(strings.renameOnlyLocal) } } showMenu = false }, @@ -829,7 +830,7 @@ private fun BranchItem( text = { Text(stringResource(R.string.branches_delete_branch)) }, onClick = { onDelete?.invoke() ?: run { - scope.launch { snackbarHostState.showSnackbar(context.getString(R.string.branches_delete_only_local)) } + scope.launch { snackbarHostState.showSnackbar(strings.deleteOnlyLocal) } } showMenu = false }, diff --git a/app/src/main/java/jamgmilk/fuwagit/ui/screen/credentials/CredentialInfoDialogs.kt b/app/src/main/java/jamgmilk/fuwagit/ui/screen/credentials/CredentialInfoDialogs.kt index 7397f4a..b44b1b4 100644 --- a/app/src/main/java/jamgmilk/fuwagit/ui/screen/credentials/CredentialInfoDialogs.kt +++ b/app/src/main/java/jamgmilk/fuwagit/ui/screen/credentials/CredentialInfoDialogs.kt @@ -61,6 +61,10 @@ fun HttpsCredentialInfoDialog( val clipboardManager = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager val scope = rememberCoroutineScope() + val credentialsHostCopied = stringResource(R.string.credentials_host_copied) + val credentialsUsernameCopied = stringResource(R.string.credentials_username_copied) + val credentialsPasswordCopied = stringResource(R.string.credentials_password_copied) + var passwordValue by remember { mutableStateOf(null) } var showPassword by remember { mutableStateOf(false) } var showDeleteConfirm by remember { mutableStateOf(false) } @@ -93,12 +97,12 @@ fun HttpsCredentialInfoDialog( Column(modifier = Modifier.fillMaxWidth().verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(12.dp)) { SensitiveInfoRow( label = stringResource(R.string.credential_info_host), value = credential.host, - onCopy = { clipboardManager.setPrimaryClip(ClipData.newPlainText(null, credential.host)); scope.launch { snackbarHostState.showSnackbar(context.getString(R.string.credentials_host_copied)) } } + onCopy = { clipboardManager.setPrimaryClip(ClipData.newPlainText(null, credential.host)); scope.launch { snackbarHostState.showSnackbar(credentialsHostCopied) } } ) HorizontalDivider(color = colors.outline.copy(alpha = 0.2f)) SensitiveInfoRow( label = stringResource(R.string.credential_info_username), value = credential.username, - onCopy = { clipboardManager.setPrimaryClip(ClipData.newPlainText(null, credential.username)); scope.launch { snackbarHostState.showSnackbar(context.getString(R.string.credentials_username_copied)) } } + onCopy = { clipboardManager.setPrimaryClip(ClipData.newPlainText(null, credential.username)); scope.launch { snackbarHostState.showSnackbar(credentialsUsernameCopied) } } ) HorizontalDivider(color = colors.outline.copy(alpha = 0.2f)) if (!isDecryptionUnlocked && passwordValue == null) { @@ -117,7 +121,7 @@ fun HttpsCredentialInfoDialog( SensitiveInfoRow( label = stringResource(R.string.credential_info_password), value = passwordValue ?: "", isSensitive = true, isRevealed = showPassword, onToggleReveal = { showPassword = !showPassword }, - onCopy = { clipboardManager.setPrimaryClip(ClipData.newPlainText(null, passwordValue ?: "")); scope.launch { snackbarHostState.showSnackbar(context.getString(R.string.credentials_password_copied)) } } + onCopy = { clipboardManager.setPrimaryClip(ClipData.newPlainText(null, passwordValue ?: "")); scope.launch { snackbarHostState.showSnackbar(credentialsPasswordCopied) } } ) } HorizontalDivider(color = colors.outline.copy(alpha = 0.2f)) @@ -154,6 +158,13 @@ fun SshKeyInfoDialog( val clipboardManager = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager val scope = rememberCoroutineScope() + val credentialsNameCopied = stringResource(R.string.credentials_name_copied) + val credentialsTypeCopied = stringResource(R.string.credentials_type_copied) + val credentialsFingerprintCopied = stringResource(R.string.credentials_fingerprint_copied) + val credentialsCommentCopied = stringResource(R.string.credentials_comment_copied) + val credentialsPublicKeyCopied = stringResource(R.string.credentials_public_key_copied) + val credentialsPrivateKeyCopied = stringResource(R.string.credentials_private_key_copied) + var privateKeyValue by remember { mutableStateOf(null) } var showPrivateKey by remember { mutableStateOf(false) } var showDeleteConfirm by remember { mutableStateOf(false) } @@ -184,17 +195,17 @@ fun SshKeyInfoDialog( }, text = { Column(modifier = Modifier.fillMaxWidth().verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(12.dp)) { - SensitiveInfoRow(label = stringResource(R.string.credential_info_name), value = key.name, onCopy = { clipboardManager.setPrimaryClip(ClipData.newPlainText(null, key.name)); scope.launch { snackbarHostState.showSnackbar(context.getString(R.string.credentials_name_copied)) } }) + SensitiveInfoRow(label = stringResource(R.string.credential_info_name), value = key.name, onCopy = { clipboardManager.setPrimaryClip(ClipData.newPlainText(null, key.name)); scope.launch { snackbarHostState.showSnackbar(credentialsNameCopied) } }) HorizontalDivider(color = colors.outline.copy(alpha = 0.2f)) - SensitiveInfoRow(label = stringResource(R.string.credential_info_type), value = key.type, valueColor = colors.tertiary, onCopy = { clipboardManager.setPrimaryClip(ClipData.newPlainText(null, key.type)); scope.launch { snackbarHostState.showSnackbar(context.getString(R.string.credentials_type_copied)) } }) + SensitiveInfoRow(label = stringResource(R.string.credential_info_type), value = key.type, valueColor = colors.tertiary, onCopy = { clipboardManager.setPrimaryClip(ClipData.newPlainText(null, key.type)); scope.launch { snackbarHostState.showSnackbar(credentialsTypeCopied) } }) HorizontalDivider(color = colors.outline.copy(alpha = 0.2f)) - SensitiveInfoRow(label = stringResource(R.string.credential_info_fingerprint), value = key.fingerprint, isMonospace = true, onCopy = { clipboardManager.setPrimaryClip(ClipData.newPlainText(null, key.fingerprint)); scope.launch { snackbarHostState.showSnackbar(context.getString(R.string.credentials_fingerprint_copied)) } }) + SensitiveInfoRow(label = stringResource(R.string.credential_info_fingerprint), value = key.fingerprint, isMonospace = true, onCopy = { clipboardManager.setPrimaryClip(ClipData.newPlainText(null, key.fingerprint)); scope.launch { snackbarHostState.showSnackbar(credentialsFingerprintCopied) } }) HorizontalDivider(color = colors.outline.copy(alpha = 0.2f)) if (key.comment.isNotBlank()) { - SensitiveInfoRow(label = stringResource(R.string.credential_info_comment), value = key.comment, onCopy = { clipboardManager.setPrimaryClip(ClipData.newPlainText(null, key.comment)); scope.launch { snackbarHostState.showSnackbar(context.getString(R.string.credentials_comment_copied)) } }) + SensitiveInfoRow(label = stringResource(R.string.credential_info_comment), value = key.comment, onCopy = { clipboardManager.setPrimaryClip(ClipData.newPlainText(null, key.comment)); scope.launch { snackbarHostState.showSnackbar(credentialsCommentCopied) } }) HorizontalDivider(color = colors.outline.copy(alpha = 0.2f)) } - SensitiveInfoRow(label = stringResource(R.string.credential_info_public_key), value = key.publicKey, isMonospace = true, isSensitive = false, isRevealed = true, showToggle = false, onCopy = { clipboardManager.setPrimaryClip(ClipData.newPlainText(null, key.publicKey)); scope.launch { snackbarHostState.showSnackbar(context.getString(R.string.credentials_public_key_copied)) } }) + SensitiveInfoRow(label = stringResource(R.string.credential_info_public_key), value = key.publicKey, isMonospace = true, isSensitive = false, isRevealed = true, showToggle = false, onCopy = { clipboardManager.setPrimaryClip(ClipData.newPlainText(null, key.publicKey)); scope.launch { snackbarHostState.showSnackbar(credentialsPublicKeyCopied) } }) HorizontalDivider(color = colors.outline.copy(alpha = 0.2f)) if (!isDecryptionUnlocked && privateKeyValue == null) { Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { @@ -210,7 +221,7 @@ fun SshKeyInfoDialog( } HorizontalDivider(color = colors.outline.copy(alpha = 0.2f)) } else if (privateKeyValue != null) { - SensitiveInfoRow(label = stringResource(R.string.credential_info_private_key), value = privateKeyValue ?: "", isMonospace = true, isSensitive = true, isRevealed = showPrivateKey, onToggleReveal = { showPrivateKey = !showPrivateKey }, onCopy = { clipboardManager.setPrimaryClip(ClipData.newPlainText(null, privateKeyValue ?: "")); scope.launch { snackbarHostState.showSnackbar(context.getString(R.string.credentials_private_key_copied)) } }) + SensitiveInfoRow(label = stringResource(R.string.credential_info_private_key), value = privateKeyValue ?: "", isMonospace = true, isSensitive = true, isRevealed = showPrivateKey, onToggleReveal = { showPrivateKey = !showPrivateKey }, onCopy = { clipboardManager.setPrimaryClip(ClipData.newPlainText(null, privateKeyValue ?: "")); scope.launch { snackbarHostState.showSnackbar(credentialsPrivateKeyCopied) } }) HorizontalDivider(color = colors.outline.copy(alpha = 0.2f)) } InfoRow(label = stringResource(R.string.credential_info_passphrase), value = if (key.passphrase != null) stringResource(R.string.credential_info_passphrase_protected) else stringResource(R.string.credential_info_passphrase_none), valueColor = if (key.passphrase != null) colors.tertiary else colors.onSurfaceVariant) diff --git a/app/src/main/java/jamgmilk/fuwagit/ui/screen/credentials/CredentialScreen.kt b/app/src/main/java/jamgmilk/fuwagit/ui/screen/credentials/CredentialScreen.kt index 1b9cfb6..332015c 100644 --- a/app/src/main/java/jamgmilk/fuwagit/ui/screen/credentials/CredentialScreen.kt +++ b/app/src/main/java/jamgmilk/fuwagit/ui/screen/credentials/CredentialScreen.kt @@ -12,7 +12,6 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -42,12 +41,15 @@ fun CredentialScreen( onBack: () -> Unit, modifier: Modifier = Modifier ) { - val context = LocalContext.current val scope = rememberCoroutineScope() val snackbarHostState = remember { androidx.compose.material3.SnackbarHostState() } val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val credentialsFailedGenerateSshKey = stringResource(R.string.credentials_failed_generate_ssh_key) + val credentialsErrorGeneratingSshKey = stringResource(R.string.credentials_error_generating_ssh_key) + val credentialsErrorImportingKey = stringResource(R.string.credentials_error_importing_key) + var dialogState by remember { mutableStateOf(CredentialDialogState.None) } var showDeleteConfirm by remember { mutableStateOf?>(null) } @@ -149,12 +151,12 @@ fun CredentialScreen( ) } else { scope.launch { - snackbarHostState.showSnackbar(context.getString(R.string.credentials_failed_generate_ssh_key)) + snackbarHostState.showSnackbar(credentialsFailedGenerateSshKey) } } } catch (e: Exception) { scope.launch { - snackbarHostState.showSnackbar(context.getString(R.string.credentials_error_generating_ssh_key, e.message ?: "")) + snackbarHostState.showSnackbar(String.format(credentialsErrorGeneratingSshKey, e.message ?: "")) } } dialogState = CredentialDialogState.None @@ -189,7 +191,7 @@ fun CredentialScreen( } catch (e: Exception) { scope.launch { snackbarHostState.showSnackbar( - context.getString(R.string.credentials_error_importing_key, e.message ?: "") + String.format(credentialsErrorImportingKey, e.message ?: "") ) } } diff --git a/app/src/main/java/jamgmilk/fuwagit/ui/screen/credentials/ImportExportDialogs.kt b/app/src/main/java/jamgmilk/fuwagit/ui/screen/credentials/ImportExportDialogs.kt index 1d2f598..c9d2962 100644 --- a/app/src/main/java/jamgmilk/fuwagit/ui/screen/credentials/ImportExportDialogs.kt +++ b/app/src/main/java/jamgmilk/fuwagit/ui/screen/credentials/ImportExportDialogs.kt @@ -65,6 +65,7 @@ fun ExportCredentialsDialog( val clipboardManager = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager val uiState by viewModel.uiState.collectAsStateWithLifecycle() val exportedData = uiState.exportedData + val credentialsCopiedToClipboard = stringResource(R.string.credentials_copied_to_clipboard) var isLoading by remember { mutableStateOf(false) } LaunchedEffect(Unit) { @@ -108,7 +109,7 @@ fun ExportCredentialsDialog( Surface(shape = RoundedCornerShape(12.dp), color = colors.surfaceVariant.copy(alpha = 0.5f), modifier = Modifier.fillMaxWidth()) { Row(modifier = Modifier.fillMaxWidth().padding(12.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { Text(text = "${exportedData.take(50)}...", style = MaterialTheme.typography.bodySmall, fontFamily = FontFamily.Monospace, modifier = Modifier.weight(1f), maxLines = 1, overflow = TextOverflow.Ellipsis) - IconButton(onClick = { clipboardManager.setPrimaryClip(ClipData.newPlainText(null, exportedData)); scope.launch { snackbarHostState.showSnackbar(context.getString(R.string.credentials_copied_to_clipboard)) } }) { + IconButton(onClick = { clipboardManager.setPrimaryClip(ClipData.newPlainText(null, exportedData)); scope.launch { snackbarHostState.showSnackbar(credentialsCopiedToClipboard) } }) { Icon(Icons.Default.ContentCopy, contentDescription = stringResource(R.string.action_copy), tint = colors.onSurfaceVariant) } } diff --git a/app/src/main/java/jamgmilk/fuwagit/ui/screen/myrepos/CloneContent.kt b/app/src/main/java/jamgmilk/fuwagit/ui/screen/myrepos/CloneContent.kt index b2743d7..53d664c 100644 --- a/app/src/main/java/jamgmilk/fuwagit/ui/screen/myrepos/CloneContent.kt +++ b/app/src/main/java/jamgmilk/fuwagit/ui/screen/myrepos/CloneContent.kt @@ -31,7 +31,6 @@ import androidx.compose.material.icons.filled.Error import androidx.compose.material.icons.filled.Folder import androidx.compose.material.icons.filled.Key import androidx.compose.material.icons.filled.Link -import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.filled.ReportProblem import androidx.compose.material.icons.filled.Speed import androidx.compose.material3.Button @@ -60,8 +59,6 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext -import androidx.fragment.app.FragmentActivity -import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight @@ -69,6 +66,8 @@ import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.compose.collectAsStateWithLifecycle import jamgmilk.fuwagit.R import jamgmilk.fuwagit.core.util.UrlUtils import jamgmilk.fuwagit.domain.model.credential.HttpsCredential @@ -96,6 +95,10 @@ internal fun CloneContent( val credentialsUiState by credentialsViewModel.uiState.collectAsStateWithLifecycle() val strAuthFailed = stringResource(R.string.clone_auth_failed) + val cloneCloneSuccess = stringResource(R.string.clone_clone_success) + val biometricUnlockTitle = stringResource(R.string.biometric_unlock_title) + val credentialsUnlockBiometricSubtitle = stringResource(R.string.credentials_unlock_biometric_subtitle) + val credentialsUsePassword = stringResource(R.string.credentials_use_password) var cloneUrl by remember { mutableStateOf("") } var debouncedUrl by remember { mutableStateOf("") } @@ -176,7 +179,7 @@ internal fun CloneContent( scope.launch { val credentialId = if (isHttps) selectedHttpsUuid else selectedSshUuid myReposViewModel.addRepo(fullPath, null, credentialId) - snackbarHostState.showSnackbar(context.getString(R.string.clone_clone_success)) + snackbarHostState.showSnackbar(cloneCloneSuccess) } onCloneComplete(fullPath) }.onError { e -> @@ -396,9 +399,9 @@ internal fun CloneContent( activity?.let { credentialsViewModel.unlockWithBiometric( it, - context.getString(R.string.biometric_unlock_title), - context.getString(R.string.credentials_unlock_biometric_subtitle), - context.getString(R.string.credentials_use_password) + biometricUnlockTitle, + credentialsUnlockBiometricSubtitle, + credentialsUsePassword ) } }, diff --git a/app/src/main/java/jamgmilk/fuwagit/ui/screen/myrepos/MyReposScreen.kt b/app/src/main/java/jamgmilk/fuwagit/ui/screen/myrepos/MyReposScreen.kt index 0e86b55..40798eb 100644 --- a/app/src/main/java/jamgmilk/fuwagit/ui/screen/myrepos/MyReposScreen.kt +++ b/app/src/main/java/jamgmilk/fuwagit/ui/screen/myrepos/MyReposScreen.kt @@ -94,11 +94,12 @@ fun MyReposScreen( modifier: Modifier = Modifier, onNavigateToAddRepository: () -> Unit = {} ) { - val context = LocalContext.current val uiState by myReposViewModel.uiState.collectAsStateWithLifecycle() val currentRepoInfo by myReposViewModel.currentRepoInfo.collectAsStateWithLifecycle() val scope = rememberCoroutineScope() + val myreposPathCopied = stringResource(R.string.myrepos_path_copied) + val folders = uiState.repoItems val selectedTarget = currentRepoInfo.repoPath @@ -216,9 +217,9 @@ fun MyReposScreen( showRepoInfoDialog.value = true } }, - onShowSnackbar = { path -> + onShowSnackbar = { _ -> scope.launch { - snackbarHostState.showSnackbar(context.getString(R.string.myrepos_path_copied)) + snackbarHostState.showSnackbar(myreposPathCopied) } } ) @@ -642,8 +643,6 @@ private fun RepoHeader( item: RepoFolderItem, onCopyPath: () -> Unit ) { - val context = LocalContext.current - Row( modifier = Modifier .fillMaxWidth() diff --git a/app/src/main/java/jamgmilk/fuwagit/ui/screen/settings/SettingsScreen.kt b/app/src/main/java/jamgmilk/fuwagit/ui/screen/settings/SettingsScreen.kt index d8ff675..7d575ee 100644 --- a/app/src/main/java/jamgmilk/fuwagit/ui/screen/settings/SettingsScreen.kt +++ b/app/src/main/java/jamgmilk/fuwagit/ui/screen/settings/SettingsScreen.kt @@ -129,6 +129,19 @@ fun SettingsScreen( val settingsUiState by settingsViewModel.uiState.collectAsStateWithLifecycle() val credentialsUiState by credentialsViewModel.uiState.collectAsStateWithLifecycle() + val credentialsMasterPasswordSetSuccessfully = stringResource(R.string.credentials_master_password_set_successfully) + val biometricEnableTitle = stringResource(R.string.biometric_enable_title) + val biometricEnableSubtitle = stringResource(R.string.biometric_enable_subtitle) + val settingsBiometricCancel = stringResource(R.string.settings_biometric_cancel) + val settingsPleaseSetMasterPasswordFirst = stringResource(R.string.settings_please_set_master_password_first) + val settingsClearKnownHostsDeleted = stringResource(R.string.settings_clear_known_hosts_deleted) + val settingsCommitEditmsgDeleted = stringResource(R.string.settings_commit_editmsg_deleted) + val settingsNoCommitEditmsg = stringResource(R.string.settings_no_commit_editmsg) + val settingsNoRepositorySelected = stringResource(R.string.settings_no_repository_selected) + val biometricUnlockTitle = stringResource(R.string.biometric_unlock_title) + val credentialsUnlockBiometricSubtitle = stringResource(R.string.credentials_unlock_biometric_subtitle) + val credentialsUsePassword = stringResource(R.string.credentials_use_password) + var showFilePicker by rememberSaveable { mutableStateOf(false) } var pendingBiometricEnable by rememberSaveable { mutableStateOf(false) } val snackbarHostState = remember { SnackbarHostState() } @@ -176,7 +189,7 @@ fun SettingsScreen( if (!previousMasterPasswordSet && credentialsUiState.isMasterPasswordSet) { scope.launch { snackbarHostState.showSnackbar( - message = context.getString(R.string.credentials_master_password_set_successfully) + message = credentialsMasterPasswordSetSuccessfully ) } } @@ -193,9 +206,9 @@ fun SettingsScreen( pendingBiometricEnable = false credentialsViewModel.enableBiometric( activity = activity, - title = context.getString(R.string.biometric_enable_title), - subtitle = context.getString(R.string.biometric_enable_subtitle), - negativeButtonText = context.getString(R.string.settings_biometric_cancel) + title = biometricEnableTitle, + subtitle = biometricEnableSubtitle, + negativeButtonText = settingsBiometricCancel ) } } @@ -233,7 +246,7 @@ fun SettingsScreen( if (!credentialsUiState.isMasterPasswordSet) { scope.launch { snackbarHostState.showSnackbar( - message = context.getString(R.string.settings_please_set_master_password_first) + message = settingsPleaseSetMasterPasswordFirst ) } } else { @@ -258,9 +271,9 @@ fun SettingsScreen( activity?.let { credentialsViewModel.enableBiometric( activity = it, - title = context.getString(R.string.biometric_enable_title), - subtitle = context.getString(R.string.biometric_enable_subtitle), - negativeButtonText = context.getString(R.string.settings_biometric_cancel) + title = biometricEnableTitle, + subtitle = biometricEnableSubtitle, + negativeButtonText = settingsBiometricCancel ) } } @@ -306,7 +319,7 @@ fun SettingsScreen( onResetOnboarding = { settingsViewModel.resetOnboarding() }, onClearKnownHostsComplete = { scope.launch { - snackbarHostState.showSnackbar(context.getString(R.string.settings_clear_known_hosts_deleted)) + snackbarHostState.showSnackbar(settingsClearKnownHostsDeleted) } }, onClearCommitEditMsgComplete = { @@ -316,12 +329,12 @@ fun SettingsScreen( val commitEditMsg = java.io.File(repoPath, ".git/COMMIT_EDITMSG") if (commitEditMsg.exists()) { commitEditMsg.delete() - snackbarHostState.showSnackbar(context.getString(R.string.settings_commit_editmsg_deleted)) + snackbarHostState.showSnackbar(settingsCommitEditmsgDeleted) } else { - snackbarHostState.showSnackbar(context.getString(R.string.settings_no_commit_editmsg)) + snackbarHostState.showSnackbar(settingsNoCommitEditmsg) } } else { - snackbarHostState.showSnackbar(context.getString(R.string.settings_no_repository_selected)) + snackbarHostState.showSnackbar(settingsNoRepositorySelected) } } }, @@ -365,9 +378,9 @@ fun SettingsScreen( activity?.let { credentialsViewModel.unlockWithBiometric( it, - context.getString(R.string.biometric_unlock_title), - context.getString(R.string.credentials_unlock_biometric_subtitle), - context.getString(R.string.credentials_use_password) + biometricUnlockTitle, + credentialsUnlockBiometricSubtitle, + credentialsUsePassword ) } }, diff --git a/app/src/main/java/jamgmilk/fuwagit/ui/screen/status/StatusScreen.kt b/app/src/main/java/jamgmilk/fuwagit/ui/screen/status/StatusScreen.kt index 5f4c1e2..664d0dd 100644 --- a/app/src/main/java/jamgmilk/fuwagit/ui/screen/status/StatusScreen.kt +++ b/app/src/main/java/jamgmilk/fuwagit/ui/screen/status/StatusScreen.kt @@ -77,6 +77,14 @@ fun StatusScreen( val scope = rememberCoroutineScope() val snackbarHostState = remember { SnackbarHostState() } + val statusPushFailed = stringResource(R.string.status_push_failed) + val statusPullFailed = stringResource(R.string.status_pull_failed) + val statusFetchFailed = stringResource(R.string.status_fetch_failed) + val vmChangesDiscarded = stringResource(R.string.vm_changes_discarded) + val biometricUnlockTitle = stringResource(R.string.biometric_unlock_title) + val credentialsUnlockBiometricSubtitle = stringResource(R.string.credentials_unlock_biometric_subtitle) + val credentialsUsePassword = stringResource(R.string.credentials_use_password) + LaunchedEffect(uiState.repoPath) { if (uiState.repoPath != null) { statusViewModel.refreshAll() @@ -90,25 +98,25 @@ fun StatusScreen( scope.launch { snackbarHostState.showSnackbar(event.message) } } is StatusEvent.PushError -> { - scope.launch { snackbarHostState.showSnackbar(context.getString(R.string.status_push_failed, event.message)) } + scope.launch { snackbarHostState.showSnackbar(String.format(statusPushFailed, event.message)) } } is StatusEvent.PullSuccess -> { scope.launch { snackbarHostState.showSnackbar(event.message) } } is StatusEvent.PullError -> { - scope.launch { snackbarHostState.showSnackbar(context.getString(R.string.status_pull_failed, event.message)) } + scope.launch { snackbarHostState.showSnackbar(String.format(statusPullFailed, event.message)) } } is StatusEvent.FetchSuccess -> { scope.launch { snackbarHostState.showSnackbar(event.message) } } is StatusEvent.FetchError -> { - scope.launch { snackbarHostState.showSnackbar(context.getString(R.string.status_fetch_failed, event.message)) } + scope.launch { snackbarHostState.showSnackbar(String.format(statusFetchFailed, event.message)) } } is StatusEvent.CredentialUnlockRequired -> { credentialStoreViewModel.showUnlockDialog() } is StatusEvent.DiscardChangesSuccess -> { - scope.launch { snackbarHostState.showSnackbar(context.getString(R.string.vm_changes_discarded, event.fileName)) } + scope.launch { snackbarHostState.showSnackbar(String.format(vmChangesDiscarded, event.fileName)) } } is StatusEvent.DiscardChangesError -> { val message = "${event.reason}\n${event.suggestion}" @@ -355,9 +363,9 @@ fun StatusScreen( onUnlockWithBiometric = { credentialStoreViewModel.unlockWithBiometric( activity, - context.getString(R.string.biometric_unlock_title), - context.getString(R.string.credentials_unlock_biometric_subtitle), - context.getString(R.string.credentials_use_password) + biometricUnlockTitle, + credentialsUnlockBiometricSubtitle, + credentialsUsePassword ) }, passwordHint = credentialUiState.passwordHint, diff --git a/app/src/main/java/jamgmilk/fuwagit/ui/screen/tags/TagsScreen.kt b/app/src/main/java/jamgmilk/fuwagit/ui/screen/tags/TagsScreen.kt index b5f8093..ab2ad2e 100644 --- a/app/src/main/java/jamgmilk/fuwagit/ui/screen/tags/TagsScreen.kt +++ b/app/src/main/java/jamgmilk/fuwagit/ui/screen/tags/TagsScreen.kt @@ -3,7 +3,6 @@ package jamgmilk.fuwagit.ui.screen.tags import android.content.ClipData import android.content.ClipboardManager import android.content.Context -import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.animateContentSize import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -47,8 +46,8 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextFieldDefaults -import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable @@ -70,7 +69,6 @@ import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle import jamgmilk.fuwagit.R import jamgmilk.fuwagit.domain.model.git.GitTag -import jamgmilk.fuwagit.ui.components.DangerousOperationType import jamgmilk.fuwagit.ui.components.ScreenTemplate import jamgmilk.fuwagit.ui.theme.AppShapes import kotlinx.coroutines.launch @@ -149,7 +147,6 @@ fun TagsContent( onShowSnackbar: (String) -> Unit = {} ) { val uiState by tagsViewModel.uiState.collectAsStateWithLifecycle() - val colors = MaterialTheme.colorScheme var tagForDetail by remember { mutableStateOf(null) } if (uiState.tags.isEmpty()) { @@ -164,7 +161,6 @@ fun TagsContent( ) } - // Tag 详情对话框 if (tagForDetail != null) { TagDetailDialog( tag = tagForDetail!!, @@ -174,9 +170,6 @@ fun TagsContent( } } -/** - * 标签相关对话框集合(创建、删除、推送、操作结果) - */ @Composable fun TagsDialogs( tagsViewModel: TagsViewModel, @@ -185,17 +178,22 @@ fun TagsDialogs( val uiState by tagsViewModel.uiState.collectAsStateWithLifecycle() var showCreateDialog by remember { mutableStateOf(false) } var createTagType by remember { mutableStateOf(CreateTagType.Annotated) } - val context = LocalContext.current val scope = rememberCoroutineScope() + val vmTagAnnotatedCreated = stringResource(R.string.vm_tag_annotated_created) + val vmTagLightweightCreated = stringResource(R.string.vm_tag_lightweight_created) + val vmTagDeleted = stringResource(R.string.vm_tag_deleted) + val vmTagPushSuccess = stringResource(R.string.vm_tag_push_success) + val vmCheckoutSuccess = stringResource(R.string.vm_checkout_success) + LaunchedEffect(Unit) { tagsViewModel.events.collect { event -> when (event) { is TagUiEvent.CreateSuccess -> { val message = if (event.isAnnotated) { - context.getString(R.string.vm_tag_annotated_created, event.tagName) + String.format(vmTagAnnotatedCreated, event.tagName) } else { - context.getString(R.string.vm_tag_lightweight_created, event.tagName) + String.format(vmTagLightweightCreated, event.tagName) } scope.launch { snackbarHostState.showSnackbar(message) } } @@ -203,13 +201,13 @@ fun TagsDialogs( scope.launch { snackbarHostState.showSnackbar(event.message, duration = SnackbarDuration.Long) } } is TagUiEvent.DeleteSuccess -> { - scope.launch { snackbarHostState.showSnackbar(context.getString(R.string.vm_tag_deleted, event.tagName)) } + scope.launch { snackbarHostState.showSnackbar(String.format(vmTagDeleted, event.tagName)) } } is TagUiEvent.DeleteError -> { scope.launch { snackbarHostState.showSnackbar(event.message, duration = SnackbarDuration.Long) } } is TagUiEvent.PushSuccess -> { - scope.launch { snackbarHostState.showSnackbar(context.getString(R.string.vm_tag_push_success, event.tagName)) } + scope.launch { snackbarHostState.showSnackbar(String.format(vmTagPushSuccess, event.tagName)) } } is TagUiEvent.PushAllSuccess -> { scope.launch { snackbarHostState.showSnackbar(event.message) } @@ -218,7 +216,7 @@ fun TagsDialogs( scope.launch { snackbarHostState.showSnackbar(event.message, duration = SnackbarDuration.Long) } } is TagUiEvent.CheckoutSuccess -> { - scope.launch { snackbarHostState.showSnackbar(context.getString(R.string.vm_checkout_success, event.tagName)) } + scope.launch { snackbarHostState.showSnackbar(String.format(vmCheckoutSuccess, event.tagName)) } } is TagUiEvent.CheckoutError -> { scope.launch { snackbarHostState.showSnackbar(event.message, duration = SnackbarDuration.Long) } @@ -403,7 +401,6 @@ private fun SearchFilterBar( .fillMaxWidth() .padding(12.dp) ) { - // 搜索框 OutlinedTextField( value = searchQuery, onValueChange = onSearchQueryChange, From 9fa897f571d4bb7018bf1b373beca0c2b1e1f9c4 Mon Sep 17 00:00:00 2001 From: Mirurin Date: Tue, 28 Apr 2026 19:33:27 +0800 Subject: [PATCH 24/24] chore: fix various lint warnings and code style issues --- .idea/dictionaries/project.xml | 14 + README.md | 172 ++---- README.zh-Hans.md | 64 ++ .../fuwagit/SshCloneInstrumentedTest.kt | 83 --- .../java/jamgmilk/fuwagit/MainActivity.kt | 1 - .../jamgmilk/fuwagit/core/util/SshKeyUtils.kt | 2 +- .../fuwagit/data/jgit/JGitCommitDataSource.kt | 9 +- .../fuwagit/data/jgit/JGitSshDataSource.kt | 6 +- .../data/local/security/MasterKeyManager.kt | 5 - .../fuwagit/ui/components/ConfirmDialogs.kt | 7 +- .../fuwagit/ui/components/SectionCard.kt | 1 - .../ui/screen/branches/BranchesScreen.kt | 5 +- .../credentials/MasterPasswordScreen.kt | 2 - .../ui/screen/credentials/PasswordDialogs.kt | 576 ------------------ .../ui/screen/history/CommitDetails.kt | 1 - .../ui/screen/history/HistoryScreen.kt | 7 +- .../fuwagit/ui/screen/main/AppNavHost.kt | 16 +- .../ui/screen/myrepos/AddRepositoryScreen.kt | 5 +- .../fuwagit/ui/screen/myrepos/LocalContent.kt | 6 +- .../ui/screen/myrepos/MyReposScreen.kt | 4 +- .../ui/screen/settings/SettingsScreen.kt | 3 +- .../ui/screen/status/StatusComponents.kt | 5 +- .../ui/screen/status/StatusViewModel.kt | 3 +- .../fuwagit/ui/screen/tags/TagDetailDialog.kt | 5 +- .../jamgmilk/fuwagit/ui/util/DateTimeUtils.kt | 4 +- .../jamgmilk/fuwagit/util/CrashLogManager.kt | 1 - .../ic_launcher.xml | 0 .../ic_launcher_round.xml | 0 app/src/main/res/values-b+zh+Hans/strings.xml | 121 +--- app/src/main/res/values/strings.xml | 143 ++--- .../java/jamgmilk/fuwagit/AppResultTest.kt | 451 -------------- .../jamgmilk/fuwagit/CredentialModelsTest.kt | 301 --------- .../java/jamgmilk/fuwagit/GitModelsTest.kt | 432 ------------- settings.gradle.kts | 2 + 34 files changed, 241 insertions(+), 2216 deletions(-) create mode 100644 README.zh-Hans.md delete mode 100644 app/src/androidTest/java/jamgmilk/fuwagit/SshCloneInstrumentedTest.kt rename app/src/main/res/{mipmap-anydpi-v26 => mipmap-anydpi}/ic_launcher.xml (100%) rename app/src/main/res/{mipmap-anydpi-v26 => mipmap-anydpi}/ic_launcher_round.xml (100%) delete mode 100644 app/src/test/java/jamgmilk/fuwagit/AppResultTest.kt delete mode 100644 app/src/test/java/jamgmilk/fuwagit/CredentialModelsTest.kt delete mode 100644 app/src/test/java/jamgmilk/fuwagit/GitModelsTest.kt diff --git a/.idea/dictionaries/project.xml b/.idea/dictionaries/project.xml index b55efce..53f7aa4 100644 --- a/.idea/dictionaries/project.xml +++ b/.idea/dictionaries/project.xml @@ -1,14 +1,28 @@ + EDITMSG Fuwa + Hmac JamGmilk Mizuiro + aarch dcim + editmsg filepicker + gitee + gitlabhq + hostkey + jamgmilk + mitm myrepos + nistp pbkdf + priv + privatekey + publickey snackbar + userauth \ No newline at end of file diff --git a/README.md b/README.md index 48a4130..002341f 100644 --- a/README.md +++ b/README.md @@ -1,148 +1,64 @@ -

- FuwaGit Logo -

+
-#

FuwaGit

- -
- -

- API - Kotlin - Jetpack Compose - Material You -

- -
- -
- -# Overview - -FuwaGit is a lightweight and powerful Git client for Android that brings full Git operations to your mobile device. Whether you're managing repositories, committing changes, or collaborating with others, FuwaGit provides a smooth and intuitive experience with secure credential management. - -Built with modern Android technologies including Jetpack Compose and Material Design 3, FuwaGit offers a beautiful interface while maintaining robust functionality for developers on the go. - -Fuwa (ふわ): light and airy~ +**English** | [简体中文](README.zh-Hans.md)
-# Screenshots - - -

- - - -

- - -# Download - -Go to the [Releases](https://github.com/JamGmilk/FuwaGit/releases/latest) and download the latest APK. - -# Installation Instructions - -1. Clone the repository: - ```bash - git clone https://github.com/JamGmilk/FuwaGit.git - ``` -2. Install dependencies using Gradle: - ```bash - ./gradlew build - ``` -3. The debug APK will be generated at: - ```bash - app/build/outputs/apk/debug/app-debug.apk - ``` - -# Tech Stack - -| Component | Technology | -|:----------|:----------| -| Language | Kotlin | -| UI Framework | Jetpack Compose | -| Design System | Material Design 3 | -| Git Library | Eclipse JGit 6.8.0 | -| SSH | JSch | -| DI | Hilt | -| Min SDK | 26 (Android 8.0) | -| Target SDK | 36 | - -# ✨ Features +
-## Git Operations +FuwaGit Logo -- **Clone** - Clone remote repositories via HTTPS or SSH -- **Commit** - Stage changes and create commits with custom messages -- **Push** - Push commits to remote repositories -- **Pull** - Pull changes from remote with merge/rebase support -- **Fetch** - Fetch updates without merging -- **Branch Management** - Create, delete, rename, checkout branches -- **Merge** - Merge branches with conflict detection -- **Rebase** - Interactive rebase support -- **Tags** - Create, delete, and push tags (lightweight and annotated) -- **Reset** - Soft, mixed, and hard reset support -- **Diff** - View file differences with syntax highlighting +# FuwaGit -## Repository Management +![API](https://img.shields.io/badge/API%2026+-50f270?logo=android&logoColor=black&style=for-the-badge) +![Kotlin](https://img.shields.io/badge/Kotlin-a503fc?logo=kotlin&logoColor=white&style=for-the-badge) +![Jetpack Compose](https://img.shields.io/badge/Jetpack%20Compose-4285F4?logo=jetpackcompose&logoColor=white&style=for-the-badge) +![Material You](https://custom-icon-badges.demolab.com/badge/Material%20You-lightblue?logo=material-you&logoColor=333&style=for-the-badge) -- **Multi-Repo Support** - Manage multiple repositories simultaneously -- **Local Repos** - Add existing local Git repositories -- **Remote Cloning** - Clone from GitHub, GitLab, Bitbucket, or any Git server -- **Repository Info** - View detailed repository information -- **Clean** - Remove untracked files with preview +FuwaGit is a lightweight and powerful Git client for Android. Built with modern technologies like Jetpack Compose and Material Design 3, it offers a beautiful interface for developers on the go. -## Security +*Fuwa (ふわ): light and airy~* -- **Master Password** - Encrypt all stored credentials with AES-256 -- **Biometric Unlock** - Use fingerprint to quickly unlock credentials -- **Secure Storage** - Android Keystore-backed credential encryption -- **SSH Key Management** - Generate and store Ed25519/RSA SSH keys -- **HTTPS Credentials** - Securely store usernames and personal access tokens -- **Auto-Lock** - Configurable automatic vault locking +--- +
-## User Experience +## Screenshots -- **Material Design 3** - Modern and beautiful UI with dynamic theming -- **Dark/Light Mode** - System-following or manual theme selection -- **Multi-language** - English and Simplified Chinese support +| | | | +|:------------------------------------------------------:|:------------------------------------------------------:|:------------------------------------------------------:| +| | | | +## Download -# Dependencies +Grab the latest APK from the [Releases](https://github.com/JamGmilk/FuwaGit/releases/latest) page. -- [Eclipse JGit](https://www.eclipse.org/jgit/) - Pure Java implementation of Git -- [JSch](https://github.com/mwiede/jsch) - SSH2 protocol implementation -- [Bouncy Castle](https://www.bouncycastle.org/) - Cryptographic algorithms -- [Jetpack Compose](https://developer.android.com/jetpack/compose) - Modern declarative UI -- [Material 3](https://developer.android.com/compose/material3) - Material Design components -- [Hilt](https://dagger.dev/hilt/) - Dependency injection -- [Kotlinx Serialization](https://kotlinlang.org/docs/serialization.html) - JSON parsing +## Tech Stack +| Component | Technology | +|:------------------|:------------------| +| **Language** | Kotlin | +| **UI Framework** | Jetpack Compose | +| **Design System** | Material Design 3 | +| **Git Library** | Eclipse JGit | +| **SSH** | JSch | +| **DI** | Hilt | +| **Min SDK** | 26 (Android 8.0) | -# License +## Features -```xml -MIT License +### Git Operations +- **Full Lifecycle**: Clone (HTTPS/SSH), Commit, Push, Pull, and Fetch. +- **Branching**: Create, delete, rename, and checkout branches easily. +- **Advanced**: Merge with conflict detection, interactive rebase, and tags support. +- **Resets**: Support for Soft, Mixed, and Hard resets. +- **Diff View**: Built-in syntax highlighting for viewing changes. -Copyright (c) 2026 JamGmilk +### Security & UX +- **Biometric Lock**: Protect your Git credentials with fingerprint/face unlock. +- **AES-256 Encryption**: Secure storage backed by the Android Keystore. +- **SSH Key Gen**: Generate Ed25519 or RSA keys directly in the app. +- **Dynamic Theming**: Full support for Material You and Dark Mode. -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. -``` -
+## License + Copyright 2026 JamGmilk + MIT License diff --git a/README.zh-Hans.md b/README.zh-Hans.md new file mode 100644 index 0000000..9581d70 --- /dev/null +++ b/README.zh-Hans.md @@ -0,0 +1,64 @@ +
+ +[English](README.md) | **简体中文** + +
+ +
+ +FuwaGit 图标 + +# FuwaGit + +![API](https://img.shields.io/badge/API%2026+-50f270?logo=android&logoColor=black&style=for-the-badge) +![Kotlin](https://img.shields.io/badge/Kotlin-a503fc?logo=kotlin&logoColor=white&style=for-the-badge) +![Jetpack Compose](https://img.shields.io/badge/Jetpack%20Compose-4285F4?logo=jetpackcompose&logoColor=white&style=for-the-badge) +![Material You](https://custom-icon-badges.demolab.com/badge/Material%20You-lightblue?logo=material-you&logoColor=333&style=for-the-badge) + +FuwaGit 是一款轻量且强大的 Android 端 Git 客户端。基于 Jetpack Compose 和 Material Design 3 等现代技术构建,致力于为移动端的开发者提供优雅且流畅的交互体验。 + +*Fuwa (ふわ): 轻盈、蓬松 ~* + +--- +
+ +## 截图 + +| | | | +|:------------------------------------------------------:|:------------------------------------------------------:|:------------------------------------------------------:| +| | | | + +## 下载 + +这里喵~ [Releases](https://github.com/JamGmilk/FuwaGit/releases/latest) + +## 🛠技术栈 + +| 组件 | 技术 | +|:----------|:----------| +| **编程语言** | Kotlin | +| **UI 框架** | Jetpack Compose | +| **设计系统** | Material Design 3 | +| **Git 核心库** | Eclipse JGit | +| **SSH 协议** | JSch | +| **依赖注入** | Hilt | +| **最低支持版本** | API 26 (Android 8.0) | + +## 功能特性 + +### Git 操作 +- **完整生命周期**: 支持 Clone (HTTPS/SSH)、Commit、Push、Pull 和 Fetch。 +- **分支管理**: 轻松创建、删除、重命名及切换分支。 +- **高级功能**: 支持带有冲突检测的 Merge (合并)、交互式 Rebase (变基) 及 Tag (标签) 管理。 +- **重置功能**: 支持 Soft、Mixed 和 Hard Reset (回退)。 +- **差异查看**: 内置支持语法高亮的代码 Diff 查看器。 + +### 安全与体验 +- **生物识别锁定**: 支持通过指纹或面部识别保护您的 Git 凭据。 +- **AES-256 加密**: 存储凭据均通过 Android Keystore 支持的加密算法加密。 +- **SSH 密钥生成**: 支持直接在应用内生成 Ed25519 或 RSA 密钥。 +- **动态配色**: 完美适配 Material You 动态主题及深色模式。 + +## 许可协议 + Copyright 2026 JamGmilk + MIT License diff --git a/app/src/androidTest/java/jamgmilk/fuwagit/SshCloneInstrumentedTest.kt b/app/src/androidTest/java/jamgmilk/fuwagit/SshCloneInstrumentedTest.kt deleted file mode 100644 index aafacbe..0000000 --- a/app/src/androidTest/java/jamgmilk/fuwagit/SshCloneInstrumentedTest.kt +++ /dev/null @@ -1,83 +0,0 @@ -package jamgmilk.fuwagit - -import androidx.compose.ui.test.assertIsEnabled -import androidx.compose.ui.test.junit4.createAndroidComposeRule -import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.performClick -import androidx.compose.ui.test.performTextInput -import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.platform.app.InstrumentationRegistry -import androidx.test.uiautomator.UiDevice -import androidx.test.uiautomator.UiSelector -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith - -@RunWith(AndroidJUnit4::class) -class SshCloneInstrumentedTest { - - @get:Rule - val composeTestRule = createAndroidComposeRule() - - private val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) - - @Test - fun sshCloneCloneButtonBecomesEnabled() { - val testUrl = "git@github.com:JamGmilk/FuwaGit.git" - - composeTestRule.waitForIdle() - - composeTestRule.onNodeWithText("Add Repository") - .performClick() - - composeTestRule.waitForIdle() - - composeTestRule.onNodeWithText("Repository URL") - .performTextInput(testUrl) - - composeTestRule.waitForIdle() - - composeTestRule.onNodeWithText("Credential") - .performClick() - - composeTestRule.waitForIdle() - - composeTestRule.onNodeWithText("SSH") - .performClick() - - composeTestRule.waitForIdle() - - composeTestRule.onNodeWithText("Mirurin") - .performClick() - - composeTestRule.waitForIdle() - - composeTestRule.onNodeWithText("Select") - .performClick() - - composeTestRule.waitForIdle() - - composeTestRule.onNodeWithText("Destination") - .performClick() - - Thread.sleep(2000) - - val documentsFolder = device.findObject(UiSelector().text("Documents")) - documentsFolder.click() - - Thread.sleep(2000) - - val fuwaGitFolder = device.findObject(UiSelector().text("FuwaGit喵")) - fuwaGitFolder.click() - - composeTestRule.waitForIdle() - - composeTestRule.onNodeWithText("Select") - .performClick() - - composeTestRule.waitForIdle() - - composeTestRule.onNodeWithText("Clone Repository") - .assertIsEnabled() - } -} \ No newline at end of file diff --git a/app/src/main/java/jamgmilk/fuwagit/MainActivity.kt b/app/src/main/java/jamgmilk/fuwagit/MainActivity.kt index 1629f47..093772b 100644 --- a/app/src/main/java/jamgmilk/fuwagit/MainActivity.kt +++ b/app/src/main/java/jamgmilk/fuwagit/MainActivity.kt @@ -1,7 +1,6 @@ package jamgmilk.fuwagit import android.os.Bundle -import android.view.WindowInsetsController import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.appcompat.app.AppCompatActivity diff --git a/app/src/main/java/jamgmilk/fuwagit/core/util/SshKeyUtils.kt b/app/src/main/java/jamgmilk/fuwagit/core/util/SshKeyUtils.kt index d57e2a7..06100aa 100644 --- a/app/src/main/java/jamgmilk/fuwagit/core/util/SshKeyUtils.kt +++ b/app/src/main/java/jamgmilk/fuwagit/core/util/SshKeyUtils.kt @@ -304,7 +304,7 @@ private fun detectOpenSshKeyType(keyContent: ByteArray): String { private fun readString(dis: java.io.DataInputStream): String { val length = dis.readInt() - if (length <= 0 || length > 262144) { + if (length !in 1..262144) { return "" } val bytes = ByteArray(length) diff --git a/app/src/main/java/jamgmilk/fuwagit/data/jgit/JGitCommitDataSource.kt b/app/src/main/java/jamgmilk/fuwagit/data/jgit/JGitCommitDataSource.kt index e3db38a..c2192a8 100644 --- a/app/src/main/java/jamgmilk/fuwagit/data/jgit/JGitCommitDataSource.kt +++ b/app/src/main/java/jamgmilk/fuwagit/data/jgit/JGitCommitDataSource.kt @@ -148,11 +148,10 @@ class JGitCommitDataSource @Inject constructor( commitBuilder.call().id.name() }.let { result -> if (result.isFailure) { - val exception = result.exceptionOrNull() - when { - exception is LockFailedException -> Result.failure(Exception("Cannot commit: repository lock failed.")) - exception is JGitInternalException -> Result.failure(Exception("Git error: ${exception.message}")) - exception is Exception -> Result.failure(exception) + when (val exception = result.exceptionOrNull()) { + is LockFailedException -> Result.failure(Exception("Cannot commit: repository lock failed.")) + is JGitInternalException -> Result.failure(Exception("Git error: ${exception.message}")) + is Exception -> Result.failure(exception) else -> result } } else { diff --git a/app/src/main/java/jamgmilk/fuwagit/data/jgit/JGitSshDataSource.kt b/app/src/main/java/jamgmilk/fuwagit/data/jgit/JGitSshDataSource.kt index 4126ad4..b61904a 100644 --- a/app/src/main/java/jamgmilk/fuwagit/data/jgit/JGitSshDataSource.kt +++ b/app/src/main/java/jamgmilk/fuwagit/data/jgit/JGitSshDataSource.kt @@ -118,10 +118,10 @@ class JGitSshDataSource @Inject constructor( cleanupConnection(channel, session) privateKeyBytes.secureZero() passphraseBytes?.secureZero() - if (serverBanner.isNotBlank()) { - return Result.success(serverBanner) + return if (serverBanner.isNotBlank()) { + Result.success(serverBanner) } else { - return Result.success("SSH connection successful to $username@$hostname") + Result.success("SSH connection successful to $username@$hostname") } } catch (e: com.jcraft.jsch.JSchException) { debugLog("SSH connection failed: ${e.message}") diff --git a/app/src/main/java/jamgmilk/fuwagit/data/local/security/MasterKeyManager.kt b/app/src/main/java/jamgmilk/fuwagit/data/local/security/MasterKeyManager.kt index d70c2d5..9b3d050 100644 --- a/app/src/main/java/jamgmilk/fuwagit/data/local/security/MasterKeyManager.kt +++ b/app/src/main/java/jamgmilk/fuwagit/data/local/security/MasterKeyManager.kt @@ -2,16 +2,11 @@ package jamgmilk.fuwagit.data.local.security import android.content.Context import android.content.SharedPreferences -import android.security.keystore.KeyGenParameterSpec -import android.security.keystore.KeyProperties import android.util.Base64 -import android.util.Log import androidx.core.content.edit import dagger.hilt.android.qualifiers.ApplicationContext -import jamgmilk.fuwagit.BuildConfig import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -import java.security.KeyStore import java.security.SecureRandom import javax.crypto.Cipher import javax.crypto.KeyGenerator diff --git a/app/src/main/java/jamgmilk/fuwagit/ui/components/ConfirmDialogs.kt b/app/src/main/java/jamgmilk/fuwagit/ui/components/ConfirmDialogs.kt index e861d70..255163e 100644 --- a/app/src/main/java/jamgmilk/fuwagit/ui/components/ConfirmDialogs.kt +++ b/app/src/main/java/jamgmilk/fuwagit/ui/components/ConfirmDialogs.kt @@ -54,6 +54,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight @@ -466,7 +467,6 @@ fun CleanPreviewDialog( Column( verticalArrangement = Arrangement.spacedBy(12.dp) ) { - // 显示消息(如果有) if (message != null) { Text( text = message, @@ -475,10 +475,9 @@ fun CleanPreviewDialog( ) } - // 显示文件列表(如果有) if (untrackedFiles.isNotEmpty()) { Text( - text = stringResource(R.string.dialog_clean_files_to_delete_format, untrackedFiles.size), + text = pluralStringResource(R.plurals.clean_files_to_delete, untrackedFiles.size), style = MaterialTheme.typography.bodyMedium, color = colors.onSurfaceVariant ) @@ -608,7 +607,7 @@ fun CleanResultDialog( verticalArrangement = Arrangement.spacedBy(12.dp) ) { Text( - text = stringResource(R.string.dialog_clean_deleted_count_format, cleanedFiles.size), + text = pluralStringResource(R.plurals.clean_deleted_count, cleanedFiles.size), style = MaterialTheme.typography.bodyMedium, color = colors.onSurfaceVariant ) diff --git a/app/src/main/java/jamgmilk/fuwagit/ui/components/SectionCard.kt b/app/src/main/java/jamgmilk/fuwagit/ui/components/SectionCard.kt index 992d26e..e5582e8 100644 --- a/app/src/main/java/jamgmilk/fuwagit/ui/components/SectionCard.kt +++ b/app/src/main/java/jamgmilk/fuwagit/ui/components/SectionCard.kt @@ -7,7 +7,6 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp @Composable diff --git a/app/src/main/java/jamgmilk/fuwagit/ui/screen/branches/BranchesScreen.kt b/app/src/main/java/jamgmilk/fuwagit/ui/screen/branches/BranchesScreen.kt index c807f8c..d4655ac 100644 --- a/app/src/main/java/jamgmilk/fuwagit/ui/screen/branches/BranchesScreen.kt +++ b/app/src/main/java/jamgmilk/fuwagit/ui/screen/branches/BranchesScreen.kt @@ -65,6 +65,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow @@ -495,7 +496,7 @@ private fun BranchListContent( item(contentType = "header") { SectionHeader( title = stringResource(R.string.branches_local_branches), - subtitle = stringResource(R.string.branches_count_format, localBranches.size), + subtitle = pluralStringResource(R.plurals.branches_count, localBranches.size), icon = Icons.Outlined.AccountTree, color = colors.primary ) @@ -538,7 +539,7 @@ private fun BranchListContent( item { SectionHeader( title = stringResource(R.string.branches_remote_branches), - subtitle = stringResource(R.string.branches_count_format, remoteBranches.size), + subtitle = pluralStringResource(R.plurals.branches_count, remoteBranches.size), icon = Icons.Default.Cloud, color = colors.secondary ) diff --git a/app/src/main/java/jamgmilk/fuwagit/ui/screen/credentials/MasterPasswordScreen.kt b/app/src/main/java/jamgmilk/fuwagit/ui/screen/credentials/MasterPasswordScreen.kt index 2522a44..cde4397 100644 --- a/app/src/main/java/jamgmilk/fuwagit/ui/screen/credentials/MasterPasswordScreen.kt +++ b/app/src/main/java/jamgmilk/fuwagit/ui/screen/credentials/MasterPasswordScreen.kt @@ -48,7 +48,6 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight @@ -175,7 +174,6 @@ private fun MasterPasswordContent( onComplete: () -> Unit ) { val colors = MaterialTheme.colorScheme - val context = LocalContext.current var oldPassword by remember { mutableStateOf("") } var password by remember { mutableStateOf("") } diff --git a/app/src/main/java/jamgmilk/fuwagit/ui/screen/credentials/PasswordDialogs.kt b/app/src/main/java/jamgmilk/fuwagit/ui/screen/credentials/PasswordDialogs.kt index 73cb37a..5e2915f 100644 --- a/app/src/main/java/jamgmilk/fuwagit/ui/screen/credentials/PasswordDialogs.kt +++ b/app/src/main/java/jamgmilk/fuwagit/ui/screen/credentials/PasswordDialogs.kt @@ -1,38 +1,24 @@ package jamgmilk.fuwagit.ui.screen.credentials -import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Fingerprint -import androidx.compose.material.icons.filled.Key -import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.filled.Visibility import androidx.compose.material.icons.filled.VisibilityOff import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable @@ -43,128 +29,14 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.VisualTransformation -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.fragment.app.FragmentActivity import jamgmilk.fuwagit.R -@Composable -fun SetupPasswordDialog( - onDismiss: () -> Unit, - onConfirm: (password: String, hint: String?) -> Unit, - error: String? = null, - isLoading: Boolean = false -) { - var password by remember { mutableStateOf("") } - var confirmPassword by remember { mutableStateOf("") } - var hint by remember { mutableStateOf("") } - var showPassword by remember { mutableStateOf(false) } - var showConfirmPassword by remember { mutableStateOf(false) } - - val passwordMatchError = if (confirmPassword.isNotEmpty() && password != confirmPassword) { - stringResource(R.string.credentials_passwords_do_not_match) - } else null - - val passwordLengthError = if (password.isNotEmpty() && password.length < 6) { - stringResource(R.string.credentials_at_least_6_characters) - } else null - - AlertDialog( - onDismissRequest = onDismiss, - shape = RoundedCornerShape(20.dp), - title = { - Text( - text = stringResource(R.string.credentials_setup_master_password), - style = MaterialTheme.typography.titleLarge - ) - }, - text = { - Column( - modifier = Modifier.padding(vertical = 8.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - Text( - text = stringResource(R.string.credentials_setup_description), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - - Spacer(Modifier.height(4.dp)) - - OutlinedTextField( - value = password, - onValueChange = { password = it }, - label = { Text(stringResource(R.string.credentials_password_label)) }, - placeholder = { Text(stringResource(R.string.credentials_password_placeholder)) }, - visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation(), - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Password, - imeAction = ImeAction.Next - ), - singleLine = true, - isError = passwordLengthError != null, - supportingText = passwordLengthError?.let { { Text(it) } }, - modifier = Modifier.fillMaxWidth() - ) - - OutlinedTextField( - value = confirmPassword, - onValueChange = { confirmPassword = it }, - label = { Text(stringResource(R.string.credentials_confirm_password_label)) }, - visualTransformation = if (showConfirmPassword) VisualTransformation.None else PasswordVisualTransformation(), - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Password, - imeAction = ImeAction.Next - ), - singleLine = true, - isError = passwordMatchError != null, - supportingText = passwordMatchError?.let { { Text(it) } }, - modifier = Modifier.fillMaxWidth() - ) - - OutlinedTextField( - value = hint, - onValueChange = { hint = it }, - label = { Text(stringResource(R.string.credentials_password_hint_optional)) }, - placeholder = { Text(stringResource(R.string.credentials_password_hint_placeholder)) }, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), - singleLine = true, - modifier = Modifier.fillMaxWidth() - ) - - error?.let { errorMsg -> - Text( - text = errorMsg, - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodySmall - ) - } - } - }, - confirmButton = { - Button( - onClick = { onConfirm(password, hint.ifBlank { null }) }, - enabled = !isLoading && password.length >= 6 && password == confirmPassword - ) { - Text(if (isLoading) stringResource(R.string.credentials_setting) else stringResource(R.string.credentials_set_password)) - } - }, - dismissButton = { - TextButton(onClick = onDismiss) { - Text(stringResource(R.string.action_cancel)) - } - } - ) -} - @Composable fun UnlockDialog( onDismiss: () -> Unit, @@ -278,451 +150,3 @@ fun UnlockDialog( } ) } - -@Composable -fun ChangeMasterPasswordDialog( - onDismiss: () -> Unit, - onConfirm: (oldPassword: String, newPassword: String, confirmPassword: String, hint: String?) -> Unit, - passwordHint: String? = null, - error: String? = null, - isLoading: Boolean = false -) { - val colors = MaterialTheme.colorScheme - var oldPassword by remember { mutableStateOf("") } - var newPassword by remember { mutableStateOf("") } - var confirmPassword by remember { mutableStateOf("") } - var hint by remember { mutableStateOf("") } - var showOldPassword by remember { mutableStateOf(false) } - var showNewPassword by remember { mutableStateOf(false) } - var showConfirmPassword by remember { mutableStateOf(false) } - - val passwordMatchError = if (confirmPassword.isNotEmpty() && newPassword != confirmPassword) { - stringResource(R.string.credentials_passwords_do_not_match) - } else null - - val passwordLengthError = if (newPassword.isNotEmpty() && newPassword.length < 6) { - stringResource(R.string.credentials_at_least_6_characters) - } else null - - val isFormValid = oldPassword.isNotBlank() && newPassword.length >= 6 && newPassword == confirmPassword - - AlertDialog( - onDismissRequest = onDismiss, - icon = { - Box( - modifier = Modifier - .size(48.dp) - .background(colors.primary.copy(alpha = 0.15f), CircleShape), - contentAlignment = Alignment.Center - ) { - Icon( - Icons.Default.Key, - contentDescription = null, - tint = colors.primary, - modifier = Modifier.size(24.dp) - ) - } - }, - title = { - Text( - text = stringResource(R.string.credentials_change_master_password), - fontWeight = androidx.compose.ui.text.font.FontWeight.Bold - ) - }, - text = { - Column( - modifier = Modifier.verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - Text( - text = stringResource(R.string.credentials_change_password_description), - style = MaterialTheme.typography.bodyMedium, - color = colors.onSurfaceVariant - ) - - Spacer(Modifier.height(4.dp)) - - OutlinedTextField( - value = oldPassword, - onValueChange = { oldPassword = it }, - label = { Text(stringResource(R.string.credentials_current_password_label)) }, - visualTransformation = if (showOldPassword) VisualTransformation.None else PasswordVisualTransformation(), - trailingIcon = { - IconButton(onClick = { showOldPassword = !showOldPassword }) { - Icon( - if (showOldPassword) Icons.Default.VisibilityOff else Icons.Default.Visibility, - contentDescription = if (showOldPassword) stringResource(R.string.credentials_hide) else stringResource(R.string.credentials_show_hide) - ) - } - }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Next), - singleLine = true, - isError = error != null, - supportingText = error?.let { { Text(it, color = colors.error) } }, - shape = RoundedCornerShape(12.dp), - modifier = Modifier.fillMaxWidth(), - colors = OutlinedTextFieldDefaults.colors( - focusedBorderColor = colors.primary, - focusedLabelColor = colors.primary, - cursorColor = colors.primary - ) - ) - - HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp), color = colors.outline.copy(alpha = 0.2f)) - - OutlinedTextField( - value = newPassword, - onValueChange = { newPassword = it }, - label = { Text(stringResource(R.string.credentials_new_password_label)) }, - placeholder = { Text(stringResource(R.string.credentials_password_placeholder)) }, - visualTransformation = if (showNewPassword) VisualTransformation.None else PasswordVisualTransformation(), - trailingIcon = { - IconButton(onClick = { showNewPassword = !showNewPassword }) { - Icon( - if (showNewPassword) Icons.Default.VisibilityOff else Icons.Default.Visibility, - contentDescription = if (showNewPassword) stringResource(R.string.credentials_hide) else stringResource(R.string.credentials_show_hide) - ) - } - }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Next), - singleLine = true, - isError = passwordLengthError != null, - supportingText = passwordLengthError?.let { { Text(it) } }, - shape = RoundedCornerShape(12.dp), - modifier = Modifier.fillMaxWidth(), - colors = OutlinedTextFieldDefaults.colors( - focusedBorderColor = colors.primary, - focusedLabelColor = colors.primary, - cursorColor = colors.primary - ) - ) - - OutlinedTextField( - value = confirmPassword, - onValueChange = { confirmPassword = it }, - label = { Text(stringResource(R.string.credentials_confirm_new_password_label)) }, - visualTransformation = if (showConfirmPassword) VisualTransformation.None else PasswordVisualTransformation(), - trailingIcon = { - IconButton(onClick = { showConfirmPassword = !showConfirmPassword }) { - Icon( - if (showConfirmPassword) Icons.Default.VisibilityOff else Icons.Default.Visibility, - contentDescription = if (showConfirmPassword) stringResource(R.string.credentials_hide) else stringResource(R.string.credentials_show_hide) - ) - } - }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Next), - singleLine = true, - isError = passwordMatchError != null, - supportingText = passwordMatchError?.let { { Text(it) } }, - shape = RoundedCornerShape(12.dp), - modifier = Modifier.fillMaxWidth(), - colors = OutlinedTextFieldDefaults.colors( - focusedBorderColor = colors.primary, - focusedLabelColor = colors.primary, - cursorColor = colors.primary - ) - ) - - OutlinedTextField( - value = hint, - onValueChange = { hint = it }, - label = { Text(stringResource(R.string.credentials_password_hint_optional)) }, - placeholder = { Text(stringResource(R.string.credentials_password_hint_placeholder)) }, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), - singleLine = true, - shape = RoundedCornerShape(12.dp), - modifier = Modifier.fillMaxWidth(), - colors = OutlinedTextFieldDefaults.colors( - focusedBorderColor = colors.primary, - focusedLabelColor = colors.primary, - cursorColor = colors.primary - ) - ) - - passwordHint?.let { existingHint -> - if (existingHint.isNotBlank() && hint.isBlank()) { - Text( - text = stringResource(R.string.credentials_current_hint_format, existingHint), - style = MaterialTheme.typography.bodySmall, - color = colors.onSurfaceVariant - ) - } - } - } - }, - confirmButton = { - Button( - onClick = { onConfirm(oldPassword, newPassword, confirmPassword, hint.ifBlank { null }) }, - enabled = !isLoading && isFormValid, - colors = ButtonDefaults.buttonColors( - containerColor = colors.primary, - contentColor = colors.onPrimary - ), - shape = RoundedCornerShape(12.dp) - ) { - if (isLoading) { - CircularProgressIndicator(modifier = Modifier.size(18.dp), color = colors.onPrimary, strokeWidth = 2.dp) - } else { - Icon(Icons.Default.Check, contentDescription = null, modifier = Modifier.size(18.dp)) - } - Spacer(Modifier.width(6.dp)) - Text(stringResource(R.string.credentials_change_password)) - } - }, - dismissButton = { - TextButton(onClick = onDismiss) { - Text(stringResource(R.string.action_cancel)) - } - }, - shape = RoundedCornerShape(24.dp) - ) -} - -@Composable -fun SetupMasterPasswordDialog( - onDismiss: () -> Unit, - onConfirm: (password: String, confirmPassword: String, hint: String?) -> Unit, - error: String? = null, - isLoading: Boolean = false -) { - val colors = MaterialTheme.colorScheme - var password by remember { mutableStateOf("") } - var confirmPassword by remember { mutableStateOf("") } - var hint by remember { mutableStateOf("") } - var showPassword by remember { mutableStateOf(false) } - var showConfirmPassword by remember { mutableStateOf(false) } - - val passwordMatchError = if (confirmPassword.isNotEmpty() && password != confirmPassword) { - stringResource(R.string.credentials_passwords_do_not_match) - } else null - - val passwordLengthError = if (password.isNotEmpty() && password.length < 6) { - stringResource(R.string.credentials_at_least_6_characters) - } else null - - val isFormValid = password.length >= 6 && password == confirmPassword - - AlertDialog( - onDismissRequest = onDismiss, - icon = { - Box( - modifier = Modifier - .size(48.dp) - .background(colors.primary.copy(alpha = 0.15f), CircleShape), - contentAlignment = Alignment.Center - ) { - Icon(Icons.Default.Lock, contentDescription = null, tint = colors.primary, modifier = Modifier.size(24.dp)) - } - }, - title = { - Text(text = stringResource(R.string.credentials_setup_master_password_full), fontWeight = androidx.compose.ui.text.font.FontWeight.Bold) - }, - text = { - Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { - Text( - text = stringResource(R.string.credentials_setup_full_description), - style = MaterialTheme.typography.bodyMedium, - color = colors.onSurfaceVariant - ) - - Spacer(Modifier.height(4.dp)) - - OutlinedTextField( - value = password, - onValueChange = { password = it }, - label = { Text(stringResource(R.string.credentials_password_label)) }, - placeholder = { Text(stringResource(R.string.credentials_password_placeholder)) }, - visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation(), - trailingIcon = { - IconButton(onClick = { showPassword = !showPassword }) { - Icon(if (showPassword) Icons.Default.VisibilityOff else Icons.Default.Visibility, - contentDescription = if (showPassword) stringResource(R.string.credentials_hide) else stringResource(R.string.credentials_show_hide)) - } - }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Next), - singleLine = true, - isError = passwordLengthError != null, - supportingText = passwordLengthError?.let { { Text(it) } }, - shape = RoundedCornerShape(12.dp), - modifier = Modifier.fillMaxWidth(), - colors = OutlinedTextFieldDefaults.colors(focusedBorderColor = colors.primary, focusedLabelColor = colors.primary, cursorColor = colors.primary) - ) - - OutlinedTextField( - value = confirmPassword, - onValueChange = { confirmPassword = it }, - label = { Text(stringResource(R.string.credentials_confirm_password_label)) }, - visualTransformation = if (showConfirmPassword) VisualTransformation.None else PasswordVisualTransformation(), - trailingIcon = { - IconButton(onClick = { showConfirmPassword = !showConfirmPassword }) { - Icon(if (showConfirmPassword) Icons.Default.VisibilityOff else Icons.Default.Visibility, - contentDescription = if (showConfirmPassword) stringResource(R.string.credentials_hide) else stringResource(R.string.credentials_show_hide)) - } - }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Next), - singleLine = true, - isError = passwordMatchError != null, - supportingText = passwordMatchError?.let { { Text(it) } }, - shape = RoundedCornerShape(12.dp), - modifier = Modifier.fillMaxWidth(), - colors = OutlinedTextFieldDefaults.colors(focusedBorderColor = colors.primary, focusedLabelColor = colors.primary, cursorColor = colors.primary) - ) - - OutlinedTextField( - value = hint, - onValueChange = { hint = it }, - label = { Text(stringResource(R.string.credentials_password_hint_optional)) }, - placeholder = { Text(stringResource(R.string.credentials_password_hint_placeholder)) }, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), - singleLine = true, - shape = RoundedCornerShape(12.dp), - modifier = Modifier.fillMaxWidth(), - colors = OutlinedTextFieldDefaults.colors(focusedBorderColor = colors.primary, focusedLabelColor = colors.primary, cursorColor = colors.primary) - ) - - error?.let { errorMsg -> - Text(text = errorMsg, color = colors.error, style = MaterialTheme.typography.bodySmall) - } - } - }, - confirmButton = { - Button( - onClick = { onConfirm(password, confirmPassword, hint.ifBlank { null }) }, - enabled = !isLoading && isFormValid, - colors = ButtonDefaults.buttonColors(containerColor = colors.primary), - shape = RoundedCornerShape(12.dp) - ) { - if (isLoading) { - CircularProgressIndicator(modifier = Modifier.size(18.dp), color = Color.White, strokeWidth = 2.dp) - } else { - Icon(Icons.Default.Check, contentDescription = null, modifier = Modifier.size(18.dp)) - } - Spacer(Modifier.width(6.dp)) - Text(stringResource(R.string.credentials_set_password)) - } - }, - dismissButton = { - TextButton(onClick = onDismiss) { - Text(stringResource(R.string.action_cancel)) - } - }, - shape = RoundedCornerShape(24.dp) - ) -} - -@Composable -fun SetupMasterPasswordContent( - onConfirm: (password: String, hint: String?) -> Unit, - error: String? = null, - isLoading: Boolean = false -) { - val colors = MaterialTheme.colorScheme - var password by remember { mutableStateOf("") } - var confirmPassword by remember { mutableStateOf("") } - var hint by remember { mutableStateOf("") } - var showPassword by remember { mutableStateOf(false) } - var showConfirmPassword by remember { mutableStateOf(false) } - - val passwordMatchError = if (confirmPassword.isNotEmpty() && password != confirmPassword) { - stringResource(R.string.credentials_passwords_do_not_match) - } else null - - val passwordLengthError = if (password.isNotEmpty() && password.length < 6) { - stringResource(R.string.credentials_at_least_6_characters) - } else null - - val isFormValid = password.length >= 6 && password == confirmPassword - - Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) { - Box( - modifier = Modifier.size(72.dp).background(colors.primary.copy(alpha = 0.12f), CircleShape), - contentAlignment = Alignment.Center - ) { - Icon(Icons.Default.Lock, contentDescription = null, tint = colors.primary, modifier = Modifier.size(36.dp)) - } - - Spacer(Modifier.height(16.dp)) - - Text( - text = stringResource(R.string.credentials_setup_description), - style = MaterialTheme.typography.bodyMedium, - color = colors.onSurfaceVariant, - textAlign = TextAlign.Center - ) - - Spacer(Modifier.height(20.dp)) - - OutlinedTextField( - value = password, - onValueChange = { password = it }, - label = { Text(stringResource(R.string.credentials_password_label)) }, - placeholder = { Text(stringResource(R.string.credentials_password_placeholder)) }, - visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation(), - trailingIcon = { - IconButton(onClick = { showPassword = !showPassword }) { - Icon(if (showPassword) Icons.Default.VisibilityOff else Icons.Default.Visibility, - contentDescription = if (showPassword) stringResource(R.string.credentials_hide) else stringResource(R.string.credentials_show_hide)) - } - }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Next), - singleLine = true, - isError = passwordLengthError != null, - supportingText = passwordLengthError?.let { { Text(it) } }, - modifier = Modifier.fillMaxWidth() - ) - - Spacer(Modifier.height(16.dp)) - - OutlinedTextField( - value = confirmPassword, - onValueChange = { confirmPassword = it }, - label = { Text(stringResource(R.string.credentials_confirm_password_label)) }, - visualTransformation = if (showConfirmPassword) VisualTransformation.None else PasswordVisualTransformation(), - trailingIcon = { - IconButton(onClick = { showConfirmPassword = !showConfirmPassword }) { - Icon(if (showConfirmPassword) Icons.Default.VisibilityOff else Icons.Default.Visibility, - contentDescription = if (showConfirmPassword) stringResource(R.string.credentials_hide) else stringResource(R.string.credentials_show_hide)) - } - }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Next), - singleLine = true, - isError = passwordMatchError != null, - supportingText = passwordMatchError?.let { { Text(it) } }, - modifier = Modifier.fillMaxWidth() - ) - - Spacer(Modifier.height(16.dp)) - - OutlinedTextField( - value = hint, - onValueChange = { hint = it }, - label = { Text(stringResource(R.string.credentials_password_hint_optional)) }, - placeholder = { Text(stringResource(R.string.credentials_password_hint_placeholder)) }, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), - singleLine = true, - modifier = Modifier.fillMaxWidth() - ) - - error?.let { errorMsg -> - Spacer(Modifier.height(12.dp)) - Text(text = errorMsg, color = colors.error, style = MaterialTheme.typography.bodySmall) - } - - Spacer(Modifier.height(24.dp)) - - Button( - onClick = { onConfirm(password, hint.ifBlank { null }) }, - enabled = !isLoading && isFormValid, - modifier = Modifier.fillMaxWidth().height(50.dp), - shape = RoundedCornerShape(12.dp) - ) { - if (isLoading) { - CircularProgressIndicator(modifier = Modifier.size(20.dp), color = colors.onPrimary, strokeWidth = 2.dp) - Spacer(Modifier.width(8.dp)) - Text(stringResource(R.string.credentials_setting)) - } else { - Text(stringResource(R.string.credentials_set_password), fontSize = 16.sp) - } - } - } -} diff --git a/app/src/main/java/jamgmilk/fuwagit/ui/screen/history/CommitDetails.kt b/app/src/main/java/jamgmilk/fuwagit/ui/screen/history/CommitDetails.kt index b7e813b..3b001fd 100644 --- a/app/src/main/java/jamgmilk/fuwagit/ui/screen/history/CommitDetails.kt +++ b/app/src/main/java/jamgmilk/fuwagit/ui/screen/history/CommitDetails.kt @@ -58,7 +58,6 @@ import androidx.compose.ui.unit.dp import jamgmilk.fuwagit.R import jamgmilk.fuwagit.domain.model.git.GitChangeType import jamgmilk.fuwagit.domain.model.git.GitCommit -import jamgmilk.fuwagit.domain.model.git.GitCommitDetail import jamgmilk.fuwagit.domain.model.git.GitCommitFileChange import jamgmilk.fuwagit.domain.model.git.GitResetMode import java.text.SimpleDateFormat diff --git a/app/src/main/java/jamgmilk/fuwagit/ui/screen/history/HistoryScreen.kt b/app/src/main/java/jamgmilk/fuwagit/ui/screen/history/HistoryScreen.kt index ca9ba5d..505418f 100644 --- a/app/src/main/java/jamgmilk/fuwagit/ui/screen/history/HistoryScreen.kt +++ b/app/src/main/java/jamgmilk/fuwagit/ui/screen/history/HistoryScreen.kt @@ -23,6 +23,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -75,13 +76,13 @@ fun HistoryScreen( Scaffold( snackbarHost = { SnackbarHost(snackbarHostState) } - ) { _ -> + ) { innerPadding -> ScreenTemplate( title = stringResource(R.string.screen_history), - modifier = modifier.fillMaxSize(), + modifier = modifier.fillMaxSize().padding(innerPadding), actions = { Text( - text = stringResource(R.string.history_commits_count_format, history.size), + text = pluralStringResource(R.plurals.history_commits_count, history.size), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) diff --git a/app/src/main/java/jamgmilk/fuwagit/ui/screen/main/AppNavHost.kt b/app/src/main/java/jamgmilk/fuwagit/ui/screen/main/AppNavHost.kt index ef35e8f..8b2dd11 100644 --- a/app/src/main/java/jamgmilk/fuwagit/ui/screen/main/AppNavHost.kt +++ b/app/src/main/java/jamgmilk/fuwagit/ui/screen/main/AppNavHost.kt @@ -115,6 +115,10 @@ fun AppNavHost(navController: NavHostController, startDestination: String = NavR viewModelStoreOwner = activity ) + val biometricUnlockTitle = stringResource(R.string.biometric_unlock_title) + val credentialsUnlockBiometricSubtitle = stringResource(R.string.credentials_unlock_biometric_subtitle) + val credentialsUsePassword = stringResource(R.string.credentials_use_password) + LaunchedEffect(Unit) { HostKeyAskHelper.requests.collect { request -> pendingRequests.add(request) @@ -253,9 +257,9 @@ fun AppNavHost(navController: NavHostController, startDestination: String = NavR onUnlockWithBiometric = { credentialStoreViewModel.unlockWithBiometric( activity, - context.getString(R.string.biometric_unlock_title), - context.getString(R.string.credentials_unlock_biometric_subtitle), - context.getString(R.string.credentials_use_password) + biometricUnlockTitle, + credentialsUnlockBiometricSubtitle, + credentialsUsePassword ) } ) @@ -280,9 +284,9 @@ fun AppNavHost(navController: NavHostController, startDestination: String = NavR onUnlockWithBiometric = { credentialStoreViewModel.unlockWithBiometric( activity, - context.getString(R.string.biometric_unlock_title), - context.getString(R.string.credentials_unlock_biometric_subtitle), - context.getString(R.string.credentials_use_password) + biometricUnlockTitle, + credentialsUnlockBiometricSubtitle, + credentialsUsePassword ) }, passwordHint = credentialUiState.passwordHint, diff --git a/app/src/main/java/jamgmilk/fuwagit/ui/screen/myrepos/AddRepositoryScreen.kt b/app/src/main/java/jamgmilk/fuwagit/ui/screen/myrepos/AddRepositoryScreen.kt index 10e50a8..48b5e6e 100644 --- a/app/src/main/java/jamgmilk/fuwagit/ui/screen/myrepos/AddRepositoryScreen.kt +++ b/app/src/main/java/jamgmilk/fuwagit/ui/screen/myrepos/AddRepositoryScreen.kt @@ -29,19 +29,17 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import jamgmilk.fuwagit.R -import jamgmilk.fuwagit.core.util.UrlUtils import jamgmilk.fuwagit.ui.components.SubSettingsTemplate import jamgmilk.fuwagit.ui.navigation.AddRepoTab import jamgmilk.fuwagit.ui.screen.credentials.CredentialStoreViewModel @@ -59,7 +57,6 @@ fun AddRepositoryScreen( credentialsViewModel: CredentialStoreViewModel = hiltViewModel(), snackbarHostState: SnackbarHostState = remember { SnackbarHostState() } ) { - val context = LocalContext.current val scope = rememberCoroutineScope() var currentTab by remember { mutableStateOf(selectedTab) } diff --git a/app/src/main/java/jamgmilk/fuwagit/ui/screen/myrepos/LocalContent.kt b/app/src/main/java/jamgmilk/fuwagit/ui/screen/myrepos/LocalContent.kt index 9949547..74f08d7 100644 --- a/app/src/main/java/jamgmilk/fuwagit/ui/screen/myrepos/LocalContent.kt +++ b/app/src/main/java/jamgmilk/fuwagit/ui/screen/myrepos/LocalContent.kt @@ -20,8 +20,6 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Label import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.CheckCircle -import androidx.compose.material.icons.filled.Folder -import androidx.compose.material.icons.filled.FolderOpen import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.Link import androidx.compose.material3.Button @@ -43,12 +41,12 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import jamgmilk.fuwagit.R -import jamgmilk.fuwagit.core.util.PathUtils import jamgmilk.fuwagit.ui.components.FilePickerDialog import jamgmilk.fuwagit.ui.components.SectionCard import jamgmilk.fuwagit.ui.theme.AppShapes @@ -221,7 +219,7 @@ private fun RemoteSelectorDropdown( ) { Column(modifier = Modifier.padding(12.dp)) { Text( - text = stringResource(R.string.local_remote_count_format, remotes.size), + text = pluralStringResource(R.plurals.remote_count, remotes.size), style = MaterialTheme.typography.labelMedium, color = colors.onSurfaceVariant ) diff --git a/app/src/main/java/jamgmilk/fuwagit/ui/screen/myrepos/MyReposScreen.kt b/app/src/main/java/jamgmilk/fuwagit/ui/screen/myrepos/MyReposScreen.kt index 40798eb..20eb50e 100644 --- a/app/src/main/java/jamgmilk/fuwagit/ui/screen/myrepos/MyReposScreen.kt +++ b/app/src/main/java/jamgmilk/fuwagit/ui/screen/myrepos/MyReposScreen.kt @@ -129,10 +129,10 @@ fun MyReposScreen( Box(modifier = modifier.fillMaxSize()) { Scaffold( snackbarHost = { SnackbarHost(snackbarHostState) } - ) { _ -> + ) { innerPadding -> ScreenTemplate( title = stringResource(R.string.myrepos_screen_title), - modifier = Modifier.fillMaxSize() + modifier = Modifier.fillMaxSize().padding(innerPadding) ) { Surface( modifier = Modifier diff --git a/app/src/main/java/jamgmilk/fuwagit/ui/screen/settings/SettingsScreen.kt b/app/src/main/java/jamgmilk/fuwagit/ui/screen/settings/SettingsScreen.kt index 7d575ee..8ef40db 100644 --- a/app/src/main/java/jamgmilk/fuwagit/ui/screen/settings/SettingsScreen.kt +++ b/app/src/main/java/jamgmilk/fuwagit/ui/screen/settings/SettingsScreen.kt @@ -88,6 +88,7 @@ import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.lerp import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight @@ -580,7 +581,7 @@ private fun SecuritySettingsCard( 600L -> stringResource(R.string.settings_auto_lock_10_minutes) 1800L -> stringResource(R.string.settings_auto_lock_30_minutes) 3600L -> stringResource(R.string.settings_auto_lock_1_hour) - else -> stringResource(R.string.settings_auto_lock_seconds_format, (autoLockTimeout.toLongOrNull() ?: 0L).toInt()) + else -> pluralStringResource(R.plurals.settings_auto_lock_seconds, (autoLockTimeout.toLongOrNull() ?: 0L).toInt()) } ExpandableSettingsItem( diff --git a/app/src/main/java/jamgmilk/fuwagit/ui/screen/status/StatusComponents.kt b/app/src/main/java/jamgmilk/fuwagit/ui/screen/status/StatusComponents.kt index fc51352..7c8ea47 100644 --- a/app/src/main/java/jamgmilk/fuwagit/ui/screen/status/StatusComponents.kt +++ b/app/src/main/java/jamgmilk/fuwagit/ui/screen/status/StatusComponents.kt @@ -50,6 +50,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight @@ -608,7 +609,7 @@ internal fun CommitCard( ) Spacer(Modifier.width(4.dp)) Text( - text = stringResource(R.string.status_staged_count, stagedCount), + text = pluralStringResource(R.plurals.status_staged_count, stagedCount), style = MaterialTheme.typography.labelSmall, color = colors.primary ) @@ -798,7 +799,7 @@ internal fun TerminalLogsCard( ) if (logs.isNotEmpty()) { Text( - text = stringResource(R.string.terminal_lines_format, logs.size), + text = pluralStringResource(R.plurals.terminal_lines_count, logs.size), style = MaterialTheme.typography.labelSmall, color = colors.onSurfaceVariant.copy(alpha = 0.6f) ) diff --git a/app/src/main/java/jamgmilk/fuwagit/ui/screen/status/StatusViewModel.kt b/app/src/main/java/jamgmilk/fuwagit/ui/screen/status/StatusViewModel.kt index 3c289d8..808a7ac 100644 --- a/app/src/main/java/jamgmilk/fuwagit/ui/screen/status/StatusViewModel.kt +++ b/app/src/main/java/jamgmilk/fuwagit/ui/screen/status/StatusViewModel.kt @@ -575,8 +575,7 @@ class StatusViewModel @Inject constructor( fun onCredentialUnlocked() { viewModelScope.launch { - val pendingOp = _uiState.value.pendingGitOperation - when (pendingOp) { + when (val pendingOp = _uiState.value.pendingGitOperation) { is PendingGitOperation.Pull -> executePull(pendingOp.repoPath) is PendingGitOperation.Push -> executePush(pendingOp.repoPath) is PendingGitOperation.Fetch -> executeFetch(pendingOp.repoPath) diff --git a/app/src/main/java/jamgmilk/fuwagit/ui/screen/tags/TagDetailDialog.kt b/app/src/main/java/jamgmilk/fuwagit/ui/screen/tags/TagDetailDialog.kt index bd63175..5beaac1 100644 --- a/app/src/main/java/jamgmilk/fuwagit/ui/screen/tags/TagDetailDialog.kt +++ b/app/src/main/java/jamgmilk/fuwagit/ui/screen/tags/TagDetailDialog.kt @@ -38,7 +38,7 @@ import jamgmilk.fuwagit.R import jamgmilk.fuwagit.domain.model.git.GitTag import java.text.SimpleDateFormat import java.util.Date -import java.util.Locale +import androidx.compose.ui.platform.LocalLocale @Composable fun TagDetailDialog( @@ -49,7 +49,6 @@ fun TagDetailDialog( val colors = MaterialTheme.colorScheme val context = LocalContext.current - // Pre-fetch strings for use in non-composable contexts val strTagNameLabel = stringResource(R.string.tags_tag_name_label) val strTagNameCopied = stringResource(R.string.tags_tag_name_copied) @@ -107,7 +106,7 @@ fun TagDetailDialog( } if (tag.timestamp != null) { - val dateFormat = SimpleDateFormat("MMM dd, yyyy HH:mm:ss", Locale.getDefault()) + val dateFormat = SimpleDateFormat("MMM dd, yyyy HH:mm:ss", LocalLocale.current.platformLocale) DetailRow(label = stringResource(R.string.tags_tag_created), value = dateFormat.format(Date(tag.timestamp))) } } diff --git a/app/src/main/java/jamgmilk/fuwagit/ui/util/DateTimeUtils.kt b/app/src/main/java/jamgmilk/fuwagit/ui/util/DateTimeUtils.kt index 76ae367..efb8a38 100644 --- a/app/src/main/java/jamgmilk/fuwagit/ui/util/DateTimeUtils.kt +++ b/app/src/main/java/jamgmilk/fuwagit/ui/util/DateTimeUtils.kt @@ -5,7 +5,7 @@ import androidx.compose.ui.res.stringResource import jamgmilk.fuwagit.R import java.text.SimpleDateFormat import java.util.Date -import java.util.Locale +import androidx.compose.ui.platform.LocalLocale @Composable fun formatRelativeTime(timestamp: Long): String { @@ -18,7 +18,7 @@ fun formatRelativeTime(timestamp: Long): String { diff < 86_400_000 -> stringResource(R.string.history_time_hours_ago, diff / 3_600_000) diff < 604_800_000 -> stringResource(R.string.history_time_days_ago, diff / 86_400_000) else -> { - val sdf = SimpleDateFormat("MMM dd", Locale.getDefault()) + val sdf = SimpleDateFormat("MMM dd", LocalLocale.current.platformLocale) sdf.format(Date(timestamp)) } } diff --git a/app/src/main/java/jamgmilk/fuwagit/util/CrashLogManager.kt b/app/src/main/java/jamgmilk/fuwagit/util/CrashLogManager.kt index 43f1d44..bac3688 100644 --- a/app/src/main/java/jamgmilk/fuwagit/util/CrashLogManager.kt +++ b/app/src/main/java/jamgmilk/fuwagit/util/CrashLogManager.kt @@ -2,7 +2,6 @@ package jamgmilk.fuwagit.util import android.content.Context import android.content.Intent -import android.content.pm.PackageInfo import android.os.Build import android.util.Log import androidx.core.content.pm.PackageInfoCompat diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi/ic_launcher.xml similarity index 100% rename from app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml rename to app/src/main/res/mipmap-anydpi/ic_launcher.xml diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml similarity index 100% rename from app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml rename to app/src/main/res/mipmap-anydpi/ic_launcher_round.xml diff --git a/app/src/main/res/values-b+zh+Hans/strings.xml b/app/src/main/res/values-b+zh+Hans/strings.xml index b2debaf..16f9e6e 100644 --- a/app/src/main/res/values-b+zh+Hans/strings.xml +++ b/app/src/main/res/values-b+zh+Hans/strings.xml @@ -1,7 +1,7 @@ - + - FuwaGit + FuwaGit 取消 @@ -15,7 +15,6 @@ 选择 复制 导入 - 导出 预览 完成 重命名 @@ -23,7 +22,6 @@ 创建 添加 初始化 - 应用 重试 展开 收起 @@ -78,7 +76,6 @@ 启用生物识别解锁 使用指纹快速访问凭据 解锁凭据 - 生物识别验证不可用 自动锁定超时 会话将在%1$s后过期 从不 @@ -87,7 +84,9 @@ 10 分钟 30 分钟 1 小时 - %1$d 秒 + + %d 秒 + 设置超时时间(秒)。凭据将在此空闲期后自动锁定。输入 0 可禁用自动锁定(永不过期)。 @@ -108,13 +107,6 @@ 首次 Push 时自动设置上游分支 - 同步与备份 - 自动同步 - 后台定期 Pull/Push - 安全冲突策略 - Merge 时优先不覆盖 - 同步前备份 - Pull/Rebase 前创建快照 存储 @@ -142,7 +134,6 @@ 删除当前仓库中的 .git/COMMIT_EDITMSG 清除 COMMIT_EDITMSG 当前仓库中没有 COMMIT_EDITMSG 文件 - COMMIT_EDITMSG 文件已删除 COMMIT_EDITMSG 文件已删除 未找到 COMMIT_EDITMSG 文件 未选择仓库 @@ -176,9 +167,11 @@ 工作目录干净 已暂存 可以提交 + + %d 个已暂存 + 没有可提交的内容 提交更改 - %1$d 个已暂存 输入提交信息… 提交 操作 @@ -209,10 +202,12 @@ 新分支名称 输入新名称 未找到分支 + + %d 个分支 + 初始化仓库以查看分支 本地分支 远程分支 - %1$d 个分支 没有本地分支 没有远程分支 远程 @@ -275,11 +270,9 @@ 使用生物识别解锁 使用指纹快速解锁 使用密码 - 取消 正在更改… 更改密码 主密码已设置 - 主密码已更改 添加 HTTPS 凭据 主机 github.com @@ -352,7 +345,6 @@ 在此粘贴JSON数据… 当前提示:%1$s 已复制到剪贴板! - 已复制到剪贴板! 安全警告 正在导入… @@ -398,7 +390,6 @@ 清理未跟踪的文件 从工作目录中移除未跟踪的文件。此操作无法撤销。点击\'预览\'查看将被删除的文件。 仓库已添加 - 仓库添加成功 仓库信息 此目录不是 Git 仓库 正在扫描未跟踪的文件… @@ -411,8 +402,6 @@ 添加本地 远程 URL 仓库 URL - HTTPS - SSH HTTPS 凭据(可选) 选择 SSH 密钥 HTTPS:%1$s @@ -451,14 +440,9 @@ 远程 URL(可选) 选择一个本地文件夹开始使用 Git 管理它。 选择文件夹 - 远程(找到 %1$d 个) - 未选择文件夹 - 检测到 Git 仓库 - 不是 Git 仓库 - 选择文件夹 - 仓库:%1$s - 用户:%1$s - HEAD:%1$s + + 远程(%d 个) + 远程 URL @@ -487,8 +471,6 @@ 安全提醒 只有在信任此服务器时才接受。接受未知主机密钥可能导致中间人攻击。 信任 - 主机密钥被拒绝 - 无法验证主机:凭据保险库已锁定 %d秒后自动拒绝 生成 SSH 密钥对失败 @@ -504,7 +486,6 @@ 未知 - %1$d 个提交 加载历史失败 暂无提交 进行第一次提交以查看历史 @@ -518,6 +499,9 @@ 重置到此提交 加载详情失败 重置到提交完成 + + %d 个提交 + 刚刚 %1$d 分钟前 %1$d 小时前 @@ -575,16 +559,14 @@ 加载差异失败 - %1$d行 暂无输出 + + %d 行 + 警告 - 操作成功 - 操作失败 - 错误:%1$s - 建议:%1$s 确认重置%1$s @@ -604,10 +586,8 @@ 重置%1$s - 检测到 Merge 冲突 %1$s个冲突 %1$s个冲突需要解决 - 以下文件存在冲突: 如何解决: 1. 编辑每个文件以解决冲突标记(<<<<<<, ======, >>>>>>) 2. 编辑后点击\"标记为已解决\" @@ -624,25 +604,17 @@ 清理信息 清理失败 清理未跟踪的文件 - 以下%1$d个未跟踪的文件将被永久删除: + + 以下 %d 个未跟踪的文件将被永久删除: + 未找到未跟踪的文件。 + + 成功删除 %d 个文件: + 删除全部 清理完成 - 成功删除%1$d个文件: 没有文件被删除 - - 应用到所有仓库 - 这将把以下设置应用到所有已保存的仓库: - 也应用到全局配置 - 这将更新全局设置 - - - 成功 - 完成但有问题 - %1$d/%2$d个仓库更新成功 - 失败的仓库: - 无法访问此目录 空文件夹 @@ -650,49 +622,18 @@ 文件夹名称 无效的文件夹名称或已存在 - - user.name:%1$s - user.email:%1$s - %1$s:%2$s - - 密码不匹配 - 密码必须至少 6 个字符 - 旧密码不正确 分支\'%1$s\'已成功删除 - 该分支包含未 Merge 的提交。使用强制删除来移除它。 - 无法删除当前 Checkout 的分支。请先切换到其他分支。 - 未知错误 成功将\'%1$s\' Merge 到当前分支 - 在状态屏幕中手动解决冲突,然后提交更改。 - 该分支包含未 Merge 的提交。这对于 Merge 操作是正常的。 - 请检查当前分支是否是最新的后重试。 成功 Rebase 到\'%1$s\' - 解决冲突后运行\'git rebase --continue\',或运行\'git rebase --abort\'取消。 - 当前分支已经与目标分支同步。 - 运行\'git rebase --abort\'取消 Rebase 操作。 - 冲突已解决 - 选择目标仓库 - 选择目标仓库路径 - Git仓库 - 不是Git仓库 \'%1$s\'的更改已丢弃 - 确保文件未被暂存或被其他进程锁定。 - 没有未跟踪的文件需要清理 - 清理失败:%1$s - 获取未跟踪文件失败:%1$s - 获取文件列表失败:%1$s 已切换到分支\'%1$s\' - 切换分支失败 分支\'%1$s\'已成功创建 - 创建分支失败 分支已重命名为\'%1$s\' - 重命名分支失败 轻量级标签\'%1$s\'已创建 附注标签\'%1$s\'已创建 标签\'%1$s\'已删除 标签\'%1$s\'已成功推送 - 执行标签操作失败 欢迎使用 FuwaGit @@ -728,16 +669,6 @@ 要开始了哟~ - 密码不匹配 - 密码必须至少 6 个字符 - 生物识别错误 - 生物识别设置失败 未找到凭据 - 分支包含未合并的提交 - 使用强制删除来移除它 - 无法删除当前检出的分支 - 请先切换到另一个分支 - 当前分支已与目标分支同步。 - 分支包含未合并的提交。这是合并操作的预期行为。 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 74aaa6f..ff02f91 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,7 +1,48 @@ - + + + + + %d second + %d seconds + + + + %d staged + %d staged + + + + %d branch + %d branches + + + + Remote (%d found) + Remote (%d found) + + + + %d commit + %d commits + + + + %d line + %d lines + + + + The following %d untracked file will be permanently deleted: + The following %d untracked files will be permanently deleted: + + + + Successfully deleted %d file: + Successfully deleted %d files: + - FuwaGit + FuwaGit Cancel @@ -15,7 +56,6 @@ Select Copy Import - Export Preview Finish Rename @@ -23,7 +63,6 @@ Create Add Initialize - Apply Retry Expand Collapse @@ -83,7 +122,7 @@ 10 minutes 30 minutes 1 hour - %1$d seconds + Set the timeout duration in seconds. Credentials will auto-lock after this period of inactivity. Enter 0 to disable auto-lock (never expires). @@ -104,13 +143,6 @@ Automatically set upstream branch when pushing for the first time - Sync & Backup - Auto Sync - Periodic pull/push in background - Safe Conflict Strategy - Prefer no-overwrite during merge - Backup Before Sync - Create snapshot before pull/rebase Storage @@ -141,7 +173,6 @@ Delete .git/COMMIT_EDITMSG in current repo Clear COMMIT_EDITMSG No COMMIT_EDITMSG file in current repo - COMMIT_EDITMSG file deleted Export Logs Share crash logs for bug reporting No crash logs to export @@ -174,7 +205,7 @@ Ready to commit Nothing to commit Commit Changes - %1$d staged + Enter commit message… Commit Actions @@ -208,7 +239,7 @@ Initialize a repository to see branches Local Branches Remote Branches - %1$d branches + No local branches No remote branches Remote @@ -260,7 +291,6 @@ Passwords do not match At least 6 characters Incorrect password - Cancel Set Password Setting… Changing… @@ -276,7 +306,6 @@ Enable Biometric Unlock Use your fingerprint to quickly access credentials Unlock Credentials - Biometric authentication not available Add HTTPS Credential Host github.com @@ -293,7 +322,6 @@ Set Master Password Create a master password to protect your sensitive credentials. You\'ll need this password to view passwords and private keys. Master password has been set - Master password has been changed Generate SSH Key Key Name My Key @@ -354,7 +382,6 @@ Copied to clipboard! Security Warning Importing… - Copied to clipboard! Host @@ -398,7 +425,6 @@ Clean Untracked Files Remove untracked files from the working directory. This action cannot be undone. Click \'Preview\' to see which files will be deleted. Repository added - Repository added successfully Repository Info This directory is not a Git repository Scanning for untracked files… @@ -411,8 +437,6 @@ Add Local Remote URL Repository URL - HTTPS - SSH HTTPS Credential (optional) Select SSH Key HTTPS: %1$s @@ -451,14 +475,6 @@ Remote URL (Optional) Select a local folder to begin managing it with Git. Select Folder - Remote (%1$d found) - No folder selected - Git repository detected - Not a Git repository - Pick folder - Repository: %1$s - User: %1$s - HEAD: %1$s Remote URL @@ -489,8 +505,6 @@ Security Notice Only accept if you trust this server. Accepting unknown host keys could allow man-in-the-middle attacks. Trust - Host key rejected - Cannot verify host: credential vault is locked Auto-reject in %ds @@ -503,7 +517,7 @@ Unknown - %1$d commits + Failed to load history No commits yet Make your first commit to see history @@ -574,16 +588,12 @@ Failed to load diff - %1$d lines + No output yet Warning - Operation Successful - Operation Failed - Error: %1$s - Suggestion: %1$s Confirm %1$s Reset @@ -603,10 +613,8 @@ Reset %1$s - Merge Conflicts Detected %1$s Conflicts %1$s conflict(s) need resolution - The following files have conflicts: How to resolve: 1. Edit each file to resolve conflict markers (<<<<<<, ======, >>>>>>) 2. Click \'Mark as Resolved\' for each file after editing @@ -623,25 +631,11 @@ Clean Info Clean Failed Clean Untracked Files - The following %1$d untracked file(s) will be permanently deleted: No untracked files found. Delete All Clean Completed - Successfully deleted %1$d file(s): No files were deleted - - Apply to All Repositories - This will apply the following settings to all saved repositories: - Also apply to Global Config - This will update the global settings - - - Success - Completed with Issues - %1$d/%2$d repositories updated successfully - Failed repositories: - Cannot access this directory Empty folder @@ -649,49 +643,18 @@ Folder name Invalid folder name or already exists - - user.name: %1$s - user.email: %1$s - %1$s: %2$s - - Passwords do not match - Password must be at least 6 characters - Incorrect old password Branch \'%1$s\' deleted successfully - The branch contains commits that haven\'t been merged. Use force delete to remove it anyway. - Cannot delete the currently checked out branch. Switch to another branch first. - Unknown error Successfully merged \'%1$s\' into current branch - Resolve the conflicts manually in the Status screen, then commit the changes. - The branch contains unmerged commits. This is expected for a merge operation. - Check if the current branch is up to date and try again. Successfully rebased onto \'%1$s\' - Resolve conflicts and run \'git rebase --continue\', or \'git rebase --abort\' to cancel. - The current branch is already up to date with the target branch. - Run \'git rebase --abort\' to cancel the rebase operation. - Conflicts resolved - Select a target repo - Select a target repo path - Git repository - Not a git repository Changes to \'%1$s\' have been discarded - Make sure the file is not staged or locked by another process. - No untracked files to clean - Failed to clean: %1$s - Failed to get untracked files: %1$s - Failed to get file list: %1$s Switched to branch \'%1$s\' - Failed to switch branch Branch \'%1$s\' created successfully - Failed to create branch Branch renamed to \'%1$s\' - Failed to rename branch Lightweight tag \'%1$s\' created Annotated tag \'%1$s\' created Tag \'%1$s\' deleted Tag \'%1$s\' pushed successfully - Failed to perform tag operation Welcome to FuwaGit @@ -727,16 +690,6 @@ You\'re All Set! - Passwords do not match - Password must be at least 6 characters - Biometric error - Biometric setup failed No credentials found - The branch contains commits that have not been merged - Use force delete to remove it anyway - Cannot delete the currently checked out branch - Switch to another branch first - The current branch is already up to date with the target branch. - The branch contains unmerged commits. This is expected for a merge operation. diff --git a/app/src/test/java/jamgmilk/fuwagit/AppResultTest.kt b/app/src/test/java/jamgmilk/fuwagit/AppResultTest.kt deleted file mode 100644 index 35bcb3c..0000000 --- a/app/src/test/java/jamgmilk/fuwagit/AppResultTest.kt +++ /dev/null @@ -1,451 +0,0 @@ -package jamgmilk.fuwagit - -import jamgmilk.fuwagit.core.result.AppException -import jamgmilk.fuwagit.core.result.AppResult -import jamgmilk.fuwagit.core.result.toAppResult -import org.junit.Assert.* -import org.junit.Test - -class AppResultTest { - - // ==================== AppResult.Success 基本测试 ==================== - - @Test - fun `Success wraps data correctly`() { - val result: AppResult = AppResult.Success(42) - - assertTrue(result.isSuccess) - assertFalse(result.isFailure) - assertEquals(42, result.getOrNull()) - assertNull(result.exceptionOrNull()) - } - - @Test - fun `Success with String data`() { - val result: AppResult = AppResult.Success("test") - assertEquals("test", result.getOrNull()) - } - - @Test - fun `Success with List data`() { - val result: AppResult> = AppResult.Success(listOf(1, 2, 3)) - assertEquals(listOf(1, 2, 3), result.getOrNull()) - } - - @Test - fun `Success with null data`() { - val result: AppResult = AppResult.Success(null) - assertTrue(result.isSuccess) - assertNull(result.getOrNull()) - } - - @Test - fun `Success message is null`() { - val result = AppResult.Success(42) - assertNull(result.message) - } - - // ==================== AppResult.Error 基本测试 ==================== - - @Test - fun `Error wraps exception correctly`() { - val exception = AppException.InvalidPassword() - val result: AppResult = AppResult.Error(exception) - - assertFalse(result.isSuccess) - assertTrue(result.isFailure) - assertNull(result.getOrNull()) - assertEquals(exception, result.exceptionOrNull()) - } - - @Test - fun `Error message returns exception message`() { - val result = AppResult.Error(AppException.CredentialNotFound("uuid-123")) - assertEquals("Credential not found: uuid-123", result.message) - } - - @Test - fun `Error getOrNull returns null`() { - val result = AppResult.Error(AppException.DecryptionFailed()) - assertNull(result.getOrNull()) - } - - // ==================== AppResult.map 测试 ==================== - - @Test - fun `map transforms success data`() { - val result = AppResult.Success(5) - val mapped = result.map { it * 2 } - - assertTrue(mapped.isSuccess) - assertEquals(10, mapped.getOrNull()) - } - - @Test - fun `map preserves error`() { - val result: AppResult = AppResult.Error(AppException.InvalidPassword()) - val mapped = result.map { it * 2 } - - assertTrue(mapped.isFailure) - assertNull(mapped.getOrNull()) - } - - @Test - fun `map with type change`() { - val result = AppResult.Success(5) - val mapped = result.map { "Number: $it" } - - assertTrue(mapped.isSuccess) - assertEquals("Number: 5", mapped.getOrNull()) - } - - @Test - fun `map error to success with different type`() { - val result: AppResult = AppResult.Error(AppException.InvalidPassword()) - val mapped = result.map { it * 2 } - - assertTrue(mapped.isFailure) - } - - // ==================== AppResult.onSuccess 测试 ==================== - - @Test - fun `onSuccess executes action for success`() { - val result = AppResult.Success(10) - var captured = 0 - - result.onSuccess { captured = it } - - assertEquals(10, captured) - } - - @Test - fun `onSuccess does not execute for error`() { - val result: AppResult = AppResult.Error(AppException.InvalidPassword()) - var executed = false - - result.onSuccess { executed = true } - - assertFalse(executed) - } - - @Test - fun `onSuccess returns same result`() { - val result = AppResult.Success(10) - val returned = result.onSuccess { it * 2 } - - assertSame(result, returned) - } - - // ==================== AppResult.onError 测试 ==================== - - @Test - fun `onError executes action for error`() { - val exception = AppException.InvalidPassword() - val result: AppResult = AppResult.Error(exception) - var captured: AppException? = null - - result.onError { captured = it } - - assertEquals(exception, captured) - } - - @Test - fun `onError does not execute for success`() { - val result = AppResult.Success(42) - var executed = false - - result.onError { executed = true } - - assertFalse(executed) - } - - @Test - fun `onError returns same result`() { - val result: AppResult = AppResult.Error(AppException.InvalidPassword()) - val returned = result.onError { } - - assertSame(result, returned) - } - - // ==================== AppResult.catching 测试 ==================== - - @Test - fun `catching wraps successful computation`() { - val result = AppResult.catching { 100 } - - assertTrue(result.isSuccess) - assertEquals(100, result.getOrNull()) - } - - @Test - fun `catching wraps successful list computation`() { - val result = AppResult.catching { - listOf(1, 2, 3) - } - - assertTrue(result.isSuccess) - assertEquals(listOf(1, 2, 3), result.getOrNull()) - } - - @Test - fun `catching wraps AppException`() { - val result = AppResult.catching { - throw AppException.InvalidPassword() - } - - assertTrue(result.isFailure) - assertTrue(result.exceptionOrNull() is AppException.InvalidPassword) - } - - @Test - fun `catching wraps generic exception as Unknown`() { - val result = AppResult.catching { - throw RuntimeException("Something went wrong") - } - - assertTrue(result.isFailure) - val exception = result.exceptionOrNull() - assertTrue(exception is AppException.Unknown) - assertEquals("Something went wrong", exception?.message) - } - - @Test - fun `catching with null return`() { - val result = AppResult.catching { null } - - assertTrue(result.isSuccess) - assertNull(result.getOrNull()) - } - - @Test - fun `catching with throwable subclass of AppException`() { - val result = AppResult.catching { - throw AppException.DecryptionFailed("Custom error") - } - - assertTrue(result.isFailure) - assertTrue(result.exceptionOrNull() is AppException.DecryptionFailed) - } - - // ==================== toAppResult 扩展函数测试 ==================== - - @Test - fun `toAppResult converts success Result`() { - val result: Result = Result.success(42) - val appResult = result.toAppResult() - - assertTrue(appResult.isSuccess) - assertEquals(42, appResult.getOrNull()) - } - - @Test - fun `toAppResult converts failure Result with AppException`() { - val cause = AppException.InvalidPassword() - val result: Result = Result.failure(cause) - val appResult = result.toAppResult() - - assertTrue(appResult.isFailure) - assertEquals(cause, appResult.exceptionOrNull()) - } - - @Test - fun `toAppResult converts failure Result with generic exception`() { - val cause = RuntimeException("Network error") - val result: Result = Result.failure(cause) - val appResult = result.toAppResult() - - assertTrue(appResult.isFailure) - assertTrue(appResult.exceptionOrNull() is AppException.Unknown) - } - - @Test - fun `toAppResult converts failure Result with unknown exception`() { - val cause = IllegalStateException("Unknown state") - val result: Result = Result.failure(cause) - val appResult = result.toAppResult() - - assertTrue(appResult.isFailure) - assertTrue(appResult.exceptionOrNull() is AppException.Unknown) - assertEquals("Unknown state", appResult.exceptionOrNull()?.message) - } -} - -class AppExceptionTest { - - // ==================== Credential Exceptions ==================== - - @Test - fun `CredentialNotFound has correct message`() { - val exception = AppException.CredentialNotFound("uuid-456") - assertEquals("Credential not found: uuid-456", exception.message) - } - - @Test - fun `MasterKeyNotUnlocked has default message`() { - val exception = AppException.MasterKeyNotUnlocked() - assertEquals("Master key not unlocked", exception.message) - } - - @Test - fun `MasterKeyNotUnlocked can override message`() { - val exception = AppException.MasterKeyNotUnlocked("Custom message") - assertEquals("Custom message", exception.message) - } - - @Test - fun `InvalidPassword has default message`() { - val exception = AppException.InvalidPassword() - assertEquals("Invalid password", exception.message) - } - - @Test - fun `PasswordMismatch has default message`() { - val exception = AppException.PasswordMismatch() - assertEquals("Passwords do not match", exception.message) - } - - @Test - fun `BiometricError has custom message`() { - val exception = AppException.BiometricError("Hardware unavailable") - assertEquals("Hardware unavailable", exception.message) - } - - @Test - fun `DecryptionFailed has default message`() { - val exception = AppException.DecryptionFailed() - assertEquals("Decryption failed", exception.message) - } - - @Test - fun `DecryptionFailed can have custom message`() { - val exception = AppException.DecryptionFailed("Custom decryption error") - assertEquals("Custom decryption error", exception.message) - } - - @Test - fun `GitOperationFailed has operation and message`() { - val exception = AppException.GitOperationFailed("clone", "Connection refused") - assertEquals("Connection refused", exception.message) - } - - @Test - fun `RepositoryNotFound has correct message`() { - val exception = AppException.RepositoryNotFound("/path/to/repo") - assertEquals("Repository not found: /path/to/repo", exception.message) - } - - @Test - fun `RepositoryAlreadyExists has correct message`() { - val exception = AppException.RepositoryAlreadyExists("/path/to/repo") - assertEquals("Repository already exists: /path/to/repo", exception.message) - } - - @Test - fun `BranchNotFound has correct message`() { - val exception = AppException.BranchNotFound("feature-x") - assertEquals("Branch not found: feature-x", exception.message) - } - - @Test - fun `BranchAlreadyExists has correct message`() { - val exception = AppException.BranchAlreadyExists("feature-x") - assertEquals("Branch already exists: feature-x", exception.message) - } - - @Test - fun `MergeConflict has custom message`() { - val exception = AppException.MergeConflict("Auto-merge failed") - assertEquals("Auto-merge failed", exception.message) - } - - @Test - fun `RebaseConflict has custom message`() { - val exception = AppException.RebaseConflict("Rebase failed") - assertEquals("Rebase failed", exception.message) - } - - @Test - fun `CheckoutConflict has custom message`() { - val exception = AppException.CheckoutConflict("Cannot checkout") - assertEquals("Cannot checkout", exception.message) - } - - @Test - fun `RemoteNotFound has correct message`() { - val exception = AppException.RemoteNotFound("origin") - assertEquals("Remote not found: origin", exception.message) - } - - @Test - fun `PushRejected has custom message`() { - val exception = AppException.PushRejected("Rejected by remote") - assertEquals("Rejected by remote", exception.message) - } - - @Test - fun `PullRejected has custom message`() { - val exception = AppException.PullRejected("Rejected locally") - assertEquals("Rejected locally", exception.message) - } - - @Test - fun `InvalidRepository has path and message`() { - val exception = AppException.InvalidRepository("/path", "Not a git repo") - assertEquals("Not a git repo", exception.message) - } - - @Test - fun `NoRemoteConfigured has default message`() { - val exception = AppException.NoRemoteConfigured() - assertEquals("No remote repository configured", exception.message) - } - - @Test - fun `CommitFailed has custom message`() { - val exception = AppException.CommitFailed("Nothing to commit") - assertEquals("Nothing to commit", exception.message) - } - - @Test - fun `ResetFailed has custom message`() { - val exception = AppException.ResetFailed("Cannot reset") - assertEquals("Cannot reset", exception.message) - } - - @Test - fun `CloneFailed has custom message`() { - val exception = AppException.CloneFailed("Network error") - assertEquals("Network error", exception.message) - } - - @Test - fun `AuthenticationFailed has default message`() { - val exception = AppException.AuthenticationFailed() - assertEquals("Authentication failed", exception.message) - } - - @Test - fun `AuthenticationFailed with custom message`() { - val exception = AppException.AuthenticationFailed("Token expired") - assertEquals("Token expired", exception.message) - } - - @Test - fun `NetworkError has custom message`() { - val exception = AppException.NetworkError("Connection timeout") - assertEquals("Connection timeout", exception.message) - } - - @Test - fun `Validation has custom message`() { - val exception = AppException.Validation("Invalid input") - assertEquals("Invalid input", exception.message) - } - - @Test - fun `Unknown has custom message`() { - val exception = AppException.Unknown("Unexpected error") - assertEquals("Unexpected error", exception.message) - } -} diff --git a/app/src/test/java/jamgmilk/fuwagit/CredentialModelsTest.kt b/app/src/test/java/jamgmilk/fuwagit/CredentialModelsTest.kt deleted file mode 100644 index 1804ea5..0000000 --- a/app/src/test/java/jamgmilk/fuwagit/CredentialModelsTest.kt +++ /dev/null @@ -1,301 +0,0 @@ -package jamgmilk.fuwagit - -import jamgmilk.fuwagit.domain.model.credential.CloneCredential -import jamgmilk.fuwagit.domain.model.credential.HttpsCredential -import jamgmilk.fuwagit.domain.model.credential.SshKey -import org.junit.Assert.* -import org.junit.Test - -class CredentialModelsTest { - - // ==================== HttpsCredential 测试 ==================== - - @Test - fun `HttpsCredential creation with all fields`() { - val credential = HttpsCredential( - uuid = "uuid-1234", - host = "github.com", - username = "testuser", - password = "secret123", - createdAt = 1000L, - updatedAt = 2000L - ) - - assertEquals("uuid-1234", credential.uuid) - assertEquals("github.com", credential.host) - assertEquals("testuser", credential.username) - assertEquals("secret123", credential.password) - assertEquals(1000L, credential.createdAt) - assertEquals(2000L, credential.updatedAt) - } - - @Test - fun `HttpsCredential default timestamps are recent`() { - val beforeCreate = System.currentTimeMillis() - 1000 - val credential = HttpsCredential( - uuid = "uuid-1", - host = "gitlab.com", - username = "user", - password = "pass" - ) - val afterCreate = System.currentTimeMillis() + 1000 - - assertTrue(credential.createdAt >= beforeCreate) - assertTrue(credential.createdAt <= afterCreate) - assertTrue(credential.updatedAt >= beforeCreate) - assertTrue(credential.updatedAt <= afterCreate) - } - - @Test - fun `HttpsCredential data class equality`() { - val cred1 = HttpsCredential("uuid1", "github.com", "user1", "pass1") - val cred2 = HttpsCredential("uuid1", "github.com", "user1", "pass1") - val cred3 = HttpsCredential("uuid2", "github.com", "user1", "pass1") - - assertEquals(cred1, cred2) - assertNotEquals(cred1, cred3) - } - - @Test - fun `HttpsCredential for different hosts`() { - val githubCred = HttpsCredential("u1", "github.com", "gh_user", "gh_pass") - val gitlabCred = HttpsCredential("u2", "gitlab.com", "gl_user", "gl_pass") - val bitbucketCred = HttpsCredential("u3", "bitbucket.org", "bb_user", "bb_pass") - - assertEquals("github.com", githubCred.host) - assertEquals("gitlab.com", gitlabCred.host) - assertEquals("bitbucket.org", bitbucketCred.host) - } - - // ==================== SshKey 测试 ==================== - - @Test - fun `SshKey creation with passphrase`() { - val sshKey = SshKey( - uuid = "ssh-uuid-1", - name = "Work Key", - type = "RSA", - publicKey = "ssh-rsa AAAAB3Nza... user@host", - privateKey = "-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----", - passphrase = "mypassphrase", - fingerprint = "SHA256:abc123def456", - createdAt = 3000L - ) - - assertEquals("ssh-uuid-1", sshKey.uuid) - assertEquals("Work Key", sshKey.name) - assertEquals("RSA", sshKey.type) - assertNotNull(sshKey.passphrase) - assertEquals("mypassphrase", sshKey.passphrase) - assertEquals("SHA256:abc123def456", sshKey.fingerprint) - } - - @Test - fun `SshKey creation without passphrase`() { - val sshKey = SshKey( - uuid = "ssh-uuid-2", - name = "Personal Key", - type = "Ed25519", - publicKey = "ssh-ed25519 AAAA...", - privateKey = "-----BEGIN OPENSSH PRIVATE KEY-----\n...", - passphrase = null, - fingerprint = "SHA256:noPassKey" - ) - - assertNull(sshKey.passphrase) - assertEquals("Ed25519", sshKey.type) - } - - @Test - fun `SshKey comment extraction from public key with comment`() { - val sshKey = SshKey( - uuid = "u1", - name = "Test Key", - type = "RSA", - publicKey = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC user@host", - privateKey = "private-key-data", - fingerprint = "SHA256:fp1" - ) - - assertEquals("user@host", sshKey.comment) - } - - @Test - fun `SshKey comment extraction from public key without comment`() { - val sshKey = SshKey( - uuid = "u2", - name = "Minimal Key", - type = "Ed25519", - publicKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI", - privateKey = "private", - fingerprint = "SHA256:fp2" - ) - - assertEquals("", sshKey.comment) - } - - @Test - fun `SshKey comment extraction with email-style comment`() { - val sshKey = SshKey( - uuid = "u3", - name = "Key With Email", - type = "RSA", - publicKey = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC developer@example.com", - privateKey = "private", - fingerprint = "SHA256:fp3" - ) - - assertEquals("developer@example.com", sshKey.comment) - } - - @Test - fun `SshKey comment extraction handles malformed public key`() { - val sshKey = SshKey( - uuid = "u4", - name = "Bad Key", - type = "RSA", - publicKey = "invalid-public-key-format", - privateKey = "private", - fingerprint = "SHA256:fp4" - ) - - assertEquals("", sshKey.comment) - } - - @Test - fun `SshKey comment extraction handles empty public key`() { - val sshKey = SshKey( - uuid = "u5", - name = "Empty Key", - type = "RSA", - publicKey = "", - privateKey = "private", - fingerprint = "SHA256:fp5" - ) - - assertEquals("", sshKey.comment) - } - - @Test - fun `SshKey data class equality`() { - val key1 = SshKey("u1", "Key1", "RSA", "pub", "priv", "pass", "fp1") - val key2 = SshKey("u1", "Key1", "RSA", "pub", "priv", "pass", "fp1") - val key3 = SshKey("u2", "Key2", "RSA", "pub", "priv", "pass", "fp2") - - assertEquals(key1, key2) - assertNotEquals(key1, key3) - } - - @Test - fun `SshKey default passphrase is null`() { - val sshKey = SshKey( - uuid = "u6", - name = "Default Passphrase Key", - type = "Ed25519", - publicKey = "ssh-ed25519 AAA...", - privateKey = "-----BEGIN OPENSSH PRIVATE KEY-----...", - fingerprint = "SHA256:default" - ) - - assertNull(sshKey.passphrase) - } - - @Test - fun `SshKey default createdAt is recent`() { - val beforeCreate = System.currentTimeMillis() - 1000 - val sshKey = SshKey( - uuid = "u7", - name = "Key With Default Time", - type = "Ed25519", - publicKey = "ssh-ed25519 AAA...", - privateKey = "private", - fingerprint = "SHA256:time" - ) - val afterCreate = System.currentTimeMillis() + 1000 - - assertTrue(sshKey.createdAt >= beforeCreate) - assertTrue(sshKey.createdAt <= afterCreate) - } - - // ==================== CloneCredential 密封类测试 ==================== - - @Test - fun `CloneCredential Https subtype`() { - val httpsCred = CloneCredential.Https( - username = "myuser", - password = "mypassword" - ) - - assertTrue(httpsCred is CloneCredential) - assertTrue(httpsCred is CloneCredential.Https) - assertEquals("myuser", httpsCred.username) - assertEquals("mypassword", httpsCred.password) - } - - @Test - fun `CloneCredential Ssh subtype`() { - val sshCred = CloneCredential.Ssh( - privateKey = "-----BEGIN RSA PRIVATE KEY-----\nkeydata\n-----END RSA PRIVATE KEY-----", - passphrase = "secret" - ) - - assertTrue(sshCred is CloneCredential) - assertTrue(sshCred is CloneCredential.Ssh) - assertNotNull(sshCred.privateKey) - assertEquals("secret", sshCred.passphrase) - } - - @Test - fun `CloneCredential Ssh without passphrase`() { - val sshCred = CloneCredential.Ssh( - privateKey = "private-key-no-pass", - passphrase = null - ) - - assertNull(sshCred.passphrase) - } - - @Test - fun `CloneCredential when expression matching`() { - val credentials: List = listOf( - CloneCredential.Https("user1", "pass1"), - CloneCredential.Ssh("key1", null), - CloneCredential.Https("user2", "pass2"), - CloneCredential.Ssh("key2", "passphrase") - ) - - var httpsCount = 0 - var sshCount = 0 - - credentials.forEach { cred -> - when (cred) { - is CloneCredential.Https -> httpsCount++ - is CloneCredential.Ssh -> sshCount++ - } - } - - assertEquals(2, httpsCount) - assertEquals(2, sshCount) - } - - @Test - fun `CloneCredential Https equals with same values`() { - val cred1 = CloneCredential.Https("user", "pass") - val cred2 = CloneCredential.Https("user", "pass") - assertEquals(cred1, cred2) - } - - @Test - fun `CloneCredential Ssh equals with same values`() { - val cred1 = CloneCredential.Ssh("key", "pass") - val cred2 = CloneCredential.Ssh("key", "pass") - assertEquals(cred1, cred2) - } - - @Test - fun `CloneCredential Https and Ssh are different types`() { - val httpsCred = CloneCredential.Https("user", "pass") - val sshCred = CloneCredential.Ssh("key", null) - assertNotEquals(httpsCred, sshCred) - } -} \ No newline at end of file diff --git a/app/src/test/java/jamgmilk/fuwagit/GitModelsTest.kt b/app/src/test/java/jamgmilk/fuwagit/GitModelsTest.kt deleted file mode 100644 index c1b6caa..0000000 --- a/app/src/test/java/jamgmilk/fuwagit/GitModelsTest.kt +++ /dev/null @@ -1,432 +0,0 @@ -package jamgmilk.fuwagit - -import jamgmilk.fuwagit.domain.model.git.* -import org.junit.Assert.* -import org.junit.Test - -class GitModelsTest { - - // ==================== GitCommit 计算属性测试 ==================== - - @Test - fun `GitCommit isMerge returns true when multiple parents`() { - val commit = GitCommit( - hash = "abc123", - shortHash = "abc123", - authorName = "Author", - authorEmail = "a@b.com", - message = "Merge branch", - timestamp = 1000L, - parentHashes = listOf("parent1", "parent2") - ) - assertTrue(commit.isMerge) - } - - @Test - fun `GitCommit isMerge returns false with single parent`() { - val commit = GitCommit( - hash = "abc123", - shortHash = "abc123", - authorName = "Author", - authorEmail = "a@b.com", - message = "Normal commit", - timestamp = 1000L, - parentHashes = listOf("parent1") - ) - assertFalse(commit.isMerge) - } - - @Test - fun `GitCommit isMerge returns false with no parents`() { - val commit = GitCommit( - hash = "abc123", - shortHash = "abc123", - authorName = "Author", - authorEmail = "a@b.com", - message = "Initial commit", - timestamp = 1000L - ) - assertFalse(commit.isMerge) - assertTrue(commit.isInitialCommit) - } - - @Test - fun `GitCommit shortMessage truncates long messages at 72 chars`() { - val longMessage = "A".repeat(100) - val commit = GitCommit( - hash = "abc", - shortHash = "abc", - authorName = "A", - authorEmail = "a@b.com", - message = longMessage, - timestamp = 0L - ) - assertEquals(72, commit.shortMessage.length) - assertFalse(commit.shortMessage.endsWith("...")) - } - - @Test - fun `GitCommit shortMessage keeps short messages intact`() { - val shortMessage = "Short message" - val commit = GitCommit( - hash = "abc", - shortHash = "abc", - authorName = "A", - authorEmail = "a@b.com", - message = shortMessage, - timestamp = 0L - ) - assertEquals(shortMessage, commit.shortMessage) - } - - @Test - fun `GitCommit shortMessage takes first line only`() { - val multiLineMessage = "First line\nSecond line\nThird line" - val commit = GitCommit( - hash = "abc", - shortHash = "abc", - authorName = "A", - authorEmail = "a@b.com", - message = multiLineMessage, - timestamp = 0L - ) - assertEquals("First line", commit.shortMessage) - } - - @Test - fun `GitCommit formattedTimestamp formats correctly`() { - val commit = GitCommit( - hash = "abc", - shortHash = "abc", - authorName = "A", - authorEmail = "a@b.com", - message = "msg", - timestamp = 0L - ) - val formatted = commit.formattedTimestamp - assertTrue(formatted.matches(Regex("\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}"))) - } - - @Test - fun `GitCommit authorDisplayName handles full name`() { - val commit = GitCommit( - hash = "abc", - shortHash = "abc", - authorName = "John Doe", - authorEmail = "john@example.com", - message = "msg", - timestamp = 0L - ) - assertEquals("John D.", commit.authorDisplayName) - } - - @Test - fun `GitCommit authorDisplayName handles name with email format`() { - val commit = GitCommit( - hash = "abc", - shortHash = "abc", - authorName = "John Doe ", - authorEmail = "john@example.com", - message = "msg", - timestamp = 0L - ) - assertEquals("John Doe", commit.authorDisplayName) - } - - @Test - fun `GitCommit authorDisplayName falls back to email username`() { - val commit = GitCommit( - hash = "abc", - shortHash = "abc", - authorName = "", - authorEmail = "john@example.com", - message = "msg", - timestamp = 0L - ) - assertEquals("john", commit.authorDisplayName) - } - - @Test - fun `GitCommit authorDisplayName handles single name`() { - val commit = GitCommit( - hash = "abc", - shortHash = "abc", - authorName = "John", - authorEmail = "john@example.com", - message = "msg", - timestamp = 0L - ) - assertEquals("John", commit.authorDisplayName) - } - - @Test - fun `GitCommit parentCount returns correct number`() { - val commitWith3Parents = GitCommit( - hash = "abc", - shortHash = "abc", - authorName = "A", - authorEmail = "a@b.com", - message = "msg", - timestamp = 0L, - parentHashes = listOf("p1", "p2", "p3") - ) - assertEquals(3, commitWith3Parents.parentCount) - - val commitWithNoParents = GitCommit( - hash = "abc", - shortHash = "abc", - authorName = "A", - authorEmail = "a@b.com", - message = "msg", - timestamp = 0L - ) - assertEquals(0, commitWithNoParents.parentCount) - } - - @Test - fun `GitCommit primaryParentHash returns first parent`() { - val commit = GitCommit( - hash = "abc", - shortHash = "abc", - authorName = "A", - authorEmail = "a@b.com", - message = "msg", - timestamp = 0L, - parentHashes = listOf("first", "second") - ) - assertEquals("first", commit.primaryParentHash) - } - - @Test - fun `GitCommit primaryParentHash returns null for no parents`() { - val commit = GitCommit( - hash = "abc", - shortHash = "abc", - authorName = "A", - authorEmail = "a@b.com", - message = "msg", - timestamp = 0L - ) - assertNull(commit.primaryParentHash) - } - - // ==================== GitCommitFileChange 计算属性测试 ==================== - - @Test - fun `GitCommitFileChange totalChanges calculates correctly`() { - val change = GitCommitFileChange( - path = "file.kt", - name = "file.kt", - changeType = GitChangeType.Added, - additions = 50, - deletions = 10 - ) - assertEquals(60, change.totalChanges) - } - - @Test - fun `GitCommitFileChange totalChanges with zeros`() { - val change = GitCommitFileChange( - path = "file.kt", - name = "file.kt", - changeType = GitChangeType.Added - ) - assertEquals(0, change.totalChanges) - } - - // ==================== GitCommitDetail 计算属性测试 ==================== - - @Test - fun `GitCommitDetail totalChanges calculates correctly`() { - val detail = GitCommitDetail( - totalAdditions = 100, - totalDeletions = 50 - ) - assertEquals(150, detail.totalChanges) - } - - // ==================== PullResult 计算属性测试 ==================== - - @Test - fun `PullResult isUpToDate returns true for ALREADY_UP_TO_DATE`() { - val result = PullResult( - isSuccessful = true, - message = "Up to date", - mergeResult = MergeResultDetail(MergeStatus.ALREADY_UP_TO_DATE) - ) - assertTrue(result.isUpToDate) - assertFalse(result.isFastForward) - assertFalse(result.isMerged) - } - - @Test - fun `PullResult isFastForward returns true for FAST_FORWARD`() { - val result = PullResult( - isSuccessful = true, - message = "Fast forward", - mergeResult = MergeResultDetail(MergeStatus.FAST_FORWARD, commitCount = 5) - ) - assertTrue(result.isFastForward) - assertFalse(result.isUpToDate) - assertEquals(5, result.commitCount) - } - - @Test - fun `PullResult isMerged returns true for MERGED`() { - val result = PullResult( - isSuccessful = true, - message = "Merged", - mergeResult = MergeResultDetail(MergeStatus.MERGED, commitCount = 3) - ) - assertTrue(result.isMerged) - assertEquals(3, result.commitCount) - } - - @Test - fun `PullResult commitCount returns zero when no merge result`() { - val result = PullResult( - isSuccessful = false, - message = "Failed" - ) - assertEquals(0, result.commitCount) - } - - // ==================== CleanResult 计算属性测试 ==================== - - @Test - fun `CleanResult isEmpty returns true for empty list`() { - val result = CleanResult(files = emptyList(), isDryRun = false) - assertTrue(result.isEmpty) - assertEquals(0, result.count) - } - - @Test - fun `CleanResult isEmpty returns false for non-empty list`() { - val result = CleanResult(files = listOf("file1", "file2"), isDryRun = false) - assertFalse(result.isEmpty) - assertEquals(2, result.count) - } - - // ==================== GitBranch 测试 ==================== - - @Test - fun `GitBranch local current branch`() { - val branch = GitBranch( - name = "main", - fullRef = "refs/heads/main", - isRemote = false, - isCurrent = true - ) - assertEquals("main", branch.name) - assertFalse(branch.isRemote) - assertTrue(branch.isCurrent) - } - - @Test - fun `GitBranch remote tracking branch`() { - val branch = GitBranch( - name = "origin/main", - fullRef = "refs/remotes/origin/main", - isRemote = true, - isCurrent = false - ) - assertTrue(branch.isRemote) - assertFalse(branch.isCurrent) - } - - // ==================== GitRepoStatus 测试 ==================== - - @Test - fun `GitRepoStatus clean repository`() { - val status = GitRepoStatus( - isGitRepo = true, - branch = "main", - hasUncommittedChanges = false, - untrackedCount = 0, - message = "Clean" - ) - assertTrue(status.isGitRepo) - assertFalse(status.hasUncommittedChanges) - assertEquals(0, status.untrackedCount) - } - - @Test - fun `GitRepoStatus dirty repository`() { - val status = GitRepoStatus( - isGitRepo = true, - branch = "develop", - hasUncommittedChanges = true, - untrackedCount = 5, - message = "Changes" - ) - assertTrue(status.hasUncommittedChanges) - assertEquals(5, status.untrackedCount) - } - - // ==================== MergeResultDetail 测试 ==================== - - @Test - fun `MergeResultDetail with conflicts map`() { - val detail = MergeResultDetail( - mergeStatus = MergeStatus.CONFLICTING, - conflicts = mapOf("file1.kt" to 2, "file2.kt" to 1) - ) - assertEquals(MergeStatus.CONFLICTING, detail.mergeStatus) - assertEquals(2, detail.conflicts.size) - } - - @Test - fun `MergeResultDetail with fast forward flag`() { - val detail = MergeResultDetail( - mergeStatus = MergeStatus.FAST_FORWARD, - commitCount = 10, - fastForward = true - ) - assertTrue(detail.fastForward) - assertEquals(10, detail.commitCount) - } - - // ==================== RebaseResultDetail 测试 ==================== - - @Test - fun `RebaseResultDetail with conflicts`() { - val detail = RebaseResultDetail( - status = RebaseStatus.CONFLICTING, - conflicts = listOf("file1.kt", "file2.kt") - ) - assertEquals(RebaseStatus.CONFLICTING, detail.status) - assertEquals(2, detail.conflicts.size) - } - - @Test - fun `RebaseResultDetail OK status`() { - val detail = RebaseResultDetail( - status = RebaseStatus.OK, - commitCount = 5 - ) - assertEquals(RebaseStatus.OK, detail.status) - assertEquals(5, detail.commitCount) - assertTrue(detail.conflicts.isEmpty()) - } - - // ==================== FetchResult 测试 ==================== - - @Test - fun `FetchResult successful with messages`() { - val result = FetchResult( - isSuccessful = true, - messages = listOf("From origin", " * [new branch] feature -> origin/feature") - ) - assertTrue(result.isSuccessful) - assertEquals(2, result.messages.size) - } - - @Test - fun `FetchResult failed`() { - val result = FetchResult( - isSuccessful = false, - messages = listOf("Could not resolve remote") - ) - assertFalse(result.isSuccessful) - } -} \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index f5df9a2..eb1dee9 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -15,7 +15,9 @@ plugins { id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" } dependencyResolutionManagement { + @Suppress("UnstableApiUsage") repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + @Suppress("UnstableApiUsage") repositories { google() mavenCentral()