@detonator/location 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,38 @@
1
+ plugins {
2
+ alias(libs.plugins.androidLibrary)
3
+ }
4
+
5
+ android {
6
+ namespace 'com.iconshot.detonator.location'
7
+ compileSdk 34
8
+
9
+ defaultConfig {
10
+ minSdk 29
11
+
12
+ testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
13
+ consumerProguardFiles "consumer-rules.pro"
14
+ }
15
+
16
+ buildTypes {
17
+ release {
18
+ minifyEnabled false
19
+ proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
20
+ }
21
+ }
22
+ compileOptions {
23
+ sourceCompatibility JavaVersion.VERSION_11
24
+ targetCompatibility JavaVersion.VERSION_11
25
+ }
26
+ }
27
+
28
+ dependencies {
29
+ implementation project(':Detonator')
30
+
31
+ implementation "com.google.android.gms:play-services-location:21.3.0"
32
+
33
+ implementation libs.appcompat
34
+ implementation libs.material
35
+ testImplementation libs.junit
36
+ androidTestImplementation libs.ext.junit
37
+ androidTestImplementation libs.espresso.core
38
+ }
File without changes
@@ -0,0 +1,21 @@
1
+ # Add project specific ProGuard rules here.
2
+ # You can control the set of applied configuration files using the
3
+ # proguardFiles setting in build.gradle.
4
+ #
5
+ # For more details, see
6
+ # http://developer.android.com/guide/developing/tools/proguard.html
7
+
8
+ # If your project uses WebView with JS, uncomment the following
9
+ # and specify the fully qualified class name to the JavaScript interface
10
+ # class:
11
+ #-keepclassmembers class fqcn.of.javascript.interface.for.webview {
12
+ # public *;
13
+ #}
14
+
15
+ # Uncomment this to preserve the line number information for
16
+ # debugging stack traces.
17
+ #-keepattributes SourceFile,LineNumberTable
18
+
19
+ # If you keep the line number information, uncomment this to
20
+ # hide the original source file name.
21
+ #-renamesourcefileattribute SourceFile
@@ -0,0 +1,26 @@
1
+ package com.iconshot.detonator.location;
2
+
3
+ import android.content.Context;
4
+
5
+ import androidx.test.platform.app.InstrumentationRegistry;
6
+ import androidx.test.ext.junit.runners.AndroidJUnit4;
7
+
8
+ import org.junit.Test;
9
+ import org.junit.runner.RunWith;
10
+
11
+ import static org.junit.Assert.*;
12
+
13
+ /**
14
+ * Instrumented test, which will execute on an Android device.
15
+ *
16
+ * @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
17
+ */
18
+ @RunWith(AndroidJUnit4.class)
19
+ public class ExampleInstrumentedTest {
20
+ @Test
21
+ public void useAppContext() {
22
+ // Context of the app under test.
23
+ Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext();
24
+ assertEquals("com.iconshot.detonator.location.test", appContext.getPackageName());
25
+ }
26
+ }
@@ -0,0 +1,11 @@
1
+ <?xml version="1.0" encoding="utf-8"?>
2
+ <manifest xmlns:android="http://schemas.android.com/apk/res/android">
3
+ <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
4
+ <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
5
+ <uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
6
+
7
+ <application>
8
+ <activity android:name=".LocationActivity"
9
+ android:exported="false"/>
10
+ </application>
11
+ </manifest>
@@ -0,0 +1,58 @@
1
+ package com.iconshot.detonator.location;
2
+
3
+ import android.Manifest;
4
+ import android.app.Activity;
5
+ import android.content.pm.PackageManager;
6
+ import android.os.Bundle;
7
+
8
+ import androidx.annotation.NonNull;
9
+ import androidx.core.app.ActivityCompat;
10
+
11
+ import java.util.function.Consumer;
12
+
13
+ public class LocationActivity extends Activity {
14
+ public static Consumer<Boolean> permissionResultCallback;
15
+
16
+ public static Boolean background;
17
+
18
+ @Override
19
+ protected void onCreate(Bundle savedInstanceState) {
20
+ super.onCreate(savedInstanceState);
21
+
22
+ // activities get recreated on permission downgrade (e.g. Allow in use -> Don't allow)
23
+
24
+ if (background == null) {
25
+ finish();
26
+
27
+ return;
28
+ }
29
+
30
+ if (background) {
31
+ ActivityCompat.requestPermissions(
32
+ this,
33
+ new String[]{Manifest.permission.ACCESS_BACKGROUND_LOCATION},
34
+ 100
35
+ );
36
+ } else {
37
+ ActivityCompat.requestPermissions(
38
+ this,
39
+ new String[]{Manifest.permission.ACCESS_FINE_LOCATION},
40
+ 100
41
+ );
42
+ }
43
+ }
44
+
45
+ @Override
46
+ public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions,
47
+ @NonNull int[] grantResults) {
48
+ boolean hasGrantedLocationPermission = grantResults[0] == PackageManager.PERMISSION_GRANTED;
49
+
50
+ boolean permissionsGranted = hasGrantedLocationPermission;
51
+
52
+ if (permissionResultCallback != null) {
53
+ permissionResultCallback.accept(permissionsGranted);
54
+ }
55
+
56
+ finish();
57
+ }
58
+ }
@@ -0,0 +1,155 @@
1
+ package com.iconshot.detonator.location;
2
+
3
+ import android.Manifest;
4
+ import android.content.Intent;
5
+ import android.content.pm.PackageManager;
6
+
7
+ import androidx.core.content.ContextCompat;
8
+
9
+ import com.google.android.gms.location.FusedLocationProviderClient;
10
+ import com.google.android.gms.location.LocationServices;
11
+ import com.google.android.gms.location.Priority;
12
+ import com.google.android.gms.tasks.CancellationTokenSource;
13
+ import com.iconshot.detonator.Detonator;
14
+ import com.iconshot.detonator.module.Module;
15
+
16
+ public class LocationModule extends Module {
17
+ private FusedLocationProviderClient locationClient;
18
+
19
+ public LocationModule(Detonator detonator) {
20
+ super(detonator);
21
+ }
22
+
23
+ @Override
24
+ public void setUp() {
25
+ locationClient = LocationServices.getFusedLocationProviderClient(detonator.context);
26
+
27
+ detonator.setRequestListener("com.iconshot.detonator.location::requestPermission", (promise, value, edge) -> {
28
+ RequestPermissionOptions options = detonator.decode(value, RequestPermissionOptions.class);
29
+
30
+ boolean permissionGranted = options.background
31
+ ? ContextCompat.checkSelfPermission(
32
+ detonator.context,
33
+ Manifest.permission.ACCESS_BACKGROUND_LOCATION
34
+ ) == PackageManager.PERMISSION_GRANTED
35
+ : ContextCompat.checkSelfPermission(
36
+ detonator.context,
37
+ Manifest.permission.ACCESS_FINE_LOCATION
38
+ ) == PackageManager.PERMISSION_GRANTED;
39
+
40
+ if (permissionGranted) {
41
+ promise.resolve(true);
42
+
43
+ return;
44
+ }
45
+
46
+ LocationActivity.background = options.background;
47
+
48
+ LocationActivity.permissionResultCallback = granted -> {
49
+ promise.resolve(granted);
50
+ };
51
+
52
+ detonator.context.startActivity(new Intent(detonator.context, LocationActivity.class));
53
+ });
54
+
55
+ detonator.setRequestListener("com.iconshot.detonator.location::getLocation", (promise, value, edge) -> {
56
+ GetLocationOptions options = detonator.decode(value, GetLocationOptions.class);
57
+
58
+ CancellationTokenSource token = new CancellationTokenSource();
59
+
60
+ Runnable timeoutRunnable = () -> {
61
+ token.cancel();
62
+
63
+ promise.reject("Location request timed out.");
64
+ };
65
+
66
+ detonator.uiHandler.postDelayed(timeoutRunnable, options.timeout);
67
+
68
+ locationClient.getCurrentLocation(
69
+ options.accurate
70
+ ? Priority.PRIORITY_HIGH_ACCURACY
71
+ : Priority.PRIORITY_BALANCED_POWER_ACCURACY,
72
+ token.getToken()
73
+ ).addOnSuccessListener(location -> {
74
+ detonator.uiHandler.removeCallbacks(timeoutRunnable);
75
+
76
+ token.cancel();
77
+
78
+ if (location == null) {
79
+ promise.reject("Location not available.");
80
+
81
+ return;
82
+ }
83
+
84
+ if (!location.hasAccuracy()) {
85
+ promise.reject("Location lacks accuracy.");
86
+
87
+ return;
88
+ }
89
+
90
+ if (location.getAccuracy() > options.maxAccuracy) {
91
+ promise.reject("Location not accurate enough.");
92
+
93
+ return;
94
+ }
95
+
96
+ long age = System.currentTimeMillis() - location.getTime();
97
+
98
+ if (age > options.maxAge) {
99
+ promise.reject("Location too old.");
100
+
101
+ return;
102
+ }
103
+
104
+ LocationData data = new LocationData();
105
+
106
+ data.latitude = location.getLatitude();
107
+ data.longitude = location.getLongitude();
108
+ data.altitude = location.hasAltitude() ? location.getAltitude() : null;
109
+ data.accuracy = location.hasAccuracy() ? location.getAccuracy() : null;
110
+ data.speed = location.hasSpeed() ? location.getSpeed() : null;
111
+ data.time = location.getTime();
112
+ data.bearing = location.hasBearing() ? location.getBearing() : null;
113
+ data.provider = location.getProvider();
114
+
115
+ data.verticalAccuracy = location.hasVerticalAccuracy()
116
+ ? location.getVerticalAccuracyMeters()
117
+ : null;
118
+
119
+ data.course = data.bearing;
120
+
121
+ promise.resolve(data);
122
+ }).addOnFailureListener(error -> {
123
+ detonator.uiHandler.removeCallbacks(timeoutRunnable);
124
+
125
+ token.cancel();
126
+
127
+ promise.reject("Location not available.");
128
+ });
129
+ });
130
+ }
131
+
132
+ public static class RequestPermissionOptions {
133
+ boolean background;
134
+ }
135
+
136
+ public static class GetLocationOptions {
137
+ boolean accurate;
138
+ int maxAge;
139
+ double maxAccuracy;
140
+ int timeout;
141
+ }
142
+
143
+ public static class LocationData {
144
+ double latitude;
145
+ double longitude;
146
+ Double altitude;
147
+ Float accuracy;
148
+ Float speed;
149
+ long time;
150
+ Float bearing;
151
+ String provider;
152
+ Float verticalAccuracy;
153
+ Float course;
154
+ }
155
+ }
@@ -0,0 +1,17 @@
1
+ package com.iconshot.detonator.location;
2
+
3
+ import org.junit.Test;
4
+
5
+ import static org.junit.Assert.*;
6
+
7
+ /**
8
+ * Example local unit test, which will execute on the development machine (host).
9
+ *
10
+ * @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
11
+ */
12
+ public class ExampleUnitTest {
13
+ @Test
14
+ public void addition_isCorrect() {
15
+ assertEquals(4, 2 + 2);
16
+ }
17
+ }
@@ -0,0 +1,23 @@
1
+ export interface LocationData {
2
+ latitude: number;
3
+ longitude: number;
4
+ altitude: number | null;
5
+ accuracy: number | null;
6
+ speed: number | null;
7
+ time: number;
8
+ bearing: number | null;
9
+ provider: string | null;
10
+ verticalAccuracy: number | null;
11
+ course: number | null;
12
+ }
13
+ export declare class Location {
14
+ static requestPermission(options?: Partial<{
15
+ background: boolean;
16
+ }>): Promise<boolean>;
17
+ static getLocation(options?: Partial<{
18
+ accurate: boolean;
19
+ maxAge: number;
20
+ maxAccuracy: number;
21
+ timeout: number;
22
+ }>): Promise<LocationData>;
23
+ }
@@ -0,0 +1,21 @@
1
+ import Detonator, { Platform } from "detonator";
2
+ export class Location {
3
+ static async requestPermission(options = {}) {
4
+ const tmpOptions = { ...options };
5
+ tmpOptions.background = options.background ?? false;
6
+ return await Detonator.request("com.iconshot.detonator.location::requestPermission", tmpOptions).fetchAndDecode();
7
+ }
8
+ static async getLocation(options = {}) {
9
+ const tmpOptions = { ...options };
10
+ tmpOptions.accurate = options.accurate ?? false;
11
+ if (Platform.get() === "ios") {
12
+ tmpOptions.accurate = true;
13
+ }
14
+ tmpOptions.maxAge = options.maxAge ?? 15000;
15
+ tmpOptions.maxAccuracy =
16
+ options.maxAccuracy ?? (tmpOptions.accurate ? 60 : 200);
17
+ tmpOptions.timeout =
18
+ options.timeout ?? (tmpOptions.accurate ? 20000 : 10000);
19
+ return await Detonator.request("com.iconshot.detonator.location::getLocation", tmpOptions).fetchAndDecode();
20
+ }
21
+ }
@@ -0,0 +1 @@
1
+ export * from "./Location";
@@ -0,0 +1 @@
1
+ export * from "./Location";
@@ -0,0 +1,28 @@
1
+ // swift-tools-version: 5.10
2
+ // The swift-tools-version declares the minimum version of Swift required to build this package.
3
+
4
+ import PackageDescription
5
+
6
+ let package = Package(
7
+ name: "DetonatorLocation",
8
+ platforms: [
9
+ .iOS(.v13),
10
+ ],
11
+ products: [
12
+ // Products define the executables and libraries a package produces, making them visible to other packages.
13
+ .library(
14
+ name: "DetonatorLocation",
15
+ targets: ["DetonatorLocation"]),
16
+ ],
17
+ dependencies: [
18
+ .package(name: "Detonator", path: "../../../../../node_modules/detonator/ios/Detonator")
19
+ ],
20
+ targets: [
21
+ // Targets are the basic building blocks of a package, defining a module or a test suite.
22
+ // Targets can depend on other targets in this package and products from dependencies.
23
+ .target(
24
+ name: "DetonatorLocation",
25
+ dependencies: ["Detonator"]),
26
+
27
+ ]
28
+ )
@@ -0,0 +1,124 @@
1
+ import CoreLocation
2
+
3
+ import Detonator
4
+
5
+ public class LocationGetter: NSObject, CLLocationManagerDelegate {
6
+ private let locationManager = CLLocationManager()
7
+
8
+ private let promise: RequestPromise
9
+ private let options: GetLocationOptions
10
+
11
+ private var timeoutTimer: Timer?
12
+ private var fulfillTimer: Timer?
13
+
14
+ private var ended: Bool = false
15
+
16
+ public var onEnded: (() -> Void)!
17
+
18
+ init(_ promise: RequestPromise, _ options: GetLocationOptions) {
19
+ self.promise = promise
20
+ self.options = options
21
+
22
+ super.init()
23
+
24
+ locationManager.delegate = self
25
+ }
26
+
27
+ public func run() -> Void {
28
+ timeoutTimer = Timer.scheduledTimer(
29
+ withTimeInterval: TimeInterval(options.timeout) / 1000.0,
30
+ repeats: false
31
+ ) { _ in
32
+ self.timeoutTimer?.invalidate()
33
+
34
+ self.promise.reject("Location request timed out.")
35
+
36
+ self.end()
37
+ }
38
+
39
+ locationManager.requestLocation()
40
+ }
41
+
42
+ private func end() -> Void {
43
+ ended = true
44
+
45
+ onEnded()
46
+ }
47
+
48
+ public func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
49
+ if ended {
50
+ return
51
+ }
52
+
53
+ timeoutTimer?.invalidate()
54
+
55
+ fulfillTimer?.invalidate()
56
+
57
+ fulfillTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false) { _ in
58
+ guard let location = locations.last else {
59
+ self.promise.reject("Location not available.")
60
+
61
+ self.end()
62
+
63
+ return
64
+ }
65
+
66
+ if location.horizontalAccuracy < 0 {
67
+ self.promise.reject("Location lacks accuracy.")
68
+
69
+ self.end()
70
+
71
+ return
72
+ }
73
+
74
+ if Double(location.horizontalAccuracy) > self.options.maxAccuracy {
75
+ self.promise.reject("Location not accurate enough.")
76
+
77
+ self.end()
78
+
79
+ return
80
+ }
81
+
82
+ let age = Int(Date().timeIntervalSince1970 * 1000) - Int(location.timestamp.timeIntervalSince1970 * 1000)
83
+
84
+ if age > self.options.maxAge {
85
+ self.promise.reject("Location too old.")
86
+
87
+ self.end()
88
+
89
+ return
90
+ }
91
+
92
+ let data = LocationData(
93
+ latitude: location.coordinate.latitude,
94
+ longitude: location.coordinate.longitude,
95
+ altitude: location.verticalAccuracy < 0 ? nil : location.altitude,
96
+ accuracy: location.horizontalAccuracy,
97
+ speed: location.speed >= 0 ? location.speed : nil,
98
+ time: Int(location.timestamp.timeIntervalSince1970 * 1000),
99
+ bearing: location.course >= 0 ? location.course : nil,
100
+ provider: "Apple",
101
+ verticalAccuracy: location.verticalAccuracy >= 0 ? location.verticalAccuracy : nil,
102
+ course: location.course >= 0 ? location.course : nil,
103
+ )
104
+
105
+ self.promise.resolve(data)
106
+
107
+ self.end()
108
+ }
109
+ }
110
+
111
+ // either update or failure will happen, not both
112
+
113
+ public func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
114
+ if ended {
115
+ return
116
+ }
117
+
118
+ timeoutTimer?.invalidate()
119
+
120
+ promise.reject("Location not available.")
121
+
122
+ end()
123
+ }
124
+ }
@@ -0,0 +1,154 @@
1
+ import Foundation
2
+ import UIKit
3
+ import CoreLocation
4
+
5
+ import Detonator
6
+
7
+ public class LocationModule: Module, CLLocationManagerDelegate {
8
+ private let locationManager = CLLocationManager()
9
+
10
+ private var requestPermissionPromise: RequestPromise?
11
+ private var requestPermissionBackground: Bool?
12
+ private var requestPermissionTimer: Timer?
13
+
14
+ private var locationGetters: [LocationGetter] = []
15
+
16
+ public override func setUp() -> Void {
17
+ locationManager.delegate = self
18
+
19
+ NotificationCenter.default.addObserver(
20
+ self,
21
+ selector: #selector(self.permissionDialogOpened),
22
+ name: UIApplication.willResignActiveNotification,
23
+ object: nil
24
+ )
25
+
26
+ NotificationCenter.default.addObserver(
27
+ self,
28
+ selector: #selector(self.permissionDialogClosed),
29
+ name: UIApplication.didBecomeActiveNotification,
30
+ object: nil
31
+ )
32
+
33
+ detonator.setRequestListener("com.iconshot.detonator.location::requestPermission") { promise, value, edge in
34
+ let options: RequestPermissionOptions = self.detonator.decode(value)!
35
+
36
+ let status = CLLocationManager.authorizationStatus()
37
+
38
+ if options.background {
39
+ if status == .authorizedAlways {
40
+ promise.resolve(true)
41
+
42
+ return
43
+ }
44
+ } else {
45
+ if status == .authorizedWhenInUse || status == .authorizedAlways {
46
+ promise.resolve(true)
47
+
48
+ return
49
+ }
50
+ }
51
+
52
+ if status == .denied || status == .restricted {
53
+ promise.resolve(false)
54
+
55
+ return
56
+ }
57
+
58
+ self.requestPermissionPromise = promise
59
+ self.requestPermissionBackground = options.background
60
+
61
+ if options.background {
62
+ self.locationManager.requestAlwaysAuthorization()
63
+
64
+ self.requestPermissionTimer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: false) { _ in
65
+ self.resolveBackgroundRequestPermissionIfDialogNotOpened()
66
+ }
67
+ } else {
68
+ self.locationManager.requestWhenInUseAuthorization()
69
+ }
70
+ }
71
+
72
+ detonator.setRequestListener("com.iconshot.detonator.location::getLocation") { promise, value, edge in
73
+ let options: GetLocationOptions = self.detonator.decode(value)!
74
+
75
+ let locationGetter = LocationGetter(promise, options)
76
+
77
+ self.locationGetters.append(locationGetter)
78
+
79
+ locationGetter.onEnded = {
80
+ self.locationGetters.removeAll { $0 === locationGetter }
81
+ }
82
+
83
+ locationGetter.run()
84
+ }
85
+ }
86
+
87
+ @objc private func permissionDialogOpened() {
88
+ guard let requestPermissionPromise = self.requestPermissionPromise else {
89
+ return
90
+ }
91
+
92
+ self.requestPermissionTimer?.invalidate()
93
+
94
+ self.requestPermissionTimer = nil
95
+ }
96
+
97
+ @objc private func permissionDialogClosed() {
98
+ guard
99
+ let requestPermissionPromise = self.requestPermissionPromise,
100
+ let requestPermissionBackground = self.requestPermissionBackground
101
+ else {
102
+ return
103
+ }
104
+
105
+ let status = CLLocationManager.authorizationStatus()
106
+
107
+ let granted = requestPermissionBackground
108
+ ? status == .authorizedAlways
109
+ : status == .authorizedWhenInUse || status == .authorizedAlways
110
+
111
+ requestPermissionPromise.resolve(granted)
112
+
113
+ self.requestPermissionPromise = nil
114
+ self.requestPermissionBackground = nil
115
+ }
116
+
117
+ private func resolveBackgroundRequestPermissionIfDialogNotOpened() {
118
+ guard let requestPermissionPromise = self.requestPermissionPromise else {
119
+ return
120
+ }
121
+
122
+ requestPermissionPromise.resolve(false)
123
+
124
+ self.requestPermissionTimer?.invalidate()
125
+
126
+ self.requestPermissionPromise = nil
127
+ self.requestPermissionBackground = nil
128
+ self.requestPermissionTimer = nil
129
+ }
130
+ }
131
+
132
+ private struct RequestPermissionOptions: Decodable {
133
+ let background: Bool
134
+ }
135
+
136
+ public struct GetLocationOptions: Decodable {
137
+ let accurate: Bool
138
+ let maxAge: Int
139
+ let maxAccuracy: Double
140
+ let timeout: Int
141
+ }
142
+
143
+ public struct LocationData: Encodable {
144
+ let latitude: Double
145
+ let longitude: Double
146
+ let altitude: Double?
147
+ let accuracy: Double?
148
+ let speed: Double?
149
+ let time: Int
150
+ let bearing: Double?
151
+ let provider: String?
152
+ let verticalAccuracy: Double?
153
+ let course: Double?
154
+ }
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "@detonator/location",
3
+ "version": "1.0.0",
4
+ "description": "Location module for Detonator.",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "https://github.com/iconshot/detonator-location.git"
8
+ },
9
+ "type": "module",
10
+ "main": "./dist/esm/index.js",
11
+ "types": "./dist/esm/index.d.ts",
12
+ "scripts": {
13
+ "remove-dist": "rm -rf dist",
14
+ "bundle-dist": "npm run remove-dist && tsc",
15
+ "build": "npm run bundle-dist",
16
+ "prepare": "npm run build"
17
+ },
18
+ "keywords": [
19
+ "location",
20
+ "detonator",
21
+ "untrue"
22
+ ],
23
+ "license": "MIT",
24
+ "peerDependencies": {
25
+ "detonator": "^1.11.2",
26
+ "untrue": "^5.18.0"
27
+ },
28
+ "devDependencies": {
29
+ "typescript": "^5.9.3"
30
+ }
31
+ }