mirror of
https://github.com/tauri-apps/plugins-workspace.git
synced 2026-05-11 12:37:34 +02:00
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user