Native Google sign-in via Credential Manager
Some checks failed
build / apk (push) Has been cancelled
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:
parent
ed08921a78
commit
147b17dc44
5 changed files with 123 additions and 2 deletions
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue