feat(smart-app): implement complete mobile app MVP

- App.tsx: full navigation (Auth stack + Main tabs with 5 screens)
- Auth: LoginScreen, RegisterScreen, ForgotPasswordScreen
- HomeScreen: dashboard with IoT metrics, weather widget, alerts, quick actions, sensors
- MapScreen: interactive map with layer toggles (6 layers)
- MarketplaceScreen: categories (6), products (5), search
- ChatScreen: AI chat with quick prompts (4), bot responses
- ProfileScreen: user info, stats, menu (9 items), logout
- AlertsScreen: alert list with severity, acknowledge
- SensorsScreen: sensor list with type filters (6 types), search
- ZonesScreen: zone cards with stats
- SettingsScreen: language picker (FR/EN/ES/DE), privacy, about
- Stores: iotStore (sensors, zones, alerts), notificationStore, uiStore + i18n
- Hooks: useSensors, useAlerts, useNotifications, useLocation
- Components: Card, Button, LoadingSpinner, ErrorBoundary, Header
- Services: iotService, notificationService (with axios API client)
- Utils: formatters (temp, AQI, noise, dates), validators (email, password, IBAN)
- Theme: colors.ts with full design system (Blue Ocean palette)
- Ditto: fixed MongoDB connection, new JWT secrets, official gateway image
This commit is contained in:
Eric FELIXINE
2026-06-01 18:00:35 -04:00
parent 08ca495bde
commit e30ae8ed09
35578 changed files with 3703534 additions and 43 deletions

View File

@@ -0,0 +1,26 @@
apply plugin: 'com.android.library'
group = 'host.exp.exponent'
version = '17.0.1'
def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
apply from: expoModulesCorePlugin
applyKotlinExpoModulesCorePlugin()
useCoreDependencies()
useDefaultAndroidSdkVersions()
useExpoPublishing()
android {
namespace "expo.modules.location"
defaultConfig {
versionCode 29
versionName "17.0.1"
}
}
dependencies {
api 'com.google.android.gms:play-services-location:21.0.1'
api('io.nlopez.smartlocation:library:3.3.3') {
transitive = false
}
}

View File

@@ -0,0 +1,11 @@
<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" />
<application>
<service
android:name=".services.LocationTaskService"
android:exported="false"
android:foregroundServiceType="location" />
</application>
</manifest>

View File

@@ -0,0 +1,5 @@
package expo.modules.location
interface LocationActivityResultListener {
fun onResult(resultCode: Int)
}

View File

@@ -0,0 +1,63 @@
package expo.modules.location
import expo.modules.kotlin.exception.CodedException
internal class NoPermissionsModuleException :
CodedException("Permissions module is null. Are you sure all the installed Expo modules are properly linked?")
internal class NoPermissionInManifestException(permissionName: String) :
CodedException("You need to add `$permissionName` to the AndroidManifest")
internal class LocationBackgroundUnauthorizedException :
CodedException("Not authorized to use background location services")
internal class LocationRequestRejectedException(cause: Exception) :
CodedException("Location request has been rejected: " + cause.message)
internal class CurrentLocationIsUnavailableException :
CodedException("Current location is unavailable. Make sure that location services are enabled")
internal class LocationRequestCancelledException :
CodedException("Location request has been cancelled")
internal class LocationSettingsUnsatisfiedException :
CodedException("Location request failed due to unsatisfied device settings")
internal class LocationUnauthorizedException :
CodedException("Not authorized to use location services")
internal class LocationUnavailableException :
CodedException("Location is unavailable. Make sure that location services are enabled")
internal class LocationUnknownException :
CodedException("Current location is unknown")
internal class SensorManagerUnavailable :
CodedException("Sensor manager is unavailable")
internal class GeocodeException(message: String?, cause: Throwable? = null) :
CodedException("An exception occurred when accessing the geocode: ${message ?: ""} ${cause?.message ?: ""}")
internal class NoGeocodeException :
CodedException("Could not find the Geocoder")
internal class TaskManagerNotFoundException :
CodedException("Could not find the task manager")
internal class GeofencingException(message: String?, cause: Throwable? = null) :
CodedException("A geofencing exception has occurred: ${message ?: ""} ${cause?.message ?: ""}")
internal class MissingActivityManagerException :
CodedException("Activity manager is unavailable")
internal class MissingUIManagerException :
CodedException("UIManager is unavailable")
internal class ConversionException(fromClass: Class<*>, toClass: Class<*>, message: String? = "") :
CodedException("Couldn't cast from ${fromClass::class.simpleName} to ${toClass::class.java.simpleName}: $message")
internal class ForegroundServiceStartNotAllowedException :
CodedException("Couldn't start the foreground service. Foreground service cannot be started when the application is in the background")
internal class ForegroundServicePermissionsException :
CodedException("Couldn't start the foreground service. Foreground service permissions were not found in the manifest")

View File

@@ -0,0 +1,230 @@
package expo.modules.location
import android.content.Context
import android.location.Location
import android.location.LocationManager
import android.os.Bundle
import com.google.android.gms.location.CurrentLocationRequest
import com.google.android.gms.location.FusedLocationProviderClient
import com.google.android.gms.location.Granularity
import com.google.android.gms.location.LocationRequest
import com.google.android.gms.location.Priority
import expo.modules.interfaces.permissions.Permissions
import expo.modules.kotlin.Promise
import expo.modules.kotlin.exception.CodedException
import expo.modules.location.records.LocationLastKnownOptions
import expo.modules.location.records.LocationOptions
import expo.modules.location.records.LocationResponse
import expo.modules.location.records.PermissionRequestResponse
import io.nlopez.smartlocation.location.config.LocationAccuracy
import io.nlopez.smartlocation.location.config.LocationParams
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlin.coroutines.suspendCoroutine
class LocationHelpers {
companion object {
/**
* Checks whether given location didn't exceed given `maxAge` and fits in the required accuracy.
*/
internal fun isLocationValid(location: Location?, options: LocationLastKnownOptions): Boolean {
if (location == null) {
return false
}
val maxAge = options.maxAge ?: Double.MAX_VALUE
val requiredAccuracy = options.requiredAccuracy ?: Double.MAX_VALUE
val timeDiff = (System.currentTimeMillis() - location.time).toDouble()
return timeDiff <= maxAge && location.accuracy <= requiredAccuracy
}
fun hasNetworkProviderEnabled(context: Context?): Boolean {
if (context == null) {
return false
}
val locationManager = context.getSystemService(Context.LOCATION_SERVICE) as? LocationManager
return locationManager != null && locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER)
}
internal fun prepareLocationRequest(options: LocationOptions): LocationRequest {
val locationParams = mapOptionsToLocationParams(options)
return LocationRequest.Builder(locationParams.interval)
.setMinUpdateIntervalMillis(locationParams.interval)
.setMaxUpdateDelayMillis(locationParams.interval)
.setMinUpdateDistanceMeters(locationParams.distance)
.setPriority(mapAccuracyToPriority(options.accuracy))
.build()
}
internal fun prepareCurrentLocationRequest(options: LocationOptions): CurrentLocationRequest {
val locationParams = mapOptionsToLocationParams(options)
return CurrentLocationRequest.Builder().apply {
setGranularity(Granularity.GRANULARITY_PERMISSION_LEVEL)
setPriority(mapAccuracyToPriority(options.accuracy))
setMaxUpdateAgeMillis(locationParams.interval)
}.build()
}
fun requestSingleLocation(locationProvider: FusedLocationProviderClient, locationRequest: CurrentLocationRequest, promise: Promise) {
try {
locationProvider.getCurrentLocation(locationRequest, null)
.addOnSuccessListener { location: Location? ->
if (location == null) {
promise.reject(CurrentLocationIsUnavailableException())
return@addOnSuccessListener
}
promise.resolve(LocationResponse(location))
}
.addOnFailureListener {
promise.reject(LocationRequestRejectedException(it))
}
.addOnCanceledListener {
promise.reject(LocationRequestCancelledException())
}
} catch (e: SecurityException) {
promise.reject(LocationRequestRejectedException(e))
}
}
fun requestContinuousUpdates(locationModule: LocationModule, locationRequest: LocationRequest, watchId: Int, promise: Promise) {
locationModule.requestLocationUpdates(
locationRequest,
watchId,
object : LocationRequestCallbacks {
override fun onLocationChanged(location: Location) {
locationModule.sendLocationResponse(watchId, LocationResponse(location))
}
override fun onRequestSuccess() {
promise.resolve(null)
}
override fun onRequestFailed(cause: CodedException) {
promise.reject(cause)
}
}
)
}
private fun mapOptionsToLocationParams(options: LocationOptions): LocationParams {
val accuracy = options.accuracy
val locationParamsBuilder = buildLocationParamsForAccuracy(accuracy)
options.timeInterval?.let {
locationParamsBuilder.setInterval(it)
}
options.distanceInterval?.let {
locationParamsBuilder.setDistance(it.toFloat())
}
return locationParamsBuilder.build()
}
private fun mapAccuracyToPriority(accuracy: Int): Int {
return when (accuracy) {
LocationModule.ACCURACY_BEST_FOR_NAVIGATION, LocationModule.ACCURACY_HIGHEST, LocationModule.ACCURACY_HIGH -> Priority.PRIORITY_HIGH_ACCURACY
LocationModule.ACCURACY_BALANCED, LocationModule.ACCURACY_LOW -> Priority.PRIORITY_BALANCED_POWER_ACCURACY
LocationModule.ACCURACY_LOWEST -> Priority.PRIORITY_LOW_POWER
else -> Priority.PRIORITY_BALANCED_POWER_ACCURACY
}
}
private fun buildLocationParamsForAccuracy(accuracy: Int): LocationParams.Builder {
return when (accuracy) {
LocationModule.ACCURACY_LOWEST -> LocationParams.Builder()
.setAccuracy(LocationAccuracy.LOWEST)
.setDistance(3000f)
.setInterval(10000)
LocationModule.ACCURACY_LOW -> LocationParams.Builder()
.setAccuracy(LocationAccuracy.LOW)
.setDistance(1000f)
.setInterval(5000)
LocationModule.ACCURACY_BALANCED -> LocationParams.Builder()
.setAccuracy(LocationAccuracy.MEDIUM)
.setDistance(100f)
.setInterval(3000)
LocationModule.ACCURACY_HIGH -> LocationParams.Builder()
.setAccuracy(LocationAccuracy.HIGH)
.setDistance(50f)
.setInterval(2000)
LocationModule.ACCURACY_HIGHEST -> LocationParams.Builder()
.setAccuracy(LocationAccuracy.HIGH)
.setDistance(25f)
.setInterval(1000)
LocationModule.ACCURACY_BEST_FOR_NAVIGATION -> LocationParams.Builder()
.setAccuracy(LocationAccuracy.HIGH)
.setDistance(0f)
.setInterval(500)
else -> LocationParams.Builder()
.setAccuracy(LocationAccuracy.MEDIUM)
.setDistance(100f)
.setInterval(3000)
}
}
fun isAnyProviderAvailable(context: Context?): Boolean {
val locationManager = context?.getSystemService(Context.LOCATION_SERVICE) as? LocationManager
?: return false
return locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER) || locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER)
}
// Decorator for Permissions.getPermissionsWithPermissionsManager, for use in Kotlin coroutines
internal suspend fun getPermissionsWithPermissionsManager(contextPermissions: Permissions, vararg permissionStrings: String): PermissionRequestResponse {
return suspendCoroutine { continuation ->
Permissions.getPermissionsWithPermissionsManager(
contextPermissions,
object : Promise {
override fun resolve(value: Any?) {
val result = value as? Bundle
?: throw ConversionException(Any::class.java, Bundle::class.java, "value returned by the permission promise is not a Bundle")
continuation.resume(PermissionRequestResponse(result))
}
override fun reject(code: String, message: String?, cause: Throwable?) {
continuation.resumeWithException(CodedException(code, message, cause))
}
},
*permissionStrings
)
}
}
// Decorator for Permissions.getPermissionsWithPermissionsManager, for use in Kotlin coroutines
internal suspend fun askForPermissionsWithPermissionsManager(contextPermissions: Permissions, vararg permissionStrings: String): Bundle {
return suspendCoroutine {
Permissions.askForPermissionsWithPermissionsManager(
contextPermissions,
object : Promise {
override fun resolve(value: Any?) {
it.resume(
value as? Bundle
?: throw ConversionException(Any::class.java, Bundle::class.java, "value returned by the permission promise is not a Bundle")
)
}
override fun reject(code: String, message: String?, cause: Throwable?) {
it.resumeWithException(CodedException(code, message, cause))
}
},
*permissionStrings
)
}
}
}
}
/**
* A singleton that keeps information about whether the app is in the foreground or not.
* This is a simple solution for passing current foreground information from the LocationModule to LocationTaskConsumer.
*/
object AppForegroundedSingleton {
var isForegrounded = false
}

