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>
This commit is contained in:
Niko Marmeladkov 2026-10-20 08:07:34 +03:00
parent a59271522c
commit 98d5b89cd2
4 changed files with 171 additions and 2 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.
- **Personal context**: the app can tell the chat where you are (down to the
street — Android geocodes the position itself) and which phone you write
from (model and firmware). 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

View file

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

View file

@ -6,6 +6,10 @@
<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"

View file

@ -8,11 +8,16 @@ 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.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
@ -59,7 +64,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.
@ -159,6 +166,16 @@ 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
private val locationPermission: ActivityResultLauncher<String> =
registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
if (!pendingLocation) return@registerForActivityResult
pendingLocation = false
if (granted) requestFix() else deliverLocationError()
}
@SuppressLint("SetJavaScriptEnabled")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@ -510,6 +527,150 @@ class MainActivity : AppCompatActivity() {
}
}
}
// 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() }
}
}
// 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 (ContextCompat.checkSelfPermission(
this, Manifest.permission.ACCESS_FINE_LOCATION,
) == PackageManager.PERMISSION_GRANTED ||
ContextCompat.checkSelfPermission(
this, Manifest.permission.ACCESS_COARSE_LOCATION,
) == PackageManager.PERMISSION_GRANTED
) {
requestFix()
return
}
pendingLocation = true
locationPermission.launch(Manifest.permission.ACCESS_FINE_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 = geocode(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()
}
// 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,
)
addresses?.firstOrNull()?.getAddressLine(0)
} 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