Compare commits

..

8 commits
v1.0.0 ... main

Author SHA1 Message Date
Niko Marmeladkov
ad92e93660 1.2.2: fix accidental pull-to-refresh; settings tabs on phones
Some checks are pending
build / apk (push) Waiting to run
- the web app reports the message list's scroll position through the bridge,
  so pull-to-refresh only fires when the list is really at the top; the
  trigger distance is bigger (the WebView itself never scrolls)
- settings tabs scroll horizontally on narrow screens (all tabs reachable)
- version 1.2.2
2026-10-05 21:27:01 +03:00
Niko Marmeladkov
bcebc1dbcf 1.2.1: clearer Google sign-in failures on microG
Some checks are pending
build / apk (push) Waiting to run
- show the ApiException status code (e.g. DEVELOPER_ERROR 10) instead of
  silently falling back to the browser
- do not leave the app when the picker is cancelled (12501/16)
- report a missing ID token explicitly (Android OAuth client / SHA-1 hint)
- version 1.2.1
2026-10-05 21:23:18 +03:00
Niko Marmeladkov
07383624fc 1.2.0: voice input in the web chat
Some checks failed
build / apk (push) Has been cancelled
- RECORD_AUDIO permission granted to the WebView (onPermissionRequest)
- the chat's single composer button becomes a microphone when the input is
  empty and voice input is available; recording stops into server-side ASR
  (/api/transcribe) when the browser has no Web Speech API
