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