Compare commits

..

4 commits
v1.0.4 ... 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
8 changed files with 227 additions and 25 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

@ -41,16 +41,27 @@ 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 ## Google sign-in
Native "Continue with Google" is attempted in this order: Native "Continue with Google" is attempted in this order:

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 = 4 versionCode = 8
versionName = "1.0.4" 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")
}
} }
} }

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"
@ -22,15 +24,36 @@
<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: after the browser finishes OAuth the <!-- Google sign-in handoff (custom scheme fallback). -->
server redirects to nikogpt://auth?code=… and the app
exchanges the one-time code inside its own WebView. -->
<intent-filter> <intent-filter>
<action android:name="android.intent.action.VIEW" /> <action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" /> <category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="nikogpt" android:host="auth" /> <data android:scheme="nikogpt" android:host="auth" />
</intent-filter> </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
@ -13,6 +16,7 @@ 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.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
@ -31,6 +35,8 @@ 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
@ -47,6 +53,7 @@ import com.google.android.gms.common.GoogleApiAvailability
import com.google.android.gms.common.api.ApiException import com.google.android.gms.common.api.ApiException
import com.google.android.libraries.identity.googleid.GetGoogleIdOption import com.google.android.libraries.identity.googleid.GetGoogleIdOption
import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential
import java.io.File
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
/** /**
@ -61,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
@ -70,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 ->
@ -82,16 +99,48 @@ class MainActivity : AppCompatActivity() {
private val legacySignIn: ActivityResultLauncher<Intent> = private val legacySignIn: ActivityResultLauncher<Intent> =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
val link = legacyLinkMode val link = legacyLinkMode
try { val account = try {
val account = GoogleSignIn.getSignedInAccountFromIntent(result.data) GoogleSignIn.getSignedInAccountFromIntent(result.data)
.getResult(ApiException::class.java) .getResult(ApiException::class.java)
val token = account?.idToken
if (!token.isNullOrBlank()) deliverToken(token, link) else openGoogleInBrowser(link)
} catch (e: ApiException) { } 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) openGoogleInBrowser(link)
return@registerForActivityResult
} catch (e: Exception) { } catch (e: Exception) {
openGoogleInBrowser(link) 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")
@ -142,7 +191,7 @@ class MainActivity : AppCompatActivity() {
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 (url.scheme == "nikogpt" && url.host == "auth") { if (isAuthUrl(url)) {
// Deep link that reached the WebView instead of the browser. // Deep link that reached the WebView instead of the browser.
handleAuthUri(url) handleAuthUri(url)
return true return true
@ -171,10 +220,27 @@ class MainActivity : AppCompatActivity() {
progress.visibility = View.GONE progress.visibility = View.GONE
// Persist cookies (the session) right after the page settles. // Persist cookies (the session) right after the page settles.
CookieManager.getInstance().flush() 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
@ -187,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
} }
} }
@ -202,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() {
@ -215,13 +297,53 @@ class MainActivity : AppCompatActivity() {
webView.restoreState(savedInstanceState) webView.restoreState(savedInstanceState)
} }
handleAuthIntent(intent) handleAuthIntent(intent)
handleShareIntent(intent)
} }
// handleAuthIntent catches the nikogpt://auth?code=… deep link from the // isAuthUrl matches both the nikogpt:// scheme and the verified App Link.
// browser and completes the sign-in inside the app's WebView. 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?) { private fun handleAuthIntent(intent: Intent?) {
val data = intent?.data ?: return val data = intent?.data ?: return
if (data.scheme == "nikogpt" && data.host == "auth") { if (isAuthUrl(data)) {
handleAuthUri(data) handleAuthUri(data)
} }
} }
@ -238,6 +360,13 @@ class MainActivity : AppCompatActivity() {
fun linkGoogle(clientId: String) { fun linkGoogle(clientId: String) {
runOnUiThread { openGooglePicker(clientId, link = true) } 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 // openGooglePicker tries the native pickers in order: Credential Manager
@ -325,6 +454,7 @@ class MainActivity : AppCompatActivity() {
super.onNewIntent(intent) super.onNewIntent(intent)
setIntent(intent) setIntent(intent)
handleAuthIntent(intent) handleAuthIntent(intent)
handleShareIntent(intent)
} }
private fun openExternally(uri: Uri) { private fun openExternally(uri: Uri) {

View file

@ -7,4 +7,8 @@
<string name="download_started">Загрузка началась</string> <string name="download_started">Загрузка началась</string>
<string name="download_failed">Не удалось начать загрузку</string> <string name="download_failed">Не удалось начать загрузку</string>
<string name="google_error">Не удалось войти через Google (%1$s)</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> </resources>

View file

@ -7,4 +7,8 @@
<string name="download_started">Download started</string> <string name="download_started">Download started</string>
<string name="download_failed">Could not start the download</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">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>