Compare commits

...

6 commits
v1.2.3 ... main

Author SHA1 Message Date
Niko Marmeladkov
0213122b42 1.4.1: the place follows the person
Some checks failed
build / apk (push) Has been cancelled
The app keeps the position fresh while it is on screen (a fix every minute
or after a few dozen meters) and pushes every fix into the chat, so the
place moves with the person instead of being a one-time setting. The
address is street-level now: the house number is left out on purpose.

Co-Authored-By: opencode <opencode@openai.com>
2026-10-20 09:06:57 +03:00
Niko Marmeladkov
552074f2cf README: point at the 1.4.0 build 2026-10-20 08:08:39 +03:00
Niko Marmeladkov
98d5b89cd2 1.4.0: personal context — location and device info
Some checks failed
build / apk (push) Has been cancelled
The app now reports where the person is (Android geocodes the position, so
the address is street-level) and which phone they write from (model and
firmware). Both are optional and switched on in Settings → Context.

Co-Authored-By: opencode <opencode@openai.com>
2026-10-20 08:07:34 +03:00
Niko Marmeladkov
a59271522c 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.
2026-10-19 08:47:38 +03:00
Niko Marmeladkov
73e098b8eb 1.3.0: share files, not just text
Some checks failed
build / apk (push) Has been cancelled
"Share to NikoGPT" now accepts any file type (single or multiple): the
stream is read through the content resolver and pushed into the web
composer as base64 chunks, so photos and documents land next to the
keyboard ready to send. Oversized files are refused with a clear
message (the chat takes up to 16 MB).
2026-10-19 08:30:39 +03:00
Niko Marmeladkov
6e02c22b4f 1.2.4: pull-to-refresh only at the top of the chat
Some checks failed
build / apk (push) Has been cancelled
The swipe gesture no longer reloads the page while a presentation, the
canvas, a dialog or any other scrolled list is on screen: the web app
reports a single pull-allowed flag (overlays + every scroller), the shell
starts from 'forbidden' until the page says otherwise and the WebView
drops its overscroll glow.
2026-10-19 08:19:09 +03:00
7 changed files with 588 additions and 19 deletions

View file

