Compare commits

..

6 commits
v1.0.1 ... 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
8 changed files with 400 additions and 14 deletions

1
.gitignore vendored
View file

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

View file

@ -41,16 +41,44 @@ Or just open the project in Android Studio.
### Release signing
Create a keystore and export it as Gradle properties or use
`apksigner` on the release output:
The published APKs are signed with the project's release keystore
(`keystore.properties` + the `.jks` file, both outside git). To build them
yourself, create a keystore and point `keystore.properties` at it:
```bash
keytool -genkeypair -v -keystore nikogpt.jks -alias nikogpt \
-keyalg RSA -keysize 2048 -validity 10000
apksigner sign --ks nikogpt.jks --out nikogpt-release.apk \
app/build/outputs/apk/release/app-release-unsigned.apk
cat > keystore.properties <<EOF
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
| Path | Purpose |

View file

@ -1,8 +1,19 @@
import java.util.Properties
plugins {
id("com.android.application")
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 {
namespace = "dev.n1ko.nikogpt"
compileSdk = 35
@ -11,8 +22,19 @@ android {
applicationId = "dev.n1ko.nikogpt"
minSdk = 29 // Android 10
targetSdk = 35
versionCode = 2
versionName = "1.0.1"
versionCode = 8
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 {
@ -23,6 +45,9 @@ android {
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
if (hasKeystore) {
signingConfig = signingConfigs.getByName("release")
}
}
}
@ -44,6 +69,14 @@ 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")
// 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">
<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
android:allowBackup="false"
@ -15,12 +17,43 @@
<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 (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>
<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>
</manifest>

View file

@ -1,10 +1,13 @@
package dev.n1ko.nikogpt
import android.Manifest
import android.annotation.SuppressLint
import android.app.AlertDialog
import android.app.DownloadManager
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.net.http.SslError
import android.os.Bundle
@ -12,6 +15,8 @@ import android.os.Environment
import android.view.View
import android.webkit.CookieManager
import android.webkit.DownloadListener
import android.webkit.JavascriptInterface
import android.webkit.PermissionRequest
import android.webkit.SslErrorHandler
import android.webkit.URLUtil
import android.webkit.ValueCallback
@ -30,11 +35,26 @@ import androidx.activity.OnBackPressedCallback
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
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.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.
@ -48,7 +68,8 @@ class MainActivity : AppCompatActivity() {
companion object {
private const val START_URL = "https://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
@ -57,6 +78,15 @@ class MainActivity : AppCompatActivity() {
private lateinit var errorBox: LinearLayout
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 val filePicker: ActivityResultLauncher<Array<String>> =
registerForActivityResult(ActivityResultContracts.OpenMultipleDocuments()) { uris ->
@ -64,6 +94,55 @@ class MainActivity : AppCompatActivity() {
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")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@ -107,10 +186,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 (isAuthUrl(url)) {
// Deep link that reached the WebView instead of the browser.
handleAuthUri(url)
return true
}
if (url.host == HOST) return false
openExternally(url)
return true
@ -135,10 +220,27 @@ class MainActivity : AppCompatActivity() {
progress.visibility = View.GONE
// Persist cookies (the session) right after the page settles.
CookieManager.getInstance().flush()
maybeDeliverShare()
}
}
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) {
progress.visibility = if (newProgress in 1..99) View.VISIBLE else View.GONE
progress.progress = newProgress
@ -151,13 +253,25 @@ class MainActivity : AppCompatActivity() {
): Boolean {
fileCallback?.onReceiveValue(null)
fileCallback = callback
return try {
filePicker.launch(arrayOf("*/*"))
true
} catch (e: ActivityNotFoundException) {
fileCallback = null
false
}
AlertDialog.Builder(this@MainActivity)
.setItems(arrayOf(getString(R.string.attach_file), getString(R.string.attach_camera))) { _, which ->
if (which == 0) {
try {
filePicker.launch(arrayOf("*/*"))
} catch (e: ActivityNotFoundException) {
fileCallback?.onReceiveValue(null)
fileCallback = null
}
} else {
openCamera()
}
}
.setOnCancelListener {
fileCallback?.onReceiveValue(null)
fileCallback = null
}
.show()
return true
}
}
@ -166,6 +280,10 @@ class MainActivity : AppCompatActivity() {
})
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) {
override fun handleOnBackPressed() {
@ -178,6 +296,165 @@ class MainActivity : AppCompatActivity() {
} else {
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) {

View file

@ -6,4 +6,9 @@
<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

@ -6,4 +6,9 @@
<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>
<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>

View file

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