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)