1.3.1: offer updates in the app
Some checks failed
build / apk (push) Has been cancelled

The shell asks /api/app for the published version once per launch and
shows an update dialog (download + system installer) when a newer build
exists. The check is on by default and switchable: Settings -> App in the
web UI (a per-device preference stored in the app) or the dialog's
"don't check again" button.
This commit is contained in:
Niko Marmeladkov 2026-10-19 08:47:38 +03:00
parent 73e098b8eb
commit a59271522c
7 changed files with 178 additions and 3 deletions

View file

@ -11,6 +11,10 @@ resources.
picker), images, generated files, settings, themes.
- **Share anything to NikoGPT**: from another app share text or any file
(photos, PDFs, audio, archives…) straight into the composer.
- **Updates itself**: the app checks the published version on launch and
offers the update right there (download + system installer). The check can
be turned off in **Settings → App**, or with "don't check again" in the
dialog.
- Downloads go through Android's `DownloadManager` (with your session
cookies), links to other sites open in the system browser.
- **Open in the browser only**: `chat.n1ko.dev` loads inside; anything else
@ -27,7 +31,7 @@ Download the APK from the releases page:
**https://git.n1ko.dev/Niko/nikogpt_android/releases**
Latest build:
https://git.n1ko.dev/Niko/nikogpt_android/releases/download/v1.3.0/nikogpt-android-1.3.0.apk
https://git.n1ko.dev/Niko/nikogpt_android/releases/download/v1.3.1/nikogpt-android-1.3.1.apk
Open the APK on the phone (allow installing apps from this source when Android
asks). The APKs are signed with the project's release key; Android may still

View file

@ -22,8 +22,8 @@ android {
applicationId = "dev.n1ko.nikogpt"
minSdk = 29 // Android 10
targetSdk = 35
versionCode = 11
versionName = "1.3.0"
versionCode = 12
versionName = "1.3.1"
}
signingConfigs {

View file

@ -2,6 +2,8 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<!-- In-app updates: install the downloaded APK (the user confirms in the system dialog). -->
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<!-- Voice input in the web chat (MediaRecorder + server-side recognition). -->
<uses-permission android:name="android.permission.RECORD_AUDIO" />

View file

@ -10,6 +10,7 @@ import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.net.http.SslError
import android.provider.Settings
import android.provider.OpenableColumns
import android.util.Base64
import android.os.Bundle
@ -28,6 +29,8 @@ import android.webkit.WebResourceRequest
import android.webkit.WebSettings
import android.webkit.WebView
import android.webkit.WebViewClient
import java.net.HttpURLConnection
import java.net.URL
import android.widget.Button
import android.widget.LinearLayout
import android.widget.ProgressBar
@ -243,6 +246,7 @@ class MainActivity : AppCompatActivity() {
// Persist cookies (the session) right after the page settles.
CookieManager.getInstance().flush()
maybeDeliverShare()
checkForUpdates()
}
}
@ -348,6 +352,11 @@ class MainActivity : AppCompatActivity() {
// One file shared into the app, waiting for the page to be ready.
private data class SharedFile(val uri: Uri, val name: String, val mime: String, val size: Long)
// Update checks: on by default, switchable from the web settings (the
// bridge below) or the update dialog ("don't check again").
private val prefs by lazy { getSharedPreferences("nikogpt", Context.MODE_PRIVATE) }
private var updateChecked = false
// handleShareIntent catches "Share to NikoGPT" from other apps: text goes
// into the composer, files of any type into the attachments.
private fun handleShareIntent(intent: Intent?) {
@ -484,6 +493,23 @@ class MainActivity : AppCompatActivity() {
fun setAtTop(atTop: Boolean) {
webAtTop = atTop
}
// The settings screen shows an "updates" switch; the preference lives
// here because it is per device, not per account.
@JavascriptInterface
fun updateChecksEnabled(): Boolean = prefs.getBoolean("update_checks", true)
@JavascriptInterface
fun setUpdateChecks(enabled: Boolean) {
prefs.edit().putBoolean("update_checks", enabled).apply()
if (enabled) {
// Switched on: check right away instead of on the next launch.
runOnUiThread {
updateChecked = false
checkForUpdates()
}
}
}
}
// openGooglePicker tries the native pickers in order: Credential Manager
@ -574,6 +600,132 @@ class MainActivity : AppCompatActivity() {
handleShareIntent(intent)
}
// checkForUpdates asks the bot for the published app version once per
// launch (and only when the check is enabled) and offers the download.
// It uses the WebView's session cookie, so it runs after the page settled.
private fun checkForUpdates() {
if (updateChecked) return
if (!prefs.getBoolean("update_checks", true)) return
if (webView.url == null) return
val cookie = CookieManager.getInstance().getCookie(START_URL)
if (cookie.isNullOrBlank()) return
updateChecked = true
Thread {
val info = fetchAppInfo(cookie) ?: return@Thread
val remote = info.optString("android_version").trim()
val apk = info.optString("apk_url").trim()
if (remote.isEmpty() || apk.isEmpty()) return@Thread
if (!versionNewer(remote, BuildConfig.VERSION_NAME)) return@Thread
runOnUiThread { showUpdateDialog(remote, apk) }
}.start()
}
// fetchAppInfo reads /api/app with the signed-in session.
private fun fetchAppInfo(cookie: String): org.json.JSONObject? {
return try {
val conn = (URL("https://$HOST/api/app").openConnection() as HttpURLConnection).apply {
requestMethod = "GET"
setRequestProperty("Cookie", cookie)
connectTimeout = 15000
readTimeout = 15000
}
val body = conn.inputStream.bufferedReader().use { it.readText() }
conn.disconnect()
org.json.JSONObject(body)
} catch (_: Exception) {
null
}
}
// versionNewer reports whether "remote" is a higher dotted version.
private fun versionNewer(remote: String, local: String): Boolean {
fun parts(v: String) = v.split('.').map { it.takeWhile(Char::isDigit).toIntOrNull() ?: 0 }
val a = parts(remote)
val b = parts(local)
for (i in 0 until maxOf(a.size, b.size)) {
val x = a.getOrElse(i) { 0 }
val y = b.getOrElse(i) { 0 }
if (x != y) return x > y
}
return false
}
private fun showUpdateDialog(version: String, apkUrl: String) {
if (isFinishing || isDestroyed) return
AlertDialog.Builder(this)
.setTitle(R.string.update_title)
.setMessage(getString(R.string.update_message, version, BuildConfig.VERSION_NAME))
.setPositiveButton(R.string.update_now) { _, _ -> startUpdateDownload(apkUrl) }
.setNegativeButton(R.string.update_later, null)
.setNeutralButton(R.string.update_never) { _, _ ->
prefs.edit().putBoolean("update_checks", false).apply()
}
.show()
}
// startUpdateDownload fetches the APK into the cache and hands it to the
// system installer. Without the "install unknown apps" grant it walks the
// user through the settings once; on any failure the browser opens the
// APK link instead.
private fun startUpdateDownload(apkUrl: String) {
if (!packageManager.canRequestPackageInstalls()) {
Toast.makeText(this, R.string.update_install_hint, Toast.LENGTH_LONG).show()
try {
startActivity(
Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES)
.setData(Uri.parse("package:$packageName"))
)
} catch (_: Exception) {
}
return
}
val progress = AlertDialog.Builder(this)
.setTitle(R.string.update_downloading)
.setCancelable(false)
.create()
progress.show()
Thread {
val target = java.io.File(cacheDir, "updates").apply { mkdirs() }
val apkFile = java.io.File(target, "nikogpt-update.apk")
var ok = false
try {
val conn = (URL(apkUrl).openConnection() as HttpURLConnection).apply {
requestMethod = "GET"
connectTimeout = 20000
readTimeout = 60000
}
conn.inputStream.use { input ->
apkFile.outputStream().use { output -> input.copyTo(output) }
}
conn.disconnect()
ok = apkFile.length() > 0
} catch (_: Exception) {
ok = false
}
runOnUiThread {
progress.dismiss()
if (ok) installApk(apkFile, apkUrl) else {
Toast.makeText(this, R.string.update_failed, Toast.LENGTH_LONG).show()
openExternally(Uri.parse(apkUrl))
}
}
}.start()
}
private fun installApk(file: java.io.File, apkUrl: String) {
try {
val uri = FileProvider.getUriForFile(this, "dev.n1ko.nikogpt.files", file)
startActivity(
Intent(Intent.ACTION_VIEW)
.setDataAndType(uri, "application/vnd.android.package-archive")
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
)
} catch (e: Exception) {
Toast.makeText(this, R.string.update_failed, Toast.LENGTH_LONG).show()
openExternally(Uri.parse(apkUrl))
}
}
private fun openExternally(uri: Uri) {
try {
startActivity(Intent(Intent.ACTION_VIEW, uri).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))

View file

@ -11,5 +11,13 @@
<string name="google_no_token">Google не вернул ID-токен (проверьте Android OAuth-клиент: package и SHA-1)</string>
<string name="attach_file">Файл</string>
<string name="share_too_big">Слишком большой файл: %1$s (в чат помещается до 16 МБ)</string>
<string name="update_title">Доступно обновление</string>
<string name="update_message">Вышла версия NikoGPT %1$s. У вас %2$s.</string>
<string name="update_now">Обновить</string>
<string name="update_later">Позже</string>
<string name="update_never">Больше не проверять</string>
<string name="update_downloading">Скачиваю обновление…</string>
<string name="update_failed">Не удалось скачать обновление — открываю его в браузере</string>
<string name="update_install_hint">Разрешите этому приложению устанавливать обновления в открывшихся настройках и нажмите «Обновить» ещё раз.</string>
<string name="attach_camera">Камера</string>
</resources>

View file

@ -11,5 +11,13 @@
<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="share_too_big">Too big to send: %1$s (the chat accepts up to 16 MB)</string>
<string name="update_title">Ready to update</string>
<string name="update_message">NikoGPT %1$s is available. You have %2$s.</string>
<string name="update_now">Update</string>
<string name="update_later">Later</string>
<string name="update_never">Don\'t check again</string>
<string name="update_downloading">Downloading the update…</string>
<string name="update_failed">Could not download the update — opening it in the browser instead</string>
<string name="update_install_hint">Allow this app to install updates in the settings that just opened, then tap Update again.</string>
<string name="attach_camera">Camera</string>
</resources>

View file

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