diff --git a/app/src/main/java/io/github/wiiznokes/gitnote/data/AppPreferences.kt b/app/src/main/java/io/github/wiiznokes/gitnote/data/AppPreferences.kt index 3dfb78d..651aa61 100644 --- a/app/src/main/java/io/github/wiiznokes/gitnote/data/AppPreferences.kt +++ b/app/src/main/java/io/github/wiiznokes/gitnote/data/AppPreferences.kt @@ -68,6 +68,7 @@ class AppPreferences( val sshUsername = stringPreference("sshUsername", "") val publicKey = stringPreference("publicKey", "") val privateKey = stringPreference("privateKey", "") + val passphrase = stringPreference("passphrase", "") val appAuthToken = stringPreference("appAuthToken", "") @@ -85,6 +86,7 @@ class AppPreferences( username = this.sshUsername.get(), publicKey = this.publicKey.get(), privateKey = this.privateKey.get(), + passphrase = this.passphrase.get().ifEmpty { null } ) } } @@ -96,6 +98,7 @@ class AppPreferences( sshUsername.update(cred.username) publicKey.update(cred.publicKey) privateKey.update(cred.privateKey) + passphrase.update(cred.passphrase ?: "") } is Cred.UserPassPlainText -> { diff --git a/app/src/main/java/io/github/wiiznokes/gitnote/ui/component/SetupPage.kt b/app/src/main/java/io/github/wiiznokes/gitnote/ui/component/SetupPage.kt index 90619a0..bca58c3 100644 --- a/app/src/main/java/io/github/wiiznokes/gitnote/ui/component/SetupPage.kt +++ b/app/src/main/java/io/github/wiiznokes/gitnote/ui/component/SetupPage.kt @@ -45,15 +45,20 @@ fun SetupPage( @Composable fun SetupLine( text: String, - content: @Composable ColumnScope.() -> Unit + horizontalAlignment: Alignment.Horizontal = Alignment.Start, + content: @Composable ColumnScope.() -> Unit, ) { Column( modifier = Modifier - .padding(bottom = 18.dp) + .padding(bottom = 18.dp), ) { Text(text = text) - content() + Column( + horizontalAlignment = horizontalAlignment + ) { + content() + } } } diff --git a/app/src/main/java/io/github/wiiznokes/gitnote/ui/destination/RemoteDestination.kt b/app/src/main/java/io/github/wiiznokes/gitnote/ui/destination/RemoteDestination.kt index a9c50a0..7e1d481 100644 --- a/app/src/main/java/io/github/wiiznokes/gitnote/ui/destination/RemoteDestination.kt +++ b/app/src/main/java/io/github/wiiznokes/gitnote/ui/destination/RemoteDestination.kt @@ -42,10 +42,10 @@ sealed interface RemoteDestination : Parcelable { @Parcelize data object Cloning : RemoteDestination -// @Parcelize -// data class LoadKeysFromDevice( -// val provider: Provider?, -// val url: String -// ) : RemoteDestination + + @Parcelize + data class LoadKeysFromDevice( + val url: String + ) : RemoteDestination } diff --git a/app/src/main/java/io/github/wiiznokes/gitnote/ui/model/Init.kt b/app/src/main/java/io/github/wiiznokes/gitnote/ui/model/Init.kt index 9109f39..14dd422 100644 --- a/app/src/main/java/io/github/wiiznokes/gitnote/ui/model/Init.kt +++ b/app/src/main/java/io/github/wiiznokes/gitnote/ui/model/Init.kt @@ -21,9 +21,10 @@ sealed class Cred : Parcelable { val username: String = "git", val publicKey: String, val privateKey: String, + val passphrase: String?, ) : Cred() { override fun toString(): String { - return "Ssh(username=$username, publicKey=$publicKey, privateKeyLen=${privateKey.length})" + return "Ssh(username=$username, publicKey=$publicKey, privateKeyLen=${privateKey.length}, passphraseLen=${passphrase?.length})" } } } diff --git a/app/src/main/java/io/github/wiiznokes/gitnote/ui/screen/setup/remote/GenerateNewSshKeysScreen.kt b/app/src/main/java/io/github/wiiznokes/gitnote/ui/screen/setup/remote/GenerateNewSshKeysScreen.kt index 28b784b..89f3a8f 100644 --- a/app/src/main/java/io/github/wiiznokes/gitnote/ui/screen/setup/remote/GenerateNewSshKeysScreen.kt +++ b/app/src/main/java/io/github/wiiznokes/gitnote/ui/screen/setup/remote/GenerateNewSshKeysScreen.kt @@ -170,6 +170,7 @@ fun GenerateNewSshKeysScreen( cred = Cred.Ssh( publicKey = publicKey.value, privateKey = privateKey.value, + passphrase = null ), onSuccess = onSuccess ) diff --git a/app/src/main/java/io/github/wiiznokes/gitnote/ui/screen/setup/remote/LoadKeysFromDeviceScreen.kt b/app/src/main/java/io/github/wiiznokes/gitnote/ui/screen/setup/remote/LoadKeysFromDeviceScreen.kt index ee60578..46bedc6 100644 --- a/app/src/main/java/io/github/wiiznokes/gitnote/ui/screen/setup/remote/LoadKeysFromDeviceScreen.kt +++ b/app/src/main/java/io/github/wiiznokes/gitnote/ui/screen/setup/remote/LoadKeysFromDeviceScreen.kt @@ -1,6 +1,9 @@ package io.github.wiiznokes.gitnote.ui.screen.setup.remote +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.material3.Button import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text @@ -8,81 +11,186 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.tooling.preview.Preview +import io.github.wiiznokes.gitnote.R import io.github.wiiznokes.gitnote.ui.component.AppPage +import io.github.wiiznokes.gitnote.ui.component.SetupButton +import io.github.wiiznokes.gitnote.ui.component.SetupLine +import io.github.wiiznokes.gitnote.ui.component.SetupPage +import io.github.wiiznokes.gitnote.ui.model.Cred +import io.github.wiiznokes.gitnote.ui.model.StorageConfiguration +import io.github.wiiznokes.gitnote.ui.viewmodel.InitState +import io.github.wiiznokes.gitnote.ui.viewmodel.SetupViewModelI +import io.github.wiiznokes.gitnote.ui.viewmodel.SetupViewModelMock private fun isKeyCorrect(key: String): Boolean { return true } + @Composable fun LoadKeysFromDeviceScreen( onBackClick: () -> Unit, - onNext: () -> Unit, + cloneState: InitState, + storageConfig: StorageConfiguration, + url: String, + vm: SetupViewModelI, + onClone: () -> Unit, + onSuccess: () -> Unit, ) { AppPage( - title = "", + title = stringResource(R.string.custom_ssh_keys), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, onBackClick = onBackClick, + onBackClickEnabled = !cloneState.isLoading() ) { - val publicKey = rememberSaveable(stateSaver = TextFieldValue.Saver) { - mutableStateOf(TextFieldValue()) - } + SetupPage { + val publicKey = rememberSaveable(stateSaver = TextFieldValue.Saver) { + mutableStateOf(TextFieldValue()) + } - val privateKey = rememberSaveable(stateSaver = TextFieldValue.Saver) { - mutableStateOf(TextFieldValue()) - } + val privateKey = rememberSaveable(stateSaver = TextFieldValue.Saver) { + mutableStateOf(TextFieldValue()) + } + + val privateKeyPassword = rememberSaveable(stateSaver = TextFieldValue.Saver) { + mutableStateOf(TextFieldValue()) + } + + SetupLine( + text = stringResource(R.string.public_key), + horizontalAlignment = Alignment.CenterHorizontally + ) { + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + value = publicKey.value, + onValueChange = { + publicKey.value = it + }, + label = { + Text(text = stringResource(R.string.public_key)) + }, + isError = !isKeyCorrect(publicKey.value.text), + ) + + LoadFileButton( + onTextLoaded = { + publicKey.value = publicKey.value.copy(text = it) + } + ) + } - val privateKeyPassword = rememberSaveable(stateSaver = TextFieldValue.Saver) { - mutableStateOf(TextFieldValue()) + SetupLine( + text = stringResource(R.string.private_key), + horizontalAlignment = Alignment.CenterHorizontally + ) { + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + value = privateKey.value, + onValueChange = { + privateKey.value = it + }, + label = { + Text(text = stringResource(R.string.private_key)) + }, + isError = !isKeyCorrect(privateKey.value.text), + ) + + LoadFileButton( + onTextLoaded = { + privateKey.value = privateKey.value.copy(text = it) + } + ) + } + + SetupLine( + text = stringResource(R.string.private_key_password) + ) { + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + value = privateKeyPassword.value, + onValueChange = { + privateKeyPassword.value = it + }, + label = { + Text(text = stringResource(R.string.private_key_password)) + }, + ) + } + + SetupLine( + text = stringResource(R.string.try_cloning) + ) { + SetupButton( + text = stringResource(R.string.clone_repo), + enabled = isKeyCorrect(publicKey.value.text) && isKeyCorrect(privateKey.value.text), + onClick = { + vm.cloneRepo( + storageConfig = storageConfig, + remoteUrl = url, + cred = Cred.Ssh( + publicKey = publicKey.value.text, + privateKey = privateKey.value.text, + passphrase = privateKeyPassword.value.text.ifEmpty { null } + ), + onSuccess = onSuccess + ) + + onClone() + }, + ) + } } + } +} - OutlinedTextField( - value = publicKey.value, - onValueChange = { - publicKey.value = it - }, - label = { - Text(text = "Public key") - }, - singleLine = true, - isError = !isKeyCorrect(publicKey.value.text), - ) +@Composable +private fun LoadFileButton( + onTextLoaded: (String) -> Unit +) { + val context = LocalContext.current - OutlinedTextField( - value = privateKey.value, - onValueChange = { - privateKey.value = it - }, - label = { - Text(text = "Private key") - }, - singleLine = true, - isError = !isKeyCorrect(privateKey.value.text), - ) + val filePickerLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.OpenDocument() + ) { uri -> + if (uri != null) { + val content = context.contentResolver.openInputStream(uri) + ?.bufferedReader() + .use { it?.readText().orEmpty() } - OutlinedTextField( - value = privateKeyPassword.value, - onValueChange = { - privateKeyPassword.value = it - }, - label = { - Text(text = "Private key password") - }, - singleLine = true, - ) + onTextLoaded(content) + } + } - Button( - onClick = { - onNext() - }, - enabled = isKeyCorrect(publicKey.value.text) && isKeyCorrect(privateKey.value.text) - ) { - Text(text = "Next") + Button( + onClick = { + filePickerLauncher.launch(arrayOf("text/*", "application/*")) } + ) { + Text( + text = stringResource(R.string.load_from_file) + ) } +} + +@Preview +@Composable +private fun LoadKeysFromDeviceScreenPreview() { + LoadKeysFromDeviceScreen( + onBackClick = {}, + cloneState = InitState.Idle, + vm = SetupViewModelMock(), + storageConfig = StorageConfiguration.App, + onSuccess = {}, + onClone = {}, + url = "url", + ) } \ No newline at end of file diff --git a/app/src/main/java/io/github/wiiznokes/gitnote/ui/screen/setup/remote/RemoteNav.kt b/app/src/main/java/io/github/wiiznokes/gitnote/ui/screen/setup/remote/RemoteNav.kt index e1f4eae..fc9d3f0 100644 --- a/app/src/main/java/io/github/wiiznokes/gitnote/ui/screen/setup/remote/RemoteNav.kt +++ b/app/src/main/java/io/github/wiiznokes/gitnote/ui/screen/setup/remote/RemoteNav.kt @@ -131,6 +131,13 @@ fun RemoteScreen( ) ) }, + onCustom = { + navController.navigate( + RemoteDestination.LoadKeysFromDevice( + url = remoteDestination.url + ) + ) + } ) is GenerateNewKeys -> GenerateNewSshKeysScreen( @@ -145,6 +152,16 @@ fun RemoteScreen( onClone = { navController.navigate(RemoteDestination.Cloning) } ) + is RemoteDestination.LoadKeysFromDevice -> LoadKeysFromDeviceScreen( + onBackClick = { navController.pop() }, + cloneState = initState, + storageConfig = storageConfig, + url = remoteDestination.url, + vm = vm, + onSuccess = onInitSuccess, + onClone = { navController.navigate(RemoteDestination.Cloning) } + ) + is RemoteDestination.Credentials -> CredentialsScreen( onBackClick = { navController.pop() }, storageConfig = storageConfig, diff --git a/app/src/main/java/io/github/wiiznokes/gitnote/ui/screen/setup/remote/SelectGenerateNewSshKeysScreen.kt b/app/src/main/java/io/github/wiiznokes/gitnote/ui/screen/setup/remote/SelectGenerateNewSshKeysScreen.kt index dca291e..440473f 100644 --- a/app/src/main/java/io/github/wiiznokes/gitnote/ui/screen/setup/remote/SelectGenerateNewSshKeysScreen.kt +++ b/app/src/main/java/io/github/wiiznokes/gitnote/ui/screen/setup/remote/SelectGenerateNewSshKeysScreen.kt @@ -9,13 +9,17 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import io.github.wiiznokes.gitnote.R import io.github.wiiznokes.gitnote.ui.component.AppPage +import io.github.wiiznokes.gitnote.ui.component.SetupButton +import io.github.wiiznokes.gitnote.ui.component.SetupLine import io.github.wiiznokes.gitnote.ui.component.SetupPage +import io.github.wiiznokes.gitnote.ui.component.SimpleButton @Composable fun SelectGenerateNewSshKeysScreen( onBackClick: () -> Unit, onGenerate: () -> Unit, + onCustom: () -> Unit, ) { AppPage( @@ -29,12 +33,17 @@ fun SelectGenerateNewSshKeysScreen( title = stringResource(R.string.we_need_ssh_keys_to_authenticate), horizontalAlignment = Alignment.CenterHorizontally ) { - Button( - onClick = { - onGenerate() - } - ) { - Text(text = stringResource(R.string.generate_new_keys)) + + SetupLine(text = "") { + SetupButton( + onClick = onGenerate, + text = stringResource(R.string.generate_new_keys) + ) + + SetupButton( + onClick = onCustom, + text = stringResource(R.string.custom_ssh_keys) + ) } } } @@ -46,6 +55,7 @@ fun SelectGenerateNewSshKeysScreen( private fun SelectGenerateNewSshKeysScreenPreview() { SelectGenerateNewSshKeysScreen( onBackClick = {}, - onGenerate = {} + onGenerate = {}, + onCustom = {} ) } \ No newline at end of file diff --git a/app/src/main/java/io/github/wiiznokes/gitnote/ui/viewmodel/SetupViewModel.kt b/app/src/main/java/io/github/wiiznokes/gitnote/ui/viewmodel/SetupViewModel.kt index c79e364..88fb86e 100644 --- a/app/src/main/java/io/github/wiiznokes/gitnote/ui/viewmodel/SetupViewModel.kt +++ b/app/src/main/java/io/github/wiiznokes/gitnote/ui/viewmodel/SetupViewModel.kt @@ -326,7 +326,8 @@ class SetupViewModel(val authFlow: SharedFlow) : ViewModel(), SetupViewM remoteUrl = provider!!.sshCloneUrlFromRepoName(repoName), cred = Cred.Ssh( publicKey = publicKey, - privateKey = privateKey + privateKey = privateKey, + passphrase = null ), onSuccess = { onSuccess() @@ -375,7 +376,8 @@ class SetupViewModel(val authFlow: SharedFlow) : ViewModel(), SetupViewM remoteUrl = provider!!.sshCloneUrlFromRepoName(repoName), cred = Cred.Ssh( publicKey = publicKey, - privateKey = privateKey + privateKey = privateKey, + passphrase = null ), onSuccess = { onSuccess() diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ab5307e..54e9fe9 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -141,6 +141,7 @@ Create private repo %1$s Need SSH keys to authenticate Generate new keys + Use custom SSH keys Custom Git Hosting Provider Select a Git Hosting Provider @@ -153,5 +154,9 @@ Extension: %1$s Parent: %1$s Pick this folder + Public key + Load from file + Private key + Private key password \ No newline at end of file diff --git a/app/src/main/rust/src/lib.rs b/app/src/main/rust/src/lib.rs index d4e35d0..4e4e697 100644 --- a/app/src/main/rust/src/lib.rs +++ b/app/src/main/rust/src/lib.rs @@ -129,6 +129,7 @@ pub enum Cred { username: String, public_key: String, private_key: String, + passphrase: Option, }, } @@ -146,6 +147,7 @@ impl Debug for Cred { username, public_key, private_key: _private_key, + passphrase: _passphrase, } => f .debug_struct("Ssh") .field("username", username) @@ -200,14 +202,24 @@ impl Cred { .l()? .into(); + let passphrase_obj = env + .get_field(cred_obj, "passphrase", "Ljava/lang/String;")? + .l()?; + let username: String = env.get_string(&username_key_obj)?.into(); let public_key: String = env.get_string(&public_key_obj)?.into(); let private_key: String = env.get_string(&private_key_obj)?.into(); + let passphrase: Option = if passphrase_obj.is_null() { + None + } else { + Some(env.get_string(&JString::from(passphrase_obj))?.into()) + }; Ok(Some(Cred::Ssh { username, public_key, private_key, + passphrase, })) } other => Err(anyhow!("Unknown class name: {}", other)), @@ -237,9 +249,7 @@ mod callback { "(I)Z", &[progress.into()], ) { - Ok(res) => { - res.z().unwrap() - } + Ok(res) => res.z().unwrap(), Err(e) => { error!("{e}"); true diff --git a/app/src/main/rust/src/libgit2/mod.rs b/app/src/main/rust/src/libgit2/mod.rs index 9ae3cb8..4b21a35 100644 --- a/app/src/main/rust/src/libgit2/mod.rs +++ b/app/src/main/rust/src/libgit2/mod.rs @@ -108,7 +108,13 @@ fn credential_helper(cred: &Cred) -> Result { username, private_key, public_key, - } => git2::Cred::ssh_key_from_memory(username, Some(public_key), private_key, None), + passphrase, + } => git2::Cred::ssh_key_from_memory( + username, + Some(public_key), + private_key, + passphrase.as_deref(), + ), } } @@ -128,7 +134,6 @@ pub fn clone_repo( .credentials(move |_url, _username_from_url, _allowed_types| credential_helper(&cred)); } - callbacks.transfer_progress(|stats: Progress| { let progress = stats.indexed_objects() as f32 / stats.total_objects() as f32 * 100.;