View File

@@ -0,0 +1,840 @@
package expo.modules.location
import android.Manifest
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.content.IntentSender.SendIntentException
import android.hardware.GeomagneticField
import android.hardware.Sensor
import android.hardware.SensorEvent
import android.hardware.SensorEventListener
import android.hardware.SensorManager
import android.location.Geocoder
import android.location.Location
import android.os.Build
import android.os.Bundle
import android.os.Looper
import android.util.Log
import androidx.annotation.ChecksSdkIntAtLeast
import androidx.core.os.bundleOf
import com.google.android.gms.common.api.ApiException
import com.google.android.gms.common.api.CommonStatusCodes
import com.google.android.gms.common.api.ResolvableApiException
import com.google.android.gms.location.FusedLocationProviderClient
import com.google.android.gms.location.LocationAvailability
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.LocationSettingsRequest
import expo.modules.core.interfaces.ActivityEventListener
import expo.modules.core.interfaces.ActivityProvider
import expo.modules.core.interfaces.LifecycleEventListener
import expo.modules.core.interfaces.services.UIManager
import expo.modules.interfaces.taskManager.TaskManagerInterface
import expo.modules.kotlin.Promise
import expo.modules.kotlin.exception.Exceptions
import expo.modules.kotlin.functions.Coroutine
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
import expo.modules.location.records.GeocodeResponse
import expo.modules.location.records.GeofencingOptions
import expo.modules.location.records.Heading
import expo.modules.location.records.HeadingEventResponse
import expo.modules.location.records.LocationLastKnownOptions
import expo.modules.location.records.LocationOptions
import expo.modules.location.records.LocationProviderStatus
import expo.modules.location.records.LocationResponse
import expo.modules.location.records.LocationTaskOptions
import expo.modules.location.records.PermissionDetailsLocationAndroid
import expo.modules.location.records.PermissionRequestResponse
import expo.modules.location.records.ReverseGeocodeLocation
import expo.modules.location.records.ReverseGeocodeResponse
import expo.modules.location.taskConsumers.GeofencingTaskConsumer
import expo.modules.location.taskConsumers.LocationTaskConsumer
import io.nlopez.smartlocation.SmartLocation
import io.nlopez.smartlocation.geocoding.utils.LocationAddress
import io.nlopez.smartlocation.location.config.LocationParams
import java.util.Locale
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlin.coroutines.suspendCoroutine
import kotlin.math.abs
class LocationModule : Module(), LifecycleEventListener, SensorEventListener, ActivityEventListener {
private var mGeofield: GeomagneticField? = null
private val mLocationCallbacks = HashMap<Int, LocationCallback>()
private val mLocationRequests = HashMap<Int, LocationRequest>()
private var mPendingLocationRequests = ArrayList<LocationActivityResultListener>()
private lateinit var mContext: Context
private lateinit var mSensorManager: SensorManager
private lateinit var mUIManager: UIManager
private lateinit var mLocationProvider: FusedLocationProviderClient
private lateinit var mActivityProvider: ActivityProvider
private var mGravity: FloatArray = FloatArray(9)
private var mGeomagnetic: FloatArray = FloatArray(9)
private var mHeadingId = 0
private var mLastAzimuth = 0f
private var mAccuracy = 0
private var mLastUpdate: Long = 0
private var mGeocoderPaused = false
private val mTaskManager: TaskManagerInterface by lazy {
return@lazy appContext.legacyModule<TaskManagerInterface>()
?: throw TaskManagerNotFoundException()
}
override fun definition() = ModuleDefinition {
Name("ExpoLocation")
OnCreate {
mContext = appContext.reactContext ?: throw Exceptions.ReactContextLost()
mUIManager = appContext.legacyModule<UIManager>() ?: throw MissingUIManagerException()
mActivityProvider = appContext.legacyModule<ActivityProvider>()
?: throw MissingActivityManagerException()
mLocationProvider = LocationServices.getFusedLocationProviderClient(mContext)
mSensorManager = mContext.getSystemService(Context.SENSOR_SERVICE) as? SensorManager
?: throw SensorManagerUnavailable()
}
Events(HEADING_EVENT_NAME, LOCATION_EVENT_NAME)
// Deprecated
AsyncFunction("requestPermissionsAsync") Coroutine { ->
val permissionsManager = appContext.permissions ?: throw NoPermissionsModuleException()
return@Coroutine if (Build.VERSION.SDK_INT == Build.VERSION_CODES.Q) {
LocationHelpers.askForPermissionsWithPermissionsManager(
permissionsManager,
Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.ACCESS_COARSE_LOCATION,
Manifest.permission.ACCESS_BACKGROUND_LOCATION
)
} else {
LocationHelpers.askForPermissionsWithPermissionsManager(permissionsManager, Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION)
}
}
// Deprecated
AsyncFunction("getPermissionsAsync") Coroutine { ->
val permissionsManager = appContext.permissions ?: throw NoPermissionsModuleException()
return@Coroutine if (Build.VERSION.SDK_INT == Build.VERSION_CODES.Q) {
LocationHelpers.getPermissionsWithPermissionsManager(
permissionsManager,
Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.ACCESS_COARSE_LOCATION,
Manifest.permission.ACCESS_BACKGROUND_LOCATION
)
} else {
getForegroundPermissionsAsync()
}
}
AsyncFunction("requestForegroundPermissionsAsync") Coroutine { ->
val permissionsManager = appContext.permissions ?: throw NoPermissionsModuleException()
LocationHelpers.askForPermissionsWithPermissionsManager(permissionsManager, Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION)
// We aren't using the values returned above, because we need to check if the user has provided fine location permissions
return@Coroutine getForegroundPermissionsAsync()
}
AsyncFunction("requestBackgroundPermissionsAsync") Coroutine { ->
return@Coroutine requestBackgroundPermissionsAsync()
}
AsyncFunction("getForegroundPermissionsAsync") Coroutine { ->
return@Coroutine getForegroundPermissionsAsync()
}
AsyncFunction("getBackgroundPermissionsAsync") Coroutine { ->
return@Coroutine getBackgroundPermissionsAsync()
}
AsyncFunction("getLastKnownPositionAsync") Coroutine { options: LocationLastKnownOptions ->
return@Coroutine getLastKnownPositionAsync(options)
}
AsyncFunction("getCurrentPositionAsync") { options: LocationOptions, promise: Promise ->
return@AsyncFunction getCurrentPositionAsync(options, promise)
}
AsyncFunction<LocationProviderStatus>("getProviderStatusAsync") {
val state = SmartLocation.with(mContext).location().state()
return@AsyncFunction LocationProviderStatus().apply {
backgroundModeEnabled = state.locationServicesEnabled()
gpsAvailable = state.isGpsAvailable
networkAvailable = state.isNetworkAvailable
locationServicesEnabled = state.locationServicesEnabled()
passiveAvailable = state.isPassiveAvailable
}
}
AsyncFunction("watchDeviceHeading") { watchId: Int ->
mHeadingId = watchId
startHeadingUpdate()
return@AsyncFunction
}
AsyncFunction("watchPositionImplAsync") { watchId: Int, options: LocationOptions, promise: Promise ->
// Check for permissions
if (isMissingForegroundPermissions()) {
promise.reject(LocationUnauthorizedException())
return@AsyncFunction
}
val locationRequest = LocationHelpers.prepareLocationRequest(options)
val showUserSettingsDialog = options.mayShowUserSettingsDialog
if (LocationHelpers.hasNetworkProviderEnabled(mContext) || !showUserSettingsDialog) {
LocationHelpers.requestContinuousUpdates(this@LocationModule, locationRequest, watchId, promise)
} else {
// Pending requests can ask the user to turn on improved accuracy mode in user's settings.
addPendingLocationRequest(
locationRequest,
object : LocationActivityResultListener {
override fun onResult(resultCode: Int) {
if (resultCode == Activity.RESULT_OK) {
LocationHelpers.requestContinuousUpdates(this@LocationModule, locationRequest, watchId, promise)
} else {
promise.reject(LocationSettingsUnsatisfiedException())
}
}
}
)
}
}
AsyncFunction("removeWatchAsync") { watchId: Int ->
if (isMissingForegroundPermissions()) {
throw LocationUnauthorizedException()
}
// Check if we want to stop watching location or compass
if (watchId == mHeadingId) {
destroyHeadingWatch()
} else {
removeLocationUpdatesForRequest(watchId)
}
return@AsyncFunction
}
AsyncFunction("geocodeAsync") Coroutine { address: String ->
return@Coroutine geocode(address)
}
AsyncFunction("reverseGeocodeAsync") Coroutine { location: ReverseGeocodeLocation ->
return@Coroutine reverseGeocode(location)
}
AsyncFunction("enableNetworkProviderAsync") Coroutine { ->
if (LocationHelpers.hasNetworkProviderEnabled(mContext)) {
return@Coroutine null
}
val locationRequest = LocationHelpers.prepareLocationRequest(LocationOptions())
return@Coroutine suspendCoroutine<String?> { continuation ->
addPendingLocationRequest(
locationRequest,
object : LocationActivityResultListener {
override fun onResult(resultCode: Int) {
if (resultCode == Activity.RESULT_OK) {
continuation.resume(null)
} else {
continuation.resumeWithException(LocationSettingsUnsatisfiedException())
}
}
}
)
}
}
AsyncFunction<Boolean>("hasServicesEnabledAsync") {
return@AsyncFunction LocationHelpers.isAnyProviderAvailable(mContext)
}
AsyncFunction("startLocationUpdatesAsync") { taskName: String, options: LocationTaskOptions ->
val shouldUseForegroundService = options.foregroundService != null
if (isMissingForegroundPermissions()) {
throw LocationBackgroundUnauthorizedException()
}
// There are two ways of starting this service.
// 1. As a background location service, this requires the background location permission.
// 2. As a user-initiated foreground service with notification, this does NOT require the background location permission.
if (!shouldUseForegroundService && isMissingBackgroundPermissions()) {
throw LocationBackgroundUnauthorizedException()
}
if (!AppForegroundedSingleton.isForegrounded && options.foregroundService != null) {
throw ForegroundServiceStartNotAllowedException()
}
if (!hasForegroundServicePermissions()) {
throw ForegroundServicePermissionsException()
}
mTaskManager.registerTask(taskName, LocationTaskConsumer::class.java, options.toMutableMap())
return@AsyncFunction
}
AsyncFunction("stopLocationUpdatesAsync") { taskName: String ->
mTaskManager.unregisterTask(taskName, LocationTaskConsumer::class.java)
return@AsyncFunction
}
AsyncFunction("hasStartedLocationUpdatesAsync") { taskName: String ->
return@AsyncFunction mTaskManager.taskHasConsumerOfClass(taskName, LocationTaskConsumer::class.java)
}
AsyncFunction("startGeofencingAsync") { taskName: String, options: GeofencingOptions ->
if (isMissingBackgroundPermissions()) {
throw LocationBackgroundUnauthorizedException()
}
mTaskManager.registerTask(taskName, GeofencingTaskConsumer::class.java, options.toMap())
return@AsyncFunction
}
AsyncFunction("hasStartedGeofencingAsync") { taskName: String ->
if (isMissingBackgroundPermissions()) {
throw LocationBackgroundUnauthorizedException()
}
return@AsyncFunction mTaskManager.taskHasConsumerOfClass(taskName, GeofencingTaskConsumer::class.java)
}
AsyncFunction("stopGeofencingAsync") { taskName: String ->
if (isMissingBackgroundPermissions()) {
throw LocationBackgroundUnauthorizedException()
}
mTaskManager.unregisterTask(taskName, GeofencingTaskConsumer::class.java)
return@AsyncFunction
}
OnActivityEntersForeground {
AppForegroundedSingleton.isForegrounded = true
}
OnActivityEntersBackground {
AppForegroundedSingleton.isForegrounded = false
}
}
private suspend fun getForegroundPermissionsAsync(): PermissionRequestResponse {
appContext.permissions?.let {
val locationPermission = LocationHelpers.getPermissionsWithPermissionsManager(it, Manifest.permission.ACCESS_COARSE_LOCATION)
val fineLocationPermission = LocationHelpers.getPermissionsWithPermissionsManager(it, Manifest.permission.ACCESS_FINE_LOCATION)
var accuracy = "none"
if (locationPermission.granted) {
accuracy = "coarse"
}
if (fineLocationPermission.granted) {
accuracy = "fine"
}
locationPermission.android = PermissionDetailsLocationAndroid(
scope = accuracy,
accuracy = accuracy
)
return locationPermission
} ?: throw NoPermissionsModuleException()
}
private suspend fun requestBackgroundPermissionsAsync(): PermissionRequestResponse {
if (!isBackgroundPermissionInManifest()) {
throw NoPermissionInManifestException("ACCESS_BACKGROUND_LOCATION")
}
if (!shouldAskBackgroundPermissions()) {
return getForegroundPermissionsAsync()
}
return appContext.permissions?.let {
val permissionResponseBundle = LocationHelpers.askForPermissionsWithPermissionsManager(it, Manifest.permission.ACCESS_BACKGROUND_LOCATION)
PermissionRequestResponse(permissionResponseBundle)
} ?: throw NoPermissionsModuleException()
}
private suspend fun getBackgroundPermissionsAsync(): PermissionRequestResponse {
if (!isBackgroundPermissionInManifest()) {
throw NoPermissionInManifestException("ACCESS_BACKGROUND_LOCATION")
}
if (!shouldAskBackgroundPermissions()) {
return getForegroundPermissionsAsync()
}
appContext.permissions?.let {
return LocationHelpers.getPermissionsWithPermissionsManager(it, Manifest.permission.ACCESS_BACKGROUND_LOCATION)
} ?: throw NoPermissionsModuleException()
}
/**
* Resolves to the last known position if it is available and matches given requirements or null otherwise.
*/
private suspend fun getLastKnownPositionAsync(options: LocationLastKnownOptions): LocationResponse? {
// Check for permissions
if (isMissingForegroundPermissions()) {
throw LocationUnauthorizedException()
}
val lastKnownLocation = getLastKnownLocation() ?: return null
if (LocationHelpers.isLocationValid(lastKnownLocation, options)) {
return LocationResponse(lastKnownLocation)
}
return null
}
/**
* Requests for the current position. Depending on given accuracy, it may take some time to resolve.
* If you don't need an up-to-date location see `getLastKnownPosition`.
*/
private fun getCurrentPositionAsync(options: LocationOptions, promise: Promise) {
// Read options
val locationRequest = LocationHelpers.prepareLocationRequest(options)
val currentLocationRequest = LocationHelpers.prepareCurrentLocationRequest(options)
val showUserSettingsDialog = options.mayShowUserSettingsDialog
// Check for permissions
if (isMissingForegroundPermissions()) {
promise.reject(LocationUnauthorizedException())
return
}
if (LocationHelpers.hasNetworkProviderEnabled(mContext) || !showUserSettingsDialog) {
LocationHelpers.requestSingleLocation(mLocationProvider, currentLocationRequest, promise)
} else {
addPendingLocationRequest(
locationRequest,
object : LocationActivityResultListener {
override fun onResult(resultCode: Int) {
if (resultCode == Activity.RESULT_OK) {
LocationHelpers.requestSingleLocation(mLocationProvider, currentLocationRequest, promise)
} else {
promise.reject(LocationSettingsUnsatisfiedException())
}
}
}
)
}
}
fun requestLocationUpdates(locationRequest: LocationRequest, requestId: Int?, callbacks: LocationRequestCallbacks) {
val locationProvider: FusedLocationProviderClient = mLocationProvider
val locationCallback: LocationCallback = object : LocationCallback() {
var isLocationAvailable = false
override fun onLocationResult(locationResult: LocationResult) {
val location = locationResult.lastLocation
if (location != null) {
callbacks.onLocationChanged(location)
} else if (!isLocationAvailable) {
callbacks.onLocationError(LocationUnavailableException())
} else {
callbacks.onRequestFailed(LocationUnknownException())
}
}
override fun onLocationAvailability(locationAvailability: LocationAvailability) {
isLocationAvailable = locationAvailability.isLocationAvailable
}
}
if (requestId != null) {
// Save location callback and request so we will be able to pause/resume receiving updates.
mLocationCallbacks[requestId] = locationCallback
mLocationRequests[requestId] = locationRequest
}
try {
locationProvider.requestLocationUpdates(locationRequest, locationCallback, Looper.getMainLooper())
callbacks.onRequestSuccess()
} catch (e: SecurityException) {
callbacks.onRequestFailed(LocationRequestRejectedException(e))
}
}
private fun addPendingLocationRequest(locationRequest: LocationRequest, listener: LocationActivityResultListener) {
// Add activity result listener to an array of pending requests.
mPendingLocationRequests.add(listener)
// If it's the first pending request, let's ask the user to turn on high accuracy location.
if (mPendingLocationRequests.size == 1) {
resolveUserSettingsForRequest(locationRequest)
}
}
/**
* Triggers system's dialog to ask the user to enable settings required for given location request.
*/
private fun resolveUserSettingsForRequest(locationRequest: LocationRequest) {
val activity = mActivityProvider.currentActivity
if (activity == null) {
// Activity not found. It could have been called in a headless mode.
executePendingRequests(Activity.RESULT_CANCELED)
return
}
val builder = LocationSettingsRequest.Builder().addLocationRequest(locationRequest)
val client = LocationServices.getSettingsClient(mContext)
val task = client.checkLocationSettings(builder.build())
task.addOnSuccessListener {
// All location settings requirements are satisfied.
executePendingRequests(Activity.RESULT_OK)
}
task.addOnFailureListener { e: Exception ->
val statusCode = (e as ApiException).statusCode
if (statusCode == CommonStatusCodes.RESOLUTION_REQUIRED) {
// Location settings are not satisfied, but this can be fixed by showing the user a dialog.
// Show the dialog by calling startResolutionForResult(), and check the result in onActivityResult().
try {
val resolvable = e as ResolvableApiException
mUIManager.registerActivityEventListener(this@LocationModule)
resolvable.startResolutionForResult(activity, CHECK_SETTINGS_REQUEST_CODE)
} catch (e: SendIntentException) {
// Ignore the error.
executePendingRequests(Activity.RESULT_CANCELED)
}
} else { // Location settings are not satisfied. However, we have no way to fix the settings so we won't show the dialog.
executePendingRequests(Activity.RESULT_CANCELED)
}
}
}
private fun executePendingRequests(resultCode: Int) {
// Propagate result to pending location requests.
for (listener in mPendingLocationRequests) {
listener.onResult(resultCode)
}
mPendingLocationRequests.clear()
}
private fun startHeadingUpdate() {
val locationControl = SmartLocation.with(mContext).location().oneFix().config(LocationParams.BEST_EFFORT)
val currLoc = locationControl.lastLocation
if (currLoc != null) {
mGeofield = GeomagneticField(
currLoc.latitude.toFloat(), currLoc.longitude.toFloat(), currLoc.altitude.toFloat(),
System.currentTimeMillis()
)
} else {
locationControl.start { location: Location ->
mGeofield = GeomagneticField(
location.latitude.toFloat(), location.longitude.toFloat(), location.altitude.toFloat(),
System.currentTimeMillis()
)
}
}
mSensorManager.registerListener(
this,
mSensorManager.getDefaultSensor(Sensor.TYPE_MAGNETIC_FIELD),
SensorManager.SENSOR_DELAY_NORMAL
)
mSensorManager.registerListener(this, mSensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER), SensorManager.SENSOR_DELAY_NORMAL)
}
private fun sendUpdate() {
val rotationMatrix = FloatArray(9)
val inclinationMatrix = FloatArray(9)
val success = SensorManager.getRotationMatrix(rotationMatrix, inclinationMatrix, mGravity, mGeomagnetic)
if (success) {
val orientation = FloatArray(3)
SensorManager.getOrientation(rotationMatrix, orientation)
// Make sure Delta is big enough to warrant an update
// Currently: 50ms and ~2 degrees of change (android has a lot of useless updates block up the sending)
if (abs(orientation[0] - mLastAzimuth) > DEGREE_DELTA && System.currentTimeMillis() - mLastUpdate > TIME_DELTA) {
mLastAzimuth = orientation[0]
mLastUpdate = System.currentTimeMillis()
val magneticNorth: Float = calcMagNorth(orientation[0])
val trueNorth: Float = calcTrueNorth(magneticNorth)
// Write data to send back to React
val response = HeadingEventResponse(
watchId = mHeadingId,
heading = Heading(
trueHeading = trueNorth,
magHeading = magneticNorth,
accuracy = mAccuracy
)
)
sendEvent(HEADING_EVENT_NAME, response.toBundle())
}
}
}
internal fun sendLocationResponse(watchId: Int, response: LocationResponse) {
val responseBundle = bundleOf()
responseBundle.putBundle("location", response.toBundle(Bundle::class.java))
responseBundle.putInt("watchId", watchId)
sendEvent(LOCATION_EVENT_NAME, responseBundle)
}
private fun calcMagNorth(azimuth: Float): Float {
val azimuthDeg = Math.toDegrees(azimuth.toDouble()).toFloat()
return (azimuthDeg + 360) % 360
}
private fun calcTrueNorth(magNorth: Float): Float {
// Need to request geo location info to calculate true north
val geofield = mGeofield.takeIf { !isMissingForegroundPermissions() } ?: return -1f
return (magNorth + geofield.declination) % 360
}
private fun stopHeadingWatch() {
mSensorManager.unregisterListener(this)
}
private fun destroyHeadingWatch() {
stopHeadingWatch()
mGravity = FloatArray(9)
mGeomagnetic = FloatArray(9)
mGeofield = null
mHeadingId = 0
mLastAzimuth = 0f
mAccuracy = 0
}
private fun startWatching() {
// if permissions not granted it won't work anyway, but this can be invoked when permission dialog disappears
if (!isMissingForegroundPermissions()) {
mGeocoderPaused = false
}
// Resume paused location updates
resumeLocationUpdates()
}
private fun stopWatching() {
// if permissions not granted it won't work anyway, but this can be invoked when permission dialog appears
if (Geocoder.isPresent() && !isMissingForegroundPermissions()) {
SmartLocation.with(mContext).geocoding().stop()
mGeocoderPaused = true
}
for (requestId in mLocationCallbacks.keys) {
pauseLocationUpdatesForRequest(requestId)
}
}
private fun pauseLocationUpdatesForRequest(requestId: Int) {
val locationCallback = mLocationCallbacks[requestId]
if (locationCallback != null) {
mLocationProvider.removeLocationUpdates(locationCallback)
}
}
private fun removeLocationUpdatesForRequest(requestId: Int) {
pauseLocationUpdatesForRequest(requestId)
mLocationCallbacks.remove(requestId)
mLocationRequests.remove(requestId)
}
private fun resumeLocationUpdates() {
for (requestId in mLocationCallbacks.keys) {
val locationCallback = mLocationCallbacks[requestId] ?: return
val locationRequest = mLocationRequests[requestId] ?: return
try {
mLocationProvider.requestLocationUpdates(locationRequest, locationCallback, Looper.myLooper())
} catch (e: SecurityException) {
Log.e(TAG, "Error occurred while resuming location updates: $e")
}
}
}
/**
* Gets the best most recent location found by the provider.
*/
private suspend fun getLastKnownLocation(): Location? {
return suspendCoroutine { continuation ->
try {
mLocationProvider.lastLocation
.addOnSuccessListener { location: Location? -> continuation.resume(location) }
.addOnCanceledListener { continuation.resume(null) }
.addOnFailureListener { continuation.resume(null) }
} catch (e: SecurityException) {
continuation.resume(null)
}
}
}
private suspend fun geocode(address: String): List<GeocodeResponse> {
if (mGeocoderPaused) {
throw GeocodeException("Geocoder is not running")
}
if (isMissingForegroundPermissions()) {
throw LocationUnauthorizedException()
}
if (!Geocoder.isPresent()) {
throw NoGeocodeException()
}
return suspendCoroutine { continuation ->
val locations = Geocoder(mContext, Locale.getDefault()).getFromLocationName(address, 1)
locations?.let { location ->
location.let {
val results = it.mapNotNull { address ->
val locationAddress = LocationAddress(address)
GeocodeResponse.from(locationAddress.location)
}
continuation.resume(results)
}
} ?: continuation.resume(emptyList())
}
}
private suspend fun reverseGeocode(location: ReverseGeocodeLocation): List<ReverseGeocodeResponse> {
if (mGeocoderPaused) {
throw GeocodeException("Geocoder is not running")
}
if (isMissingForegroundPermissions()) {
throw LocationUnauthorizedException()
}
if (!Geocoder.isPresent()) {
throw NoGeocodeException()
}
val androidLocation = Location("").apply {
latitude = location.latitude
longitude = location.longitude
}
return suspendCoroutine { continuation ->
val locations = Geocoder(mContext, Locale.getDefault()).getFromLocation(androidLocation.latitude, androidLocation.longitude, 1)
locations?.let { addresses ->
val results = addresses.mapNotNull { address ->
address?.let {
ReverseGeocodeResponse(it)
}
}
continuation.resume(results)
} ?: continuation.resume(emptyList())
}
}
//region private methods
/**
* Checks whether all required permissions have been granted by the user.
*/
private fun isMissingForegroundPermissions(): Boolean {
appContext.permissions?.let {
val canAccessFineLocation = it.hasGrantedPermissions(Manifest.permission.ACCESS_FINE_LOCATION)
val canAccessCoarseLocation = it.hasGrantedPermissions(Manifest.permission.ACCESS_COARSE_LOCATION)
return !canAccessFineLocation && !canAccessCoarseLocation
} ?: throw Exceptions.AppContextLost()
}
private fun hasForegroundServicePermissions(): Boolean {
appContext.permissions?.let {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
val canAccessForegroundServiceLocation = it.hasGrantedPermissions(Manifest.permission.FOREGROUND_SERVICE_LOCATION)
val canAccessForegroundService = it.hasGrantedPermissions(Manifest.permission.FOREGROUND_SERVICE)
canAccessForegroundService && canAccessForegroundServiceLocation
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
val canAccessForegroundService = it.hasGrantedPermissions(Manifest.permission.FOREGROUND_SERVICE)
canAccessForegroundService
} else {
true
}
} ?: throw Exceptions.AppContextLost()
}
/**
* Checks if the background location permission is granted by the user.
*/
private fun isMissingBackgroundPermissions(): Boolean {
appContext.permissions?.let {
return Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && !it.hasGrantedPermissions(Manifest.permission.ACCESS_BACKGROUND_LOCATION)
}
return true
}
/**
* Check if we need to request background location permission separately.
*
* @see `https://medium.com/swlh/request-location-permission-correctly-in-android-11-61afe95a11ad`
*/
@ChecksSdkIntAtLeast(api = Build.VERSION_CODES.Q)
private fun shouldAskBackgroundPermissions(): Boolean {
return Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q
}
private fun isBackgroundPermissionInManifest(): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
appContext.permissions?.let {
return it.isPermissionPresentInManifest(Manifest.permission.ACCESS_BACKGROUND_LOCATION)
}
throw NoPermissionsModuleException()
} else {
true
}
}
/**
* Helper method that lazy-loads the location provider for the context that the module was created.
*/
companion object {
internal val TAG = LocationModule::class.java.simpleName
private const val LOCATION_EVENT_NAME = "Expo.locationChanged"
private const val HEADING_EVENT_NAME = "Expo.headingChanged"
private const val CHECK_SETTINGS_REQUEST_CODE = 42
const val ACCURACY_LOWEST = 1
const val ACCURACY_LOW = 2
const val ACCURACY_BALANCED = 3
const val ACCURACY_HIGH = 4
const val ACCURACY_HIGHEST = 5
const val ACCURACY_BEST_FOR_NAVIGATION = 6
const val GEOFENCING_EVENT_ENTER = 1
const val GEOFENCING_EVENT_EXIT = 2
const val DEGREE_DELTA = 0.0355 // in radians, about 2 degrees
const val TIME_DELTA = 50f // in milliseconds
}
override fun onHostResume() {
startWatching()
startHeadingUpdate()
}
override fun onHostPause() {
stopWatching()
stopHeadingWatch()
}
override fun onHostDestroy() {
stopWatching()
stopHeadingWatch()
}
override fun onSensorChanged(event: SensorEvent?) {
event ?: return
if (event.sensor.type == Sensor.TYPE_ACCELEROMETER) {
mGravity = event.values
} else if (event.sensor.type == Sensor.TYPE_MAGNETIC_FIELD) {
mGeomagnetic = event.values
}
sendUpdate()
}
override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) {
mAccuracy = accuracy
}
override fun onActivityResult(activity: Activity?, requestCode: Int, resultCode: Int, data: Intent?) {
if (requestCode != CHECK_SETTINGS_REQUEST_CODE) {
return
}
executePendingRequests(resultCode)
mUIManager.unregisterActivityEventListener(this)
}
override fun onNewIntent(intent: Intent?) {}
}

