|
|
|
|
@ -8,8 +8,17 @@ 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.LocationListener
|
|
|
|
|
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
|
|
|
|
|
@ -26,6 +35,8 @@ import android.webkit.WebResourceRequest
|
|
|
|
|
import android.webkit.WebSettings
|
|
|
|
|
import android.webkit.WebView
|
|
|
|
|
import android.webkit.WebViewClient
|
|
|
|
|
import java.net.HttpURLConnection
|
|
|
|
|
import java.net.URL
|
|
|
|
|
import android.widget.Button
|
|
|
|
|
import android.widget.LinearLayout
|
|
|
|
|
import android.widget.ProgressBar
|
|
|
|
|
@ -54,7 +65,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.
|
|
|
|
|
@ -80,10 +93,21 @@ class MainActivity : AppCompatActivity() {
|
|
|
|
|
|
|
|
|
|
// Shared text ("Share to NikoGPT") waiting for the page to load.
|
|
|
|
|
private var pendingShare: String? = null
|
|
|
|
|
// Whether the chat's inner scroll container is at the top; pull-to-refresh
|
|
|
|
|
// only fires there (the WebView itself never scrolls).
|
|
|
|
|
// Shared files (any type, one or many) waiting for the page to load.
|
|
|
|
|
private val pendingFiles = mutableListOf<SharedFile>()
|
|
|
|
|
private var sharing = false
|
|
|
|
|
// The web chat accepts uploads up to 16 MB per file; keep some room for
|
|
|
|
|
// several files in one share.
|
|
|
|
|
private val maxShareBytes = 16L * 1024 * 1024
|
|
|
|
|
private val maxShareTotal = 32L * 1024 * 1024
|
|
|
|
|
// Whether the web app currently allows a pull-to-refresh gesture. It
|
|
|
|
|
// reports this on every scroll and whenever an overlay opens or closes
|
|
|
|
|
// (window.NikoGPTAndroid.setAtTop), because the WebView itself never
|
|
|
|
|
// scrolls — the messages list, a presentation viewer, dialogs … all scroll
|
|
|
|
|
// inside the page. Starts false: until the page explicitly says "at the
|
|
|
|
|
// top of the chat", a pull must not reload anything.
|
|
|
|
|
@Volatile
|
|
|
|
|
private var webAtTop = true
|
|
|
|
|
private var webAtTop = false
|
|
|
|
|
// Where the camera writes the picture before it reaches the web app.
|
|
|
|
|
private var pendingCameraUri: Uri? = null
|
|
|
|
|
|
|
|
|
|
@ -143,6 +167,30 @@ 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
|
|
|
|
|
// 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> =
|
|
|
|
|
registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
|
|
|
|
|
if (!pendingLocation) return@registerForActivityResult
|
|
|
|
|
pendingLocation = false
|
|
|
|
|
if (!granted) {
|
|
|
|
|
deliverLocationError()
|
|
|
|
|
return@registerForActivityResult
|
|
|
|
|
}
|
|
|
|
|
if (tracking) startTracking() else requestFix()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@SuppressLint("SetJavaScriptEnabled")
|
|
|
|
|
override fun onCreate(savedInstanceState: Bundle?) {
|
|
|
|
|
super.onCreate(savedInstanceState)
|
|
|
|
|
@ -230,6 +278,7 @@ class MainActivity : AppCompatActivity() {
|
|
|
|
|
// Persist cookies (the session) right after the page settles.
|
|
|
|
|
CookieManager.getInstance().flush()
|
|
|
|
|
maybeDeliverShare()
|
|
|
|
|
checkForUpdates()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@ -291,8 +340,11 @@ class MainActivity : AppCompatActivity() {
|
|
|
|
|
refresh.setOnRefreshListener { webView.reload() }
|
|
|
|
|
// Pull-to-refresh only when the chat list is really at the top; a
|
|
|
|
|
// bigger trigger distance keeps normal scrolling from reloading.
|
|
|
|
|
refresh.setOnChildScrollUpCallback { _, _ -> !webAtTop }
|
|
|
|
|
refresh.setOnChildScrollUpCallback { _, _ -> !webAtTop || webView.canScrollVertically(-1) }
|
|
|
|
|
refresh.setDistanceToTriggerSync((80 * resources.displayMetrics.density).toInt())
|
|
|
|
|
// No native overscroll glow/bounce on the WebView either: the page
|
|
|
|
|
// owns every scroller.
|
|
|
|
|
webView.overScrollMode = View.OVER_SCROLL_NEVER
|
|
|
|
|
|
|
|
|
|
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
|
|
|
|
|
override fun handleOnBackPressed() {
|
|
|
|
|
@ -329,23 +381,120 @@ class MainActivity : AppCompatActivity() {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// handleShareIntent catches "Share to NikoGPT" from other apps.
|
|
|
|
|
// One file shared into the app, waiting for the page to be ready.
|
|
|
|
|
private data class SharedFile(val uri: Uri, val name: String, val mime: String, val size: Long)
|
|
|
|
|
|
|
|
|
|
// Update checks: on by default, switchable from the web settings (the
|
|
|
|
|
// bridge below) or the update dialog ("don't check again").
|
|
|
|
|
private val prefs by lazy { getSharedPreferences("nikogpt", Context.MODE_PRIVATE) }
|
|
|
|
|
private var updateChecked = false
|
|
|
|
|
|
|
|
|
|
// handleShareIntent catches "Share to NikoGPT" from other apps: text goes
|
|
|
|
|
// into the composer, files of any type into the attachments.
|
|
|
|
|
private fun handleShareIntent(intent: Intent?) {
|
|
|
|
|
if (intent == null || intent.action != Intent.ACTION_SEND) return
|
|
|
|
|
if (intent.type?.startsWith("text/") != true) return
|
|
|
|
|
if (intent == null) return
|
|
|
|
|
val action = intent.action
|
|
|
|
|
if (action != Intent.ACTION_SEND && action != Intent.ACTION_SEND_MULTIPLE) return
|
|
|
|
|
val text = intent.getStringExtra(Intent.EXTRA_TEXT)
|
|
|
|
|
if (!text.isNullOrBlank()) {
|
|
|
|
|
if (!text.isNullOrBlank() && intent.type?.startsWith("text/") == true) {
|
|
|
|
|
pendingShare = text
|
|
|
|
|
maybeDeliverShare()
|
|
|
|
|
}
|
|
|
|
|
val uris = mutableListOf<Uri>()
|
|
|
|
|
@Suppress("DEPRECATION")
|
|
|
|
|
when (action) {
|
|
|
|
|
Intent.ACTION_SEND -> intent.getParcelableExtra<Uri>(Intent.EXTRA_STREAM)?.let { uris.add(it) }
|
|
|
|
|
else -> intent.getParcelableArrayListExtra<Uri>(Intent.EXTRA_STREAM)?.let { uris.addAll(it) }
|
|
|
|
|
}
|
|
|
|
|
var total = pendingFiles.sumOf { it.size }
|
|
|
|
|
for (uri in uris) {
|
|
|
|
|
if (uri.scheme == null) continue
|
|
|
|
|
val meta = sharedFileMeta(uri) ?: continue
|
|
|
|
|
if (meta.size > maxShareBytes || total + meta.size > maxShareTotal) {
|
|
|
|
|
Toast.makeText(this, getString(R.string.share_too_big, meta.name), Toast.LENGTH_LONG).show()
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
total += meta.size
|
|
|
|
|
pendingFiles.add(meta)
|
|
|
|
|
}
|
|
|
|
|
if (pendingShare != null || pendingFiles.isNotEmpty()) maybeDeliverShare()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// maybeDeliverShare fills the composer once the page is ready.
|
|
|
|
|
// sharedFileMeta resolves the display name, MIME type and size of a shared
|
|
|
|
|
// content URI (providers may not report a size; then it stays 0 and the
|
|
|
|
|
// read loop stops at the cap).
|
|
|
|
|
private fun sharedFileMeta(uri: Uri): SharedFile? {
|
|
|
|
|
var name = ""
|
|
|
|
|
var size = -1L
|
|
|
|
|
try {
|
|
|
|
|
contentResolver.query(uri, null, null, null, null)?.use { c ->
|
|
|
|
|
if (c.moveToFirst()) {
|
|
|
|
|
val nameCol = c.getColumnIndex(OpenableColumns.DISPLAY_NAME)
|
|
|
|
|
if (nameCol >= 0) name = c.getString(nameCol) ?: ""
|
|
|
|
|
val sizeCol = c.getColumnIndex(OpenableColumns.SIZE)
|
|
|
|
|
if (sizeCol >= 0 && !c.isNull(sizeCol)) size = c.getLong(sizeCol)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} catch (_: Exception) {
|
|
|
|
|
}
|
|
|
|
|
if (name.isBlank()) name = uri.lastPathSegment?.substringAfterLast('/') ?: "file"
|
|
|
|
|
if (size < 0) {
|
|
|
|
|
size = try {
|
|
|
|
|
contentResolver.openAssetFileDescriptor(uri, "r")?.use { it.length } ?: -1L
|
|
|
|
|
} catch (_: Exception) {
|
|
|
|
|
-1L
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (size < 0) size = 0
|
|
|
|
|
val mime = contentResolver.getType(uri) ?: "application/octet-stream"
|
|
|
|
|
return SharedFile(uri, name, mime, size)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// maybeDeliverShare fills the composer once the page is ready: text as-is,
|
|
|
|
|
// then every pending file as base64 chunks (start → chunks → end), so a
|
|
|
|
|
// whole photo never travels as one giant JS string.
|
|
|
|
|
private fun maybeDeliverShare() {
|
|
|
|
|
val text = pendingShare ?: return
|
|
|
|
|
if (webView.url == null) return
|
|
|
|
|
pendingShare = null
|
|
|
|
|
webView.evaluateJavascript("window.nikoShare && window.nikoShare(" + org.json.JSONObject.quote(text) + ")", null)
|
|
|
|
|
pendingShare?.let { text ->
|
|
|
|
|
pendingShare = null
|
|
|
|
|
webView.evaluateJavascript("window.nikoShare && window.nikoShare(" + org.json.JSONObject.quote(text) + ")", null)
|
|
|
|
|
}
|
|
|
|
|
if (sharing || pendingFiles.isEmpty()) return
|
|
|
|
|
sharing = true
|
|
|
|
|
val batch = pendingFiles.toList()
|
|
|
|
|
pendingFiles.clear()
|
|
|
|
|
Thread {
|
|
|
|
|
for (file in batch) sendSharedFile(file)
|
|
|
|
|
runOnUiThread { sharing = false }
|
|
|
|
|
}.start()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// sendSharedFile streams one shared file into the page. The JS calls are
|
|
|
|
|
// posted to the main thread one by one, so the browser queue keeps the
|
|
|
|
|
// start → chunks → end order.
|
|
|
|
|
private fun sendSharedFile(file: SharedFile) {
|
|
|
|
|
try {
|
|
|
|
|
contentResolver.openInputStream(file.uri)?.use { input ->
|
|
|
|
|
val start = "window.nikoShareFileStart && window.nikoShareFileStart(" +
|
|
|
|
|
org.json.JSONObject.quote(file.name) + ", " +
|
|
|
|
|
org.json.JSONObject.quote(file.mime) + ", " + file.size + ")"
|
|
|
|
|
runOnUiThread { webView.evaluateJavascript(start, null) }
|
|
|
|
|
val buf = ByteArray(192 * 1024)
|
|
|
|
|
var sent = 0L
|
|
|
|
|
while (sent < maxShareBytes) {
|
|
|
|
|
val n = input.read(buf)
|
|
|
|
|
if (n <= 0) break
|
|
|
|
|
val chunk = org.json.JSONObject.quote(
|
|
|
|
|
Base64.encodeToString(buf, 0, n, Base64.NO_WRAP)
|
|
|
|
|
)
|
|
|
|
|
runOnUiThread {
|
|
|
|
|
webView.evaluateJavascript("window.nikoShareFileChunk && window.nikoShareFileChunk($chunk)", null)
|
|
|
|
|
}
|
|
|
|
|
sent += n
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} catch (_: Exception) {
|
|
|
|
|
}
|
|
|
|
|
runOnUiThread { webView.evaluateJavascript("window.nikoShareFileEnd && window.nikoShareFileEnd()", null) }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// handleAuthIntent catches the auth deep link (nikogpt:// or App Link) and
|
|
|
|
|
@ -376,6 +525,249 @@ class MainActivity : AppCompatActivity() {
|
|
|
|
|
fun setAtTop(atTop: Boolean) {
|
|
|
|
|
webAtTop = atTop
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// The settings screen shows an "updates" switch; the preference lives
|
|
|
|
|
// here because it is per device, not per account.
|
|
|
|
|
@JavascriptInterface
|
|
|
|
|
fun updateChecksEnabled(): Boolean = prefs.getBoolean("update_checks", true)
|
|
|
|
|
|
|
|
|
|
@JavascriptInterface
|
|
|
|
|
fun setUpdateChecks(enabled: Boolean) {
|
|
|
|
|
prefs.edit().putBoolean("update_checks", enabled).apply()
|
|
|
|
|
if (enabled) {
|
|
|
|
|
// Switched on: check right away instead of on the next launch.
|
|
|
|
|
runOnUiThread {
|
|
|
|
|
updateChecked = false
|
|
|
|
|
checkForUpdates()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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() }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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.
|
|
|
|
|
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 (hasLocationPermission()) {
|
|
|
|
|
requestFix()
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
pendingLocation = true
|
|
|
|
|
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.
|
|
|
|
|
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 = streetAddress(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()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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,
|
|
|
|
|
)
|
|
|
|
|
val a = addresses?.firstOrNull() ?: return null
|
|
|
|
|
listOfNotNull(a.thoroughfare, a.locality ?: a.subAdminArea, a.countryName)
|
|
|
|
|
.filter { it.isNotBlank() }
|
|
|
|
|
.joinToString(", ")
|
|
|
|
|
.ifBlank { null }
|
|
|
|
|
} 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
|
|
|
|
|
@ -466,6 +858,132 @@ class MainActivity : AppCompatActivity() {
|
|
|
|
|
handleShareIntent(intent)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// checkForUpdates asks the bot for the published app version once per
|
|
|
|
|
// launch (and only when the check is enabled) and offers the download.
|
|
|
|
|
// It uses the WebView's session cookie, so it runs after the page settled.
|
|
|
|
|
private fun checkForUpdates() {
|
|
|
|
|
if (updateChecked) return
|
|
|
|
|
if (!prefs.getBoolean("update_checks", true)) return
|
|
|
|
|
if (webView.url == null) return
|
|
|
|
|
val cookie = CookieManager.getInstance().getCookie(START_URL)
|
|
|
|
|
if (cookie.isNullOrBlank()) return
|
|
|
|
|
updateChecked = true
|
|
|
|
|
Thread {
|
|
|
|
|
val info = fetchAppInfo(cookie) ?: return@Thread
|
|
|
|
|
val remote = info.optString("android_version").trim()
|
|
|
|
|
val apk = info.optString("apk_url").trim()
|
|
|
|
|
if (remote.isEmpty() || apk.isEmpty()) return@Thread
|
|
|
|
|
if (!versionNewer(remote, BuildConfig.VERSION_NAME)) return@Thread
|
|
|
|
|
runOnUiThread { showUpdateDialog(remote, apk) }
|
|
|
|
|
}.start()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// fetchAppInfo reads /api/app with the signed-in session.
|
|
|
|
|
private fun fetchAppInfo(cookie: String): org.json.JSONObject? {
|
|
|
|
|
return try {
|
|
|
|
|
val conn = (URL("https://$HOST/api/app").openConnection() as HttpURLConnection).apply {
|
|
|
|
|
requestMethod = "GET"
|
|
|
|
|
setRequestProperty("Cookie", cookie)
|
|
|
|
|
connectTimeout = 15000
|
|
|
|
|
readTimeout = 15000
|
|
|
|
|
}
|
|
|
|
|
val body = conn.inputStream.bufferedReader().use { it.readText() }
|
|
|
|
|
conn.disconnect()
|
|
|
|
|
org.json.JSONObject(body)
|
|
|
|
|
} catch (_: Exception) {
|
|
|
|
|
null
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// versionNewer reports whether "remote" is a higher dotted version.
|
|
|
|
|
private fun versionNewer(remote: String, local: String): Boolean {
|
|
|
|
|
fun parts(v: String) = v.split('.').map { it.takeWhile(Char::isDigit).toIntOrNull() ?: 0 }
|
|
|
|
|
val a = parts(remote)
|
|
|
|
|
val b = parts(local)
|
|
|
|
|
for (i in 0 until maxOf(a.size, b.size)) {
|
|
|
|
|
val x = a.getOrElse(i) { 0 }
|
|
|
|
|
val y = b.getOrElse(i) { 0 }
|
|
|
|
|
if (x != y) return x > y
|
|
|
|
|
}
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private fun showUpdateDialog(version: String, apkUrl: String) {
|
|
|
|
|
if (isFinishing || isDestroyed) return
|
|
|
|
|
AlertDialog.Builder(this)
|
|
|
|
|
.setTitle(R.string.update_title)
|
|
|
|
|
.setMessage(getString(R.string.update_message, version, BuildConfig.VERSION_NAME))
|
|
|
|
|
.setPositiveButton(R.string.update_now) { _, _ -> startUpdateDownload(apkUrl) }
|
|
|
|
|
.setNegativeButton(R.string.update_later, null)
|
|
|
|
|
.setNeutralButton(R.string.update_never) { _, _ ->
|
|
|
|
|
prefs.edit().putBoolean("update_checks", false).apply()
|
|
|
|
|
}
|
|
|
|
|
.show()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// startUpdateDownload fetches the APK into the cache and hands it to the
|
|
|
|
|
// system installer. Without the "install unknown apps" grant it walks the
|
|
|
|
|
// user through the settings once; on any failure the browser opens the
|
|
|
|
|
// APK link instead.
|
|
|
|
|
private fun startUpdateDownload(apkUrl: String) {
|
|
|
|
|
if (!packageManager.canRequestPackageInstalls()) {
|
|
|
|
|
Toast.makeText(this, R.string.update_install_hint, Toast.LENGTH_LONG).show()
|
|
|
|
|
try {
|
|
|
|
|
startActivity(
|
|
|
|
|
Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES)
|
|
|
|
|
.setData(Uri.parse("package:$packageName"))
|
|
|
|
|
)
|
|
|
|
|
} catch (_: Exception) {
|
|
|
|
|
}
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
val progress = AlertDialog.Builder(this)
|
|
|
|
|
.setTitle(R.string.update_downloading)
|
|
|
|
|
.setCancelable(false)
|
|
|
|
|
.create()
|
|
|
|
|
progress.show()
|
|
|
|
|
Thread {
|
|
|
|
|
val target = java.io.File(cacheDir, "updates").apply { mkdirs() }
|
|
|
|
|
val apkFile = java.io.File(target, "nikogpt-update.apk")
|
|
|
|
|
var ok = false
|
|
|
|
|
try {
|
|
|
|
|
val conn = (URL(apkUrl).openConnection() as HttpURLConnection).apply {
|
|
|
|
|
requestMethod = "GET"
|
|
|
|
|
connectTimeout = 20000
|
|
|
|
|
readTimeout = 60000
|
|
|
|
|
}
|
|
|
|
|
conn.inputStream.use { input ->
|
|
|
|
|
apkFile.outputStream().use { output -> input.copyTo(output) }
|
|
|
|
|
}
|
|
|
|
|
conn.disconnect()
|
|
|
|
|
ok = apkFile.length() > 0
|
|
|
|
|
} catch (_: Exception) {
|
|
|
|
|
ok = false
|
|
|
|
|
}
|
|
|
|
|
runOnUiThread {
|
|
|
|
|
progress.dismiss()
|
|
|
|
|
if (ok) installApk(apkFile, apkUrl) else {
|
|
|
|
|
Toast.makeText(this, R.string.update_failed, Toast.LENGTH_LONG).show()
|
|
|
|
|
openExternally(Uri.parse(apkUrl))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}.start()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private fun installApk(file: java.io.File, apkUrl: String) {
|
|
|
|
|
try {
|
|
|
|
|
val uri = FileProvider.getUriForFile(this, "dev.n1ko.nikogpt.files", file)
|
|
|
|
|
startActivity(
|
|
|
|
|
Intent(Intent.ACTION_VIEW)
|
|
|
|
|
.setDataAndType(uri, "application/vnd.android.package-archive")
|
|
|
|
|
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
|
|
|
|
|
)
|
|
|
|
|
} catch (e: Exception) {
|
|
|
|
|
Toast.makeText(this, R.string.update_failed, Toast.LENGTH_LONG).show()
|
|
|
|
|
openExternally(Uri.parse(apkUrl))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private fun openExternally(uri: Uri) {
|
|
|
|
|
try {
|
|
|
|
|
startActivity(Intent(Intent.ACTION_VIEW, uri).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
|
|
|
|
|
@ -499,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) {
|
|
|
|
|
|