Skip to content

Commit 5679c6c

Browse files
authored
Merge pull request #42 from LPK98/isira
Add background running applications and improve crash safety
2 parents 6c40792 + 563f46d commit 5679c6c

6 files changed

Lines changed: 184 additions & 84 deletions

File tree

app.config.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ export default {
2727

2828
ios: {
2929
supportsTablet: true,
30+
config: {
31+
googleMapsApiKey: process.env.GOOGLE_MAPS_API_KEY,
32+
},
3033
infoPlist: {
3134
NSLocationWhenInUseUsageDescription:
3235
"This app uses your location to show nearby places and reports.",
@@ -56,6 +59,11 @@ export default {
5659
"FOREGROUND_SERVICE",
5760
"FOREGROUND_SERVICE_LOCATION",
5861
],
62+
config: {
63+
googleMaps: {
64+
apiKey: process.env.GOOGLE_MAPS_API_KEY,
65+
},
66+
},
5967
package: "com.anonymous.CrimelinkAnalyzer_app",
6068
jsEngine: "hermes",
6169
},

app/(screens)/SafetyZone.tsx

Lines changed: 79 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import OverlayButton from "@/src/components/UI/OverlayButton";
22
import Searchbar from "@/src/components/UI/Searchbar";
3+
import { appConfig } from "@/src/constants/appConfig";
34
import { images } from "@/src/constants/images";
45
import { getCrimeLocations } from "@/src/services/safetyzoneService";
56
import { useTheme } from "@/src/theme/ThemeProvider";
@@ -18,7 +19,7 @@ import {
1819
View,
1920
useWindowDimensions,
2021
} from "react-native";
21-
import MapView, { Marker, Region } from "react-native-maps";
22+
import MapView, { Marker, PROVIDER_GOOGLE, Region } from "react-native-maps";
2223
import {
2324
SafeAreaView,
2425
useSafeAreaInsets,
@@ -88,6 +89,7 @@ const SafetyZone = () => {
8889
const [region, setRegion] = useState<Region | null>(null);
8990
const [isFullScreen, setIsFullScreen] = useState(false);
9091
const [crimeLocations, setCrimeLocations] = useState<CrimeLocationType[]>([]);
92+
const hasGoogleMapsApiKey = Boolean(appConfig.googleMapsApiKey?.trim());
9193

9294
useEffect(() => {
9395
const loadLocation = async () => {
@@ -265,56 +267,69 @@ const SafetyZone = () => {
265267
},
266268
]}
267269
>
268-
<MapView
269-
ref={mapRef}
270-
style={styles.map}
271-
provider="google"
272-
region={region}
273-
onMapReady={() => onMapReady(mapRef)}
274-
onRegionChangeComplete={(nextRegion) => {
275-
if (isAnimatingRef.current) return;
276-
setRegion(nextRegion);
277-
}}
278-
showsUserLocation={true}
279-
showsMyLocationButton={false}
280-
minZoomLevel={6}
281-
maxZoomLevel={18}
282-
>
283-
{crimeLocations.map((loc, index) => (
284-
<Marker
285-
key={`${loc.latitude}-${loc.longitude}-${index}`}
286-
coordinate={{
287-
latitude: loc.latitude,
288-
longitude: loc.longitude,
270+
{hasGoogleMapsApiKey ? (
271+
<>
272+
<MapView
273+
ref={mapRef}
274+
style={styles.map}
275+
provider={PROVIDER_GOOGLE}
276+
region={region}
277+
onMapReady={() => onMapReady(mapRef)}
278+
onRegionChangeComplete={(nextRegion) => {
279+
if (isAnimatingRef.current) return;
280+
setRegion(nextRegion);
289281
}}
290-
title={loc.crimeType}
291-
/>
292-
))}
293-
</MapView>
294-
295-
<View style={styles.mapActions} pointerEvents="box-none">
296-
<OverlayButton
297-
icon="fullscreen"
298-
iconColor="#ffffff"
299-
size={24}
300-
onPress={() => setIsFullScreen((v) => !v)}
301-
/>
302-
<OverlayButton
303-
icon="add"
304-
size={24}
305-
onPress={() => zoom("in")}
306-
/>
307-
<OverlayButton
308-
icon="remove"
309-
size={24}
310-
onPress={() => zoom("out")}
311-
/>
312-
<OverlayButton
313-
icon="my-location"
314-
size={24}
315-
onPress={myLocation}
316-
/>
317-
</View>
282+
showsUserLocation={true}
283+
showsMyLocationButton={false}
284+
minZoomLevel={6}
285+
maxZoomLevel={18}
286+
>
287+
{crimeLocations.map((loc, index) => (
288+
<Marker
289+
key={`${loc.latitude}-${loc.longitude}-${index}`}
290+
coordinate={{
291+
latitude: loc.latitude,
292+
longitude: loc.longitude,
293+
}}
294+
title={loc.crimeType}
295+
/>
296+
))}
297+
</MapView>
298+
299+
<View style={styles.mapActions} pointerEvents="box-none">
300+
<OverlayButton
301+
icon="fullscreen"
302+
iconColor="#ffffff"
303+
size={24}
304+
onPress={() => setIsFullScreen((v) => !v)}
305+
/>
306+
<OverlayButton
307+
icon="add"
308+
size={24}
309+
onPress={() => zoom("in")}
310+
/>
311+
<OverlayButton
312+
icon="remove"
313+
size={24}
314+
onPress={() => zoom("out")}
315+
/>
316+
<OverlayButton
317+
icon="my-location"
318+
size={24}
319+
onPress={myLocation}
320+
/>
321+
</View>
322+
</>
323+
) : (
324+
<View style={styles.mapUnavailable}>
325+
<Text
326+
style={[styles.mapUnavailableText, { color: colors.text }]}
327+
>
328+
Google Maps API key is missing. Set GOOGLE_MAPS_API_KEY for
329+
this EAS profile.
330+
</Text>
331+
</View>
332+
)}
318333
</View>
319334
</View>
320335

@@ -351,7 +366,7 @@ const SafetyZone = () => {
351366
</View>
352367
</ImageBackground>
353368

354-
{isFullScreen && (
369+
{isFullScreen && hasGoogleMapsApiKey && (
355370
<View
356371
style={[
357372
styles.fullScreenOverlay,
@@ -362,7 +377,7 @@ const SafetyZone = () => {
362377
<MapView
363378
ref={fullMapRef}
364379
style={{ flex: 1 }}
365-
provider="google"
380+
provider={PROVIDER_GOOGLE}
366381
region={region}
367382
onMapReady={() => onMapReady(fullMapRef)}
368383
onRegionChangeComplete={(r) => {
@@ -485,6 +500,18 @@ const styles = StyleSheet.create({
485500
flex: 1,
486501
width: "100%",
487502
},
503+
mapUnavailable: {
504+
flex: 1,
505+
alignItems: "center",
506+
justifyContent: "center",
507+
paddingHorizontal: 20,
508+
},
509+
mapUnavailableText: {
510+
fontSize: 14,
511+
fontWeight: "600",
512+
textAlign: "center",
513+
lineHeight: 20,
514+
},
488515
mapActions: {
489516
position: "absolute",
490517
top: 10,

app/_layout.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import { router, Stack, usePathname } from "expo-router";
33
import React, { useEffect } from "react";
44
import AuthProvider from "../src/context/AuthContext";
55
import { useAuth } from "../src/hooks/useAuth";
6+
import { bootstrapLocationTracking } from "../src/services/location/locationBootstrap";
7+
import "../src/services/location/locationTracker";
68
import "./global.css";
79

810
function Guard({ children }: { children: React.ReactNode }) {
@@ -20,6 +22,12 @@ function Guard({ children }: { children: React.ReactNode }) {
2022
}
2123
}, [user, loading, pathname]);
2224

25+
useEffect(() => {
26+
if (loading) return;
27+
28+
void bootstrapLocationTracking();
29+
}, [loading]);
30+
2331
return <>{children}</>;
2432
}
2533

src/components/UI/Searchbar.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,18 @@
1+
import { useTheme } from "@/src/theme/ThemeProvider";
12
import React from "react";
23
import { TextInput, View } from "react-native";
34

45
const Searchbar = () => {
6+
const { colors } = useTheme();
7+
58
return (
69
<View style={{ padding: 10 }}>
710
<TextInput
811
placeholder="🔍 Search location or sector..."
912
className="bg-white rounded-lg p-2 w-full"
1013
style={{
1114
fontSize: 16,
12-
color: "black",
15+
color: colors.white,
1316
paddingHorizontal: 12,
1417
borderRadius: 30,
1518
paddingVertical: 10,
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { isDutyTrackingEnabled } from "@/src/auth/auth";
2+
import { initLocationDB } from "@/src/services/location/locationDB";
3+
import {
4+
isTrackingActive,
5+
startTracking,
6+
stopTracking,
7+
} from "@/src/services/location/locationTracker";
8+
9+
let hasBootstrappedLocationTracking = false;
10+
11+
export async function bootstrapLocationTracking() {
12+
if (hasBootstrappedLocationTracking) return;
13+
14+
hasBootstrappedLocationTracking = true;
15+
16+
initLocationDB();
17+
18+
const dutyTrackingEnabled = await isDutyTrackingEnabled();
19+
const trackingActive = await isTrackingActive();
20+
21+
if (dutyTrackingEnabled && !trackingActive) {
22+
try {
23+
await startTracking();
24+
} catch (error) {
25+
// Startup should not crash if permissions are missing; user can re-enable from Duty toggle.
26+
console.warn("[LOCATION] Failed to resume tracking on app start:", error);
27+
}
28+
return;
29+
}
30+
31+
if (!dutyTrackingEnabled && trackingActive) {
32+
await stopTracking();
33+
}
34+
}

src/services/location/locationTracker.ts

Lines changed: 51 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -20,38 +20,48 @@ function createLocationPermissionError(code: string, message: string) {
2020
return error;
2121
}
2222

23-
TaskManager.defineTask(TASK_NAME, async ({ data, error }) => {
24-
console.log("[LOCATION] Background task triggered"); //REMOVE: for testing
25-
if (error) {
26-
console.error("[LOCATION] Task error", error); //REMOVE: for testing
27-
return;
28-
}
29-
30-
const location = (data as any)?.locations?.[0];
31-
if (!location) return;
32-
33-
const { latitude, longitude, accuracy, speed, heading } = location.coords;
34-
35-
if (accuracy != null && accuracy > 50) return;
23+
if (!TaskManager.isTaskDefined(TASK_NAME)) {
24+
TaskManager.defineTask(TASK_NAME, async ({ data, error }) => {
25+
console.log("[LOCATION] Background task triggered"); //REMOVE: for testing
26+
if (error) {
27+
console.error("[LOCATION] Task error", error); //REMOVE: for testing
28+
return;
29+
}
3630

37-
addPendingLocation({
38-
id: Crypto.randomUUID(),
39-
ts: new Date(location.timestamp).toISOString(),
40-
latitude,
41-
longitude,
42-
accuracyM: accuracy ?? null,
43-
speedMps: speed ?? null,
44-
headingDeg: heading ?? null,
45-
provider: "gps",
46-
meta: JSON.stringify({ battery: null }),
31+
const location = (data as any)?.locations?.[0];
32+
if (!location) return;
33+
34+
const { latitude, longitude, accuracy, speed, heading } = location.coords;
35+
36+
if (accuracy != null && accuracy > 50) return;
37+
38+
addPendingLocation({
39+
id: Crypto.randomUUID(),
40+
ts: new Date(location.timestamp).toISOString(),
41+
latitude,
42+
longitude,
43+
accuracyM: accuracy ?? null,
44+
speedMps: speed ?? null,
45+
headingDeg: heading ?? null,
46+
provider: "gps",
47+
meta: JSON.stringify({ battery: null }),
48+
});
49+
50+
try {
51+
await removePendingLocations();
52+
} catch {}
53+
const count = countPendingLocations(); //REMOVE:for testing
54+
console.log("[LOCATION] Pending rows in SQLite:", count);
4755
});
56+
}
57+
58+
export function getTrackingTaskName() {
59+
return TASK_NAME;
60+
}
4861

49-
try {
50-
await removePendingLocations();
51-
} catch {}
52-
const count = countPendingLocations(); //REMOVE:for testing
53-
console.log("[LOCATION] Pending rows in SQLite:", count);
54-
});
62+
export async function isTrackingActive() {
63+
return Location.hasStartedLocationUpdatesAsync(TASK_NAME);
64+
}
5565

5666
export async function startTracking() {
5767
const isBackgroundLocationAvailable =
@@ -103,7 +113,7 @@ export async function startTracking() {
103113
);
104114
}
105115

106-
const hasStarted = await Location.hasStartedLocationUpdatesAsync(TASK_NAME);
116+
const hasStarted = await isTrackingActive();
107117
console.log("[LOCATION] Already started:", hasStarted); //REMOVE: for testing
108118
if (hasStarted) return;
109119

@@ -113,11 +123,21 @@ export async function startTracking() {
113123
distanceInterval: 15,
114124
showsBackgroundLocationIndicator: true,
115125
pausesUpdatesAutomatically: false,
126+
...(Platform.OS === "android"
127+
? {
128+
foregroundService: {
129+
notificationTitle: "Duty tracking active",
130+
notificationBody:
131+
"Crime Link Analyzer is collecting location while you are on duty.",
132+
notificationColor: "#0B57D0",
133+
},
134+
}
135+
: {}),
116136
});
117137
}
118138

119139
export async function stopTracking() {
120-
const hasStarted = await Location.hasStartedLocationUpdatesAsync(TASK_NAME);
140+
const hasStarted = await isTrackingActive();
121141
if (!hasStarted) return;
122142

123143
await Location.stopLocationUpdatesAsync(TASK_NAME);

0 commit comments

Comments
 (0)