View File

@@ -0,0 +1,11 @@
package expo.modules.location
import android.location.Location
import expo.modules.kotlin.exception.CodedException
interface LocationRequestCallbacks {
fun onLocationChanged(location: Location) {}
fun onLocationError(cause: CodedException) {}
fun onRequestSuccess() {}
fun onRequestFailed(cause: CodedException) {}
}

View File

@@ -0,0 +1,101 @@
package expo.modules.location.records
import expo.modules.kotlin.records.Field
import expo.modules.kotlin.records.Record
import expo.modules.location.LocationModule.Companion.ACCURACY_BALANCED
import java.io.Serializable
import expo.modules.kotlin.types.Enumerable
enum class GeofencingRegionState : Enumerable {
UNKNOWN,
INSIDE,
OUTSIDE
}
internal class LocationLastKnownOptions(
@Field var maxAge: Double? = null,
@Field var requiredAccuracy: Double? = null
) : Record, Serializable
internal open class LocationOptions(
@Field var accuracy: Int = ACCURACY_BALANCED,
@Field var distanceInterval: Int? = null,
@Field var mayShowUserSettingsDialog: Boolean = true,
@Field var timeInterval: Long? = null
) : Record, Serializable {
constructor(map: Map<String, Any?>) : this(
accuracy = map["accuracy"] as? Int ?: ACCURACY_BALANCED,
distanceInterval = map["distanceInterval"] as? Int?,
mayShowUserSettingsDialog = map["mayShowUserSettingsDialog"] as? Boolean? ?: true,
timeInterval = map["timeInterval"] as? Long
)
}
internal class ReverseGeocodeLocation(
@Field var latitude: Double,
@Field var longitude: Double,
@Field var accuracy: Float? = null,
@Field var altitude: Double? = null
) : Record, Serializable
internal class LocationTaskOptions(
@Field var deferredUpdatesDistance: Float? = 0f,
@Field var deferredUpdatesInterval: Float? = 0f,
@Field var deferredUpdatesTimeout: Float? = null,
@Field var foregroundService: LocationTaskServiceOptions? = null
) : LocationOptions() {
internal fun toMutableMap() = mutableMapOf(
"accuracy" to accuracy,
"distanceInterval" to distanceInterval,
"mayShowUserSettingsDialog" to mayShowUserSettingsDialog,
"timeInterval" to timeInterval,
"deferredUpdatesDistance" to deferredUpdatesDistance,
"deferredUpdatesInterval" to deferredUpdatesInterval,
"deferredUpdatesTimeout" to deferredUpdatesTimeout,
"foregroundService" to (foregroundService?.toMutableMap() ?: mutableMapOf())
)
}
internal class LocationTaskServiceOptions(
@Field var notificationTitle: String? = null,
@Field var notificationBody: String? = null,
@Field var killServiceOnDestroy: Boolean? = null,
@Field var notificationColor: String? = null
) : Record, Serializable {
internal fun toMutableMap() = mutableMapOf(
"notificationTitle" to notificationTitle,
"notificationBody" to notificationBody,
"killServiceOnDestroy" to killServiceOnDestroy,
"notificationColor" to notificationColor
)
}
internal class GeofencingOptions(
@Field var regions: List<Region>
) : Record, Serializable {
internal fun toMap(): Map<String, Any?> = mapOf(
"regions" to regions.map { it.toMap() }
)
}
internal class Region(
@Field var identifier: String? = null,
@Field var latitude: Double = .0,
@Field var longitude: Double = .0,
@Field var notifyOnEnter: Boolean? = true,
@Field var notifyOnExit: Boolean? = true,
@Field var radius: Double? = .0,
@Field var state: GeofencingRegionState = GeofencingRegionState.UNKNOWN
) : Record, Serializable {
internal fun toMap() = mapOf<String, Any?>(
"identifier" to identifier,
"latitude" to latitude,
"longitude" to longitude,
"notifyOnEnter" to notifyOnEnter,
"notifyOnExit" to notifyOnExit,
"radius" to radius,
"state" to state
)
}

View File

@@ -0,0 +1,220 @@
package expo.modules.location.records
import android.location.Address
import android.location.Location
import android.os.BaseBundle
import android.os.Build
import android.os.Bundle
import android.os.PersistableBundle
import android.util.Log
import expo.modules.kotlin.records.Field
import expo.modules.kotlin.records.Record
import expo.modules.location.ConversionException
import expo.modules.location.LocationModule
import java.io.Serializable
internal class PermissionRequestResponse(
@Field var canAskAgain: Boolean?,
@Field var expires: String?,
@Field var granted: Boolean,
@Field var status: String?,
@Field var android: PermissionDetailsLocationAndroid?
) : Record, Serializable {
constructor(bundle: Bundle) : this(
canAskAgain = bundle.getBoolean("canAskAgain"),
expires = bundle.getString("expires")
?: throw ConversionException(Bundle::class.java, PermissionRequestResponse::class.java, "value under `expires` key is undefined"),
granted = bundle.getBoolean("granted"),
status = bundle.getString("status")
?: throw ConversionException(Bundle::class.java, PermissionRequestResponse::class.java, "value under `status` key is undefined"),
android = bundle.getBundle("android")?.let { PermissionDetailsLocationAndroid(it) }
)
}
internal class PermissionDetailsLocationAndroid(
@Field var scope: String,
@Field var accuracy: String
) : Record, Serializable {
constructor(bundle: Bundle) : this(
scope = (bundle.getString("accuracy") ?: "none"),
accuracy = (bundle.getString("accuracy") ?: "none")
)
}
internal class LocationProviderStatus(
@Field var backgroundModeEnabled: Boolean? = null,
@Field var gpsAvailable: Boolean? = false,
@Field var networkAvailable: Boolean? = null,
@Field var locationServicesEnabled: Boolean = false,
@Field var passiveAvailable: Boolean? = null
) : Record, Serializable
internal class Heading(
@Field var trueHeading: Float = -1f,
@Field var magHeading: Float = -1f,
@Field var accuracy: Int = 0
) {
internal fun toBundle(): Bundle {
return Bundle().apply {
putFloat("trueHeading", trueHeading)
putFloat("magHeading", magHeading)
putInt("accuracy", accuracy)
}
}
}
internal class HeadingEventResponse(
@Field var watchId: Int? = null,
@Field var heading: Heading? = null
) : Record, Serializable {
internal fun toBundle(): Bundle {
return Bundle().apply {
watchId?.let { putInt("watchId", it) }
heading?.let { putBundle("heading", it.toBundle()) }
}
}
}
internal class LocationResponse(
@Field var coords: LocationObjectCoords? = null,
@Field var timestamp: Double? = null,
@Field var mocked: Boolean? = null
) : Record, Serializable {
constructor(location: Location) : this(
coords = LocationObjectCoords(location),
timestamp = location.time.toDouble(),
mocked = location.isFromMockProvider
)
internal fun <BundleType : BaseBundle> toBundle(bundleTypeClass: Class<BundleType>): BundleType {
val bundle: BundleType = when (bundleTypeClass) {
PersistableBundle::class.java -> PersistableBundle()
else -> Bundle()
} as? BundleType
?: throw ConversionException(LocationResponse::class.java, bundleTypeClass, "Unsupported bundleTypeClass")
return bundle.apply {
timestamp?.let { putDouble("timestamp", it) }
mocked?.let { putBoolean("mocked", it) }
if (bundle is PersistableBundle) {
(this as PersistableBundle).putPersistableBundle("coords", coords?.toBundle(PersistableBundle::class.java))
} else if (bundle is Bundle) {
(this as Bundle).putBundle("coords", coords?.toBundle(Bundle::class.java))
}
}
}
}
internal class LocationObjectCoords(
@Field var latitude: Double? = null,
@Field var longitude: Double? = null,
@Field var altitude: Double? = null,
@Field var accuracy: Double? = null,
@Field var altitudeAccuracy: Double? = null,
@Field var heading: Double? = null,
@Field var speed: Double? = null
) : Record, Serializable {
constructor(location: Location) : this(
latitude = location.latitude,
longitude = location.longitude,
altitude = location.altitude,
accuracy = location.accuracy.toDouble(),
altitudeAccuracy = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
location.verticalAccuracyMeters.toDouble()
} else {
null
},
heading = location.bearing.toDouble(),
speed = location.speed.toDouble()
)
internal fun <BundleType : BaseBundle> toBundle(bundleTypeClass: Class<BundleType>): BundleType {
val bundle: BundleType = when (bundleTypeClass) {
PersistableBundle::class.java -> PersistableBundle()
else -> Bundle()
} as? BundleType
?: throw ConversionException(LocationObjectCoords::class.java, bundleTypeClass, "Requested an unsupported bundle type")
bundle.apply {
latitude?.let { putDouble("latitude", it) }
longitude?.let { putDouble("longitude", it) }
altitude?.let { putDouble("altitude", it) }
accuracy?.let { putDouble("accuracy", it) }
altitudeAccuracy?.let { putDouble("altitudeAccuracy", it) }
heading?.let { putDouble("heading", it) }
speed?.let { putDouble("speed", it) }
}
return bundle
}
}
internal class GeocodeResponse(
@Field var latitude: Double,
@Field var longitude: Double,
@Field var accuracy: Float? = null,
@Field var altitude: Double? = null
) : Record, Serializable {
companion object {
fun from(location: Location): GeocodeResponse? {
return try {
GeocodeResponse(
latitude = location.latitude,
longitude = location.longitude,
accuracy = location.accuracy,
altitude = location.altitude
)
} catch (e: Exception) {
if (e is IllegalAccessException || e is InstantiationException) {
Log.e(LocationModule.TAG, "Unexpected exception was thrown when converting location to coords bundle: ", e)
}
null
}
}
}
}
internal class ReverseGeocodeResponse(
@Field var city: String?,
@Field var district: String?,
@Field var streetNumber: String?,
@Field var street: String?,
@Field var region: String?,
@Field var subregion: String?,
@Field var country: String?,
@Field var postalCode: String?,
@Field var name: String?,
@Field var isoCountryCode: String,
@Field var timezone: String?,
@Field var formattedAddress: String?
) : Record, Serializable {
constructor(address: Address) : this(
city = address.locality,
district = address.subLocality,
streetNumber = address.subThoroughfare,
street = address.thoroughfare,
region = address.adminArea,
subregion = address.subAdminArea,
country = address.countryName,
postalCode = address.postalCode,
name = address.featureName,
isoCountryCode = address.countryCode,
timezone = null,
formattedAddress = constructFormattedAddress(address)
)
companion object {
fun constructFormattedAddress(address: Address): String? {
if (address.maxAddressLineIndex == -1) {
return null
}
val sb = StringBuilder()
for (i in 0..address.maxAddressLineIndex) {
sb.append(address.getAddressLine(i))
if (i < address.maxAddressLineIndex) {
sb.append(", ")
}
}
return sb.toString()
}
}
}

