Compare commits
2 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0213122b42 | ||
|
|
552074f2cf |
3 changed files with 125 additions and 19 deletions
11
README.md
11
README.md
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue