diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 9c1fd4a..c05da3c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -11,8 +11,8 @@ android { applicationId = "dev.n1ko.nikogpt" minSdk = 29 // Android 10 targetSdk = 35 - versionCode = 2 - versionName = "1.0.1" + versionCode = 3 + versionName = "1.0.3" } buildTypes { @@ -44,6 +44,11 @@ dependencies { implementation("androidx.core:core-ktx:1.15.0") implementation("androidx.appcompat:appcompat:1.7.0") implementation("androidx.activity:activity-ktx:1.9.3") + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7") implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0") implementation("androidx.webkit:webkit:1.12.1") + // Native "Continue with Google" (account picker sheet inside the app). + implementation("androidx.credentials:credentials:1.3.0") + implementation("androidx.credentials:credentials-play-services-auth:1.3.0") + implementation("com.google.android.libraries.identity.googleid:googleid:1.1.0") } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index b76d809..c1e24e6 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -15,12 +15,22 @@ + + + + + + + diff --git a/app/src/main/java/dev/n1ko/nikogpt/MainActivity.kt b/app/src/main/java/dev/n1ko/nikogpt/MainActivity.kt index 867c087..3d1f79a 100644 --- a/app/src/main/java/dev/n1ko/nikogpt/MainActivity.kt +++ b/app/src/main/java/dev/n1ko/nikogpt/MainActivity.kt @@ -12,6 +12,7 @@ import android.os.Environment import android.view.View import android.webkit.CookieManager import android.webkit.DownloadListener +import android.webkit.JavascriptInterface import android.webkit.SslErrorHandler import android.webkit.URLUtil import android.webkit.ValueCallback @@ -34,7 +35,14 @@ import androidx.core.view.ViewCompat import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.updatePadding +import androidx.credentials.CredentialManager +import androidx.credentials.GetCredentialRequest +import androidx.credentials.exceptions.GetCredentialException +import androidx.lifecycle.lifecycleScope import androidx.swiperefreshlayout.widget.SwipeRefreshLayout +import com.google.android.libraries.identity.googleid.GetGoogleIdOption +import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential +import kotlinx.coroutines.launch /** * The whole app: a thin, open-source WebView around the NikoGPT web chat. @@ -107,10 +115,16 @@ class MainActivity : AppCompatActivity() { CookieManager.getInstance().setAcceptCookie(true) CookieManager.getInstance().setAcceptThirdPartyCookies(webView, false) WebView.setWebContentsDebuggingEnabled(BuildConfig.DEBUG) + webView.addJavascriptInterface(AndroidBridge(), "NikoGPTAndroid") webView.webViewClient = object : WebViewClient() { override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean { val url = request.url + if (url.scheme == "nikogpt" && url.host == "auth") { + // Deep link that reached the WebView instead of the browser. + handleAuthUri(url) + return true + } if (url.host == HOST) return false openExternally(url) return true @@ -178,6 +192,96 @@ class MainActivity : AppCompatActivity() { } else { webView.restoreState(savedInstanceState) } + handleAuthIntent(intent) + } + + // handleAuthIntent catches the nikogpt://auth?code=… deep link from the + // browser and completes the sign-in inside the app's WebView. + private fun handleAuthIntent(intent: Intent?) { + val data = intent?.data ?: return + if (data.scheme == "nikogpt" && data.host == "auth") { + handleAuthUri(data) + } + } + + // AndroidBridge is exposed to the web app as window.NikoGPTAndroid: the + // Google buttons call it and the app opens the native credential picker. + private inner class AndroidBridge { + @JavascriptInterface + fun signInGoogle(clientId: String) { + runOnUiThread { openGooglePicker(clientId, link = false) } + } + + @JavascriptInterface + fun linkGoogle(clientId: String) { + runOnUiThread { openGooglePicker(clientId, link = true) } + } + } + + // openGooglePicker requests an ID token from Google Play services + // (Credential Manager). Without a usable provider — e.g. microG, no + // accounts, user cancelled — it falls back to the browser flow with the + // nikogpt:// hand-back. + private fun openGooglePicker(clientId: String, link: Boolean) { + if (clientId.isBlank()) { + openGoogleInBrowser(link) + return + } + val option = GetGoogleIdOption.Builder() + .setFilterByAuthorizedAccounts(false) + .setServerClientId(clientId) + .setAutoSelectEnabled(false) + .build() + val request = GetCredentialRequest.Builder() + .addCredentialOption(option) + .build() + lifecycleScope.launch { + try { + val response = CredentialManager.create(this@MainActivity) + .getCredential(this@MainActivity, request) + val idToken = GoogleIdTokenCredential.createFrom(response.credential.data).idToken + deliverToken(idToken, link) + } catch (e: GetCredentialException) { + openGoogleInBrowser(link) + } catch (e: Exception) { + openGoogleInBrowser(link) + } + } + } + + // deliverToken hands the ID token back to the page, which signs in + // through the API inside this WebView (so the cookie stays in the app). + private fun deliverToken(idToken: String, link: Boolean) { + val fn = if (link) "nikoGoogleLinkToken" else "nikoGoogleToken" + val js = "window.$fn && window.$fn(" + org.json.JSONObject.quote(idToken) + ")" + webView.evaluateJavascript(js, null) + } + + private fun openGoogleInBrowser(link: Boolean) { + val query = buildString { + if (link) append("link=1&") + append("app=1") + } + webView.loadUrl("https://$HOST/api/auth/google?$query") + } + + private fun handleAuthUri(uri: Uri) { + val code = uri.getQueryParameter("code") + if (!code.isNullOrBlank()) { + webView.loadUrl("https://$HOST/api/auth/app?code=" + Uri.encode(code)) + refresh.isRefreshing = true + return + } + val error = uri.getQueryParameter("error") + if (!error.isNullOrBlank()) { + Toast.makeText(this, getString(R.string.google_error, error), Toast.LENGTH_LONG).show() + } + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + setIntent(intent) + handleAuthIntent(intent) } private fun openExternally(uri: Uri) { diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index f0db285..f20ba8a 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -6,4 +6,5 @@ Нет приложения, которое может открыть эту ссылку Загрузка началась Не удалось начать загрузку + Не удалось войти через Google (%1$s) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 72a9b25..498fa8d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -6,4 +6,5 @@ No app can open this link Download started Could not start the download + Google sign-in failed (%1$s)