View File

@@ -0,0 +1,120 @@
package expo.modules.location.services
import android.annotation.TargetApi
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.graphics.Color
import android.os.Binder
import android.os.Build
import android.os.Bundle
import android.os.IBinder
class LocationTaskService : Service() {
private var mChannelId: String? = null
private var mKillService = false
private lateinit var mParentContext: Context
private val mServiceId = sServiceId++
private val mBinder: IBinder = ServiceBinder()
inner class ServiceBinder : Binder() {
val service: LocationTaskService
get() = this@LocationTaskService
}
override fun onBind(intent: Intent): IBinder {
return mBinder
}
@TargetApi(26)
override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int {
val extras = intent.extras
if (extras != null) {
mChannelId = extras.getString("appId") + ":" + extras.getString("taskName")
mKillService = extras.getBoolean("killService", false)
}
return START_REDELIVER_INTENT
}
fun setParentContext(context: Context) {
// Background location logic is still outside LocationTaskService,
// so we have to save parent context in order to make sure it won't be destroyed by the OS.
mParentContext = context
}
fun stop() {
stopForeground(true)
stopSelf()
}
override fun onTaskRemoved(rootIntent: Intent) {
if (mKillService) {
super.onTaskRemoved(rootIntent)
stop()
}
}
fun startForeground(serviceOptions: Bundle) {
val notification = buildServiceNotification(serviceOptions)
startForeground(mServiceId, notification)
}
//region private
@TargetApi(26)
private fun buildServiceNotification(serviceOptions: Bundle): Notification {
prepareChannel(mChannelId)
val builder = Notification.Builder(this, mChannelId)
val title = serviceOptions.getString("notificationTitle")
val body = serviceOptions.getString("notificationBody")
val color = colorStringToInteger(serviceOptions.getString("notificationColor"))
title?.let { builder.setContentTitle(title) }
body?.let { builder.setContentText(body) }
color?.let {
builder.setColorized(true).setColor(color)
} ?: run {
builder.setColorized(false)
}
mParentContext.packageManager.getLaunchIntentForPackage(mParentContext.packageName)?.let {
it.flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
// We're defaulting to the behaviour prior API 31 (mutable) even though Android recommends immutability
val mutableFlag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0
val contentIntent = PendingIntent.getActivity(this, 0, it, PendingIntent.FLAG_UPDATE_CURRENT or mutableFlag)
builder.setContentIntent(contentIntent)
}
return builder.setCategory(Notification.CATEGORY_SERVICE)
.setSmallIcon(applicationInfo.icon)
.build()
}
@TargetApi(26)
private fun prepareChannel(id: String?) {
val notificationManager = getSystemService(NOTIFICATION_SERVICE) as? NotificationManager
?: return
val appName = applicationInfo.loadLabel(packageManager).toString()
var channel = notificationManager.getNotificationChannel(id)
if (channel == null) {
channel = NotificationChannel(id, appName, NotificationManager.IMPORTANCE_LOW)
channel.description = "Background location notification channel"
notificationManager.createNotificationChannel(channel)
}
}
private fun colorStringToInteger(color: String?): Int? {
return try {
Color.parseColor(color)
} catch (e: Exception) {
null
}
} //endregion
companion object {
private var sServiceId = 481756
}
}

