diff --git a/core/app/src/main/java/com/tom/rv2ide/artificial/login/CodexLoginManager.kt b/core/app/src/main/java/com/tom/rv2ide/artificial/login/CodexLoginManager.kt new file mode 100644 index 000000000..16709d567 --- /dev/null +++ b/core/app/src/main/java/com/tom/rv2ide/artificial/login/CodexLoginManager.kt @@ -0,0 +1,337 @@ +package com.tom.rv2ide.artificial.login + +import android.app.Activity +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent +import android.net.Uri +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.coroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import org.json.JSONObject +import java.io.BufferedReader +import java.io.IOException +import java.io.InputStreamReader +import java.io.OutputStream +import java.io.OutputStreamWriter +import java.net.BindException +import java.net.InetAddress +import java.net.InetSocketAddress +import java.net.ServerSocket +import java.net.Socket +import java.net.SocketTimeoutException +import java.net.URLEncoder +import java.nio.charset.StandardCharsets +import java.security.MessageDigest +import java.security.SecureRandom +import java.util.Base64 +import java.util.concurrent.TimeUnit + +class CodexLoginManager { + companion object { + private const val CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann" + private const val AUTHORIZATION_URL = "https://auth.openai.com/oauth/authorize" + private const val TOKEN_URL = "https://auth.openai.com/oauth/token" + private const val CALLBACK_PATH = "/auth/callback" + private const val LOOPBACK_HOST = "127.0.0.1" + private const val DEFAULT_PORT = 1455 + private const val CALLBACK_TIMEOUT_MS = 180_000L + private const val RESPONSE_BODY_CHARSET = "utf-8" + private const val ORIGINATOR_VALUE = "codex_cli_rs" + private val SCOPE_STRING = listOf( + "openid", + "profile", + "email", + "offline_access", + "api.connectors.read", + "api.connectors.invoke" + ).joinToString(" ") + } + + private val httpClient = OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .writeTimeout(30, TimeUnit.SECONDS) + .build() + + @Throws(IOException::class) + suspend fun loginWithChatGPT(context: Context): String { + val pkce = createPkceCodes() + val state = generateState() + val (serverSocket, actualPort) = createServerSocket() + val redirectUri = "http://$LOOPBACK_HOST:$actualPort$CALLBACK_PATH" + val authUrl = buildAuthorizationUrl(redirectUri, pkce, state) + + try { + withContext(Dispatchers.Main) { + openBrowser(context, authUrl) + } + + val callback = withTimeout(CALLBACK_TIMEOUT_MS) { + waitForCallback(serverSocket, state) + } + + callback.error?.let { + throw IllegalStateException(callback.errorDescription ?: it) + } + + val code = callback.code ?: throw IllegalStateException("Missing authorization code from ChatGPT login.") + val tokens = exchangeCodeForTokens(code, redirectUri, pkce) + return exchangeIdTokenForApiKey(tokens.idToken) + } finally { + try { + serverSocket.close() + } catch (_: IOException) { + } + } + } + + private fun createServerSocket(): Pair { + val server = ServerSocket() + server.reuseAddress = true + try { + server.bind(InetSocketAddress(InetAddress.getByName(LOOPBACK_HOST), DEFAULT_PORT)) + } catch (bindException: BindException) { + server.bind(InetSocketAddress(InetAddress.getByName(LOOPBACK_HOST), 0)) + } + return server to server.localPort + } + + private fun buildAuthorizationUrl(redirectUri: String, pkce: PkceCodes, state: String): String { + val params = listOf( + "response_type" to "code", + "client_id" to CLIENT_ID, + "redirect_uri" to redirectUri, + "scope" to SCOPE_STRING, + "code_challenge" to pkce.codeChallenge, + "code_challenge_method" to "S256", + "id_token_add_organizations" to "true", + "codex_cli_simplified_flow" to "true", + "state" to state, + "originator" to ORIGINATOR_VALUE + ) + + val encoded = params.joinToString("&") { "${it.first}=${urlEncode(it.second)}" } + return "$AUTHORIZATION_URL?$encoded" + } + + private fun urlEncode(value: String): String { + return URLEncoder.encode(value, StandardCharsets.UTF_8.name()).replace("+", "%20") + } + + private fun openBrowser(context: Context, authUrl: String) { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(authUrl)) + if (context !is Activity) { + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + try { + context.startActivity(intent) + } catch (error: ActivityNotFoundException) { + throw IllegalStateException("No browser found to complete the ChatGPT login.", error) + } + } + + private suspend fun waitForCallback(server: ServerSocket, expectedState: String): CallbackResult { + return withContext(Dispatchers.IO) { + server.soTimeout = 1_000 + while (true) { + coroutineContext.ensureActive() + try { + val socket = server.accept() + val callback = handleSocket(socket, expectedState) + if (callback != null) { + return@withContext callback + } + } catch (socketTimeout: SocketTimeoutException) { + continue + } + } + } + } + + private fun handleSocket(socket: Socket, expectedState: String): CallbackResult? { + socket.use { + val reader = BufferedReader(InputStreamReader(it.getInputStream())) + val requestLine = reader.readLine() ?: return null + val segments = requestLine.split(" ") + if (segments.size < 2) { + drainRequest(reader) + sendHttpResponse(it.getOutputStream(), 400, "Invalid request") + return null + } + + val target = segments[1] + val uri = Uri.parse("http://$LOOPBACK_HOST$target") + val path = uri.path ?: "" + drainRequest(reader) + + if (path != CALLBACK_PATH) { + sendHttpResponse(it.getOutputStream(), 404, "

Not found

") + return null + } + + val code = uri.getQueryParameter("code") + val state = uri.getQueryParameter("state") + val error = uri.getQueryParameter("error") + val errorDescription = uri.getQueryParameter("error_description") + val body = when { + error != null -> buildErrorPage(errorDescription ?: "An error occurred") + code != null -> buildSuccessPage() + else -> buildErrorPage("Missing authorization code") + } + sendHttpResponse(it.getOutputStream(), 200, body) + + if (error != null) { + return CallbackResult(null, state, error, errorDescription) + } + + if (state != expectedState) { + return CallbackResult(null, state, "State mismatch", "The login response does not match the request state.") + } + + return CallbackResult(code, state, null, null) + } + } + + private fun drainRequest(reader: BufferedReader) { + while (true) { + val line = reader.readLine() ?: break + if (line.isEmpty()) { + break + } + } + } + + private fun sendHttpResponse(output: OutputStream, statusCode: Int, body: String) { + val payload = body.toByteArray(StandardCharsets.UTF_8) + OutputStreamWriter(output, StandardCharsets.UTF_8).use { writer -> + writer.write("HTTP/1.1 $statusCode ${statusText(statusCode)}\r\n") + writer.write("Content-Type: text/html; charset=$RESPONSE_BODY_CHARSET\r\n") + writer.write("Content-Length: ${payload.size}\r\n") + writer.write("Connection: close\r\n") + writer.write("\r\n") + writer.flush() + output.write(payload) + output.flush() + } + } + + private fun statusText(code: Int): String = when (code) { + 200 -> "OK" + 400 -> "Bad Request" + 404 -> "Not Found" + else -> "OK" + } + + + private fun buildErrorPage(message: String): String { + return """ + | + |Login error + | + |

Unable to complete login

+ |

${message}

+ | + | + |""".trimMargin() + } + + private suspend fun exchangeCodeForTokens(code: String, redirectUri: String, pkce: PkceCodes): ExchangedTokens { + return withContext(Dispatchers.IO) { + val body = "grant_type=authorization_code&code=${urlEncode(code)}&redirect_uri=${urlEncode(redirectUri)}&client_id=${urlEncode(CLIENT_ID)}&code_verifier=${urlEncode(pkce.codeVerifier)}" + val request = Request.Builder() + .url(TOKEN_URL) + .header("Content-Type", "application/x-www-form-urlencoded") + .post(body.toRequestBody("application/x-www-form-urlencoded".toMediaType())) + .build() + + val response = httpClient.newCall(request).execute() + response.use { resp -> + val responseBody = resp.body?.string().orEmpty() + if (!resp.isSuccessful) { + throw IOException("Token exchange failed: ${resp.code} - $responseBody") + } + val json = JSONObject(responseBody) + val idToken = json.optString("id_token") + val accessToken = json.optString("access_token") + val refreshToken = json.optString("refresh_token") + if (idToken.isBlank()) { + throw IllegalStateException("Token exchange did not return an id_token") + } + return@withContext ExchangedTokens(idToken, accessToken, refreshToken) + } + } + } + + private suspend fun exchangeIdTokenForApiKey(idToken: String): String { + return withContext(Dispatchers.IO) { + val form = "grant_type=${urlEncode("urn:ietf:params:oauth:grant-type:token-exchange")}&client_id=${urlEncode(CLIENT_ID)}&requested_token=${urlEncode("openai-api-key")}&subject_token=${urlEncode(idToken)}&subject_token_type=${urlEncode("urn:ietf:params:oauth:token-type:id_token")}" + val request = Request.Builder() + .url(TOKEN_URL) + .header("Content-Type", "application/x-www-form-urlencoded") + .post(form.toRequestBody("application/x-www-form-urlencoded".toMediaType())) + .build() + + val response = httpClient.newCall(request).execute() + response.use { resp -> + val body = resp.body?.string().orEmpty() + if (!resp.isSuccessful) { + throw IOException("API key exchange failed: ${resp.code} - $body") + } + val json = JSONObject(body) + return@withContext json.getString("access_token") + } + } + } + + private fun generateState(): String { + val bytes = ByteArray(32) + SecureRandom().nextBytes(bytes) + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes) + } + private fun createPkceCodes(): PkceCodes { + val random = SecureRandom() + val verifierBytes = ByteArray(32) + random.nextBytes(verifierBytes) + val verifier = Base64.getUrlEncoder().withoutPadding().encodeToString(verifierBytes) + val digest = MessageDigest.getInstance("SHA-256").digest(verifier.toByteArray(StandardCharsets.US_ASCII)) + val challenge = Base64.getUrlEncoder().withoutPadding().encodeToString(digest) + return PkceCodes(verifier, challenge) + } + + private data class CallbackResult( + val code: String?, + val state: String?, + val error: String?, + val errorDescription: String? + ) + + private data class ExchangedTokens( + val idToken: String, + val accessToken: String, + val refreshToken: String + ) + + private data class PkceCodes( + val codeVerifier: String, + val codeChallenge: String + ) + + private fun buildSuccessPage(): String { + return """ + | + |Login complete + | + |

Login completed

+ |

The chat login completed successfully. Close this tab to return to the IDE.

+ | + | + |""".trimMargin() + } +} diff --git a/core/app/src/main/java/com/tom/rv2ide/fragments/sidebar/AIPreferencesFragment.kt b/core/app/src/main/java/com/tom/rv2ide/fragments/sidebar/AIPreferencesFragment.kt index 4912b3da8..ea20947b1 100644 --- a/core/app/src/main/java/com/tom/rv2ide/fragments/sidebar/AIPreferencesFragment.kt +++ b/core/app/src/main/java/com/tom/rv2ide/fragments/sidebar/AIPreferencesFragment.kt @@ -10,6 +10,7 @@ import android.widget.AutoCompleteTextView import androidx.fragment.app.Fragment import androidx.lifecycle.lifecycleScope import com.google.android.material.materialswitch.MaterialSwitch +import com.google.android.material.materialbutton.MaterialButton import com.google.android.material.snackbar.Snackbar import com.google.android.material.textfield.TextInputLayout import com.google.android.material.textview.MaterialTextView @@ -18,10 +19,13 @@ import com.tom.rv2ide.artificial.agents.AIAgentManager import com.tom.rv2ide.artificial.agents.Agents import com.tom.rv2ide.artificial.dialogs.ProviderSwitchDialog import com.tom.rv2ide.managers.CodeCompletionManager +import com.tom.rv2ide.preferences.internal.prefManager import kotlinx.coroutines.Job +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.delay import kotlinx.coroutines.launch import com.tom.rv2ide.artificial.dialogs.LocalLLMConfigDialog +import com.tom.rv2ide.artificial.login.CodexLoginManager class AIPreferencesFragment( private val aiAgent: AIAgentManager, @@ -35,11 +39,12 @@ class AIPreferencesFragment( private lateinit var codeCompletionToggle: MaterialSwitch private lateinit var currentProviderText: MaterialTextView private lateinit var currentModelText: MaterialTextView - + private lateinit var chatgptLoginButton: MaterialButton + private lateinit var chatgptLoginSummary: MaterialTextView private val providerSwitchDialog by lazy { ProviderSwitchDialog(requireContext()) } - private var completionStateMonitorJob: Job? = null private var isCompletionEnabled = true + private var isChatGPTLoginInProgress = false override fun onCreateView( inflater: LayoutInflater, @@ -48,15 +53,16 @@ class AIPreferencesFragment( ): View? { return inflater.inflate(R.layout.fragment_ai_preferences, container, false) } - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) initializeViews(view) setupProviderDropdown() setupModelDropdown() + setupChatGPTLogin() setupToggles() updateCurrentStatus() + updateChatGPTLoginSummary() startCompletionStateMonitoring() } @@ -66,6 +72,7 @@ class AIPreferencesFragment( updateProviderDropdownSelection() updateModelDropdown() syncCodeCompletionToggle() + updateChatGPTLoginSummary() } override fun onPause() { @@ -79,6 +86,8 @@ class AIPreferencesFragment( autoSwitchToggle = view.findViewById(R.id.autoSwitchToggle) codeCompletionToggle = view.findViewById(R.id.codeCompletionToggle) currentProviderText = view.findViewById(R.id.currentProviderText) + chatgptLoginButton = view.findViewById(R.id.chatgptLoginButton) + chatgptLoginSummary = view.findViewById(R.id.chatgptLoginSummary) currentModelText = view.findViewById(R.id.currentModelText) } @@ -183,6 +192,65 @@ class AIPreferencesFragment( } } + + private fun setupChatGPTLogin() { + setChatGPTLoginInProgress(false) + chatgptLoginButton.setOnClickListener { + if (isChatGPTLoginInProgress) return@setOnClickListener + lifecycleScope.launch { + setChatGPTLoginInProgress(true) + try { + val apiKey = CodexLoginManager().loginWithChatGPT(requireContext()) + prefManager.putString(OPENAI_API_KEY_PREF_KEY, apiKey) + updateChatGPTLoginSummary() + if (agents.getProvider() == "openai") { + aiAgent.reinitializeWithSelectedModel() + updateCurrentStatus() + } + showSnackbar(getString(R.string.ai_agent_chatgpt_login_success)) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + val message = e.message?.takeUnless(String::isBlank) + ?: getString(R.string.ai_agent_chatgpt_login_failure_generic) + showSnackbar(getString(R.string.ai_agent_chatgpt_login_failure, message)) + } finally { + setChatGPTLoginInProgress(false) + } + } + } + } + + private fun setChatGPTLoginInProgress(inProgress: Boolean) { + isChatGPTLoginInProgress = inProgress + chatgptLoginButton.isEnabled = !inProgress + chatgptLoginButton.text = getString( + if (inProgress) R.string.ai_agent_chatgpt_login_button_in_progress + else R.string.ai_agent_chatgpt_login_button + ) + if (inProgress) { + chatgptLoginSummary.text = getString(R.string.ai_agent_chatgpt_login_summary_signing_in) + } else { + updateChatGPTLoginSummary() + } + } + + private fun updateChatGPTLoginSummary() { + val apiKey = prefManager.getString(OPENAI_API_KEY_PREF_KEY, "") + chatgptLoginSummary.text = if (apiKey.isBlank()) { + getString(R.string.ai_agent_chatgpt_login_summary_not_signed_in) + } else { + val maskedKey = maskApiKey(apiKey) + getString(R.string.ai_agent_chatgpt_login_summary_signed_in, maskedKey) + } + } + + private fun maskApiKey(apiKey: String): String { + if (apiKey.length <= 8) return apiKey + return "${apiKey.take(4)}…${apiKey.takeLast(4)}" + } + + private fun setupToggles() { autoSwitchToggle.isChecked = providerSwitchDialog.isAutoSwitchEnabled() autoSwitchToggle.setOnCheckedChangeListener { _, isChecked -> diff --git a/core/app/src/main/res/layout/fragment_ai_preferences.xml b/core/app/src/main/res/layout/fragment_ai_preferences.xml index 6bd6157bb..6ea15257b 100644 --- a/core/app/src/main/res/layout/fragment_ai_preferences.xml +++ b/core/app/src/main/res/layout/fragment_ai_preferences.xml @@ -30,6 +30,7 @@ + + + + Enable AI-powered code generation Gemini API Key Enter your Google Gemini API key to use AI features + Sign in with ChatGPT + Signing in with ChatGPT… + Not signed in with ChatGPT + Signed in with ChatGPT (%1$s) + Waiting for ChatGPT login... + ChatGPT login completed + ChatGPT login failed: %1$s + Unknown error Enter Gemini API Key