1.4.0: personal context — location and device info
Some checks failed
build / apk (push) Has been cancelled
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>
This commit is contained in:
parent
a59271522c
commit
98d5b89cd2
4 changed files with 171 additions and 2 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -8,11 +8,16 @@ 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.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 +64,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 +166,16 @@ 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
|
||||
private val locationPermission: ActivityResultLauncher<String> =
|
||||
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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue