From 98d5b89cd2b3a94fec803e938068dc4b2823a05e Mon Sep 17 00:00:00 2001 From: Niko Marmeladkov Date: Tue, 20 Oct 2026 08:07:34 +0300 Subject: [PATCH] =?UTF-8?q?1.4.0:=20personal=20context=20=E2=80=94=20locat?= =?UTF-8?q?ion=20and=20device=20info?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 4 + app/build.gradle.kts | 4 +- app/src/main/AndroidManifest.xml | 4 + .../java/dev/n1ko/nikogpt/MainActivity.kt | 161 ++++++++++++++++++ 4 files changed, 171 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 0516204..e66e175 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 73ef813..f63542f 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 = 12 - versionName = "1.3.1" + versionCode = 13 + versionName = "1.4.0" } signingConfigs { diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 3c4875d..d403f83 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -6,6 +6,10 @@ + + + = + 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