- version 1.2.0
2026-10-05 20:33:26 +03:00
Niko Marmeladkov
898bcb792b 1.1.0: release signing, App Links, camera and share, update check
Some checks failed
build / apk (push) Has been cancelled
- signed release builds via keystore.properties (gitignored)
- Android App Links (https://chat.n1ko.dev/app-auth) verified through
  /.well-known/assetlinks.json; nikogpt:// stays as fallback
- attach sheet with File/Camera (FileProvider)
- Share to NikoGPT (ACTION_SEND text)
- user agent carries the app version so the web UI can offer updates
2026-10-05 18:14:21 +03:00
Niko Marmeladkov
b2914f685a Google sign-in fallback chain: Credential Manager -> legacy API (microG) -> browser
Some checks failed
build / apk (push) Has been cancelled
- try Credential Manager first; on any failure fall back to the legacy
  GoogleSignIn API, which microG implements
- only when no Google provider is available (isGooglePlayServicesAvailable
  fails) use the browser flow with the nikogpt:// hand-back
- README: document the order and the Android OAuth client requirements
- version 1.0.4
2026-10-05 17:31:30 +03:00
Niko Marmeladkov
147b17dc44 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
2026-10-05 17:15:51 +03:00
Niko Marmeladkov
ed08921a78 Fix session loss on restart; English-only resources
Some checks failed
build / apk (push) Has been cancelled
- flush the WebView cookie store in onStop/onPageFinished: Chromium batches
  cookie writes, so the session could be lost when Android killed the process
- English default strings + values-ru translations
- bump to 1.0.1
2026-10-05 16:26:21 +03:00
Niko Marmeladkov
8a2a075894 README: absolute releases URL 2026-10-05 16:18:55 +03:00
8 changed files with 432 additions and 22 deletions

1
.gitignore vendored
View file

@ -1,6 +1,7 @@
.gradle/ .gradle/
build/ build/
local.properties local.properties
keystore.properties
*.iml *.iml
.idea/ .idea/
.DS_Store .DS_Store

View file

@ -19,9 +19,14 @@ resources.
## Install ## Install
Download the APK from the [releases page](../../releases) and open it on the Download the APK from the releases page:
phone (allow installing apps from this source when Android asks). The APK is **https://git.n1ko.dev/Niko/nikogpt_android/releases**
debug-signed for now, so Android may show a warning.
Latest build:
https://git.n1ko.dev/Niko/nikogpt_android/releases/download/v1.0.0/nikogpt-android-1.0.0.apk
Open the APK on the phone (allow installing apps from this source when Android
asks). The APK is debug-signed for now, so Android may show a warning.
## Build ## Build
@ -36,16 +41,44 @@ Or just open the project in Android Studio.
### Release signing ### Release signing
Create a keystore and export it as Gradle properties or use The published APKs are signed with the project's release keystore
`apksigner` on the release output: (`keystore.properties` + the `.jks` file, both outside git). To build them
yourself, create a keystore and point `keystore.properties` at it:
```bash ```bash
keytool -genkeypair -v -keystore nikogpt.jks -alias nikogpt \ keytool -genkeypair -v -keystore nikogpt.jks -alias nikogpt \
-keyalg RSA -keysize 2048 -validity 10000 -keyalg RSA -keysize 2048 -validity 10000
apksigner sign --ks nikogpt.jks --out nikogpt-release.apk \ cat > keystore.properties <<EOF
app/build/outputs/apk/release/app-release-unsigned.apk storeFile=../nikogpt.jks
storePassword=...
keyAlias=nikogpt
keyPassword=...
EOF
./gradlew assembleRelease
``` ```
**Installing over a debug-signed build requires uninstalling it first** (the
signatures differ). Add the release certificate's SHA-1 to the Android OAuth
client (Google Cloud) and its SHA-256 to `web.android_cert_sha256` in the bot
config for App Links.
## Google sign-in
Native "Continue with Google" is attempted in this order:
1. **Credential Manager** (modern GMS) — the standard account sheet;
2. **legacy Google Sign-In API** — this is what **microG** implements, so the
native picker works on de-Googled ROMs with microG and a Google account
added there;
3. **browser flow** — used when neither provider is available (no GMS, no
microG, or cancelled); the browser returns to the app through the
`nikogpt://auth` deep link and the session lands in the WebView.
Native sign-in needs an **Android-type OAuth client** in the same Google Cloud
project, with the app's package name (`dev.n1ko.nikogpt`) and the SHA-1 of the
signing certificate. The web client id (from the server) is passed as
`serverClientId`.
## Structure ## Structure
| Path | Purpose | | Path | Purpose |

View file

@ -1,8 +1,19 @@
import java.util.Properties
plugins { plugins {
id("com.android.application") id("com.android.application")
id("org.jetbrains.kotlin.android") id("org.jetbrains.kotlin.android")
} }
// Release signing: keystore.properties (gitignored) points at the keystore.
// Without it, release builds stay unsigned and CI can sign them itself.
val keystorePropertiesFile = rootProject.file("keystore.properties")
val keystoreProperties = Properties()
val hasKeystore = keystorePropertiesFile.exists()
if (hasKeystore) {
keystorePropertiesFile.inputStream().use { keystoreProperties.load(it) }
}
android { android {
namespace = "dev.n1ko.nikogpt" namespace = "dev.n1ko.nikogpt"
compileSdk = 35 compileSdk = 35
@ -11,8 +22,19 @@ android {
applicationId = "dev.n1ko.nikogpt" applicationId = "dev.n1ko.nikogpt"
minSdk = 29 // Android 10 minSdk = 29 // Android 10
targetSdk = 35 targetSdk = 35
versionCode = 1 versionCode = 8
versionName = "1.0.0" versionName = "1.2.2"
}
signingConfigs {
if (hasKeystore) {
create("release") {
storeFile = rootProject.file(keystoreProperties.getProperty("storeFile"))
storePassword = keystoreProperties.getProperty("storePassword")
keyAlias = keystoreProperties.getProperty("keyAlias")
keyPassword = keystoreProperties.getProperty("keyPassword")
}
}
} }
buildTypes { buildTypes {
@ -23,6 +45,9 @@ android {
getDefaultProguardFile("proguard-android-optimize.txt"), getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro", "proguard-rules.pro",
) )
if (hasKeystore) {
signingConfig = signingConfigs.getByName("release")
}
} }
} }
@ -44,6 +69,14 @@ dependencies {
implementation("androidx.core:core-ktx:1.15.0") implementation("androidx.core:core-ktx:1.15.0")
implementation("androidx.appcompat:appcompat:1.7.0") implementation("androidx.appcompat:appcompat:1.7.0")
implementation("androidx.activity:activity-ktx:1.9.3") 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.swiperefreshlayout:swiperefreshlayout:1.1.0")
implementation("androidx.webkit:webkit:1.12.1") 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")
// Legacy Google Sign-In: microG implements this API, Credential Manager is
// GMS-only. Both are tried before the browser fallback.
implementation("com.google.android.gms:play-services-auth:21.2.0")
} }

