Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions frontend/app.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ module.exports = {
expo: {
name: 'Timeflow',
slug: 'timeflow',
scheme: 'timeflow',
version: '1.0.0',
orientation: 'portrait',
icon: './assets/icon.png',
Expand Down
3 changes: 3 additions & 0 deletions frontend/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ import './src/infrastructure/location/geofenceTask';
import { registerRootComponent } from 'expo';

import App from './App';
import { startLocationProbeOnStartup } from './src/infrastructure/location/locationProbe';

startLocationProbeOnStartup();

// 注册根组件会向应用注册表登记主组件。
// 无论通过开发容器还是原生构建加载应用,它都会完成必要的运行环境设置。
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM"/>
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import com.facebook.react.uimanager.ViewManager

class AlarmPackage : ReactPackage {
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> {
return listOf(AlarmModule(reactContext))
return listOf(AlarmModule(reactContext), LocationModule(reactContext))
}

override fun createViewManagers(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
package com.timeflow.alarm

import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.location.Location
import android.location.LocationManager
import android.util.Log
import androidx.core.content.ContextCompat
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContextBaseJavaModule
import com.facebook.react.bridge.ReactMethod
import com.facebook.react.bridge.WritableMap

class LocationModule(private val reactContext: ReactApplicationContext) :
ReactContextBaseJavaModule(reactContext) {

override fun getName(): String = NAME

@ReactMethod
fun getLastKnownLocation(promise: Promise) {
try {
val snapshot = readBestLastKnownLocation()
if (snapshot == null) {
Log.i(NAME, "getLastKnownLocation no usable cached location")
promise.resolve(null)
return
}

Log.i(
NAME,
"getLastKnownLocation provider=${snapshot.provider} ageMs=${(System.currentTimeMillis() - snapshot.observedAtMillis).coerceAtLeast(0L)} accuracy=${snapshot.accuracyMeters}",
)
promise.resolve(snapshot.toWritableMap())
} catch (error: Exception) {
promise.reject("LOCATION_FALLBACK_FAILED", error.message, error)
}
}

private fun readBestLastKnownLocation(): LocationSnapshot? {
if (!hasLocationPermission()) {
Log.i(NAME, "getLastKnownLocation missing foreground location permission")
return null
}

val locationManager = reactContext.getSystemService(Context.LOCATION_SERVICE) as? LocationManager
?: return null
val snapshots = PROVIDERS.mapNotNull { provider ->
LocationSnapshotReader.readProviderSnapshot(provider) { currentProvider ->
locationManager.getLastKnownLocation(currentProvider)?.toSnapshot(currentProvider)
}
}
return LocationSnapshotSelector.chooseBest(snapshots, System.currentTimeMillis())
}

private fun hasLocationPermission(): Boolean {
val fine = ContextCompat.checkSelfPermission(
reactContext,
Manifest.permission.ACCESS_FINE_LOCATION,
) == PackageManager.PERMISSION_GRANTED
val coarse = ContextCompat.checkSelfPermission(
reactContext,
Manifest.permission.ACCESS_COARSE_LOCATION,
) == PackageManager.PERMISSION_GRANTED
return fine || coarse
}

private fun Location.toSnapshot(provider: String): LocationSnapshot? {
if (!hasAccuracy() || !accuracy.isFinite() || accuracy < 0f) {
return null
}

return LocationSnapshot(
provider = provider,
latitude = latitude,
longitude = longitude,
accuracyMeters = accuracy.toDouble(),
observedAtMillis = time,
)
}

private fun LocationSnapshot.toWritableMap(): WritableMap {
return Arguments.createMap().apply {
putString("provider", provider)
putDouble("latitude", latitude)
putDouble("longitude", longitude)
putDouble("accuracyMeters", accuracyMeters)
putDouble("observedAtMillis", observedAtMillis.toDouble())
}
}

companion object {
const val NAME = "TimeflowLocation"
private val PROVIDERS = listOf(
LocationManager.NETWORK_PROVIDER,
LocationManager.PASSIVE_PROVIDER,
LocationManager.GPS_PROVIDER,
"fused",
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.timeflow.alarm

object LocationSnapshotReader {
@JvmStatic
fun readProviderSnapshot(
provider: String,
readSnapshot: (String) -> LocationSnapshot?,
): LocationSnapshot? {
return runCatching { readSnapshot(provider) }.getOrNull()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package com.timeflow.alarm

data class LocationSnapshot(
val provider: String,
val latitude: Double,
val longitude: Double,
val accuracyMeters: Double,
val observedAtMillis: Long,
)

object LocationSnapshotSelector {
@JvmStatic
fun chooseBest(
candidates: List<LocationSnapshot>,
nowMillis: Long,
): LocationSnapshot? {
return candidates
.asSequence()
.filter { snapshot -> isValid(snapshot, nowMillis) }
.maxWithOrNull(
compareBy<LocationSnapshot> { it.observedAtMillis }
.thenBy { -it.accuracyMeters },
)
}

private fun isValid(snapshot: LocationSnapshot, nowMillis: Long): Boolean {
val hasAcceptableAge = snapshot.observedAtMillis > 0L &&
snapshot.observedAtMillis <= nowMillis &&
nowMillis - snapshot.observedAtMillis <= MAX_AGE_MS

return snapshot.latitude.isFinite() &&
snapshot.longitude.isFinite() &&
snapshot.accuracyMeters.isFinite() &&
snapshot.latitude in -90.0..90.0 &&
snapshot.longitude in -180.0..180.0 &&
snapshot.accuracyMeters in 0.0..MAX_ACCURACY_METERS &&
hasAcceptableAge
}

private const val MAX_AGE_MS = 60_000L
private const val MAX_ACCURACY_METERS = 200.0
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package com.timeflow.alarm;

import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertTrue;

import org.junit.Test;

import java.util.concurrent.atomic.AtomicBoolean;

public class LocationSnapshotReaderTest {
@Test
public void readProviderSnapshot_alwaysAttemptsTheCachedLocationRead() {
AtomicBoolean cacheRead = new AtomicBoolean(false);
LocationSnapshot cached = new LocationSnapshot("network", 12.345678, 98.765432, 31.0, 2_000L);

LocationSnapshot snapshot = LocationSnapshotReader.readProviderSnapshot("network", provider -> {
cacheRead.set(true);
assertEquals("network", provider);
return cached;
});

assertTrue(cacheRead.get());
assertSame(cached, snapshot);
}

@Test
public void readProviderSnapshot_returnsNullWhenTheProviderReadFails() {
LocationSnapshot snapshot = LocationSnapshotReader.readProviderSnapshot("missing", provider -> {
throw new IllegalArgumentException("provider unavailable");
});

assertNull(snapshot);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
package com.timeflow.alarm;

import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNull;

import org.junit.Test;

import java.util.Arrays;
import java.util.Collections;

public class LocationSnapshotSelectorTest {

private static final long NOW_MILLIS = 100_000L;

@Test
public void chooseBest_prefersTheNewestSnapshot() {
LocationSnapshot oldGps = new LocationSnapshot("gps", 31.0, 121.0, 5.0, 90_000L);
LocationSnapshot freshNetwork = new LocationSnapshot(
"network",
31.187169,
121.605098,
30.0,
95_000L
);

LocationSnapshot selected = LocationSnapshotSelector.chooseBest(
Arrays.asList(oldGps, freshNetwork),
NOW_MILLIS
);

assertEquals("network", selected.getProvider());
assertEquals(31.187169, selected.getLatitude(), 0.000001);
}

@Test
public void chooseBest_prefersAccuracyWhenTimestampsMatch() {
LocationSnapshot coarse = new LocationSnapshot("network", 31.0, 121.0, 30.0, 95_000L);
LocationSnapshot precise = new LocationSnapshot("gps", 31.0, 121.0, 5.0, 95_000L);

LocationSnapshot selected = LocationSnapshotSelector.chooseBest(
Arrays.asList(coarse, precise),
NOW_MILLIS
);

assertEquals("gps", selected.getProvider());
}

@Test
public void chooseBest_returnsNullWhenThereAreNoSnapshots() {
assertNull(LocationSnapshotSelector.chooseBest(Collections.emptyList(), NOW_MILLIS));
}

@Test
public void chooseBest_rejectsSnapshotsOlderThanSixtySeconds() {
LocationSnapshot stale = new LocationSnapshot("gps", 31.0, 121.0, 5.0, 39_999L);

assertNull(LocationSnapshotSelector.chooseBest(Collections.singletonList(stale), NOW_MILLIS));
}

@Test
public void chooseBest_rejectsSnapshotsWithoutAnObservationTime() {
LocationSnapshot missingTime = new LocationSnapshot("gps", 31.0, 121.0, 5.0, 0L);

assertNull(LocationSnapshotSelector.chooseBest(
Collections.singletonList(missingTime),
NOW_MILLIS
));
}

@Test
public void chooseBest_rejectsSnapshotsFromTheFuture() {
LocationSnapshot future = new LocationSnapshot("gps", 31.0, 121.0, 5.0, 100_001L);

assertNull(LocationSnapshotSelector.chooseBest(Collections.singletonList(future), NOW_MILLIS));
}

@Test
public void chooseBest_rejectsMissingOrInvalidAccuracy() {
LocationSnapshot negative = new LocationSnapshot("gps", 31.0, 121.0, -1.0, 95_000L);
LocationSnapshot notANumber = new LocationSnapshot("network", 31.0, 121.0, Double.NaN, 95_000L);

assertNull(LocationSnapshotSelector.chooseBest(
Arrays.asList(negative, notANumber),
NOW_MILLIS
));
}

@Test
public void chooseBest_rejectsSnapshotsLessAccurateThanTwoHundredMeters() {
LocationSnapshot inaccurate = new LocationSnapshot("network", 31.0, 121.0, 200.1, 95_000L);

assertNull(LocationSnapshotSelector.chooseBest(
Collections.singletonList(inaccurate),
NOW_MILLIS
));
}

@Test
public void chooseBest_acceptsTheAgeAndAccuracyBoundaries() {
LocationSnapshot boundary = new LocationSnapshot("network", 31.0, 121.0, 200.0, 40_000L);

LocationSnapshot selected = LocationSnapshotSelector.chooseBest(
Collections.singletonList(boundary),
NOW_MILLIS
);

assertEquals("network", selected.getProvider());
}
}
Loading
Loading