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/
build/
local.properties
keystore.properties
*.iml
.idea/
.DS_Store

View file

@ -41,16 +41,27 @@ 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:

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

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"
@ -22,15 +24,36 @@
<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. -->
<!-- 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
@ -13,6 +16,7 @@ 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
@ -31,6 +35,8 @@ 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
@ -47,6 +53,7 @@ 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
/**
@ -61,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
@ -70,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 ->
@ -82,16 +99,48 @@ class MainActivity : AppCompatActivity() {
private val legacySignIn: ActivityResultLauncher<Intent> =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
val link = legacyLinkMode
try {
val account = GoogleSignIn.getSignedInAccountFromIntent(result.data)
val account = try {
GoogleSignIn.getSignedInAccountFromIntent(result.data)
.getResult(ApiException::class.java)
val token = account?.idToken
if (!token.isNullOrBlank()) deliverToken(token, link) else openGoogleInBrowser(link)
} 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")
@ -142,7 +191,7 @@ class MainActivity : AppCompatActivity() {
webView.webViewClient = object : WebViewClient() {
override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean {
val url = request.url
if (url.scheme == "nikogpt" && url.host == "auth") {
if (isAuthUrl(url)) {
// Deep link that reached the WebView instead of the browser.
handleAuthUri(url)
return true
@ -171,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
@ -187,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
}
}
@ -202,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() {
@ -215,13 +297,53 @@ class MainActivity : AppCompatActivity() {
webView.restoreState(savedInstanceState)
}
handleAuthIntent(intent)
handleShareIntent(intent)
}
// handleAuthIntent catches the nikogpt://auth?code=… deep link from the
// browser and completes the sign-in inside the app's WebView.
// 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 (data.scheme == "nikogpt" && data.host == "auth") {
if (isAuthUrl(data)) {
handleAuthUri(data)
}
}
@ -238,6 +360,13 @@ class MainActivity : AppCompatActivity() {
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
@ -325,6 +454,7 @@ class MainActivity : AppCompatActivity() {
super.onNewIntent(intent)
setIntent(intent)
handleAuthIntent(intent)
handleShareIntent(intent)
}
private fun openExternally(uri: Uri) {

View file

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

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