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:
parent
73e098b8eb
commit
a59271522c
7 changed files with 178 additions and 3 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue