diff --git a/frontend/app.config.js b/frontend/app.config.js index ea4eeb9a..75b945c1 100644 --- a/frontend/app.config.js +++ b/frontend/app.config.js @@ -2,6 +2,7 @@ module.exports = { expo: { name: 'Timeflow', slug: 'timeflow', + scheme: 'timeflow', version: '1.0.0', orientation: 'portrait', icon: './assets/icon.png', diff --git a/frontend/index.ts b/frontend/index.ts index d3f0c0f2..6892f60d 100644 --- a/frontend/index.ts +++ b/frontend/index.ts @@ -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(); // 注册根组件会向应用注册表登记主组件。 // 无论通过开发容器还是原生构建加载应用,它都会完成必要的运行环境设置。 diff --git a/frontend/modules/timeflow-alarm/android/src/main/AndroidManifest.xml b/frontend/modules/timeflow-alarm/android/src/main/AndroidManifest.xml index f1e89a38..16849614 100644 --- a/frontend/modules/timeflow-alarm/android/src/main/AndroidManifest.xml +++ b/frontend/modules/timeflow-alarm/android/src/main/AndroidManifest.xml @@ -1,4 +1,6 @@ + + diff --git a/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/AlarmPackage.kt b/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/AlarmPackage.kt index 8fb12c2f..363b04ff 100644 --- a/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/AlarmPackage.kt +++ b/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/AlarmPackage.kt @@ -7,7 +7,7 @@ import com.facebook.react.uimanager.ViewManager class AlarmPackage : ReactPackage { override fun createNativeModules(reactContext: ReactApplicationContext): List { - return listOf(AlarmModule(reactContext)) + return listOf(AlarmModule(reactContext), LocationModule(reactContext)) } override fun createViewManagers( diff --git a/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/LocationModule.kt b/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/LocationModule.kt new file mode 100644 index 00000000..38dfaf16 --- /dev/null +++ b/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/LocationModule.kt @@ -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", + ) + } +} diff --git a/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/LocationSnapshotReader.kt b/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/LocationSnapshotReader.kt new file mode 100644 index 00000000..286a0c8a --- /dev/null +++ b/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/LocationSnapshotReader.kt @@ -0,0 +1,11 @@ +package com.timeflow.alarm + +object LocationSnapshotReader { + @JvmStatic + fun readProviderSnapshot( + provider: String, + readSnapshot: (String) -> LocationSnapshot?, + ): LocationSnapshot? { + return runCatching { readSnapshot(provider) }.getOrNull() + } +} diff --git a/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/LocationSnapshotSelector.kt b/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/LocationSnapshotSelector.kt new file mode 100644 index 00000000..b1d5e981 --- /dev/null +++ b/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/LocationSnapshotSelector.kt @@ -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, + nowMillis: Long, + ): LocationSnapshot? { + return candidates + .asSequence() + .filter { snapshot -> isValid(snapshot, nowMillis) } + .maxWithOrNull( + compareBy { 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 +} diff --git a/frontend/modules/timeflow-alarm/android/src/test/java/com/timeflow/alarm/LocationSnapshotReaderTest.java b/frontend/modules/timeflow-alarm/android/src/test/java/com/timeflow/alarm/LocationSnapshotReaderTest.java new file mode 100644 index 00000000..25619f68 --- /dev/null +++ b/frontend/modules/timeflow-alarm/android/src/test/java/com/timeflow/alarm/LocationSnapshotReaderTest.java @@ -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); + } +} diff --git a/frontend/modules/timeflow-alarm/android/src/test/java/com/timeflow/alarm/LocationSnapshotSelectorTest.java b/frontend/modules/timeflow-alarm/android/src/test/java/com/timeflow/alarm/LocationSnapshotSelectorTest.java new file mode 100644 index 00000000..121b311c --- /dev/null +++ b/frontend/modules/timeflow-alarm/android/src/test/java/com/timeflow/alarm/LocationSnapshotSelectorTest.java @@ -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()); + } +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d4b701ca..76ce4547 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -13,6 +13,7 @@ "@irvingouj/expo-audio-stream": "3.1.0", "expo": "~57.0.7", "expo-audio": "~57.0.3", + "expo-dev-client": "~57.0.13", "expo-location": "~57.0.9", "expo-notifications": "~57.0.10", "expo-secure-store": "~57.0.1", @@ -7020,6 +7021,65 @@ "react-native": "*" } }, + "node_modules/expo-dev-client": { + "version": "57.0.13", + "resolved": "https://registry.npmmirror.com/expo-dev-client/-/expo-dev-client-57.0.13.tgz", + "integrity": "sha512-6wtJ22BAGKNeqYxaA8NyVwNbFj0fet+pRphMvneTqtE9aoawrJKLVHDJ+Gln5lZpcCQd92utpYFwiBmdS/2LRg==", + "license": "MIT", + "dependencies": { + "expo-dev-launcher": "~57.0.13", + "expo-dev-menu": "~57.0.13", + "expo-dev-menu-interface": "~57.0.0", + "expo-manifests": "~57.0.1", + "expo-updates-interface": "~57.0.1" + }, + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/expo-dev-launcher": { + "version": "57.0.13", + "resolved": "https://registry.npmmirror.com/expo-dev-launcher/-/expo-dev-launcher-57.0.13.tgz", + "integrity": "sha512-o3vz98Sdivei1rDclIiRpiOtqWsex/+w6NqpeJkyvjDj4nrte2s5dN/4BNfjJZKxy5+SMRZqD+wZI2JUIAVHdQ==", + "license": "MIT", + "dependencies": { + "@expo/schema-utils": "^57.0.2", + "expo-dev-menu": "~57.0.13", + "expo-manifests": "~57.0.1" + }, + "peerDependencies": { + "expo": "*", + "react-native": "*" + } + }, + "node_modules/expo-dev-menu": { + "version": "57.0.13", + "resolved": "https://registry.npmmirror.com/expo-dev-menu/-/expo-dev-menu-57.0.13.tgz", + "integrity": "sha512-Xs2wOVpbAaOvNWl4gMEjZPi0uaFnCoNB6Qsd4+jqnsUdYb/Q7HSBrGd7QttKZ2KHnaG1Q2cTtR9saHOXVWFaEg==", + "license": "MIT", + "dependencies": { + "expo-dev-menu-interface": "~57.0.0" + }, + "peerDependencies": { + "expo": "*", + "react-native": "*" + } + }, + "node_modules/expo-dev-menu-interface": { + "version": "57.0.0", + "resolved": "https://registry.npmmirror.com/expo-dev-menu-interface/-/expo-dev-menu-interface-57.0.0.tgz", + "integrity": "sha512-F47VdzOHYc19FhI/jBgctpO8a5UskTIxG6a1E5t3W5gF8VImuvBQffdXXfLHhsuCl7dS3v3U0R45cleeVXO1Zg==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/expo-json-utils": { + "version": "57.0.1", + "resolved": "https://registry.npmmirror.com/expo-json-utils/-/expo-json-utils-57.0.1.tgz", + "integrity": "sha512-cgTe1NqzQdYs/WN+3nIY5IZg8s0pb0xaTUbhYvxQDn137GbwRfHoGM2se3m3Vsl4Qu+B9G4RPEK5WJDEU2Do7g==", + "license": "MIT" + }, "node_modules/expo-location": { "version": "57.0.9", "resolved": "https://registry.npmjs.org/expo-location/-/expo-location-57.0.9.tgz", @@ -7032,6 +7092,18 @@ "expo": "*" } }, + "node_modules/expo-manifests": { + "version": "57.0.1", + "resolved": "https://registry.npmmirror.com/expo-manifests/-/expo-manifests-57.0.1.tgz", + "integrity": "sha512-qB/mDG2dYdl+EvUeQuqP8KFYCFgFCQjJYdWIHo8SFBgDzMYmdF286DFY2M1M9Okr99wkb5M4tgA3aCcwv3aEQA==", + "license": "MIT", + "dependencies": { + "expo-json-utils": "~57.0.1" + }, + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-modules-autolinking": { "version": "57.0.8", "resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-57.0.8.tgz", @@ -7151,6 +7223,15 @@ "react-native": "*" } }, + "node_modules/expo-updates-interface": { + "version": "57.0.1", + "resolved": "https://registry.npmmirror.com/expo-updates-interface/-/expo-updates-interface-57.0.1.tgz", + "integrity": "sha512-+LUWwJ0gf/TEKMVdQAw/Gjih4dvrk+URgy24X9qEGKuuMDZqjBRm9T4yQyBVALGL5TTdPUaB6ILxx3lshm3pwQ==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo/node_modules/@expo/cli": { "version": "57.0.9", "resolved": "https://registry.npmjs.org/@expo/cli/-/cli-57.0.9.tgz", @@ -7825,6 +7906,7 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, diff --git a/frontend/package.json b/frontend/package.json index 401a61c8..54d96910 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,6 +12,7 @@ "@irvingouj/expo-audio-stream": "3.1.0", "expo": "~57.0.7", "expo-audio": "~57.0.3", + "expo-dev-client": "~57.0.13", "expo-location": "~57.0.9", "expo-notifications": "~57.0.10", "expo-secure-store": "~57.0.1", diff --git a/frontend/src/features/assistant/application/AssistantContinuousConversationService.ts b/frontend/src/features/assistant/application/AssistantContinuousConversationService.ts index eb923cf7..29ff5b45 100644 --- a/frontend/src/features/assistant/application/AssistantContinuousConversationService.ts +++ b/frontend/src/features/assistant/application/AssistantContinuousConversationService.ts @@ -19,7 +19,7 @@ const SAMPLE_RATE_HZ = 16000; const CHANNELS = 1; // 共享连接的握手超时(AuthenticatedWebSocketClient 内部固定 5s)已经不归这里管; // 这个只是给定位单独留的预算,拿不到就不带,不能让 connect() 本身被定位拖住。 -const LOCATION_TIMEOUT_MS = 2000; +const LOCATION_TIMEOUT_MS = 500; // 整场空闲超时:会话建立、听到一句真实语音、或播完一次回复之后,这个窗口内 // 完全没有下一次真实语音就自动挂断。三分钟落在产品要求的 1~5 分钟区间中段; // 单独的"等待用户输入 10~30 秒"档位按设计简化,不再单独实现——这一档就是 diff --git a/frontend/src/features/assistant/application/AssistantConversationService.ts b/frontend/src/features/assistant/application/AssistantConversationService.ts index bb6073b4..bc458731 100644 --- a/frontend/src/features/assistant/application/AssistantConversationService.ts +++ b/frontend/src/features/assistant/application/AssistantConversationService.ts @@ -14,7 +14,7 @@ const SAMPLE_RATE_HZ = 16000; const CHANNELS = 1; // 共享连接的握手超时(AuthenticatedWebSocketClient 内部固定 5s)已经不归这里管; // 这个只是给定位单独留的预算,拿不到就不带,不能让 connect() 本身被定位拖住。 -const LOCATION_TIMEOUT_MS = 2000; +const LOCATION_TIMEOUT_MS = 500; /** * 一次"按住说话"编排的真实实现,按 AGENTS.md 第 6 节的时序把 transport / capture / diff --git a/frontend/src/infrastructure/location/ExpoLocationMonitor.ts b/frontend/src/infrastructure/location/ExpoLocationMonitor.ts index 670e2fdb..751729c2 100644 --- a/frontend/src/infrastructure/location/ExpoLocationMonitor.ts +++ b/frontend/src/infrastructure/location/ExpoLocationMonitor.ts @@ -17,6 +17,11 @@ import { type GeofenceTaskPayload, } from './geofenceTask'; import type { LocationProvider } from './LocationProvider'; +import { getNativeLastKnownLocationSample } from './NativeLocationFallback'; + +type ExpoLocationMonitorOptions = { + nativeFallback?: () => Promise; +}; type ActiveWatch = { listener_id: string; @@ -49,7 +54,7 @@ export class ExpoLocationMonitor implements LocationMonitorPort, LocationProvide private unsubscribeTask: (() => void) | null; private readonly appStateSub: NativeEventSubscription; - constructor() { + constructor(private readonly options: ExpoLocationMonitorOptions = {}) { this.unsubscribeTask = subscribeGeofenceTaskEvents(this.handleTaskEvent); this.appStateSub = AppState.addEventListener('change', this.handleAppState); } @@ -135,7 +140,7 @@ export class ExpoLocationMonitor implements LocationMonitorPort, LocationProvide const { status } = await Location.getForegroundPermissionsAsync(); if (status !== 'granted') { console.warn('[geofence] getCurrentSample skipped: foreground permission not granted'); - return this.lastSample; + return this.getNativeCachedSample(); } const position = await Location.getCurrentPositionAsync({}); const sample = toSample(position); @@ -143,8 +148,24 @@ export class ExpoLocationMonitor implements LocationMonitorPort, LocationProvide return sample; } catch (error) { console.warn('[geofence] getCurrentSample failed', error); - return this.lastSample; + return this.getNativeCachedSample(); + } + } + + private async getNativeCachedSample(): Promise { + const nativeFallback = this.options.nativeFallback ?? getNativeLastKnownLocationSample; + try { + const sample = await nativeFallback(); + if (sample !== null) { + this.lastSample = sample; + return sample; + } + } catch (error) { + console.warn('[geofence] failed to read native cached location', { + errorType: error instanceof Error ? error.name : typeof error, + }); } + return this.lastSample; } dispose(): void { @@ -238,7 +259,16 @@ export class ExpoLocationMonitor implements LocationMonitorPort, LocationProvide return; } // Android 要求先拿到前台权限才能申请后台权限,所以这两步不能对调顺序。 - const { status: background } = await Location.getBackgroundPermissionsAsync(); + const backgroundPermission = await Location.getBackgroundPermissionsAsync().catch((error) => { + console.warn('[geofence] syncRegions skipped: background permission status unavailable', { + errorType: error instanceof Error ? error.name : typeof error, + }); + return null; + }); + if (backgroundPermission == null) { + return; + } + const { status: background } = backgroundPermission; if (background !== 'granted') { console.warn('[geofence] syncRegions skipped: background permission not granted'); return; diff --git a/frontend/src/infrastructure/location/ExpoLocationProvider.ts b/frontend/src/infrastructure/location/ExpoLocationProvider.ts index dfcf0a7f..ab3cd7f5 100644 --- a/frontend/src/infrastructure/location/ExpoLocationProvider.ts +++ b/frontend/src/infrastructure/location/ExpoLocationProvider.ts @@ -3,10 +3,15 @@ import * as Location from 'expo-location'; import type { LocationSample } from '../../features/reminder/domain'; import type { LocationProvider } from './LocationProvider'; +import { getNativeLastKnownLocationSample } from './NativeLocationFallback'; const LAST_KNOWN_MAX_AGE_MS = 60_000; const LAST_KNOWN_REQUIRED_ACCURACY_METERS = 200; +type ExpoLocationProviderOptions = { + nativeFallback?: () => Promise; +}; + /** * 真实定位实现。语音握手优先使用短时间内的缓存位置,避免等待 GPS 冷启动;同时 * 在后台刷新当前位置,让下一次连接获得更新的位置。权限被拒绝或定位失败都返回 @@ -15,6 +20,8 @@ const LAST_KNOWN_REQUIRED_ACCURACY_METERS = 200; export class ExpoLocationProvider implements LocationProvider { private freshSampleInFlight: Promise | null = null; + constructor(private readonly options: ExpoLocationProviderOptions = {}) {} + async getCurrentSample(): Promise { // Permission prompting is centralized in the authenticated startup flow. // A voice handshake must never open a system dialog or hang on a permission @@ -31,6 +38,12 @@ export class ExpoLocationProvider implements LocationProvider { return cached; } + const nativeSample = await this.getNativeCachedSample(); + if (nativeSample !== null) { + void this.getFreshSample(); + return nativeSample; + } + return this.getFreshSample(); } @@ -49,6 +62,18 @@ export class ExpoLocationProvider implements LocationProvider { } } + private async getNativeCachedSample(): Promise { + const nativeFallback = this.options.nativeFallback ?? getNativeLastKnownLocationSample; + try { + return await nativeFallback(); + } catch (error) { + console.warn('[location-search] failed to read native cached location', { + errorType: error instanceof Error ? error.name : typeof error, + }); + return null; + } + } + private getFreshSample(): Promise { if (this.freshSampleInFlight !== null) { return this.freshSampleInFlight; diff --git a/frontend/src/infrastructure/location/NativeLocationFallback.ts b/frontend/src/infrastructure/location/NativeLocationFallback.ts new file mode 100644 index 00000000..1720362a --- /dev/null +++ b/frontend/src/infrastructure/location/NativeLocationFallback.ts @@ -0,0 +1,66 @@ +import { NativeModules } from 'react-native'; + +import type { LocationObservation } from '../../contracts/reminder'; + +type NativeLocationPayload = { + accuracyMeters?: unknown; + latitude?: unknown; + longitude?: unknown; + observedAtMillis?: unknown; + provider?: unknown; +}; + +type TimeflowLocationModule = { + getLastKnownLocation?: () => Promise; +}; + +export async function getNativeLastKnownLocationSample(): Promise { + const module = NativeModules.TimeflowLocation as TimeflowLocationModule | undefined; + if (typeof module?.getLastKnownLocation !== 'function') { + return null; + } + + try { + return toObservation(await module.getLastKnownLocation()); + } catch (error) { + console.warn('[location-search] native cached location failed', { + errorType: error instanceof Error ? error.name : typeof error, + }); + return null; + } +} + +function toObservation(payload: NativeLocationPayload | null): LocationObservation | null { + if (payload === null) { + return null; + } + + const latitude = payload.latitude; + const longitude = payload.longitude; + const observedAtMillis = payload.observedAtMillis; + if ( + !isFiniteNumber(latitude) || + !isFiniteNumber(longitude) || + !isFiniteNumber(observedAtMillis) || + latitude < -90 || + latitude > 90 || + longitude < -180 || + longitude > 180 + ) { + return null; + } + + const accuracyMeters = isFiniteNumber(payload.accuracyMeters) + ? Math.max(0, payload.accuracyMeters) + : 0; + return { + accuracy_meters: accuracyMeters, + latitude, + longitude, + observed_at: new Date(observedAtMillis).toISOString(), + }; +} + +function isFiniteNumber(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value); +} diff --git a/frontend/src/infrastructure/location/locationProbe.ts b/frontend/src/infrastructure/location/locationProbe.ts new file mode 100644 index 00000000..56954321 --- /dev/null +++ b/frontend/src/infrastructure/location/locationProbe.ts @@ -0,0 +1,22 @@ +import { getNativeLastKnownLocationSample } from './NativeLocationFallback'; + +export function startLocationProbeOnStartup(): void { + if (!__DEV__ || process.env.EXPO_PUBLIC_LOCATION_PROBE_ON_START !== '1') { + return; + } + + void getNativeLastKnownLocationSample().then((sample) => { + if (sample === null) { + console.warn('[location-probe] native cached location unavailable'); + return; + } + + // eslint-disable-next-line no-console -- this opt-in development probe reports successful reads + console.info('[location-probe] native cached location ready', { + accuracy_meters: sample.accuracy_meters, + latitude: sample.latitude, + longitude: sample.longitude, + observed_at: sample.observed_at, + }); + }); +} diff --git a/frontend/tests/unit/features/assistant/application/AssistantContinuousConversationService.test.ts b/frontend/tests/unit/features/assistant/application/AssistantContinuousConversationService.test.ts index c38f8b66..ac362113 100644 --- a/frontend/tests/unit/features/assistant/application/AssistantContinuousConversationService.test.ts +++ b/frontend/tests/unit/features/assistant/application/AssistantContinuousConversationService.test.ts @@ -97,6 +97,7 @@ function createDeps( applyCommandResult?: () => Promise; applyCategoryUpdate?: () => Promise; connection?: VoiceTransportConnection; + getCurrentSample?: LocationProvider['getCurrentSample']; requestPermission?: () => Promise; } = {}, ) { @@ -121,7 +122,7 @@ function createDeps( stop: jest.fn(async () => undefined), }; const location: LocationProvider = { - getCurrentSample: jest.fn(async () => null), + getCurrentSample: jest.fn(overrides.getCurrentSample ?? (async () => null)), }; const localScheduleWriter: LocalScheduleWriterPort = { applyCommandResult: jest.fn(overrides.applyCommandResult ?? (async () => undefined)), @@ -187,6 +188,30 @@ describe('AssistantContinuousConversationService', () => { jest.useRealTimers(); }); + it('does not hold continuous mode start on a slow location sample', async () => { + jest.useFakeTimers(); + const fake = createFakeConnection(); + const deps = createDeps({ + connection: fake.connection, + getCurrentSample: () => new Promise(() => undefined), + }); + const service = createService(deps); + + const turn = service.startTurn(); + await advanceAndFlush(499); + expect(deps.transport.connect).not.toHaveBeenCalled(); + + await advanceAndFlush(1); + expect(deps.transport.connect).toHaveBeenCalledWith(null); + + fake.emitMessage({ + ok: true, + payload: { conversation_id: 'conv_001', stream_id: 'stream_001' }, + type: 'voice.stream.started', + } as AssistantServerMessage); + await turn; + }); + it('ends the turn once the idle timeout elapses without further speech', async () => { jest.useFakeTimers(); const fake = createFakeConnection(); diff --git a/frontend/tests/unit/features/assistant/application/AssistantConversationService.test.ts b/frontend/tests/unit/features/assistant/application/AssistantConversationService.test.ts index 0124a296..e1a69014 100644 --- a/frontend/tests/unit/features/assistant/application/AssistantConversationService.test.ts +++ b/frontend/tests/unit/features/assistant/application/AssistantConversationService.test.ts @@ -26,6 +26,11 @@ async function flushAsync(iterations = 20): Promise { } } +async function advanceAndFlush(ms: number): Promise { + jest.advanceTimersByTime(ms); + await flushAsync(); +} + function createFakeConnection() { const messageHandlers = new Set<(message: AssistantServerMessage) => void>(); const audioHandlers = new Set<(chunk: ArrayBuffer) => void>(); @@ -86,6 +91,7 @@ function createDeps(overrides: { applyCommandResult?: () => Promise; applyCategoryUpdate?: () => Promise; connection?: VoiceTransportConnection; + getCurrentSample?: LocationProvider['getCurrentSample']; requestPermission?: () => Promise; startCapture?: () => Promise; }) { @@ -104,7 +110,7 @@ function createDeps(overrides: { stop: jest.fn(async () => undefined), }; const location: LocationProvider = { - getCurrentSample: jest.fn(async () => null), + getCurrentSample: jest.fn(overrides.getCurrentSample ?? (async () => null)), }; const localScheduleWriter: LocalScheduleWriterPort = { applyCommandResult: jest.fn(overrides.applyCommandResult ?? (async () => undefined)), @@ -130,6 +136,34 @@ async function completeStreamStart( } describe('AssistantConversationService', () => { + it('does not hold the push-to-talk button on a slow location sample', async () => { + jest.useFakeTimers(); + try { + const fake = createFakeConnection(); + const deps = createDeps({ + connection: fake.connection, + getCurrentSample: () => new Promise(() => undefined), + }); + const service = new AssistantConversationService({ accountId: 'acc_001' }, deps); + + const turn = service.startTurn(); + await advanceAndFlush(499); + expect(deps.transport.connect).not.toHaveBeenCalled(); + + await advanceAndFlush(1); + expect(deps.transport.connect).toHaveBeenCalledWith(null); + + fake.emitMessage({ + ok: true, + payload: { conversation_id: 'conv_001', stream_id: 'stream_001' }, + type: 'voice.stream.started', + } as AssistantServerMessage); + await turn; + } finally { + jest.useRealTimers(); + } + }); + it('never sends voice.stream.start when the microphone permission is denied', async () => { const fake = createFakeConnection(); const deps = createDeps({ diff --git a/frontend/tests/unit/infrastructure/location/expoLocationMonitor.test.ts b/frontend/tests/unit/infrastructure/location/expoLocationMonitor.test.ts index 34927dfa..06a80a65 100644 --- a/frontend/tests/unit/infrastructure/location/expoLocationMonitor.test.ts +++ b/frontend/tests/unit/infrastructure/location/expoLocationMonitor.test.ts @@ -11,6 +11,7 @@ import { drainPendingGeofenceEvents, subscribeGeofenceTaskEvents, } from '../../../../src/infrastructure/location/geofenceTask'; +import { getNativeLastKnownLocationSample } from '../../../../src/infrastructure/location/NativeLocationFallback'; jest.mock('expo-location', () => ({ getForegroundPermissionsAsync: jest.fn(), @@ -29,6 +30,10 @@ jest.mock('../../../../src/infrastructure/location/geofenceTask', () => ({ drainPendingGeofenceEvents: jest.fn(), })); +jest.mock('../../../../src/infrastructure/location/NativeLocationFallback', () => ({ + getNativeLastKnownLocationSample: jest.fn(), +})); + const getForeground = Location.getForegroundPermissionsAsync as jest.MockedFunction< typeof Location.getForegroundPermissionsAsync >; @@ -59,6 +64,9 @@ const subscribeTaskEvents = subscribeGeofenceTaskEvents as jest.MockedFunction< const drainPending = drainPendingGeofenceEvents as jest.MockedFunction< typeof drainPendingGeofenceEvents >; +const getNativeCachedSample = getNativeLastKnownLocationSample as jest.MockedFunction< + typeof getNativeLastKnownLocationSample +>; function granted(): Location.LocationPermissionResponse { return { @@ -130,6 +138,7 @@ describe('ExpoLocationMonitor', () => { requestForeground.mockResolvedValue(granted()); requestBackground.mockResolvedValue(granted()); getCurrentPosition.mockResolvedValue(position()); + getNativeCachedSample.mockResolvedValue(null); hasStartedGeofencing.mockResolvedValue(false); stopGeofencing.mockResolvedValue(undefined); startGeofencing.mockResolvedValue(undefined); @@ -304,6 +313,34 @@ describe('ExpoLocationMonitor', () => { await expect(monitor.getCurrentSample()).resolves.toEqual(cached); }); + it('uses the native cached sample when the current position is unavailable', async () => { + const nativeSample = { + latitude: 12.345678, + longitude: 98.765432, + accuracy_meters: 31, + observed_at: '2026-08-21T02:20:54.733Z', + }; + getCurrentPosition.mockRejectedValue(new Error('Current location is unavailable')); + getNativeCachedSample.mockResolvedValue(nativeSample); + const monitor = new ExpoLocationMonitor(); + + await expect(monitor.getCurrentSample()).resolves.toEqual(nativeSample); + await expect(monitor.getLastSample()).resolves.toEqual(nativeSample); + }); + + it('keeps the last sample when the native cached location read throws', async () => { + const nativeFallback = jest.fn(async (): Promise => { + throw new Error('native location unavailable'); + }); + const monitor = new ExpoLocationMonitor({ nativeFallback }); + await monitor.watch(request(), jest.fn()); + const cached = await monitor.getLastSample(); + + getCurrentPosition.mockRejectedValue(new Error('Current location is unavailable')); + await expect(monitor.getCurrentSample()).resolves.toEqual(cached); + expect(nativeFallback).toHaveBeenCalledTimes(1); + }); + it('defaults accuracy to 0 when the platform does not report it', async () => { getCurrentPosition.mockResolvedValue(position({ accuracy: null })); const monitor = new ExpoLocationMonitor(); @@ -455,6 +492,14 @@ describe('ExpoLocationMonitor', () => { expect(startGeofencing).not.toHaveBeenCalled(); }); + it('skips registration without throwing when background permission status cannot be read', async () => { + getBackground.mockRejectedValue(new Error('ACCESS_BACKGROUND_LOCATION missing')); + const monitor = new ExpoLocationMonitor(); + await expect(monitor.watch(request(), jest.fn())).resolves.toBeDefined(); + + expect(startGeofencing).not.toHaveBeenCalled(); + }); + it('does not request or register when background permission remains denied', async () => { getBackground.mockResolvedValue(denied(false)); const monitor = new ExpoLocationMonitor(); diff --git a/frontend/tests/unit/infrastructure/location/expoLocationProvider.test.ts b/frontend/tests/unit/infrastructure/location/expoLocationProvider.test.ts index d7e3ad37..e1a185c5 100644 --- a/frontend/tests/unit/infrastructure/location/expoLocationProvider.test.ts +++ b/frontend/tests/unit/infrastructure/location/expoLocationProvider.test.ts @@ -5,6 +5,7 @@ import { ExpoLocationProvider } from '../../../../src/infrastructure/location/Ex jest.mock('expo-location', () => ({ getForegroundPermissionsAsync: jest.fn(), + requestForegroundPermissionsAsync: jest.fn(), getLastKnownPositionAsync: jest.fn(), getCurrentPositionAsync: jest.fn(), })); @@ -12,6 +13,9 @@ jest.mock('expo-location', () => ({ const getForeground = Location.getForegroundPermissionsAsync as jest.MockedFunction< typeof Location.getForegroundPermissionsAsync >; +const requestForeground = Location.requestForegroundPermissionsAsync as jest.MockedFunction< + typeof Location.requestForegroundPermissionsAsync +>; const getLastKnown = Location.getLastKnownPositionAsync as jest.MockedFunction< typeof Location.getLastKnownPositionAsync >; @@ -32,16 +36,78 @@ describe('ExpoLocationProvider', () => { beforeEach(() => { jest.clearAllMocks(); getForeground.mockResolvedValue(granted()); + requestForeground.mockResolvedValue(granted()); getLastKnown.mockResolvedValue(null); - getCurrent.mockRejectedValue(new Error('GPS unavailable')); + getCurrent.mockResolvedValue({ + coords: { + accuracy: 16, + altitude: null, + altitudeAccuracy: null, + heading: null, + latitude: 31.2304, + longitude: 121.4737, + speed: null, + }, + timestamp: Date.parse('2026-08-20T09:00:00.000Z'), + }); }); it('reads location without opening a permission prompt', async () => { - const provider = new ExpoLocationProvider(); + getCurrent.mockRejectedValueOnce(new Error('GPS unavailable')); + const provider = new ExpoLocationProvider({ nativeFallback: async () => null }); await expect(provider.getCurrentSample()).resolves.toBeNull(); expect(getForeground).toHaveBeenCalledTimes(1); + expect(requestForeground).not.toHaveBeenCalled(); expect(getLastKnown).toHaveBeenCalledTimes(1); }); + + it('uses the native Android cached location when Expo has no cached position', async () => { + const info = jest.spyOn(console, 'info').mockImplementation(() => undefined); + const nativeFallback = jest.fn(async () => ({ + accuracy_meters: 30, + latitude: 31.187169, + longitude: 121.605098, + observed_at: '2026-08-20T09:37:26.000Z', + })); + const provider = new ExpoLocationProvider({ nativeFallback }); + + await expect(provider.getCurrentSample()).resolves.toEqual({ + accuracy_meters: 30, + latitude: 31.187169, + longitude: 121.605098, + observed_at: '2026-08-20T09:37:26.000Z', + }); + expect(nativeFallback).toHaveBeenCalledTimes(1); + expect(getCurrent).toHaveBeenCalledTimes(1); + expect(info).not.toHaveBeenCalled(); + info.mockRestore(); + }); + + it('falls back to Expo current position when native cached location is unavailable', async () => { + const provider = new ExpoLocationProvider({ nativeFallback: async () => null }); + + await expect(provider.getCurrentSample()).resolves.toEqual({ + accuracy_meters: 16, + latitude: 31.2304, + longitude: 121.4737, + observed_at: '2026-08-20T09:00:00.000Z', + }); + }); + + it('falls back to Expo current position when native cached location throws', async () => { + const nativeFallback = jest.fn(async (): Promise => { + throw new Error('native location unavailable'); + }); + const provider = new ExpoLocationProvider({ nativeFallback }); + + await expect(provider.getCurrentSample()).resolves.toEqual({ + accuracy_meters: 16, + latitude: 31.2304, + longitude: 121.4737, + observed_at: '2026-08-20T09:00:00.000Z', + }); + expect(nativeFallback).toHaveBeenCalledTimes(1); + }); }); diff --git a/frontend/tests/unit/infrastructure/location/nativeLocationFallback.test.ts b/frontend/tests/unit/infrastructure/location/nativeLocationFallback.test.ts new file mode 100644 index 00000000..39607cb4 --- /dev/null +++ b/frontend/tests/unit/infrastructure/location/nativeLocationFallback.test.ts @@ -0,0 +1,60 @@ +import { beforeEach, describe, expect, it, jest } from '@jest/globals'; +import { NativeModules } from 'react-native'; + +import { getNativeLastKnownLocationSample } from '../../../../src/infrastructure/location/NativeLocationFallback'; + +const mockNativeGetLastKnownLocation = jest.fn<() => Promise>(); + +describe('getNativeLastKnownLocationSample', () => { + beforeEach(() => { + jest.clearAllMocks(); + (NativeModules as unknown as { TimeflowLocation?: unknown }).TimeflowLocation = { + getLastKnownLocation: mockNativeGetLastKnownLocation, + }; + }); + + it('maps the native Android last-known location into a LocationObservation', async () => { + mockNativeGetLastKnownLocation.mockResolvedValue({ + accuracyMeters: 30, + latitude: 31.187169, + longitude: 121.605098, + observedAtMillis: Date.parse('2026-08-20T09:37:26.000Z'), + provider: 'network', + }); + + await expect(getNativeLastKnownLocationSample()).resolves.toEqual({ + accuracy_meters: 30, + latitude: 31.187169, + longitude: 121.605098, + observed_at: '2026-08-20T09:37:26.000Z', + }); + }); + + it('returns null when the native module reports no cached location', async () => { + mockNativeGetLastKnownLocation.mockResolvedValue(null); + await expect(getNativeLastKnownLocationSample()).resolves.toBeNull(); + }); + + it('returns null when the native module is unavailable', async () => { + delete (NativeModules as unknown as { TimeflowLocation?: unknown }).TimeflowLocation; + + await expect(getNativeLastKnownLocationSample()).resolves.toBeNull(); + }); + + it('returns null when the native module rejects the location read', async () => { + mockNativeGetLastKnownLocation.mockRejectedValue(new Error('native location unavailable')); + + await expect(getNativeLastKnownLocationSample()).resolves.toBeNull(); + }); + + it('returns null when the native payload is malformed', async () => { + mockNativeGetLastKnownLocation.mockResolvedValue({ + accuracyMeters: 30, + latitude: 91, + longitude: 121.605098, + observedAtMillis: Date.parse('2026-08-20T09:37:26.000Z'), + }); + + await expect(getNativeLastKnownLocationSample()).resolves.toBeNull(); + }); +});