View File

@@ -0,0 +1,245 @@
package expo.modules.location.taskConsumers
import android.app.PendingIntent
import android.app.job.JobParameters
import android.app.job.JobService
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.os.PersistableBundle
import android.util.Log
import com.google.android.gms.location.Geofence
import com.google.android.gms.location.GeofenceStatusCodes
import com.google.android.gms.location.GeofencingClient
import com.google.android.gms.location.GeofencingEvent
import com.google.android.gms.location.GeofencingRequest
import com.google.android.gms.location.LocationServices
import expo.modules.interfaces.taskManager.TaskConsumer
import expo.modules.interfaces.taskManager.TaskConsumerInterface
import expo.modules.interfaces.taskManager.TaskInterface
import expo.modules.interfaces.taskManager.TaskManagerUtilsInterface
import expo.modules.location.GeofencingException
import expo.modules.location.LocationHelpers
import expo.modules.location.records.GeofencingRegionState
import expo.modules.location.LocationModule
import java.util.UUID
class GeofencingTaskConsumer(context: Context, taskManagerUtils: TaskManagerUtilsInterface?) : TaskConsumer(context, taskManagerUtils), TaskConsumerInterface {
private var mTask: TaskInterface? = null
private var mPendingIntent: PendingIntent? = null
private var mGeofencingClient: GeofencingClient? = null
private var mGeofencingRequest: GeofencingRequest? = null
private var mGeofencingList: MutableList<Geofence> = ArrayList()
private var mRegions: MutableMap<String, PersistableBundle> = HashMap()
//region TaskConsumerInterface
override fun taskType(): String {
return "geofencing"
}
override fun didRegister(task: TaskInterface) {
mTask = task
startGeofencing()
}
override fun didUnregister() {
stopGeofencing()
mTask = null
mPendingIntent = null
mGeofencingClient = null
mGeofencingRequest = null
mGeofencingList.clear()
}
override fun setOptions(options: Map<String, Any>) {
super.setOptions(options)
stopGeofencing()
startGeofencing()
}
override fun didReceiveBroadcast(intent: Intent) {
val event = GeofencingEvent.fromIntent(intent) ?: run {
Log.w(TAG, "Received a null geofencing event. Ignoring")
return
}
if (event.hasError()) {
val errorMessage = getErrorString(event.errorCode)
val error = Error(errorMessage)
mTask?.execute(null, error)
return
}
// Get region state and event type from given transition type.
val geofenceTransition = event.geofenceTransition
val regionState = regionStateForTransitionType(geofenceTransition)
val eventType = eventTypeFromTransitionType(geofenceTransition)
val triggeringGeofences = event.triggeringGeofences ?: return
for (geofence in triggeringGeofences) {
mRegions[geofence.requestId]?.let {
val data = PersistableBundle()
// Update region state in region bundle.
it.putInt("state", regionState.ordinal)
data.putInt("eventType", eventType)
data.putPersistableBundle("region", it)
val context = context.applicationContext
taskManagerUtils.scheduleJob(context, mTask, listOf(data))
}
}
}
override fun didExecuteJob(jobService: JobService, params: JobParameters): Boolean {
val task = mTask ?: return false
val data = taskManagerUtils.extractDataFromJobParams(params)
for (item in data) {
val bundle = Bundle()
val region = Bundle()
region.putAll(item.getPersistableBundle("region"))
bundle.putInt("eventType", item.getInt("eventType"))
bundle.putBundle("region", region)
task.execute(bundle, null) { jobService.jobFinished(params, false) }
}
// Returning `true` indicates that the job is still running, but in async mode.
// In that case we're obligated to call `jobService.jobFinished` as soon as the async block finishes.
return true
}
//endregion
//region helpers
private fun startGeofencing() {
val context = context ?: run {
Log.w(TAG, "The context has been abandoned")
return
}
if (!LocationHelpers.isAnyProviderAvailable(context)) {
Log.w(TAG, "There is no location provider available")
return
}
mRegions = HashMap()
mGeofencingList = ArrayList()
// Create geofences from task options.
val options = mTask?.options
?: throw GeofencingException("Task is null, can't start geofencing")
val regions: List<HashMap<String, Any>> = (options["regions"] as ArrayList<*>).filterIsInstance<HashMap<String, Any>>()
for (region in regions) {
val geofence = geofenceFromRegion(region)
val regionIdentifier = geofence.requestId
// Make a bundle for the region to remember its attributes. Only request ID is public in Geofence object.
mRegions[regionIdentifier] = bundleFromRegion(regionIdentifier, region)
// Add geofence to the list of observed regions.
mGeofencingList.add(geofence)
}
// Prepare pending intent, geofencing request and client.
mPendingIntent = preparePendingIntent()
mGeofencingRequest = prepareGeofencingRequest(mGeofencingList)
mGeofencingClient = LocationServices.getGeofencingClient(getContext())
try {
mPendingIntent?.let { pendingIntent ->
mGeofencingRequest?.let { geofencingRequest ->
mGeofencingClient?.addGeofences(geofencingRequest, pendingIntent)
}
}
} catch (e: SecurityException) {
Log.w(TAG, "Geofencing request has been rejected.", e)
}
}
private fun stopGeofencing() {
mPendingIntent?.let {
mGeofencingClient?.removeGeofences(it)
it.cancel()
}
}
private fun prepareGeofencingRequest(geofences: List<Geofence>): GeofencingRequest {
return GeofencingRequest.Builder()
.setInitialTrigger(GeofencingRequest.INITIAL_TRIGGER_ENTER or GeofencingRequest.INITIAL_TRIGGER_EXIT)
.addGeofences(geofences)
.build()
}
private fun preparePendingIntent(): PendingIntent {
return taskManagerUtils.createTaskIntent(context, mTask)
}
private fun getParamAsDouble(param: Any?, errorMessage: String): Double {
return when (param) {
is Double -> param
is Float -> param.toDouble()
is Int -> param.toDouble()
is Long -> param.toDouble()
is String -> param.toDoubleOrNull()
else -> null
} ?: throw GeofencingException(errorMessage)
}
private fun geofenceFromRegion(region: Map<String, Any>): Geofence {
val identifier = region["identifier"] as? String ?: UUID.randomUUID().toString()
val radius = getParamAsDouble(region["radius"], "Region: radius: `${region["radius"]}` can't be cast to Double")
val longitude = getParamAsDouble(region["longitude"], "Region: longitude: `${region["longitude"]}` can't be cast to Double")
val latitude = getParamAsDouble(region["latitude"], "Region: latitude `${region["latitude"]}` can't be cast to Double")
val notifyOnEnter = region["notifyOnEnter"] as? Boolean ?: true
val notifyOnExit = region["notifyOnExit"] as? Boolean ?: true
val transitionTypes = (if (notifyOnEnter) Geofence.GEOFENCE_TRANSITION_ENTER else 0) or if (notifyOnExit) Geofence.GEOFENCE_TRANSITION_EXIT else 0
return Geofence.Builder()
.setRequestId(identifier)
.setCircularRegion(latitude, longitude, radius.toFloat())
.setExpirationDuration(Geofence.NEVER_EXPIRE)
.setTransitionTypes(transitionTypes)
.build()
}
private fun bundleFromRegion(identifier: String, region: Map<String, Any>): PersistableBundle {
return PersistableBundle().apply {
val radius = getParamAsDouble(region["radius"], "Region: radius: `${region["radius"]}` can't be cast to Double")
val longitude = getParamAsDouble(region["longitude"], "Region: longitude: `${region["longitude"]}` can't be cast to Double")
val latitude = getParamAsDouble(region["latitude"], "Region: latitude: `${region["latitude"]}` can't be cast to Double")
putString("identifier", identifier)
putDouble("radius", radius)
putDouble("latitude", latitude)
putDouble("longitude", longitude)
putInt("state", GeofencingRegionState.UNKNOWN.ordinal)
}
}
private fun regionStateForTransitionType(transitionType: Int): GeofencingRegionState {
return when (transitionType) {
Geofence.GEOFENCE_TRANSITION_ENTER, Geofence.GEOFENCE_TRANSITION_DWELL -> GeofencingRegionState.INSIDE
Geofence.GEOFENCE_TRANSITION_EXIT -> GeofencingRegionState.OUTSIDE
else -> GeofencingRegionState.UNKNOWN
}
}
private fun eventTypeFromTransitionType(transitionType: Int): Int {
return when (transitionType) {
Geofence.GEOFENCE_TRANSITION_ENTER -> LocationModule.GEOFENCING_EVENT_ENTER
Geofence.GEOFENCE_TRANSITION_EXIT -> LocationModule.GEOFENCING_EVENT_EXIT
else -> 0
}
}
companion object {
private const val TAG = "GeofencingTaskConsumer"
private fun getErrorString(errorCode: Int): String {
return when (errorCode) {
GeofenceStatusCodes.GEOFENCE_NOT_AVAILABLE -> "Geofencing not available."
GeofenceStatusCodes.GEOFENCE_TOO_MANY_GEOFENCES -> "Too many geofences."
GeofenceStatusCodes.GEOFENCE_TOO_MANY_PENDING_INTENTS -> "Too many pending intents."
else -> "Unknown geofencing error."
}
} //endregion
}
}

