Native Google sign-in via Credential Manager
Some checks failed
build / apk (push) Has been cancelled

- window.NikoGPTAndroid bridge: the web app asks the app for the native
  account picker; the returned ID token is posted through the WebView
- falls back to the browser flow (nikogpt:// deep link handoff) when the
  provider is missing (microG) or the user cancels
- version 1.0.3
This commit is contained in:
Niko Marmeladkov 2026-10-05 17:15:51 +03:00
parent ed08921a78
commit 147b17dc44
5 changed files with 123 additions and 2 deletions

View file

@ -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")
}

View file

@ -15,12 +15,22 @@
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTask"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden|uiMode"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- Google sign-in handoff: after the browser finishes OAuth the
server redirects to nikogpt://auth?code=… and the app
exchanges the one-time code inside its own WebView. -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="nikogpt" android:host="auth" />
</intent-filter>
</activity>
</application>
</manifest>

View file

@ -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) {

View file

@ -6,4 +6,5 @@
<string name="no_app">Нет приложения, которое может открыть эту ссылку</string>
<string name="download_started">Загрузка началась</string>
<string name="download_failed">Не удалось начать загрузку</string>
<string name="google_error">Не удалось войти через Google (%1$s)</string>
</resources>

View file

@ -6,4 +6,5 @@
<string name="no_app">No app can open this link</string>
<string name="download_started">Download started</string>
<string name="download_failed">Could not start the download</string>
<string name="google_error">Google sign-in failed (%1$s)</string>
</resources>