Compare commits

..

3 commits
v1.1.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
5 changed files with 69 additions and 6 deletions

View file

@ -22,8 +22,8 @@ android {
applicationId = "dev.n1ko.nikogpt" applicationId = "dev.n1ko.nikogpt"
minSdk = 29 // Android 10 minSdk = 29 // Android 10
targetSdk = 35 targetSdk = 35
versionCode = 5 versionCode = 8
versionName = "1.1.0" versionName = "1.2.2"
} }
signingConfigs { signingConfigs {

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"

View file

@ -1,11 +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.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
@ -14,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
@ -32,6 +35,7 @@ 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.content.FileProvider
import androidx.core.view.ViewCompat import androidx.core.view.ViewCompat
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
@ -76,6 +80,10 @@ class MainActivity : AppCompatActivity() {
// Shared text ("Share to NikoGPT") waiting for the page to load. // Shared text ("Share to NikoGPT") waiting for the page to load.
private var pendingShare: String? = null 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. // Where the camera writes the picture before it reaches the web app.
private var pendingCameraUri: Uri? = null private var pendingCameraUri: Uri? = null
@ -91,16 +99,38 @@ 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. // Camera capture for the attach button.
@ -195,6 +225,22 @@ class MainActivity : AppCompatActivity() {
} }
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
@ -234,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() {
@ -310,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

View file

@ -7,6 +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_file">Файл</string>
<string name="attach_camera">Камера</string> <string name="attach_camera">Камера</string>
</resources> </resources>

View file

@ -7,6 +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_file">File</string>
<string name="attach_camera">Camera</string> <string name="attach_camera">Camera</string>
</resources> </resources>