View File

@@ -0,0 +1,322 @@
package expo.modules.location.taskConsumers
import android.app.PendingIntent
import android.app.job.JobParameters
import android.app.job.JobService
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.location.Location
import android.os.Build
import android.os.Bundle
import android.os.IBinder
import android.os.PersistableBundle
import android.util.Log
import com.google.android.gms.location.FusedLocationProviderClient
import com.google.android.gms.location.LocationRequest
import com.google.android.gms.location.LocationResult
import com.google.android.gms.location.LocationServices
import expo.modules.core.MapHelper
import expo.modules.core.arguments.MapArguments
import expo.modules.core.arguments.ReadableArguments
import expo.modules.core.interfaces.Arguments
import expo.modules.core.interfaces.LifecycleEventListener
import expo.modules.interfaces.taskManager.TaskConsumer
import expo.modules.interfaces.taskManager.TaskConsumerInterface
import expo.modules.interfaces.taskManager.TaskExecutionCallback
import expo.modules.interfaces.taskManager.TaskInterface
import expo.modules.interfaces.taskManager.TaskManagerUtilsInterface
import expo.modules.location.AppForegroundedSingleton
import expo.modules.location.LocationHelpers
import expo.modules.location.records.LocationOptions
import expo.modules.location.records.LocationResponse
import expo.modules.location.services.LocationTaskService
import expo.modules.location.services.LocationTaskService.ServiceBinder
import kotlin.math.abs
class LocationTaskConsumer(context: Context, taskManagerUtils: TaskManagerUtilsInterface?) : TaskConsumer(context, taskManagerUtils), TaskConsumerInterface, LifecycleEventListener {
private var mTask: TaskInterface? = null
private var mPendingIntent: PendingIntent? = null
private var mService: LocationTaskService? = null
private var mLocationRequest: LocationRequest? = null
private var mLastReportedLocation: Location? = null
private var mDeferredDistance = 0.0
private val mDeferredLocations: MutableList<Location> = ArrayList()
private var mIsHostPaused = true
private val mLocationClient: FusedLocationProviderClient by lazy {
LocationServices.getFusedLocationProviderClient(context)
}
//region TaskConsumerInterface
override fun taskType(): String {
return "location"
}
override fun didRegister(task: TaskInterface) {
mTask = task
startLocationUpdates()
maybeStartForegroundService()
}
override fun didUnregister() {
stopLocationUpdates()
stopForegroundService()
mTask = null
mPendingIntent = null
mLocationRequest = null
}
override fun setOptions(options: Map<String, Any>) {
super.setOptions(options)
// Restart location updates
stopLocationUpdates()
startLocationUpdates()
// Restart foreground service if its option has changed.
maybeStartForegroundService()
}
override fun didReceiveBroadcast(intent: Intent) {
mTask ?: return
val result = LocationResult.extractResult(intent)
if (result != null) {
val locations = result.locations
deferLocations(locations)
maybeReportDeferredLocations()
} else {
try {
mLocationClient.lastLocation.addOnCompleteListener { task ->
task.result?.let {
deferLocations(listOf(it))
maybeReportDeferredLocations()
}
}
} catch (e: SecurityException) {
Log.e(TAG, "Cannot get last location: " + e.message)
}
}
}
override fun didExecuteJob(jobService: JobService, params: JobParameters): Boolean {
val data = taskManagerUtils.extractDataFromJobParams(params)
val locationBundles = ArrayList<Bundle>()
for (persistableLocationBundle in data) {
val locationBundle = Bundle()
val coordsBundle = Bundle()
if (persistableLocationBundle != null) {
coordsBundle.putAll(persistableLocationBundle.getPersistableBundle("coords"))
locationBundle.putAll(persistableLocationBundle)
locationBundle.putBundle("coords", coordsBundle)
locationBundles.add(locationBundle)
}
}
executeTaskWithLocationBundles(locationBundles) { jobService.jobFinished(params, false) }
// Returning `true` indicates that the job is still running, but in async mode.
// In that case we're obligated to call `jobService.jobFinished` as soon as the async block finishes.
return true
}
//region private
private fun startLocationUpdates() {
val context = context ?: run {
Log.w(TAG, "The context has been abandoned")
return
}
if (!LocationHelpers.isAnyProviderAvailable(context)) {
Log.w(TAG, "There is no location provider available")
return
}
val task = mTask ?: run {
Log.w(TAG, "Could not find a location task for the location update")
return
}
mLocationRequest = LocationHelpers.prepareLocationRequest(LocationOptions(task.options))
mPendingIntent = preparePendingIntent()
val locationRequest = mLocationRequest ?: run {
Log.w(TAG, "Could not find a location request for the location update")
return
}
val intent = mPendingIntent ?: run {
Log.w(TAG, "Could not find intent for the location update")
return
}
try {
mLocationClient.requestLocationUpdates(locationRequest, intent)
} catch (e: SecurityException) {
Log.w(TAG, "Location request has been rejected.", e)
}
}
private fun stopLocationUpdates() {
mPendingIntent?.let {
mLocationClient.removeLocationUpdates(it)
it.cancel()
}
}
private fun maybeStartForegroundService() {
// Foreground service is available as of Android Oreo.
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
return
}
if (!AppForegroundedSingleton.isForegrounded) {
Log.w(TAG, "Foreground location task cannot be started while the app is in the background!")
return
}
val task = mTask ?: run {
Log.w(TAG, "Location task is null")
return
}
val options: ReadableArguments = MapArguments(task.options)
val useForegroundService = shouldUseForegroundService(task.options)
// Service is already running, but the task has been registered again without `foregroundService` option.
if (mService != null && !useForegroundService) {
stopForegroundService()
return
}
// Service is not running and the user don't want to start foreground service.
if (!useForegroundService) {
return
}
// Foreground service is requested but not running.
if (mService == null) {
val serviceIntent = Intent(context, LocationTaskService::class.java)
val extras = Bundle()
val serviceOptions = options.getArguments(FOREGROUND_SERVICE_KEY).toBundle()
// extras param name is appId for legacy reasons
extras.putString("appId", task.appScopeKey)
extras.putString("taskName", task.name)
extras.putBoolean("killService", serviceOptions.getBoolean("killServiceOnDestroy", false))
serviceIntent.putExtras(extras)
context.startForegroundService(serviceIntent)
context.bindService(
serviceIntent,
object : ServiceConnection {
override fun onServiceConnected(name: ComponentName, service: IBinder) {
mService = (service as? ServiceBinder)?.service
mService?.let {
it.setParentContext(context)
it.startForeground(serviceOptions)
}
}
override fun onServiceDisconnected(name: ComponentName) {
mService?.stop()
mService = null
}
},
Context.BIND_AUTO_CREATE
)
} else {
// Restart the service with new service options.
mService?.startForeground(options.getArguments(FOREGROUND_SERVICE_KEY).toBundle())
}
}
private fun stopForegroundService() {
mService?.stop()
}
private fun deferLocations(locations: List<Location>) {
val size = mDeferredLocations.size
var lastLocation = if (size > 0) mDeferredLocations[size - 1] else mLastReportedLocation
for (location in locations) {
if (lastLocation != null) {
mDeferredDistance += abs(location.distanceTo(lastLocation)).toDouble()
}
lastLocation = location
}
mDeferredLocations.addAll(locations)
}
private fun maybeReportDeferredLocations() {
if (!shouldReportDeferredLocations()) {
return
}
val context = context.applicationContext
val data: MutableList<PersistableBundle> = ArrayList()
for (location in mDeferredLocations) {
val timestamp = location.time
// Some devices may broadcast the same location multiple times (mostly twice) so we're filtering out these locations,
// so only one location at the specific timestamp can schedule a job.
if (timestamp > sLastTimestamp) {
val bundle = LocationResponse(location).toBundle(PersistableBundle::class.java)
data.add(bundle)
sLastTimestamp = timestamp
}
}
if (data.size > 0) {
// Save last reported location, reset the distance and clear a list of locations.
mLastReportedLocation = mDeferredLocations[mDeferredLocations.size - 1]
mDeferredDistance = 0.0
mDeferredLocations.clear()
// Schedule new job.
taskManagerUtils.scheduleJob(context, mTask, data)
}
}
private fun shouldReportDeferredLocations(): Boolean {
val task = mTask ?: return false
if (mDeferredLocations.size == 0) {
return false
}
if (!mIsHostPaused) {
// Don't defer location updates when the activity is in foreground state.
return true
}
val oldestLocation = mLastReportedLocation ?: mDeferredLocations[0]
val newestLocation = mDeferredLocations[mDeferredLocations.size - 1]
val options: Arguments = MapHelper(task.options)
val distance = options.getDouble("deferredUpdatesDistance")
val interval = options.getLong("deferredUpdatesInterval")
return newestLocation.time - oldestLocation.time >= interval && mDeferredDistance >= distance
}
private fun preparePendingIntent(): PendingIntent {
return taskManagerUtils.createTaskIntent(context, mTask)
}
private fun executeTaskWithLocationBundles(locationBundles: ArrayList<Bundle>, callback: TaskExecutionCallback) {
if (locationBundles.size > 0 && mTask != null) {
val data = Bundle()
data.putParcelableArrayList("locations", locationBundles)
mTask?.execute(data, null, callback)
} else {
callback.onFinished(null)
}
}
override fun onHostResume() {
mIsHostPaused = false
maybeReportDeferredLocations()
}
override fun onHostPause() {
mIsHostPaused = true
}
override fun onHostDestroy() {
mIsHostPaused = true
} //endregion
companion object {
private const val TAG = "LocationTaskConsumer"
private const val FOREGROUND_SERVICE_KEY = "foregroundService"
private var sLastTimestamp: Long = 0
fun shouldUseForegroundService(options: Map<String?, Any?>): Boolean {
return options.containsKey(FOREGROUND_SERVICE_KEY)
}
}
}