From 0213122b42b6f14ba08096c092cfce45e1d04bc1 Mon Sep 17 00:00:00 2001 From: Niko Marmeladkov Date: Tue, 20 Oct 2026 09:06:57 +0300 Subject: [PATCH] 1.4.1: the place follows the person 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 --- README.md | 11 +- app/build.gradle.kts | 4 +- .../java/dev/n1ko/nikogpt/MainActivity.kt | 129 ++++++++++++++++-- 3 files changed, 125 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index b22ed36..b3d804e 100644 --- a/README.md +++ b/README.md @@ -11,10 +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 (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**. +- **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 @@ -35,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.4.0/nikogpt-android-1.4.0.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 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f63542f..ec75c58 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 = 13 - versionName = "1.4.0" + versionCode = 14 + versionName = "1.4.1" } signingConfigs { diff --git a/app/src/main/java/dev/n1ko/nikogpt/MainActivity.kt b/app/src/main/java/dev/n1ko/nikogpt/MainActivity.kt index 306f833..6bfe254 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.location.Geocoder import android.location.Location +import android.location.LocationListener import android.location.LocationManager import android.net.Uri import android.net.http.SslError @@ -169,11 +170,25 @@ class MainActivity : AppCompatActivity() { // 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 = registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> if (!pendingLocation) return@registerForActivityResult pendingLocation = false - if (granted) requestFix() else deliverLocationError() + if (!granted) { + deliverLocationError() + return@registerForActivityResult + } + if (tracking) startTracking() else requestFix() } @SuppressLint("SetJavaScriptEnabled") @@ -540,6 +555,39 @@ class MainActivity : AppCompatActivity() { 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. @@ -572,13 +620,7 @@ class MainActivity : AppCompatActivity() { // 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 - ) { + if (hasLocationPermission()) { requestFix() return } @@ -586,6 +628,37 @@ class MainActivity : AppCompatActivity() { 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. @@ -635,7 +708,7 @@ class MainActivity : AppCompatActivity() { // address, the coordinates and the phone details to the page. private fun deliverLocation(location: Location) { Thread { - val address = geocode(location) + val address = streetAddress(location) val payload = deviceInfoJSON() .put("lat", location.latitude) .put("lon", location.longitude) @@ -645,15 +718,39 @@ class MainActivity : AppCompatActivity() { }.start() } - // geocode turns a position into a street address with the system service. - // It returns null when the device has no geocoder backend. + // 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, ) - 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) { null } @@ -920,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) {