Compare commits

..

2 commits
v1.4.0 ... 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
3 changed files with 125 additions and 19 deletions

View file

@ -11,10 +11,11 @@ resources.
picker), images, generated files, settings, themes. picker), images, generated files, settings, themes.
- **Share anything to NikoGPT**: from another app share text or any file - **Share anything to NikoGPT**: from another app share text or any file
(photos, PDFs, audio, archives…) straight into the composer. (photos, PDFs, audio, archives…) straight into the composer.
- **Personal context**: the app can tell the chat where you are (down to the - **Personal context**: the app can tell the chat where you are (the street
street — Android geocodes the position itself) and which phone you write you are on — never the house number; Android geocodes the position itself)
from (model and firmware). Both are optional and switched on in and which phone you write from (model and firmware). The place follows you:
**Settings → Context**. 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 - **Updates itself**: the app checks the published version on launch and
offers the update right there (download + system installer). The check can 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 be turned off in **Settings → App**, or with "don't check again" in the
@ -35,7 +36,7 @@ Download the APK from the releases page:
**https://git.n1ko.dev/Niko/nikogpt_android/releases** **https://git.n1ko.dev/Niko/nikogpt_android/releases**
Latest build: 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 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 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" applicationId = "dev.n1ko.nikogpt"
minSdk = 29 // Android 10 minSdk = 29 // Android 10
targetSdk = 35 targetSdk = 35
versionCode = 13 versionCode = 14
versionName = "1.4.0" versionName = "1.4.1"
} }
signingConfigs { signingConfigs {

View file

@ -10,6 +10,7 @@ import android.content.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.location.Geocoder import android.location.Geocoder
import android.location.Location import android.location.Location
import android.location.LocationListener
import android.location.LocationManager import android.location.LocationManager
import android.net.Uri import android.net.Uri
import android.net.http.SslError import android.net.http.SslError
@ -169,11 +170,25 @@ class MainActivity : AppCompatActivity() {
// Runtime location permission, bridged to the WebView: the page asks // Runtime location permission, bridged to the WebView: the page asks
// where the person is, the system asks whether it may know. // where the person is, the system asks whether it may know.
private var pendingLocation = false 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> = private val locationPermission: ActivityResultLauncher<String> =
registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
if (!pendingLocation) return@registerForActivityResult if (!pendingLocation) return@registerForActivityResult
pendingLocation = false pendingLocation = false
if (granted) requestFix() else deliverLocationError() if (!granted) {
deliverLocationError()
return@registerForActivityResult
}
if (tracking) startTracking() else requestFix()
} }
@SuppressLint("SetJavaScriptEnabled") @SuppressLint("SetJavaScriptEnabled")
@ -540,6 +555,39 @@ class MainActivity : AppCompatActivity() {
fun requestLocation() { fun requestLocation() {
runOnUiThread { startLocation() } 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. // deviceInfoJSON is the phone description the personal context carries.
@ -572,13 +620,7 @@ class MainActivity : AppCompatActivity() {
// startLocation runs the permission dance and then asks for a fix. The // startLocation runs the permission dance and then asks for a fix. The
// answer (or the error) goes back into the page. // answer (or the error) goes back into the page.
private fun startLocation() { private fun startLocation() {
if (ContextCompat.checkSelfPermission( if (hasLocationPermission()) {
this, Manifest.permission.ACCESS_FINE_LOCATION,
) == PackageManager.PERMISSION_GRANTED ||
ContextCompat.checkSelfPermission(
this, Manifest.permission.ACCESS_COARSE_LOCATION,
) == PackageManager.PERMISSION_GRANTED
) {
requestFix() requestFix()
return return
} }
@ -586,6 +628,37 @@ class MainActivity : AppCompatActivity() {
locationPermission.launch(Manifest.permission.ACCESS_FINE_LOCATION) 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 // requestFix takes a position from the system location manager. GPS is
// preferred (it is precise enough for a street); the network provider 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. // the fallback, and the last known position answers when both are slow.
@ -635,7 +708,7 @@ class MainActivity : AppCompatActivity() {
// address, the coordinates and the phone details to the page. // address, the coordinates and the phone details to the page.
private fun deliverLocation(location: Location) { private fun deliverLocation(location: Location) {
Thread { Thread {
val address = geocode(location) val address = streetAddress(location)
val payload = deviceInfoJSON() val payload = deviceInfoJSON()
.put("lat", location.latitude) .put("lat", location.latitude)
.put("lon", location.longitude) .put("lon", location.longitude)
@ -645,15 +718,39 @@ class MainActivity : AppCompatActivity() {
}.start() }.start()
} }
// geocode turns a position into a street address with the system service. // streetAddress resolves the street the person is on. The address is
// It returns null when the device has no geocoder backend. // 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? { private fun geocode(location: Location): String? {
return try { return try {
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
val addresses = Geocoder(this, Locale.getDefault()).getFromLocation( val addresses = Geocoder(this, Locale.getDefault()).getFromLocation(
location.latitude, location.longitude, 1, location.latitude, location.longitude, 1,
) )
addresses?.firstOrNull()?.getAddressLine(0) val a = addresses?.firstOrNull() ?: return null
listOfNotNull(a.thoroughfare, a.locality ?: a.subAdminArea, a.countryName)
.filter { it.isNotBlank() }
.joinToString(", ")
.ifBlank { null }
} catch (_: Exception) { } catch (_: Exception) {
null null
} }
@ -920,6 +1017,14 @@ class MainActivity : AppCompatActivity() {
// session cookie can be lost when Android kills the process, so the // session cookie can be lost when Android kills the process, so the
// user would have to log in again after every restart. // user would have to log in again after every restart.
CookieManager.getInstance().flush() 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) { override fun onSaveInstanceState(outState: Bundle) {