Compare commits

...

3 commits
v1.3.1 ... 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
4 changed files with 278 additions and 3 deletions

View file

@ -11,6 +11,11 @@ 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 (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
@ -31,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.3.1/nikogpt-android-1.3.1.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 = 12
versionName = "1.3.1"
versionCode = 14
versionName = "1.4.1"
}
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,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
@ -59,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.
@ -159,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)
@ -510,6 +542,232 @@ 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() }
}
// 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
@ -759,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) {