feat: Add geolocation and haptics plugins (#1599)

* init geolocation plugin

* ios impl (w/o js api)

* generate ts api

* use newer tauri commit

* add temporary postinstall

* include src in files

* guest-js

* just ship dist-js for now

* fix watcher

* fix android compile error

* fix android build for real

* fix heading type

* initial getCurrentPosition android impl (wip)

* prevent panics if errors (strings) are sent over the channel

* Add android watchPosition implementation

* init haptics plugin (android)

* ios and new apis (ANDROID IS LIKELY BROKEN - MAY NOT EVEN COMPILE)

* use tauri-specta that accounts for raw fn arg idents

* add complete android support (it's not working great due to random soft-/hardware support)

* fix(haptics): Fix the NotificationFeedbackType::Success and Version (#1)

* Fix success feedback and version

* Apply suggestions from code review

* Update package.json

---------

Co-authored-by: Fabian-Lars <118197967+FabianLars-crabnebula@users.noreply.github.com>

* android: improve permission callback handling

* keep track of ongoing perms requests

* rebuild

* license headers

* rm sqlite feat

* fmt

* what diff u talkin bout?

* ignore dist-js again

* fix audits

* dedupe api.js

* clippy

* changefiles

* readmes

* clean up todos

* rm dsstore

* rm wrong feats

* mirror

* covector

* rebuild

* ios requires the wry feature

* lint

* update lock

---------

Co-authored-by: fabianlars <fabianlars@fabianlars.de>
Co-authored-by: Brendan Allan <brendonovich@outlook.com>
Co-authored-by: Naman Garg <155433377+naman-crabnebula@users.noreply.github.com>
Co-authored-by: Lucas Nogueira <lucas@crabnebula.dev>
This commit is contained in:
Fabian-Lars
2024-08-02 15:45:47 +02:00
committed by GitHub
parent 34df132fb1
commit 9606089b2a
102 changed files with 4896 additions and 52 deletions
@@ -0,0 +1,28 @@
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
package app.tauri.geolocation
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.Assert.*
/**
* Instrumented test, which will execute on an Android device.
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
@RunWith(AndroidJUnit4::class)
class ExampleInstrumentedTest {
@Test
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("app.tauri.geolocation", appContext.packageName)
}
}
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<!-- <uses-feature android:name="android.hardware.gps" android:required="true" /> -->
</manifest>
@@ -0,0 +1,148 @@
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
package app.tauri.geolocation
import android.annotation.SuppressLint
import android.content.Context
import android.location.Location
import android.location.LocationManager
import android.os.SystemClock
import androidx.core.location.LocationManagerCompat
import app.tauri.Logger
import com.google.android.gms.common.ConnectionResult
import com.google.android.gms.common.GoogleApiAvailability
import com.google.android.gms.location.FusedLocationProviderClient
import com.google.android.gms.location.LocationCallback
import com.google.android.gms.location.LocationRequest
import com.google.android.gms.location.LocationResult
import com.google.android.gms.location.LocationServices
import com.google.android.gms.location.Priority
public class Geolocation(private val context: Context) {
private var fusedLocationClient: FusedLocationProviderClient? = null
private var locationCallback: LocationCallback? = null
fun isLocationServicesEnabled(): Boolean {
val lm = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
return LocationManagerCompat.isLocationEnabled(lm)
}
@SuppressWarnings("MissingPermission")
fun sendLocation(enableHighAccuracy: Boolean, successCallback: (location: Location) -> Unit, errorCallback: (error: String) -> Unit) {
val resultCode = GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(context);
if (resultCode == ConnectionResult.SUCCESS) {
val lm = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
if (this.isLocationServicesEnabled()) {
var networkEnabled = false
try {
networkEnabled = lm.isProviderEnabled(LocationManager.NETWORK_PROVIDER)
} catch (_: Exception) {
Logger.error("isProviderEnabled failed")
}
val lowPrio = if (networkEnabled) Priority.PRIORITY_BALANCED_POWER_ACCURACY else Priority.PRIORITY_LOW_POWER
val prio = if (enableHighAccuracy) Priority.PRIORITY_HIGH_ACCURACY else lowPrio
Logger.error(prio.toString())
LocationServices
.getFusedLocationProviderClient(context)
.getCurrentLocation(prio, null)
.addOnFailureListener { e -> e.message?.let { errorCallback(it) } }
.addOnSuccessListener { location ->
if (location == null) {
errorCallback("Location unavailable.")
} else {
successCallback(location)
}
}
} else {
errorCallback("Location disabled.")
}
} else {
errorCallback("Google Play Services unavailable.")
}
}
@SuppressLint("MissingPermission")
fun requestLocationUpdates(enableHighAccuracy: Boolean, timeout: Long, successCallback: (location: Location) -> Unit, errorCallback: (error: String) -> Unit) {
val resultCode = GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(context);
if (resultCode == ConnectionResult.SUCCESS) {
clearLocationUpdates()
fusedLocationClient = LocationServices.getFusedLocationProviderClient(context)
val lm = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
if (this.isLocationServicesEnabled()) {
var networkEnabled = false
try {
networkEnabled = lm.isProviderEnabled(LocationManager.NETWORK_PROVIDER)
} catch (_: Exception) {
Logger.error("isProviderEnabled failed")
}
val lowPrio = if (networkEnabled) Priority.PRIORITY_BALANCED_POWER_ACCURACY else Priority.PRIORITY_LOW_POWER
val prio = if (enableHighAccuracy) Priority.PRIORITY_HIGH_ACCURACY else lowPrio
Logger.error(prio.toString())
val locationRequest = LocationRequest.Builder(10000)
.setMaxUpdateDelayMillis(timeout)
.setMinUpdateIntervalMillis(5000)
.setPriority(prio)
.build()
locationCallback =
object : LocationCallback() {
override fun onLocationResult(locationResult: LocationResult) {
val lastLocation = locationResult.lastLocation
if (lastLocation == null) {
errorCallback("Location unavailable.")
} else {
successCallback(lastLocation)
}
}
}
fusedLocationClient?.requestLocationUpdates(locationRequest, locationCallback!!, null)
} else {
errorCallback("Location disabled.")
}
} else {
errorCallback("Google Play Services not available.")
}
}
fun clearLocationUpdates() {
if (locationCallback != null) {
fusedLocationClient?.removeLocationUpdates(locationCallback!!)
locationCallback = null
}
}
@SuppressLint("MissingPermission")
fun getLastLocation(maximumAge: Long): Location? {
var lastLoc: Location? = null
val lm = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
for (provider in lm.allProviders) {
val tmpLoc = lm.getLastKnownLocation(provider)
if (tmpLoc != null) {
val locationAge = SystemClock.elapsedRealtimeNanos() - tmpLoc.elapsedRealtimeNanos
val maxAgeNano = maximumAge * 1000000L
if (locationAge <= maxAgeNano && (lastLoc == null || lastLoc.elapsedRealtimeNanos > tmpLoc.elapsedRealtimeNanos)) {
lastLoc = tmpLoc
}
}
}
return lastLoc
}
}
@@ -0,0 +1,248 @@
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
package app.tauri.geolocation
import android.Manifest
import android.app.Activity
import android.location.Location
import android.os.Build
import android.webkit.WebView
import app.tauri.Logger
import app.tauri.PermissionState
import app.tauri.annotation.Command
import app.tauri.annotation.InvokeArg
import app.tauri.annotation.Permission
import app.tauri.annotation.PermissionCallback
import app.tauri.annotation.TauriPlugin
import app.tauri.plugin.Channel
import app.tauri.plugin.Invoke
import app.tauri.plugin.JSObject
import app.tauri.plugin.Plugin
@InvokeArg
class PositionOptions {
var enableHighAccuracy: Boolean = false
var maximumAge: Long = 0
var timeout: Long = 10000
}
@InvokeArg
class WatchArgs {
var options: PositionOptions = PositionOptions()
lateinit var channel: Channel
}
@InvokeArg
class ClearWatchArgs {
var channelId: Long = 0
}
// TODO: Plugin does not ask user to enable google location services (like gmaps does)
private const val ALIAS_LOCATION: String = "location"
private const val ALIAS_COARSE_LOCATION: String = "coarseLocation"
@TauriPlugin(
permissions = [
Permission(strings = [
Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.ACCESS_COARSE_LOCATION
],
alias = ALIAS_LOCATION
),
Permission(strings = [
Manifest.permission.ACCESS_COARSE_LOCATION
],
alias = ALIAS_COARSE_LOCATION
)
]
)
class GeolocationPlugin(private val activity: Activity): Plugin(activity) {
private lateinit var implementation: Geolocation// = Geolocation(activity.applicationContext)
private var watchers = hashMapOf<Long, Invoke>()
// If multiple permissions get requested in quick succession not all callbacks will be fired,
// So we'll store all requests ourselves instead of using the callback argument.
private var positionRequests = mutableListOf<Invoke>()
private var watchRequests = mutableListOf<Invoke>()
// If getCurrentPosition or watchPosition are called before a prior call is done requesting permission,
// the callback will be called with denied for the prior call(s) so we keep track of them to make sure
// to only run the logic on the last request.
// TODO: Find a better solution after switching to explicit requestPermissions call - likely needs changes in Tauri
private var ongoingPermissionRequests = 0;
override fun load(webView: WebView) {
super.load(webView)
implementation = Geolocation(activity.applicationContext)
}
override fun onPause() {
super.onPause()
// Clear all location updates on pause to avoid possible background location calls
implementation.clearLocationUpdates()
}
override fun onResume() {
super.onResume()
for (watcher in watchers.values) {
startWatch(watcher)
}
}
@Command
override fun checkPermissions(invoke: Invoke) {
if (implementation.isLocationServicesEnabled()) {
super.checkPermissions(invoke)
} else {
invoke.reject("Location services are disabled.")
}
}
@Command
override fun requestPermissions(invoke: Invoke) {
if (implementation.isLocationServicesEnabled()) {
super.requestPermissions(invoke)
} else {
invoke.reject("Location services are disabled.")
}
}
@Command
fun getCurrentPosition(invoke: Invoke) {
val args = invoke.parseArgs(PositionOptions::class.java)
val alias = getAlias(args.enableHighAccuracy)
if (getPermissionState(alias) != PermissionState.GRANTED) {
Logger.error("NOT GRANTED");
this.positionRequests.add(invoke)
this.ongoingPermissionRequests += 1
requestPermissionForAlias(alias, invoke, "positionPermissionCallback")
} else {
Logger.error("GRANTED");
getPosition(invoke, args)
}
}
@PermissionCallback
private fun positionPermissionCallback(invoke: Invoke) {
Logger.error("positionPermissionCallback - ongoingRequests: " + this.ongoingPermissionRequests.toString())
this.ongoingPermissionRequests -= 1
if (this.ongoingPermissionRequests > 0) {
return
}
val pRequests = this.positionRequests.toTypedArray()
val wRequests = this.watchRequests.toTypedArray()
this.positionRequests.clear()
this.watchRequests.clear()
// TODO: capacitor only checks for coarse here
val permissionGranted = getPermissionState(ALIAS_COARSE_LOCATION) == PermissionState.GRANTED;
Logger.error("positionPermissionCallback - permissionGranted: $permissionGranted");
for (inv in pRequests) {
if (permissionGranted) {
val args = inv.parseArgs(PositionOptions::class.java)
implementation.sendLocation(args.enableHighAccuracy,
{ location -> inv.resolve(convertLocation(location)) },
{ error -> inv.reject(error) })
} else {
inv.reject("Location permission was denied.")
}
}
for (inv in wRequests) {
if (permissionGranted) {
startWatch(invoke)
} else {
inv.reject("Location permission was denied.")
}
}
}
@Command
fun watchPosition(invoke: Invoke) {
val args = invoke.parseArgs(WatchArgs::class.java)
val alias = getAlias(args.options.enableHighAccuracy)
if (getPermissionState(alias) != PermissionState.GRANTED) {
this.watchRequests.add(invoke)
this.ongoingPermissionRequests += 1
requestPermissionForAlias(alias, invoke, "positionPermissionCallback")
} else {
startWatch(invoke)
}
}
private fun startWatch(invoke: Invoke) {
val args = invoke.parseArgs(WatchArgs::class.java)
implementation.requestLocationUpdates(
args.options.enableHighAccuracy,
args.options.timeout,
{ location -> args.channel.send(convertLocation(location)) },
{ error -> args.channel.sendObject(error) })
watchers[args.channel.id] = invoke
}
@Command
fun clearWatch(invoke: Invoke) {
val args = invoke.parseArgs(ClearWatchArgs::class.java)
watchers.remove(args.channelId)
if (watchers.isEmpty()) {
implementation.clearLocationUpdates()
}
invoke.resolve()
}
private fun getPosition(invoke: Invoke, options: PositionOptions) {
val location = implementation.getLastLocation(options.maximumAge)
if (location != null) {
Logger.error("getPosition location non-null")
invoke.resolve(convertLocation(location))
} else {
Logger.error("getPosition location null")
implementation.sendLocation(options.enableHighAccuracy,
{ loc -> invoke.resolve(convertLocation(loc)) },
{ error -> invoke.reject(error) })
}
}
private fun convertLocation(location: Location): JSObject {
val ret = JSObject()
val coords = JSObject()
coords.put("latitude", location.latitude)
coords.put("longitude", location.longitude)
coords.put("accuracy", location.accuracy)
coords.put("altitude", location.altitude)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
coords.put("altitudeAccuracy", location.verticalAccuracyMeters)
}
coords.put("speed", location.speed)
coords.put("heading", location.bearing)
ret.put("timestamp", location.time)
ret.put("coords", coords)
return ret
}
private fun getAlias(enableHighAccuracy: Boolean): String {
var alias = ALIAS_LOCATION;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
if (!enableHighAccuracy) {
alias = ALIAS_COARSE_LOCATION;
}
}
return alias
}
}
@@ -0,0 +1,21 @@
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
package app.tauri.geolocation
import org.junit.Test
import org.junit.Assert.*
/**
* Example local unit test, which will execute on the development machine (host).
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
class ExampleUnitTest {
@Test
fun addition_isCorrect() {
assertEquals(4, 2 + 2)
}
}