View file

@ -2,6 +2,8 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<!-- Voice input in the web chat (MediaRecorder + server-side recognition). -->
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<application <application
android:allowBackup="false" android:allowBackup="false"
@ -15,12 +17,43 @@
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"
android:launchMode="singleTask"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden|uiMode" android:configChanges="orientation|screenSize|screenLayout|keyboardHidden|uiMode"
android:windowSoftInputMode="adjustResize"> android:windowSoftInputMode="adjustResize">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
<!-- Google sign-in handoff (custom scheme fallback). -->
<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>
<!-- Android App Link: verified by /.well-known/assetlinks.json. -->
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" android:host="chat.n1ko.dev" android:pathPrefix="/app-auth" />
</intent-filter>
<!-- "Share to NikoGPT" from other apps. -->
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/plain" />
</intent-filter>
</activity> </activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="dev.n1ko.nikogpt.files"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application> </application>
</manifest> </manifest>

View file

@ -1,10 +1,13 @@
package dev.n1ko.nikogpt package dev.n1ko.nikogpt
import android.Manifest
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.app.AlertDialog
import android.app.DownloadManager import android.app.DownloadManager
import android.content.ActivityNotFoundException import android.content.ActivityNotFoundException
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri import android.net.Uri
import android.net.http.SslError import android.net.http.SslError
import android.os.Bundle import android.os.Bundle
@ -12,6 +15,8 @@ import android.os.Environment
import android.view.View import android.view.View
import android.webkit.CookieManager import android.webkit.CookieManager
import android.webkit.DownloadListener import android.webkit.DownloadListener
import android.webkit.JavascriptInterface
import android.webkit.PermissionRequest
import android.webkit.SslErrorHandler import android.webkit.SslErrorHandler
import android.webkit.URLUtil import android.webkit.URLUtil
import android.webkit.ValueCallback import android.webkit.ValueCallback
@ -30,11 +35,26 @@ import androidx.activity.OnBackPressedCallback
import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import androidx.core.view.ViewCompat import androidx.core.view.ViewCompat
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsCompat
import androidx.core.view.updatePadding 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 androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import com.google.android.gms.auth.api.signin.GoogleSignIn
import com.google.android.gms.auth.api.signin.GoogleSignInOptions
import com.google.android.gms.common.ConnectionResult
import com.google.android.gms.common.GoogleApiAvailability
import com.google.android.gms.common.api.ApiException
import com.google.android.libraries.identity.googleid.GetGoogleIdOption
import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential
import java.io.File
import kotlinx.coroutines.launch
/** /**
* The whole app: a thin, open-source WebView around the NikoGPT web chat. * The whole app: a thin, open-source WebView around the NikoGPT web chat.
@ -48,7 +68,8 @@ class MainActivity : AppCompatActivity() {
companion object { companion object {
private const val START_URL = "https://chat.n1ko.dev/" private const val START_URL = "https://chat.n1ko.dev/"
private const val HOST = "chat.n1ko.dev" private const val HOST = "chat.n1ko.dev"
private const val UA_SUFFIX = " NikoGPT-Android/1.0" // The web app reads this marker to switch to the app handoff flow.
private val UA_SUFFIX = " NikoGPT-Android/" + BuildConfig.VERSION_NAME
} }
private lateinit var webView: WebView private lateinit var webView: WebView
@ -57,6 +78,15 @@ class MainActivity : AppCompatActivity() {
private lateinit var errorBox: LinearLayout private lateinit var errorBox: LinearLayout
private lateinit var errorText: TextView private lateinit var errorText: TextView
// Shared text ("Share to NikoGPT") waiting for the page to load.
private var pendingShare: String? = null
// Whether the chat's inner scroll container is at the top; pull-to-refresh
// only fires there (the WebView itself never scrolls).
@Volatile
private var webAtTop = true
// Where the camera writes the picture before it reaches the web app.
private var pendingCameraUri: Uri? = null
private var fileCallback: ValueCallback<Array<Uri>>? = null private var fileCallback: ValueCallback<Array<Uri>>? = null
private val filePicker: ActivityResultLauncher<Array<String>> = private val filePicker: ActivityResultLauncher<Array<String>> =
registerForActivityResult(ActivityResultContracts.OpenMultipleDocuments()) { uris -> registerForActivityResult(ActivityResultContracts.OpenMultipleDocuments()) { uris ->
@ -64,6 +94,55 @@ class MainActivity : AppCompatActivity() {
fileCallback = null fileCallback = null
} }
// microG path: the legacy Google Sign-In API, which microG implements.
private var legacyLinkMode = false
private val legacySignIn: ActivityResultLauncher<Intent> =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
val link = legacyLinkMode
val account = try {
GoogleSignIn.getSignedInAccountFromIntent(result.data)
.getResult(ApiException::class.java)
} catch (e: ApiException) {
// 12501/16 = the user closed the picker: stay in the app.
if (e.statusCode == 12501 || e.statusCode == 16) return@registerForActivityResult
Toast.makeText(this, getString(R.string.google_error_code, e.statusCode), Toast.LENGTH_LONG).show()
openGoogleInBrowser(link)
return@registerForActivityResult
} catch (e: Exception) {
openGoogleInBrowser(link)
return@registerForActivityResult
}
val token = account?.idToken
if (token.isNullOrBlank()) {
// microG accepted the account but could not mint an ID token
// (e.g. device registration is off): say so, then fall back.
Toast.makeText(this, getString(R.string.google_no_token), Toast.LENGTH_LONG).show()
openGoogleInBrowser(link)
return@registerForActivityResult
}
deliverToken(token, link)
}
// Runtime microphone permission, bridged to the WebView.
private var pendingWebPermission: PermissionRequest? = null
private val audioPermission: ActivityResultLauncher<String> =
registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
val request = pendingWebPermission
pendingWebPermission = null
if (request == null) return@registerForActivityResult
if (granted) request.grant(request.resources) else request.deny()
}
// Camera capture for the attach button.
private val takePicture: ActivityResultLauncher<Uri> =
registerForActivityResult(ActivityResultContracts.TakePicture()) { ok ->
val cb = fileCallback
fileCallback = null
val uri = pendingCameraUri
pendingCameraUri = null
if (ok && uri != null) cb?.onReceiveValue(arrayOf(uri)) else cb?.onReceiveValue(null)
}
@SuppressLint("SetJavaScriptEnabled") @SuppressLint("SetJavaScriptEnabled")
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@ -107,10 +186,16 @@ class MainActivity : AppCompatActivity() {
CookieManager.getInstance().setAcceptCookie(true) CookieManager.getInstance().setAcceptCookie(true)
CookieManager.getInstance().setAcceptThirdPartyCookies(webView, false) CookieManager.getInstance().setAcceptThirdPartyCookies(webView, false)
WebView.setWebContentsDebuggingEnabled(BuildConfig.DEBUG) WebView.setWebContentsDebuggingEnabled(BuildConfig.DEBUG)
webView.addJavascriptInterface(AndroidBridge(), "NikoGPTAndroid")
webView.webViewClient = object : WebViewClient() { webView.webViewClient = object : WebViewClient() {
override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean { override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean {
val url = request.url val url = request.url
if (isAuthUrl(url)) {
// Deep link that reached the WebView instead of the browser.
handleAuthUri(url)
return true
}
if (url.host == HOST) return false if (url.host == HOST) return false
openExternally(url) openExternally(url)
return true return true
@ -133,10 +218,29 @@ class MainActivity : AppCompatActivity() {
override fun onPageFinished(view: WebView, url: String) { override fun onPageFinished(view: WebView, url: String) {
refresh.isRefreshing = false refresh.isRefreshing = false
progress.visibility = View.GONE progress.visibility = View.GONE
// Persist cookies (the session) right after the page settles.
CookieManager.getInstance().flush()
maybeDeliverShare()
} }
} }
webView.webChromeClient = object : WebChromeClient() { webView.webChromeClient = object : WebChromeClient() {
override fun onPermissionRequest(request: PermissionRequest) {
if (request.resources.none { it == PermissionRequest.RESOURCE_AUDIO_CAPTURE }) {
request.deny()
return
}
if (ContextCompat.checkSelfPermission(
this@MainActivity, Manifest.permission.RECORD_AUDIO,
) == PackageManager.PERMISSION_GRANTED
) {
request.grant(request.resources)
return
}
pendingWebPermission = request
audioPermission.launch(Manifest.permission.RECORD_AUDIO)
}
override fun onProgressChanged(view: WebView, newProgress: Int) { override fun onProgressChanged(view: WebView, newProgress: Int) {
progress.visibility = if (newProgress in 1..99) View.VISIBLE else View.GONE progress.visibility = if (newProgress in 1..99) View.VISIBLE else View.GONE
progress.progress = newProgress progress.progress = newProgress
@ -149,13 +253,25 @@ class MainActivity : AppCompatActivity() {
): Boolean { ): Boolean {
fileCallback?.onReceiveValue(null) fileCallback?.onReceiveValue(null)
fileCallback = callback fileCallback = callback
return try { AlertDialog.Builder(this@MainActivity)
filePicker.launch(arrayOf("*/*")) .setItems(arrayOf(getString(R.string.attach_file), getString(R.string.attach_camera))) { _, which ->
true if (which == 0) {
} catch (e: ActivityNotFoundException) { try {
fileCallback = null filePicker.launch(arrayOf("*/*"))
false } catch (e: ActivityNotFoundException) {
} fileCallback?.onReceiveValue(null)
fileCallback = null
}
} else {
openCamera()
}
}
.setOnCancelListener {
fileCallback?.onReceiveValue(null)
fileCallback = null
}
.show()
return true
} }
} }
@ -164,6 +280,10 @@ class MainActivity : AppCompatActivity() {
}) })
refresh.setOnRefreshListener { webView.reload() } refresh.setOnRefreshListener { webView.reload() }
// Pull-to-refresh only when the chat list is really at the top; a
// bigger trigger distance keeps normal scrolling from reloading.
refresh.setOnChildScrollUpCallback { _, _ -> !webAtTop }
refresh.setDistanceToTriggerSync((80 * resources.displayMetrics.density).toInt())
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) { onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() { override fun handleOnBackPressed() {
@ -176,6 +296,165 @@ class MainActivity : AppCompatActivity() {
} else { } else {
webView.restoreState(savedInstanceState) webView.restoreState(savedInstanceState)
} }
handleAuthIntent(intent)
handleShareIntent(intent)
}
// isAuthUrl matches both the nikogpt:// scheme and the verified App Link.
private fun isAuthUrl(uri: Uri): Boolean {
if (uri.scheme == "nikogpt" && uri.host == "auth") return true
return uri.scheme == "https" && uri.host == HOST && uri.path == "/app-auth"
}
// openCamera writes the shot into the cache and hands it to the web app.
private fun openCamera() {
try {
val dir = File(cacheDir, "camera").apply { mkdirs() }
val file = File(dir, "photo_" + System.currentTimeMillis() + ".jpg")
val uri = FileProvider.getUriForFile(this, "dev.n1ko.nikogpt.files", file)
pendingCameraUri = uri
takePicture.launch(uri)
} catch (e: Exception) {
fileCallback?.onReceiveValue(null)
fileCallback = null
}
}
// handleShareIntent catches "Share to NikoGPT" from other apps.
private fun handleShareIntent(intent: Intent?) {
if (intent == null || intent.action != Intent.ACTION_SEND) return
if (intent.type?.startsWith("text/") != true) return
val text = intent.getStringExtra(Intent.EXTRA_TEXT)
if (!text.isNullOrBlank()) {
pendingShare = text
maybeDeliverShare()
}
}
// maybeDeliverShare fills the composer once the page is ready.
private fun maybeDeliverShare() {
val text = pendingShare ?: return
if (webView.url == null) return
pendingShare = null
webView.evaluateJavascript("window.nikoShare && window.nikoShare(" + org.json.JSONObject.quote(text) + ")", null)
}
// handleAuthIntent catches the auth deep link (nikogpt:// or App Link) and
// completes the sign-in inside the app's WebView.
private fun handleAuthIntent(intent: Intent?) {
val data = intent?.data ?: return
if (isAuthUrl(data)) {
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) }
}
// The web app reports the message list's scroll position, so pulling
// down only refreshes when the list really is at the top.
@JavascriptInterface
fun setAtTop(atTop: Boolean) {
webAtTop = atTop
}
}
// openGooglePicker tries the native pickers in order: Credential Manager
// (modern GMS) → legacy Google Sign-In (microG) → browser flow.
private fun openGooglePicker(clientId: String, link: Boolean) {
if (clientId.isBlank() || !googleServicesAvailable()) {
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: Exception) {
// No Credential Manager provider (microG) — try the legacy API.
openLegacyGooglePicker(clientId, link)
}
}
}
// googleServicesAvailable is true for GMS and for microG (which reports
// itself as Play services when signature spoofing is enabled).
private fun googleServicesAvailable(): Boolean {
return try {
GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(this) == ConnectionResult.SUCCESS
} catch (e: Exception) {
false
}
}
@Deprecated("microG only implements the legacy Google Sign-In API")
private fun openLegacyGooglePicker(clientId: String, link: Boolean) {
legacyLinkMode = link
try {
val options = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN)
.requestIdToken(clientId)
.requestEmail()
.build()
legacySignIn.launch(GoogleSignIn.getClient(this, options).signInIntent)
} 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)
handleShareIntent(intent)
} }
private fun openExternally(uri: Uri) { private fun openExternally(uri: Uri) {
@ -205,6 +484,14 @@ class MainActivity : AppCompatActivity() {
} }
} }
override fun onStop() {
super.onStop()
// Chromium writes cookies in batches: without an explicit flush the
// session cookie can be lost when Android kills the process, so the
// user would have to log in again after every restart.
CookieManager.getInstance().flush()
}
override fun onSaveInstanceState(outState: Bundle) { override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState) super.onSaveInstanceState(outState)
webView.saveState(outState) webView.saveState(outState)

View file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">NikoGPT</string>
<string name="retry">Повторить</string>
<string name="load_error">Не удалось загрузить chat.n1ko.dev\n\n%1$s</string>
<string name="no_app">Нет приложения, которое может открыть эту ссылку</string>
<string name="download_started">Загрузка началась</string>
<string name="download_failed">Не удалось начать загрузку</string>
<string name="google_error">Не удалось войти через Google (%1$s)</string>
<string name="google_error_code">Не удалось войти через Google (код %1$d)</string>
<string name="google_no_token">Google не вернул ID-токен (проверьте Android OAuth-клиент: package и SHA-1)</string>
<string name="attach_file">Файл</string>
<string name="attach_camera">Камера</string>
</resources>

View file

@ -1,9 +1,14 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<string name="app_name">NikoGPT</string> <string name="app_name">NikoGPT</string>
<string name="retry">Повторить</string> <string name="retry">Retry</string>
<string name="load_error">Не удалось загрузить chat.n1ko.dev\n\n%1$s</string> <string name="load_error">Could not load chat.n1ko.dev\n\n%1$s</string>
<string name="no_app">Нет приложения, которое может открыть эту ссылку</string> <string name="no_app">No app can open this link</string>
<string name="download_started">Загрузка началась</string> <string name="download_started">Download started</string>
<string name="download_failed">Не удалось начать загрузку</string> <string name="download_failed">Could not start the download</string>
<string name="google_error">Google sign-in failed (%1$s)</string>
<string name="google_error_code">Google sign-in failed (code %1$d)</string>
<string name="google_no_token">Google did not return an ID token (check the Android OAuth client, package and SHA-1)</string>
<string name="attach_file">File</string>
<string name="attach_camera">Camera</string>
</resources> </resources>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<cache-path name="camera" path="camera/" />
</paths>