@ -9,13 +9,26 @@ resources.
- **Android 10+** (`minSdk 29`), targets Android 15.
- **Everything the web app can do**: conversations, attachments (system file
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.
- **Personal context**: the app can tell the chat where you are (the street
you are on — never the house number; Android geocodes the position itself)
and which phone you write from (model and firmware). The place follows you:
the app keeps the position fresh while it is on screen. Both are optional
and switched on in **Settings → Context**.
- **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
leaves the app.
- TLS errors are never bypassed, no cleartext traffic, no backup.
- Day/night theme follows the system; the page keeps its own theme setting.
- Back gesture navigates the chat history, pull-to-refresh reloads.
- Back gesture navigates the chat history; pull-to-refresh reloads only at
the very top of the chat — never inside a presentation, a dialog or another
list.
## Install
@ -23,7 +36,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.2.3/nikogpt-android-1.2.3.apk
https://git.n1ko.dev/Niko/nikogpt_android/releases/download/v1.4.1/nikogpt-android-1.4.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 = 9
versionName = "1.2.3"
versionCode = 14
versionName = "1.4.1"
}
signingConfigs {

View file

@ -2,8 +2,14 @@
<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" />
<!-- Personal context: the place the person is (only when they ask for it
in the chat settings). -->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<application
android:allowBackup="false"
@ -38,11 +44,16 @@
<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. -->
<!-- "Share to NikoGPT" from other apps: text or any file. -->
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/plain" />
<data android:mimeType="*/*" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND_MULTIPLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="*/*" />
</intent-filter>
</activity>

View file

@ -8,8 +8,17 @@ import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.location.Geocoder
import android.location.Location
import android.location.LocationListener
import android.location.LocationManager
import android.net.Uri
import android.net.http.SslError
import android.os.Looper
import android.provider.Settings
import android.provider.OpenableColumns
import android.util.Base64
import android.os.Build
import android.os.Bundle
import android.os.Environment
import android.view.View
@ -26,6 +35,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
@ -54,7 +65,9 @@ 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 java.util.Locale
import kotlinx.coroutines.launch
import org.json.JSONObject
/**
* The whole app: a thin, open-source WebView around the NikoGPT web chat.
@ -80,10 +93,21 @@ class MainActivity : AppCompatActivity() {
// 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).
// Shared files (any type, one or many) waiting for the page to load.
private val pendingFiles = mutableListOf<SharedFile>()
private var sharing = false
// The web chat accepts uploads up to 16 MB per file; keep some room for
// several files in one share.
private val maxShareBytes = 16L * 1024 * 1024
private val maxShareTotal = 32L * 1024 * 1024
// Whether the web app currently allows a pull-to-refresh gesture. It
// reports this on every scroll and whenever an overlay opens or closes
// (window.NikoGPTAndroid.setAtTop), because the WebView itself never
// scrolls — the messages list, a presentation viewer, dialogs … all scroll
// inside the page. Starts false: until the page explicitly says "at the
// top of the chat", a pull must not reload anything.
@Volatile
private var webAtTop = true
private var webAtTop = false
// Where the camera writes the picture before it reaches the web app.
private var pendingCameraUri: Uri? = null
@ -143,6 +167,30 @@ class MainActivity : AppCompatActivity() {
if (ok && uri != null) cb?.onReceiveValue(arrayOf(uri)) else cb?.onReceiveValue(null)
}
// Runtime location permission, bridged to the WebView: the page asks
// where the person is, the system asks whether it may know.
private var pendingLocation = false
// Whether the person shared the place: the page keeps the tracking in
// step with that switch.
private var tracking = false
// Whether the position updates are registered with the system.
private var locationUpdatesOn = false
// The last point the street was resolved for: the address only changes
// when the person moves.
private var lastGeocoded: Location? = null
private var lastAddress: String? = null
private var lastGeocodedAt = 0L
private val locationPermission: ActivityResultLauncher<String> =
registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
if (!pendingLocation) return@registerForActivityResult
pendingLocation = false
if (!granted) {
deliverLocationError()
return@registerForActivityResult
}
if (tracking) startTracking() else requestFix()
}
@SuppressLint("SetJavaScriptEnabled")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@ -230,6 +278,7 @@ class MainActivity : AppCompatActivity() {
// Persist cookies (the session) right after the page settles.
CookieManager.getInstance().flush()
maybeDeliverShare()
checkForUpdates()
}
}
@ -291,8 +340,11 @@ 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.setOnChildScrollUpCallback { _, _ -> !webAtTop || webView.canScrollVertically(-1) }
refresh.setDistanceToTriggerSync((80 * resources.displayMetrics.density).toInt())
// No native overscroll glow/bounce on the WebView either: the page
// owns every scroller.
webView.overScrollMode = View.OVER_SCROLL_NEVER
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
@ -329,23 +381,120 @@ class MainActivity : AppCompatActivity() {
}
}
// handleShareIntent catches "Share to NikoGPT" from other apps.
// 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?) {
if (intent == null || intent.action != Intent.ACTION_SEND) return
if (intent.type?.startsWith("text/") != true) return
if (intent == null) return
val action = intent.action
if (action != Intent.ACTION_SEND && action != Intent.ACTION_SEND_MULTIPLE) return
val text = intent.getStringExtra(Intent.EXTRA_TEXT)
if (!text.isNullOrBlank()) {
if (!text.isNullOrBlank() && intent.type?.startsWith("text/") == true) {
pendingShare = text
maybeDeliverShare()
}
val uris = mutableListOf<Uri>()
@Suppress("DEPRECATION")
when (action) {
Intent.ACTION_SEND -> intent.getParcelableExtra<Uri>(Intent.EXTRA_STREAM)?.let { uris.add(it) }
else -> intent.getParcelableArrayListExtra<Uri>(Intent.EXTRA_STREAM)?.let { uris.addAll(it) }
}
var total = pendingFiles.sumOf { it.size }
for (uri in uris) {
if (uri.scheme == null) continue
val meta = sharedFileMeta(uri) ?: continue
if (meta.size > maxShareBytes || total + meta.size > maxShareTotal) {
Toast.makeText(this, getString(R.string.share_too_big, meta.name), Toast.LENGTH_LONG).show()
continue
}
total += meta.size
pendingFiles.add(meta)
}
if (pendingShare != null || pendingFiles.isNotEmpty()) maybeDeliverShare()
}
// maybeDeliverShare fills the composer once the page is ready.
// sharedFileMeta resolves the display name, MIME type and size of a shared
// content URI (providers may not report a size; then it stays 0 and the
// read loop stops at the cap).
private fun sharedFileMeta(uri: Uri): SharedFile? {
var name = ""
var size = -1L
try {
contentResolver.query(uri, null, null, null, null)?.use { c ->
if (c.moveToFirst()) {
val nameCol = c.getColumnIndex(OpenableColumns.DISPLAY_NAME)
if (nameCol >= 0) name = c.getString(nameCol) ?: ""
val sizeCol = c.getColumnIndex(OpenableColumns.SIZE)
if (sizeCol >= 0 && !c.isNull(sizeCol)) size = c.getLong(sizeCol)
}
}
} catch (_: Exception) {
}
if (name.isBlank()) name = uri.lastPathSegment?.substringAfterLast('/') ?: "file"
if (size < 0) {
size = try {
contentResolver.openAssetFileDescriptor(uri, "r")?.use { it.length } ?: -1L
} catch (_: Exception) {
-1L
}
}
if (size < 0) size = 0
val mime = contentResolver.getType(uri) ?: "application/octet-stream"
return SharedFile(uri, name, mime, size)
}
// maybeDeliverShare fills the composer once the page is ready: text as-is,
// then every pending file as base64 chunks (start → chunks → end), so a
// whole photo never travels as one giant JS string.
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)
pendingShare?.let { text ->
pendingShare = null
webView.evaluateJavascript("window.nikoShare && window.nikoShare(" + org.json.JSONObject.quote(text) + ")", null)
}
if (sharing || pendingFiles.isEmpty()) return
sharing = true
val batch = pendingFiles.toList()
pendingFiles.clear()
Thread {
for (file in batch) sendSharedFile(file)
runOnUiThread { sharing = false }
}.start()
}
// sendSharedFile streams one shared file into the page. The JS calls are
// posted to the main thread one by one, so the browser queue keeps the
// start → chunks → end order.
private fun sendSharedFile(file: SharedFile) {
try {
contentResolver.openInputStream(file.uri)?.use { input ->
val start = "window.nikoShareFileStart && window.nikoShareFileStart(" +
org.json.JSONObject.quote(file.name) + ", " +
org.json.JSONObject.quote(file.mime) + ", " + file.size + ")"
runOnUiThread { webView.evaluateJavascript(start, null) }
val buf = ByteArray(192 * 1024)
var sent = 0L
while (sent < maxShareBytes) {
val n = input.read(buf)
if (n <= 0) break
val chunk = org.json.JSONObject.quote(
Base64.encodeToString(buf, 0, n, Base64.NO_WRAP)
)
runOnUiThread {
webView.evaluateJavascript("window.nikoShareFileChunk && window.nikoShareFileChunk($chunk)", null)
}
sent += n
}
}
} catch (_: Exception) {
}
runOnUiThread { webView.evaluateJavascript("window.nikoShareFileEnd && window.nikoShareFileEnd()", null) }
}
// handleAuthIntent catches the auth deep link (nikogpt:// or App Link) and
@ -376,6 +525,249 @@ 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()
}
}
}
// deviceInfo describes the phone for the personal context: the model
// and the firmware build the app runs on.
@JavascriptInterface
fun deviceInfo(): String = deviceInfoJSON().toString()
// requestLocation asks Android for a position and geocodes it with the
// system service, so the chat gets a street-level address. The result
// goes back into the page through nikoDeviceLocation.
@JavascriptInterface
fun requestLocation() {
runOnUiThread { startLocation() }
}
// startLocationUpdates begins tracking the person: every fix is
// pushed into the page, so the place follows them. The page starts
// it only while the location is shared.
@JavascriptInterface
fun startLocationUpdates() {
runOnUiThread {
tracking = true
if (hasLocationPermission()) startTracking() else {
pendingLocation = true
locationPermission.launch(Manifest.permission.ACCESS_FINE_LOCATION)
}
}
}
// stopLocationUpdates ends the tracking.
@JavascriptInterface
fun stopLocationUpdates() {
runOnUiThread {
tracking = false
stopTracking()
}
}
}
// hasLocationPermission reports whether the app may read the position.
private fun hasLocationPermission(): Boolean {
return ContextCompat.checkSelfPermission(
this, Manifest.permission.ACCESS_FINE_LOCATION,
) == PackageManager.PERMISSION_GRANTED ||
ContextCompat.checkSelfPermission(
this, Manifest.permission.ACCESS_COARSE_LOCATION,
) == PackageManager.PERMISSION_GRANTED
}
// deviceInfoJSON is the phone description the personal context carries.
private fun deviceInfoJSON(): JSONObject {
val model = deviceModel()
val firmware = deviceFirmware()
return JSONObject()
.put("model", model)
.put("firmware", firmware)
.put("os", Build.VERSION.RELEASE)
.put("app", BuildConfig.VERSION_NAME)
}
// deviceModel is the marketing name of the phone ("Google Pixel 8").
private fun deviceModel(): String {
val manufacturer = Build.MANUFACTURER?.trim().orEmpty()
val model = Build.MODEL?.trim().orEmpty()
if (manufacturer.isEmpty()) return model
if (model.startsWith(manufacturer, ignoreCase = true)) return model
return "$manufacturer $model"
}
// deviceFirmware is the firmware build ("Android 15 (AP3A.241105.007)").
private fun deviceFirmware(): String {
val release = Build.VERSION.RELEASE?.trim().orEmpty()
val build = Build.DISPLAY?.trim().orEmpty()
return if (build.isEmpty()) "Android $release" else "Android $release ($build)"
}
// startLocation runs the permission dance and then asks for a fix. The
// answer (or the error) goes back into the page.
private fun startLocation() {
if (hasLocationPermission()) {
requestFix()
return
}
pendingLocation = true
locationPermission.launch(Manifest.permission.ACCESS_FINE_LOCATION)
}
// startTracking registers the ongoing position updates: a fix every
// minute, or as soon as the person has walked a few dozen meters.
private fun startTracking() {
if (locationUpdatesOn) return
val lm = getSystemService(Context.LOCATION_SERVICE) as LocationManager
val provider = when {
lm.isProviderEnabled(LocationManager.GPS_PROVIDER) -> LocationManager.GPS_PROVIDER
lm.isProviderEnabled(LocationManager.NETWORK_PROVIDER) -> LocationManager.NETWORK_PROVIDER
else -> null
} ?: return
try {
lm.requestLocationUpdates(provider, 60_000L, 50f, locationListener, Looper.getMainLooper())
locationUpdatesOn = true
} catch (_: Exception) {
}
}
// stopTracking unregisters the position updates.
private fun stopTracking() {
if (!locationUpdatesOn) return
locationUpdatesOn = false
try {
val lm = getSystemService(Context.LOCATION_SERVICE) as LocationManager
lm.removeUpdates(locationListener)
} catch (_: Exception) {
}
}
// locationListener receives the ongoing fixes.
private val locationListener = LocationListener { location -> deliverLocation(location) }
// requestFix takes a position from the system location manager. GPS is
// preferred (it is precise enough for a street); the network provider is
// the fallback, and the last known position answers when both are slow.
private fun requestFix() {
val lm = getSystemService(Context.LOCATION_SERVICE) as LocationManager
val provider = when {
lm.isProviderEnabled(LocationManager.GPS_PROVIDER) -> LocationManager.GPS_PROVIDER
lm.isProviderEnabled(LocationManager.NETWORK_PROVIDER) -> LocationManager.NETWORK_PROVIDER
else -> null
}
if (provider == null) {
deliverLocationError()
return
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
lm.getCurrentLocation(provider, null, mainExecutor) { location ->
if (location != null) {
deliverLocation(location)
} else {
val last = lastKnownLocation(lm)
if (last != null) deliverLocation(last) else deliverLocationError()
}
}
} else {
@Suppress("DEPRECATION")
lm.requestSingleUpdate(
provider,
{ location -> deliverLocation(location) },
Looper.getMainLooper(),
)
}
}
// lastKnownLocation is the freshest position the system still remembers.
private fun lastKnownLocation(lm: LocationManager): Location? {
var best: Location? = null
for (provider in listOf(LocationManager.GPS_PROVIDER, LocationManager.NETWORK_PROVIDER)) {
val loc = try { lm.getLastKnownLocation(provider) } catch (_: Exception) { null }
if (loc == null) continue
if (System.currentTimeMillis() - loc.time > 30 * 60 * 1000) continue
if (best == null || loc.time > best.time) best = loc
}
return best
}
// deliverLocation geocodes the position in the background and hands the
// address, the coordinates and the phone details to the page.
private fun deliverLocation(location: Location) {
Thread {
val address = streetAddress(location)
val payload = deviceInfoJSON()
.put("lat", location.latitude)
.put("lon", location.longitude)
.put("accuracy", location.accuracy.toDouble())
if (address != null) payload.put("address", address)
runOnUiThread { sendLocation(payload) }
}.start()
}
// streetAddress resolves the street the person is on. The address is
// resolved only when they have actually moved: the street is what the
// chat gets, the house number is left out on purpose.
private fun streetAddress(location: Location): String? {
val prev = lastGeocoded
if (prev != null && lastAddress != null &&
prev.distanceTo(location) < 100f &&
System.currentTimeMillis() - lastGeocodedAt < 10 * 60 * 1000
) {
return lastAddress
}
val address = geocode(location)
if (address != null) {
lastGeocoded = Location(location)
lastAddress = address
lastGeocodedAt = System.currentTimeMillis()
}
return address
}
// geocode turns a position into a street address with the system
// service. It returns null when the device has no geocoder backend.
private fun geocode(location: Location): String? {
return try {
@Suppress("DEPRECATION")
val addresses = Geocoder(this, Locale.getDefault()).getFromLocation(
location.latitude, location.longitude, 1,
)
val a = addresses?.firstOrNull() ?: return null
listOfNotNull(a.thoroughfare, a.locality ?: a.subAdminArea, a.countryName)
.filter { it.isNotBlank() }
.joinToString(", ")
.ifBlank { null }
} catch (_: Exception) {
null
}
}
// deliverLocationError tells the page that no position could be found.
private fun deliverLocationError() {
runOnUiThread {
sendLocation(JSONObject().put("error", "location unavailable"))
}
}
// sendLocation calls back into the page with the location payload.
private fun sendLocation(payload: JSONObject) {
val js = "window.nikoDeviceLocation && window.nikoDeviceLocation(" +
org.json.JSONObject.quote(payload.toString()) + ")"
webView.evaluateJavascript(js, null)
}
// openGooglePicker tries the native pickers in order: Credential Manager
@ -466,6 +858,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))
@ -499,6 +1017,14 @@ class MainActivity : AppCompatActivity() {
// session cookie can be lost when Android kills the process, so the
// user would have to log in again after every restart.
CookieManager.getInstance().flush()
// The position is only tracked while the app is on screen.
stopTracking()
}
override fun onStart() {
super.onStart()
// Back on screen: the tracking continues where it stopped.
if (tracking && hasLocationPermission()) startTracking()
}
override fun onSaveInstanceState(outState: Bundle) {

View file

@ -10,5 +10,14 @@
<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="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

@ -10,5 +10,14 @@
<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="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>