diff --git a/README.md b/README.md index c6b50c3..0516204 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e5c3271..73ef813 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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 { diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index fe32656..3c4875d 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,6 +2,8 @@ + + diff --git a/app/src/main/java/dev/n1ko/nikogpt/MainActivity.kt b/app/src/main/java/dev/n1ko/nikogpt/MainActivity.kt index 2d640b1..f37f7e7 100644 --- a/app/src/main/java/dev/n1ko/nikogpt/MainActivity.kt +++ b/app/src/main/java/dev/n1ko/nikogpt/MainActivity.kt @@ -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)) diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index f91f749..e57eb84 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -11,5 +11,13 @@ Google не вернул ID-токен (проверьте Android OAuth-клиент: package и SHA-1) Файл Слишком большой файл: %1$s (в чат помещается до 16 МБ) + Доступно обновление + Вышла версия NikoGPT %1$s. У вас %2$s. + Обновить + Позже + Больше не проверять + Скачиваю обновление… + Не удалось скачать обновление — открываю его в браузере + Разрешите этому приложению устанавливать обновления в открывшихся настройках и нажмите «Обновить» ещё раз. Камера diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2912899..cf4e9d1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -11,5 +11,13 @@ Google did not return an ID token (check the Android OAuth client, package and SHA-1) File Too big to send: %1$s (the chat accepts up to 16 MB) + Ready to update + NikoGPT %1$s is available. You have %2$s. + Update + Later + Don\'t check again + Downloading the update… + Could not download the update — opening it in the browser instead + Allow this app to install updates in the settings that just opened, then tap Update again. Camera diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml index 9ec9888..25842e5 100644 --- a/app/src/main/res/xml/file_paths.xml +++ b/app/src/main/res/xml/file_paths.xml @@ -1,4 +1,5 @@ +