diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d836b52e..678d124b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -139,4 +139,8 @@ jobs: run: npm run check - name: Export build + env: + # 只是导出/校验配置,不产出真机可用的原生包;占位值够让百度定位插件的 + # apiKey 校验通过,不需要在 CI 里存真的百度 Key。 + TIMEFLOW_BAIDU_LOCATION_API_KEY: ci-placeholder run: npx expo export --platform android --output-dir dist diff --git a/.gitignore b/.gitignore index cf4adc49..b605435f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ .DS_Store .idea/ +.cursor/ .env .venv/ node_modules/ diff --git a/backend/src/timeflow/gateway/websocket/handlers/session.py b/backend/src/timeflow/gateway/websocket/handlers/session.py index 6a1468e0..682d05fe 100644 --- a/backend/src/timeflow/gateway/websocket/handlers/session.py +++ b/backend/src/timeflow/gateway/websocket/handlers/session.py @@ -1,5 +1,6 @@ """The session.hello handshake that opens an authenticated session.""" +import logging from collections.abc import Callable from dataclasses import dataclass from datetime import UTC, datetime @@ -25,6 +26,7 @@ DEFAULT_TIMEZONE = "Asia/Shanghai" DEFAULT_VOICE_MODE = "push_to_talk" _VOICE_MODES = frozenset({"push_to_talk", "continuous"}) +logger = logging.getLogger(__name__) @dataclass(frozen=True, slots=True) @@ -105,6 +107,12 @@ def perform( timezone=_resolved_timezone(hello.payload.timezone), voice_mode=_resolved_voice_mode(hello.payload.voice_mode), ) + logger.info( + "voice session authenticated: voice_mode=%s location_available=%s coordinate_system=%s", + session.voice_mode, + session.latitude is not None and session.longitude is not None, + session.coordinate_system, + ) reply = SessionReady( request_id=hello.request_id, payload=SessionReadyPayload( diff --git a/backend/src/timeflow/infrastructure/external/location/tencent_maps.py b/backend/src/timeflow/infrastructure/external/location/tencent_maps.py index b8dd28df..2415887c 100644 --- a/backend/src/timeflow/infrastructure/external/location/tencent_maps.py +++ b/backend/src/timeflow/infrastructure/external/location/tencent_maps.py @@ -93,6 +93,7 @@ async def search( candidate = _candidate(item) if candidate is not None: candidates.append(candidate) + logger.info("Tencent Maps search succeeded: candidate_count=%s", len(candidates)) return tuple(candidates) async def _get( @@ -106,12 +107,26 @@ async def _get( response = await self._client.get(f"{self._base_url}{path}", params=params) response.raise_for_status() payload = response.json() - except (httpx.RequestError, httpx.HTTPStatusError): - logger.warning("Tencent Maps request failed", extra={"operation": operation}) + except httpx.HTTPStatusError as error: + logger.warning( + "Tencent Maps request failed: operation=%s error_type=%s status_code=%s", + operation, + type(error).__name__, + error.response.status_code, + ) + raise LocationConnectionError("Tencent Maps request failed") from None + except httpx.RequestError as error: + logger.warning( + "Tencent Maps request failed: operation=%s error_type=%s", + operation, + type(error).__name__, + ) raise LocationConnectionError("Tencent Maps request failed") from None except ValueError: + logger.warning("Tencent Maps returned invalid JSON: operation=%s", operation) raise LocationProtocolError("Tencent Maps returned invalid JSON") from None if not isinstance(payload, dict): + logger.warning("Tencent Maps returned invalid response: operation=%s", operation) raise LocationProtocolError("Tencent Maps returned an invalid response") status = payload.get("status") if isinstance(status, bool) or not isinstance(status, int) or status != 0: @@ -119,7 +134,8 @@ async def _get( # contains user data, so logging them is safe and is the only way to tell # these apart later -- LocationProtocolError itself carries no detail on. logger.warning( - "Tencent Maps rejected the request: status=%s message=%s", + "Tencent Maps rejected the request: operation=%s status=%s message=%s", + operation, status, payload.get("message"), ) diff --git a/backend/src/timeflow/intelligence/location/tools.py b/backend/src/timeflow/intelligence/location/tools.py index a7176944..2ac074d0 100644 --- a/backend/src/timeflow/intelligence/location/tools.py +++ b/backend/src/timeflow/intelligence/location/tools.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +import logging from collections.abc import Mapping from dataclasses import dataclass @@ -18,6 +19,7 @@ from timeflow.intelligence.location.service import LocationSearchService LOCATION_SEARCH = "location_search" +logger = logging.getLogger(__name__) @dataclass(frozen=True, slots=True) @@ -33,10 +35,13 @@ async def execute(self, arguments: Mapping[str, object]) -> str: try: query = _query(arguments) candidates = await self.service.search(self.context, query) - except LocationInputError: + except LocationInputError as error: + logger.warning("location search rejected input: error_type=%s", type(error).__name__) return _json({"status": "invalid_input", "candidates": []}) - except (LocationConfigurationError, LocationConnectionError, LocationProtocolError): + except (LocationConfigurationError, LocationConnectionError, LocationProtocolError) as error: + logger.warning("location search provider unavailable: error_type=%s", type(error).__name__) return _json({"status": "provider_unavailable", "candidates": []}) + logger.info("location search completed: candidate_count=%s", len(candidates)) return _json( { "status": "ok", diff --git a/backend/src/timeflow/intelligence/realtime/agent.py b/backend/src/timeflow/intelligence/realtime/agent.py index 132eb051..24abe511 100644 --- a/backend/src/timeflow/intelligence/realtime/agent.py +++ b/backend/src/timeflow/intelligence/realtime/agent.py @@ -229,8 +229,19 @@ async def _session_for(self, key: tuple[str, str], stream: StreamInfo) -> _Held account_id, _ = key timezone = stream.timezone voice_mode = stream.voice_mode + client_location = _client_location_from_stream(stream) + logger.info( + "opening realtime session: voice_mode=%s location_fields_complete=%s " + "location_valid=%s coordinate_system=%s", + voice_mode, + stream.latitude is not None + and stream.longitude is not None + and stream.coordinate_system is not None, + client_location is not None, + stream.coordinate_system, + ) tools = ( - await self._tools_factory(account_id, timezone, _client_location_from_stream(stream)) + await self._tools_factory(account_id, timezone, client_location) if self._tools_factory is not None else None ) @@ -327,6 +338,9 @@ def __init__( self._question_id_factory = question_id_factory # None between replies; assigned on first use so each reply gets a fresh id. self._audio_id: str | None = None + # 保留最近一次已经交给客户端播放的 id。模型生成通常快于手机播放:等模型 + # 侧交付完成后再发生的打断,仍必须准确指出需要停止的是哪条旧音频。 + self._last_audio_id: str | None = None self._reply_id: str | None = None self._spoken = "" self._purpose = REPLY_PURPOSE @@ -373,6 +387,7 @@ async def audio(self, data: bytes) -> None: """Queue one chunk, starting the delivery on the first one.""" if self._speaking is None: self._audio_id = self._audio_id_factory() + self._last_audio_id = self._audio_id self._speaking = asyncio.create_task(self._speak()) await self._audio.put(data) @@ -389,6 +404,7 @@ async def tool_requested(self, call_id: str, name: str, arguments: dict[str, Any ) return + logger.info("realtime model requested tool: name=%s", name) result = await self._tools.run(name, arguments) if result.outcome is not None: outcome = result.outcome @@ -476,10 +492,12 @@ async def _finish_reply(self, *, canceled: bool) -> None: # own before the barge-in arrived -- but the model generates audio faster # than it plays back, so the phone can still be sounding it out. The session # only calls interrupted() this late when its own playable-until estimate - # says that's still plausible, so the client is told to stop regardless of - # what this turn still has queued locally. audio_id is empty because there - # is no reply left here to name; the client's handler does not read it. - await self._result_sink.deliver_canceled(AudioCanceled(audio_id=""), self._stream) + # says that's still plausible. Preserve the original id so the client can + # distinguish this old cancellation from a newer reply that has just begun. + if self._last_audio_id is not None: + await self._result_sink.deliver_canceled( + AudioCanceled(audio_id=self._last_audio_id), self._stream + ) self._spoken = "" self._reply_id = None self._audio_id = None diff --git a/backend/src/timeflow/intelligence/realtime/instructions.py b/backend/src/timeflow/intelligence/realtime/instructions.py index 6b70ffdb..0640fbf6 100644 --- a/backend/src/timeflow/intelligence/realtime/instructions.py +++ b/backend/src/timeflow/intelligence/realtime/instructions.py @@ -45,7 +45,7 @@ - 没提到地点,就当时间型日程处理,不要为了填 latitude/longitude 去调用 location_search 或编一个地点——地点型日程的地点仍然必须问清楚,这条只管时间型日程不要凭空加地点。 - 没说提醒方式,默认建一个 reminder_strength 为 medium 的提醒:非全天日程用 - reminder_type=before_start、reminder_offset_minutes=200(开始前 200 分钟);全天日程用 + reminder_type=before_start、reminder_offset_minutes=15(开始前 15 分钟);全天日程用 reminder_type=at_time、reminder_trigger_at 填当天上午 10:00(带时区偏移)。 地点怎么定 diff --git a/backend/src/timeflow/intelligence/realtime/schedule_tools.py b/backend/src/timeflow/intelligence/realtime/schedule_tools.py index 0b2a7db0..870b65b8 100644 --- a/backend/src/timeflow/intelligence/realtime/schedule_tools.py +++ b/backend/src/timeflow/intelligence/realtime/schedule_tools.py @@ -344,12 +344,23 @@ async def _location_search(self, arguments: dict[str, Any]) -> ToolResult: provider recovers. """ if self._location_service is None or self._client_location is None: + logger.warning( + "location search unavailable: provider_configured=%s client_location_available=%s", + self._location_service is not None, + self._client_location is not None, + ) return ToolResult(output=PROVIDER_UNAVAILABLE_RESULT) if self._location_context is None: try: + logger.info("preparing location search context") self._location_context = await self._location_service.prepare(self._client_location) - except LocationError: + except LocationError as error: + logger.warning( + "location search context preparation failed: error_type=%s", + type(error).__name__, + ) return ToolResult(output=PROVIDER_UNAVAILABLE_RESULT) + logger.info("location search context prepared") tool = build_location_search_tool(self._location_service, self._location_context) return ToolResult(output=await tool.execute(arguments)) diff --git a/backend/src/timeflow/main.py b/backend/src/timeflow/main.py index f7f800cb..3ec55f16 100644 --- a/backend/src/timeflow/main.py +++ b/backend/src/timeflow/main.py @@ -101,6 +101,16 @@ def create_app( owned_http_client, settings.tencent_map_api_key, settings.tencent_map_base_url ) ) + logger.info( + "location search configured: provider=tencent_maps timeout_seconds=%s", + settings.tencent_map_timeout_seconds, + ) + elif audio_sink is None: + logger.warning( + "location search unavailable at startup: voice_agent_mode=%s tencent_maps_configured=%s", + settings.voice_agent_mode, + settings.tencent_maps_is_configured(), + ) @asynccontextmanager async def lifespan(_application: FastAPI) -> AsyncIterator[None]: diff --git a/backend/tests/intelligence/realtime/test_realtime_agent.py b/backend/tests/intelligence/realtime/test_realtime_agent.py index 14043b58..70586811 100644 --- a/backend/tests/intelligence/realtime/test_realtime_agent.py +++ b/backend/tests/intelligence/realtime/test_realtime_agent.py @@ -495,8 +495,8 @@ def test_a_barge_in_after_the_reply_already_finished_sending_still_tells_the_cli back, so a reply can finish sending -- turn_completed() already settled it -- while the phone is still sounding it out. The session only calls interrupted() this late when its own playable-until estimate says that is still plausible, so the client - must still be told to stop even though this turn's own bookkeeping has nothing left - to name; there is no reply id left here to attach to it, hence the empty audio_id. + must still be told to stop even though this turn's current bookkeeping has already + been reset. The original audio id must be preserved so a newer reply is not stopped. """ async def scenario() -> None: @@ -515,7 +515,8 @@ async def scenario() -> None: ) (canceled,) = [payload for kind, payload in sink.calls if kind == "canceled"] - assert canceled.audio_id == "" + (audio_reply,) = [payload for kind, payload in sink.calls if kind == "audio_start"] + assert canceled.audio_id == audio_reply.audio_id asyncio.run(scenario()) diff --git a/frontend/.env.example b/frontend/.env.example index d15fbb5b..5e2ef292 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -3,3 +3,7 @@ EXPO_PUBLIC_API_URL=http://10.0.2.2:8000/api/v1 EXPO_PUBLIC_WS_URL=ws://10.0.2.2:8000/ws EXPO_PUBLIC_DEVICE_ID=device_001 + +# Baidu Location API key, consumed by app.config.js at build/prebuild time +# (plugins/withTimeflowBaiduLocation.js writes it into AndroidManifest.xml). +TIMEFLOW_BAIDU_LOCATION_API_KEY= diff --git a/frontend/.prettierignore b/frontend/.prettierignore index 7a3927bb..5951a6f6 100644 --- a/frontend/.prettierignore +++ b/frontend/.prettierignore @@ -4,3 +4,4 @@ dist/ web-build/ package-lock.json assets/ +modules/**/android/build/** diff --git a/frontend/app.config.js b/frontend/app.config.js new file mode 100644 index 00000000..700e76db --- /dev/null +++ b/frontend/app.config.js @@ -0,0 +1,81 @@ +module.exports = { + expo: { + name: 'Timeflow', + slug: 'timeflow', + version: '1.0.0', + orientation: 'portrait', + icon: './assets/icon.png', + userInterfaceStyle: 'light', + ios: { + supportsTablet: true, + infoPlist: { + NSMicrophoneUsageDescription: '需要麦克风权限来录制语音指令', + NSLocationWhenInUseUsageDescription: '需要定位权限来关联语音指令发生的地点', + UIBackgroundModes: ['location'], + }, + bundleIdentifier: 'com.anonymous.timeflow', + }, + android: { + package: 'com.anonymous.timeflow', + permissions: [ + 'RECORD_AUDIO', + 'ACCESS_COARSE_LOCATION', + 'ACCESS_FINE_LOCATION', + 'ACCESS_BACKGROUND_LOCATION', + 'FOREGROUND_SERVICE', + 'FOREGROUND_SERVICE_LOCATION', + 'SCHEDULE_EXACT_ALARM', + 'POST_NOTIFICATIONS', + 'USE_FULL_SCREEN_INTENT', + 'SYSTEM_ALERT_WINDOW', + 'REQUEST_IGNORE_BATTERY_OPTIMIZATIONS', + 'FOREGROUND_SERVICE_MEDIA_PLAYBACK', + 'VIBRATE', + ], + adaptiveIcon: { + backgroundColor: '#E6F4FE', + foregroundImage: './assets/android-icon-foreground.png', + backgroundImage: './assets/android-icon-background.png', + monochromeImage: './assets/android-icon-monochrome.png', + }, + predictiveBackGestureEnabled: false, + }, + plugins: [ + 'expo-sqlite', + [ + 'expo-location', + { + locationAlwaysAndWhenInUsePermission: + '允许 Timeflow 使用你的位置,以便在到达或离开地点时提醒你。', + locationWhenInUsePermission: '允许 Timeflow 在使用期间访问位置,以便地点提醒生效。', + isIosBackgroundLocationEnabled: true, + isAndroidBackgroundLocationEnabled: true, + isAndroidForegroundServiceEnabled: true, + }, + ], + '@irvingouj/expo-audio-stream', + [ + 'expo-audio', + { + microphonePermission: '需要麦克风权限来录制语音指令', + enableBackgroundPlayback: true, + }, + ], + [ + 'expo-notifications', + { + icon: './assets/icon.png', + color: '#15352B', + defaultChannel: 'timeflow-reminders', + }, + ], + './plugins/withTimeflowAlarm', + [ + './plugins/withTimeflowBaiduLocation', + { + apiKey: process.env.TIMEFLOW_BAIDU_LOCATION_API_KEY, + }, + ], + ], + }, +}; diff --git a/frontend/app.json b/frontend/app.json deleted file mode 100644 index d1242021..00000000 --- a/frontend/app.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "expo": { - "name": "Timeflow", - "slug": "timeflow", - "version": "1.0.0", - "orientation": "portrait", - "icon": "./assets/icon.png", - "userInterfaceStyle": "light", - "ios": { - "supportsTablet": true, - "infoPlist": { - "NSMicrophoneUsageDescription": "需要麦克风权限来录制语音指令", - "NSLocationWhenInUseUsageDescription": "需要定位权限来关联语音指令发生的地点" - }, - "bundleIdentifier": "com.anonymous.timeflow" - }, - "android": { - "adaptiveIcon": { - "backgroundColor": "#E6F4FE", - "foregroundImage": "./assets/android-icon-foreground.png", - "backgroundImage": "./assets/android-icon-background.png", - "monochromeImage": "./assets/android-icon-monochrome.png" - }, - "predictiveBackGestureEnabled": false, - "permissions": [ - "RECORD_AUDIO", - "android.permission.ACCESS_COARSE_LOCATION", - "android.permission.ACCESS_FINE_LOCATION" - ], - "package": "com.anonymous.timeflow" - }, - "plugins": ["expo-sqlite", "expo-location", "@irvingouj/expo-audio-stream"] - } -} diff --git a/frontend/assets/sounds/alarm_prompt.mp3 b/frontend/assets/sounds/alarm_prompt.mp3 new file mode 100644 index 00000000..95a3dcd0 Binary files /dev/null and b/frontend/assets/sounds/alarm_prompt.mp3 differ diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index f041633b..08307f0e 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -8,11 +8,19 @@ module.exports = defineConfig([ expoConfig, prettierConfig, { - ignores: ['dist/**', '.expo/**', 'web-build/**', 'node_modules/**'], + ignores: ['dist/**', '.expo/**', 'web-build/**', 'node_modules/**', 'modules/**'], }, { rules: { 'no-console': ['warn', { allow: ['warn', 'error'] }], }, }, + { + files: ['react-native.config.js'], + languageOptions: { + globals: { + __dirname: 'readonly', + }, + }, + }, ]); diff --git a/frontend/index.ts b/frontend/index.ts index 5600fa08..d3f0c0f2 100644 --- a/frontend/index.ts +++ b/frontend/index.ts @@ -1,3 +1,6 @@ +// 必须在根组件注册前加载,确保 TaskManager.defineTask 进入顶层作用域。 +import './src/infrastructure/location/geofenceTask'; + import { registerRootComponent } from 'expo'; import App from './App'; diff --git a/frontend/modules/timeflow-alarm/.gitignore b/frontend/modules/timeflow-alarm/.gitignore new file mode 100644 index 00000000..a4a00d77 --- /dev/null +++ b/frontend/modules/timeflow-alarm/.gitignore @@ -0,0 +1,3 @@ +android/build/ +android/.gradle/ +*.iml diff --git a/frontend/modules/timeflow-alarm/README.md b/frontend/modules/timeflow-alarm/README.md new file mode 100644 index 00000000..bf34f23f --- /dev/null +++ b/frontend/modules/timeflow-alarm/README.md @@ -0,0 +1,5 @@ +# timeflow-alarm + +本地 React Native Android 库,对外提供 `NativeModules.TimeflowAlarm`。 + +`android/` 下源码纳入版本管理。应用级权限由 `plugins/withTimeflowAlarm.js` 在 `expo prebuild` 时注入。Autolinking 通过 `react-native.config.js` 与 `file:modules/timeflow-alarm` 依赖注册 `AlarmPackage`。 diff --git a/frontend/modules/timeflow-alarm/android/build.gradle b/frontend/modules/timeflow-alarm/android/build.gradle new file mode 100644 index 00000000..b01e5c9f --- /dev/null +++ b/frontend/modules/timeflow-alarm/android/build.gradle @@ -0,0 +1,38 @@ +apply plugin: 'com.android.library' +apply plugin: 'kotlin-android' + +def getExtOrDefault(name, defaultValue) { + return rootProject.ext.has(name) ? rootProject.ext.get(name) : defaultValue +} + +android { + namespace "com.timeflow.alarm" + + compileSdkVersion getExtOrDefault('compileSdkVersion', 35) + + defaultConfig { + minSdkVersion getExtOrDefault('minSdkVersion', 24) + targetSdkVersion getExtOrDefault('targetSdkVersion', 35) + } + + sourceSets { + main { + java.srcDirs = ['src/main/java'] + assets.srcDirs = ['src/main/assets'] + } + } + + lintOptions { + abortOnError false + } +} + +repositories { + mavenCentral() + google() +} + +dependencies { + implementation 'com.facebook.react:react-android' + implementation 'androidx.core:core-ktx:1.13.1' +} diff --git a/frontend/modules/timeflow-alarm/android/src/main/AndroidManifest.xml b/frontend/modules/timeflow-alarm/android/src/main/AndroidManifest.xml new file mode 100644 index 00000000..f1e89a38 --- /dev/null +++ b/frontend/modules/timeflow-alarm/android/src/main/AndroidManifest.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/modules/timeflow-alarm/android/src/main/assets/alarm_prompt.mp3 b/frontend/modules/timeflow-alarm/android/src/main/assets/alarm_prompt.mp3 new file mode 100644 index 00000000..95a3dcd0 Binary files /dev/null and b/frontend/modules/timeflow-alarm/android/src/main/assets/alarm_prompt.mp3 differ diff --git a/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/AlarmContract.java b/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/AlarmContract.java new file mode 100644 index 00000000..9a55bbb7 --- /dev/null +++ b/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/AlarmContract.java @@ -0,0 +1,24 @@ +package com.timeflow.alarm; + +final class AlarmContract { + static final String ACTION_FIRE_ALARM = "com.timeflow.FIRE_ALARM"; + static final String ACTION_ALARM_EVENT = "com.timeflow.ALARM_EVENT"; + static final String EXTRA_ALARM_ID = "alarm_id"; + static final String EXTRA_REQUEST_CODE = "request_code"; + static final String EXTRA_TITLE = "alarm_title"; + static final String EXTRA_SCHEDULE_ID = "schedule_id"; + static final String EXTRA_EVENT_TYPE = "event_type"; + static final String EVENT_FIRED = "fired"; + static final String EVENT_DISMISSED = "dismissed"; + static final String EVENT_SNOOZED = "snoozed"; + /** 与 JS DEFAULT_SNOOZE_MINUTES 对齐。 */ + static final long SNOOZE_MINUTES = 10L; + static final String CHANNEL_ID = "timeflow_alarm_channel_v1"; + static final String PREFS_NAME = "timeflow_alarms"; + static final String ALARMS_KEY = "pending_alarms"; + static final String DISPOSITIONS_KEY = "native_dispositions"; + static final String ALARM_URI_SCHEME = "timeflow-alarm"; + + private AlarmContract() { + } +} diff --git a/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/AlarmModule.kt b/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/AlarmModule.kt new file mode 100644 index 00000000..05adef2c --- /dev/null +++ b/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/AlarmModule.kt @@ -0,0 +1,259 @@ +package com.timeflow.alarm + +import android.Manifest +import android.app.AlarmManager +import android.app.NotificationManager +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Build +import android.os.PowerManager +import android.provider.Settings +import android.util.Log +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.WritableArray +import com.facebook.react.bridge.WritableMap +import com.facebook.react.modules.core.DeviceEventManagerModule +import com.facebook.react.modules.core.PermissionAwareActivity +import com.facebook.react.modules.core.PermissionListener +import java.lang.ref.WeakReference + +class AlarmModule(private val reactContext: ReactApplicationContext) : + ReactContextBaseJavaModule(reactContext) { + + init { + reactContextRef = WeakReference(reactContext) + } + + override fun getName(): String = NAME + + @ReactMethod + fun addListener(@Suppress("UNUSED_PARAMETER") eventName: String) { + // NativeEventEmitter 要求存在该方法。 + } + + @ReactMethod + fun removeListeners(@Suppress("UNUSED_PARAMETER") count: Int) { + // NativeEventEmitter 要求存在该方法。 + } + + @ReactMethod + fun schedule( + triggerAtMillis: Double, + title: String?, + scheduleId: String?, + promise: Promise, + ) { + try { + Log.i(NAME, "schedule triggerAtMillis=$triggerAtMillis title=$title scheduleId=$scheduleId") + val alarmId = AlarmScheduler.schedule( + reactContext, + triggerAtMillis.toLong(), + title ?: "日程提醒", + scheduleId ?: "", + ) + Log.i(NAME, "scheduled alarmId=$alarmId") + val result: WritableMap = Arguments.createMap() + result.putString("alarmId", alarmId) + result.putString("scheduleId", scheduleId ?: "") + promise.resolve(result) + } catch (error: IllegalArgumentException) { + promise.reject("TRIGGER_IN_PAST", error.message, error) + } catch (error: SecurityException) { + promise.reject("EXACT_ALARM_DENIED", error.message, error) + } catch (error: Exception) { + promise.reject("SCHEDULE_FAILED", error.message, error) + } + } + + @ReactMethod + fun cancel(alarmId: String?, promise: Promise) { + try { + val cancelled = AlarmScheduler.cancel(reactContext, alarmId) + promise.resolve(cancelled) + } catch (error: Exception) { + promise.reject("CANCEL_FAILED", error.message, error) + } + } + + @ReactMethod + fun cancelAll(promise: Promise) { + try { + val cancelled = AlarmScheduler.cancelAll(reactContext) + promise.resolve(cancelled) + } catch (error: Exception) { + promise.reject("CANCEL_ALL_FAILED", error.message, error) + } + } + + @ReactMethod + fun stopRinging(promise: Promise) { + try { + AlarmNativeBridge.stopRinging(reactContext) + promise.resolve(true) + } catch (error: Exception) { + promise.reject("STOP_RINGING_FAILED", error.message, error) + } + } + + @ReactMethod + fun consumeNativeDispositions(promise: Promise) { + try { + val records = AlarmNativeBridge.consumeDispositions(reactContext) + val array: WritableArray = Arguments.createArray() + for (record in records) { + val item = Arguments.createMap() + item.putString("scheduleId", record.scheduleId) + item.putString("alarmId", record.alarmId) + item.putString("state", record.state) + item.putDouble("updatedAtMillis", record.updatedAtMillis.toDouble()) + array.pushMap(item) + } + promise.resolve(array) + } catch (error: Exception) { + promise.reject("CONSUME_DISPOSITIONS_FAILED", error.message, error) + } + } + + @ReactMethod + fun getPermissionStatus(promise: Promise) { + try { + val status = Arguments.createMap() + val alarmManager = + reactContext.getSystemService(AlarmManager::class.java) + val exactAlarm = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { + true + } else { + alarmManager?.canScheduleExactAlarms() == true + } + status.putBoolean("exactAlarm", exactAlarm) + + val overlay = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { + true + } else { + Settings.canDrawOverlays(reactContext) + } + status.putBoolean("overlay", overlay) + + val notificationManager = + reactContext.getSystemService(NotificationManager::class.java) + val fullScreen = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + true + } else { + notificationManager?.canUseFullScreenIntent() == true + } + status.putBoolean("fullScreen", fullScreen) + + val notifications = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + true + } else { + ContextCompatPermissionGranted(reactContext) + } + status.putBoolean("notifications", notifications) + + val powerManager = reactContext.getSystemService(PowerManager::class.java) + val battery = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { + true + } else { + powerManager?.isIgnoringBatteryOptimizations(reactContext.packageName) == true + } + status.putBoolean("battery", battery) + + promise.resolve(status) + } catch (error: Exception) { + promise.reject("PERMISSION_STATUS_FAILED", error.message, error) + } + } + + @ReactMethod + fun openPermissionSettings(kind: String?, promise: Promise) { + try { + val pkg = Uri.parse("package:${reactContext.packageName}") + val intent = when (kind) { + "exactAlarm" -> Intent(Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM, pkg) + "overlay" -> Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION, pkg) + "fullScreen" -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + Intent(Settings.ACTION_MANAGE_APP_USE_FULL_SCREEN_INTENT, pkg) + } else { + Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, pkg) + } + "battery" -> Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS, pkg) + else -> Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, pkg) + } + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + reactContext.startActivity(intent) + promise.resolve(true) + } catch (error: Exception) { + promise.reject("OPEN_SETTINGS_FAILED", error.message, error) + } + } + + @ReactMethod + fun requestNotificationPermission(promise: Promise) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + promise.resolve(true) + return + } + if (ContextCompatPermissionGranted(reactContext)) { + promise.resolve(true) + return + } + val activity = reactContext.currentActivity + if (activity !is PermissionAwareActivity) { + promise.reject("NO_ACTIVITY", "PermissionAwareActivity unavailable") + return + } + val listener = PermissionListener { requestCode, _, grantResults -> + if (requestCode != NOTIFICATION_REQUEST_CODE) { + return@PermissionListener false + } + val granted = grantResults.isNotEmpty() && + grantResults[0] == PackageManager.PERMISSION_GRANTED + promise.resolve(granted) + true + } + activity.requestPermissions( + arrayOf(Manifest.permission.POST_NOTIFICATIONS), + NOTIFICATION_REQUEST_CODE, + listener, + ) + } + + companion object { + const val NAME = "TimeflowAlarm" + private const val NOTIFICATION_REQUEST_CODE = 2401 + private var reactContextRef: WeakReference? = null + + @JvmStatic + fun emitAlarmEvent(type: String, scheduleId: String?, alarmId: String?, title: String?) { + val context = reactContextRef?.get() ?: return + if (!context.hasActiveReactInstance()) return + try { + val payload = Arguments.createMap() + payload.putString("type", type) + payload.putString("scheduleId", scheduleId ?: "") + payload.putString("alarmId", alarmId ?: "") + payload.putString("title", title ?: "") + payload.putDouble("atMillis", System.currentTimeMillis().toDouble()) + context + .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java) + .emit(EVENT_NAME, payload) + } catch (_: Exception) { + // 后台响铃时桥接可能已拆除,忽略发送失败。 + } + } + + const val EVENT_NAME = "TimeflowAlarmEvent" + } +} + +private fun ContextCompatPermissionGranted(context: ReactApplicationContext): Boolean { + return androidx.core.content.ContextCompat.checkSelfPermission( + context, + Manifest.permission.POST_NOTIFICATIONS, + ) == PackageManager.PERMISSION_GRANTED +} diff --git a/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/AlarmNativeBridge.java b/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/AlarmNativeBridge.java new file mode 100644 index 00000000..e36870a6 --- /dev/null +++ b/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/AlarmNativeBridge.java @@ -0,0 +1,162 @@ +package com.timeflow.alarm; + +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.util.ArrayList; +import java.util.List; + +/** + * 持久化原生 fire/dismiss,供 JS 在进程死后补水 disposition; + * 并在 React 上下文存活时向 AlarmModule 转发事件。 + */ +public final class AlarmNativeBridge { + private AlarmNativeBridge() { + } + + public static final class DispositionRecord { + public final String scheduleId; + public final String alarmId; + public final String state; + public final long updatedAtMillis; + + DispositionRecord(String scheduleId, String alarmId, String state, long updatedAtMillis) { + this.scheduleId = scheduleId; + this.alarmId = alarmId; + this.state = state; + this.updatedAtMillis = updatedAtMillis; + } + } + + public static void notifyFired( + Context context, + String scheduleId, + String alarmId, + String title + ) { + upsertDisposition(context, scheduleId, alarmId, "pending"); + sendLocalEvent(context, AlarmContract.EVENT_FIRED, scheduleId, alarmId, title); + AlarmModule.emitAlarmEvent(AlarmContract.EVENT_FIRED, scheduleId, alarmId, title); + } + + public static void notifyDismissed( + Context context, + String scheduleId, + String alarmId, + String title + ) { + upsertDisposition(context, scheduleId, alarmId, "confirmed"); + sendLocalEvent(context, AlarmContract.EVENT_DISMISSED, scheduleId, alarmId, title); + AlarmModule.emitAlarmEvent(AlarmContract.EVENT_DISMISSED, scheduleId, alarmId, title); + } + + /** 延后:落 snoozed disposition,并由调用方负责重新 schedule。 */ + public static void notifySnoozed( + Context context, + String scheduleId, + String alarmId, + String title + ) { + upsertDisposition(context, scheduleId, alarmId, "snoozed"); + sendLocalEvent(context, AlarmContract.EVENT_SNOOZED, scheduleId, alarmId, title); + AlarmModule.emitAlarmEvent(AlarmContract.EVENT_SNOOZED, scheduleId, alarmId, title); + } + + public static List consumeDispositions(Context context) { + SharedPreferences preferences = + context.getSharedPreferences(AlarmContract.PREFS_NAME, Context.MODE_PRIVATE); + String serialized = preferences.getString(AlarmContract.DISPOSITIONS_KEY, "[]"); + List records = new ArrayList<>(); + try { + JSONArray array = new JSONArray(serialized); + for (int index = 0; index < array.length(); index++) { + Object value = array.get(index); + if (!(value instanceof JSONObject)) { + continue; + } + JSONObject object = (JSONObject) value; + String scheduleId = object.optString("schedule_id", ""); + String state = object.optString("state", ""); + if (scheduleId.isEmpty() || state.isEmpty()) { + continue; + } + records.add(new DispositionRecord( + scheduleId, + object.optString("alarm_id", ""), + state, + object.optLong("updated_at", System.currentTimeMillis()) + )); + } + } catch (JSONException ignored) { + records.clear(); + } + preferences.edit().putString(AlarmContract.DISPOSITIONS_KEY, "[]").apply(); + return records; + } + + public static void stopRinging(Context context) { + AlarmSoundService.stop(context); + RingActivity.finishIfOpen(); + } + + private static void sendLocalEvent( + Context context, + String type, + String scheduleId, + String alarmId, + String title + ) { + Intent intent = new Intent(AlarmContract.ACTION_ALARM_EVENT) + .setPackage(context.getPackageName()) + .putExtra(AlarmContract.EXTRA_EVENT_TYPE, type) + .putExtra(AlarmContract.EXTRA_SCHEDULE_ID, scheduleId) + .putExtra(AlarmContract.EXTRA_ALARM_ID, alarmId) + .putExtra(AlarmContract.EXTRA_TITLE, title); + context.sendBroadcast(intent); + } + + private static void upsertDisposition( + Context context, + String scheduleId, + String alarmId, + String state + ) { + if (scheduleId == null || scheduleId.isEmpty()) { + return; + } + SharedPreferences preferences = + context.getSharedPreferences(AlarmContract.PREFS_NAME, Context.MODE_PRIVATE); + String serialized = preferences.getString(AlarmContract.DISPOSITIONS_KEY, "[]"); + JSONArray remaining = new JSONArray(); + try { + JSONArray array = new JSONArray(serialized); + for (int index = 0; index < array.length(); index++) { + Object value = array.get(index); + if (!(value instanceof JSONObject)) { + continue; + } + JSONObject object = (JSONObject) value; + if (scheduleId.equals(object.optString("schedule_id", ""))) { + continue; + } + remaining.put(object); + } + JSONObject next = new JSONObject(); + next.put("schedule_id", scheduleId); + next.put("alarm_id", alarmId == null ? "" : alarmId); + next.put("state", state); + next.put("updated_at", System.currentTimeMillis()); + remaining.put(next); + } catch (JSONException ignored) { + return; + } + preferences.edit() + .putString(AlarmContract.DISPOSITIONS_KEY, remaining.toString()) + .apply(); + } +} 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 new file mode 100644 index 00000000..8fb12c2f --- /dev/null +++ b/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/AlarmPackage.kt @@ -0,0 +1,18 @@ +package com.timeflow.alarm + +import com.facebook.react.ReactPackage +import com.facebook.react.bridge.NativeModule +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.uimanager.ViewManager + +class AlarmPackage : ReactPackage { + override fun createNativeModules(reactContext: ReactApplicationContext): List { + return listOf(AlarmModule(reactContext)) + } + + override fun createViewManagers( + reactContext: ReactApplicationContext + ): List> { + return emptyList() + } +} diff --git a/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/AlarmReceiver.java b/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/AlarmReceiver.java new file mode 100644 index 00000000..81391669 --- /dev/null +++ b/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/AlarmReceiver.java @@ -0,0 +1,36 @@ +package com.timeflow.alarm; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.os.Build; + +public final class AlarmReceiver extends BroadcastReceiver { + @Override + public void onReceive(Context context, Intent intent) { + if (!AlarmContract.ACTION_FIRE_ALARM.equals(intent.getAction())) { + return; + } + + int requestCode = intent.getIntExtra(AlarmContract.EXTRA_REQUEST_CODE, 0); + String alarmId = intent.getStringExtra(AlarmContract.EXTRA_ALARM_ID); + String scheduleId = intent.getStringExtra(AlarmContract.EXTRA_SCHEDULE_ID); + String title = intent.getStringExtra(AlarmContract.EXTRA_TITLE); + if (alarmId == null || alarmId.isEmpty()) { + alarmId = "legacy-" + requestCode; + } + if (scheduleId == null) { + scheduleId = ""; + } + Intent serviceIntent = new Intent(context, AlarmSoundService.class) + .putExtra(AlarmContract.EXTRA_ALARM_ID, alarmId) + .putExtra(AlarmContract.EXTRA_SCHEDULE_ID, scheduleId) + .putExtra(AlarmContract.EXTRA_REQUEST_CODE, requestCode) + .putExtra(AlarmContract.EXTRA_TITLE, title); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.startForegroundService(serviceIntent); + } else { + context.startService(serviceIntent); + } + } +} diff --git a/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/AlarmRingUi.java b/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/AlarmRingUi.java new file mode 100644 index 00000000..1076358e --- /dev/null +++ b/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/AlarmRingUi.java @@ -0,0 +1,320 @@ +package com.timeflow.alarm; + +import android.content.Context; +import android.content.res.ColorStateList; +import android.graphics.Color; +import android.graphics.Typeface; +import android.graphics.drawable.GradientDrawable; +import android.graphics.drawable.RippleDrawable; +import android.graphics.drawable.StateListDrawable; +import android.os.Handler; +import android.os.Looper; +import android.provider.Settings; +import android.text.TextUtils; +import android.util.StateSet; +import android.view.Gravity; +import android.view.View; +import android.view.animation.DecelerateInterpolator; +import android.widget.FrameLayout; +import android.widget.LinearLayout; +import android.widget.TextView; + +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.Locale; + +/** + * 提醒打断界面:时钟与标题居中。 + * 颜色与 src/shared/theme/index.ts 对齐。 + */ +final class AlarmRingUi { + private static final int COLOR_BACKGROUND = Color.parseColor("#F4F5F1"); + private static final int COLOR_MINT = Color.parseColor("#DDEFE5"); + private static final int COLOR_DEEP = Color.parseColor("#15352B"); + private static final int COLOR_SUB = Color.parseColor("#6C7972"); + private static final int COLOR_LIME = Color.parseColor("#D7F36A"); + + private static final String FALLBACK_TITLE = "日程提醒"; + private static final long ENTER_MILLIS = 420L; + private static final long ENTER_STAGGER_MILLIS = 60L; + private static final float ENTER_OFFSET_DP = 10f; + + private AlarmRingUi() { + } + + static int topEdgeColor() { + return COLOR_MINT; + } + + static int bottomEdgeColor() { + return COLOR_BACKGROUND; + } + + static FrameLayout build( + Context context, + String scheduleTitle, + View.OnClickListener onSnooze, + View.OnClickListener onConfirm + ) { + float d = context.getResources().getDisplayMetrics().density; + int gutter = Math.round(28 * d); + + TextView clock = text(context, formatClock(), 66, COLOR_DEEP, condensedBold()); + clock.setLetterSpacing(-0.04f); + clock.setFontFeatureSettings("tnum"); + clock.setGravity(Gravity.CENTER); + + TextView date = text(context, formatDate(), 15, COLOR_SUB, regular()); + date.setGravity(Gravity.CENTER); + + RingRoot root = new RingRoot(context, () -> { + clock.setText(formatClock()); + date.setText(formatDate()); + }); + root.setBackground(buildBackground()); + + TextView brand = text(context, "Timeflow", 36, COLOR_DEEP, bold()); + brand.setLetterSpacing(-0.03f); + brand.setGravity(Gravity.CENTER); + FrameLayout.LayoutParams brandParams = new FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.WRAP_CONTENT + ); + brandParams.topMargin = Math.round(48 * d); + root.addView(brand, brandParams); + + TextView moment = text(context, "此刻提醒", 13, COLOR_SUB, medium()); + moment.setGravity(Gravity.CENTER); + FrameLayout.LayoutParams momentParams = new FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.WRAP_CONTENT + ); + momentParams.topMargin = Math.round(92 * d); + root.addView(moment, momentParams); + + LinearLayout center = new LinearLayout(context); + center.setOrientation(LinearLayout.VERTICAL); + center.setGravity(Gravity.CENTER_HORIZONTAL); + FrameLayout.LayoutParams centerParams = new FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.WRAP_CONTENT, + Gravity.CENTER + ); + centerParams.leftMargin = gutter; + centerParams.rightMargin = gutter; + root.addView(center, centerParams); + + center.addView(clock, matchWidth()); + + LinearLayout.LayoutParams dateParams = matchWidth(); + dateParams.topMargin = Math.round(14 * d); + center.addView(date, dateParams); + + View divider = new View(context); + GradientDrawable dividerBg = new GradientDrawable(); + dividerBg.setColor(COLOR_LIME); + dividerBg.setCornerRadius(999 * d); + divider.setBackground(dividerBg); + LinearLayout.LayoutParams dividerParams = new LinearLayout.LayoutParams( + Math.round(40 * d), + Math.round(3 * d) + ); + dividerParams.gravity = Gravity.CENTER_HORIZONTAL; + dividerParams.topMargin = Math.round(26 * d); + center.addView(divider, dividerParams); + + TextView title = text(context, resolveTitle(scheduleTitle), 32, COLOR_DEEP, bold()); + title.setMaxLines(3); + title.setEllipsize(TextUtils.TruncateAt.END); + title.setLineSpacing(0f, 1.25f); + title.setGravity(Gravity.CENTER); + LinearLayout.LayoutParams titleParams = matchWidth(); + titleParams.topMargin = Math.round(20 * d); + center.addView(title, titleParams); + + LinearLayout actions = new LinearLayout(context); + actions.setOrientation(LinearLayout.VERTICAL); + FrameLayout.LayoutParams actionsParams = new FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.WRAP_CONTENT, + Gravity.BOTTOM + ); + actionsParams.leftMargin = gutter; + actionsParams.rightMargin = gutter; + actionsParams.bottomMargin = Math.round(28 * d); + root.addView(actions, actionsParams); + + TextView snooze = buildActionButton( + context, + d, + "延后 10 分钟", + COLOR_MINT, + onSnooze + ); + LinearLayout.LayoutParams snoozeParams = matchWidth(); + snoozeParams.height = Math.round(54 * d); + actions.addView(snooze, snoozeParams); + + TextView confirm = buildActionButton( + context, + d, + "确认", + COLOR_LIME, + onConfirm + ); + LinearLayout.LayoutParams confirmParams = matchWidth(); + confirmParams.height = Math.round(58 * d); + confirmParams.topMargin = Math.round(12 * d); + actions.addView(confirm, confirmParams); + + if (animatorsEnabled(context)) { + View[] sequence = {brand, moment, center, actions}; + root.post(() -> enter(sequence, d)); + } + + return root; + } + + private static TextView buildActionButton( + Context context, + float d, + String label, + int fillColor, + View.OnClickListener onClick + ) { + TextView button = text(context, label, 18, COLOR_DEEP, bold()); + button.setGravity(Gravity.CENTER); + button.setContentDescription(label); + button.setClickable(true); + button.setFocusable(true); + button.setElevation(3 * d); + button.setOnClickListener(onClick); + + StateListDrawable fill = new StateListDrawable(); + GradientDrawable focused = pill(d, Math.round(2 * d), fillColor); + GradientDrawable normal = pill(d, 0, fillColor); + fill.addState(new int[]{android.R.attr.state_focused}, focused); + fill.addState(StateSet.WILD_CARD, normal); + button.setBackground(new RippleDrawable( + ColorStateList.valueOf(Color.argb(38, 21, 53, 43)), + fill, + null + )); + return button; + } + + private static GradientDrawable pill(float d, int strokeWidth, int fillColor) { + GradientDrawable pill = new GradientDrawable(); + pill.setColor(fillColor); + pill.setCornerRadius(20 * d); + if (strokeWidth > 0) { + pill.setStroke(strokeWidth, COLOR_DEEP); + } + return pill; + } + + private static void enter(View[] sequence, float d) { + for (int index = 0; index < sequence.length; index++) { + View view = sequence[index]; + view.setAlpha(0f); + view.setTranslationY(ENTER_OFFSET_DP * d); + view.animate() + .alpha(1f) + .translationY(0f) + .setStartDelay(index * ENTER_STAGGER_MILLIS) + .setDuration(ENTER_MILLIS) + .setInterpolator(new DecelerateInterpolator(1.6f)) + .start(); + } + } + + private static String resolveTitle(String scheduleTitle) { + return scheduleTitle == null || scheduleTitle.isEmpty() ? FALLBACK_TITLE : scheduleTitle; + } + + private static String formatClock() { + return new SimpleDateFormat("HH:mm", Locale.getDefault()).format(new Date()); + } + + private static String formatDate() { + return new SimpleDateFormat("M月d日 EEEE", Locale.CHINA).format(new Date()); + } + + private static TextView text(Context context, String value, float sizeSp, int color, Typeface face) { + TextView view = new TextView(context); + view.setText(value); + view.setTextSize(sizeSp); + view.setTextColor(color); + view.setTypeface(face); + return view; + } + + private static Typeface condensedBold() { + return Typeface.create("sans-serif-condensed", Typeface.BOLD); + } + + private static Typeface medium() { + return Typeface.create("sans-serif-medium", Typeface.NORMAL); + } + + private static Typeface bold() { + return Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD); + } + + private static Typeface regular() { + return Typeface.create(Typeface.SANS_SERIF, Typeface.NORMAL); + } + + private static GradientDrawable buildBackground() { + return new GradientDrawable( + GradientDrawable.Orientation.TOP_BOTTOM, + new int[]{COLOR_MINT, COLOR_BACKGROUND, COLOR_BACKGROUND, COLOR_BACKGROUND} + ); + } + + private static LinearLayout.LayoutParams matchWidth() { + return new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ); + } + + private static boolean animatorsEnabled(Context context) { + return Settings.Global.getFloat( + context.getContentResolver(), + Settings.Global.ANIMATOR_DURATION_SCALE, + 1f + ) > 0f; + } + + private static final class RingRoot extends FrameLayout { + private static final long TICK_MILLIS = 20_000L; + + private final Handler handler = new Handler(Looper.getMainLooper()); + private final Runnable onTick; + private final Runnable ticker = new Runnable() { + @Override + public void run() { + onTick.run(); + handler.postDelayed(this, TICK_MILLIS); + } + }; + + RingRoot(Context context, Runnable onTick) { + super(context); + this.onTick = onTick; + } + + @Override + protected void onAttachedToWindow() { + super.onAttachedToWindow(); + handler.postDelayed(ticker, TICK_MILLIS); + } + + @Override + protected void onDetachedFromWindow() { + handler.removeCallbacks(ticker); + super.onDetachedFromWindow(); + } + } +} diff --git a/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/AlarmScheduler.java b/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/AlarmScheduler.java new file mode 100644 index 00000000..1469561f --- /dev/null +++ b/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/AlarmScheduler.java @@ -0,0 +1,376 @@ +package com.timeflow.alarm; + +import android.app.AlarmManager; +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.net.Uri; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +/** + * 与 UI 无关的 AlarmManager 调度器。 + * 持久化 UUID / scheduleId / triggerAtMillis / requestCode,供精确取消与 rebuild。 + */ +public final class AlarmScheduler { + private AlarmScheduler() { + } + + public static final class AlarmRecord { + public final String alarmId; + public final String scheduleId; + public final long triggerAtMillis; + public final int requestCode; + public final String title; + public final boolean legacy; + + AlarmRecord( + String alarmId, + String scheduleId, + long triggerAtMillis, + int requestCode, + String title, + boolean legacy + ) { + this.alarmId = alarmId; + this.scheduleId = scheduleId == null ? "" : scheduleId; + this.triggerAtMillis = triggerAtMillis; + this.requestCode = requestCode; + this.title = title; + this.legacy = legacy; + } + } + + public static String schedule( + Context context, + long triggerAtMillis, + String title, + String scheduleId + ) { + if (triggerAtMillis <= System.currentTimeMillis()) { + throw new IllegalArgumentException("trigger_in_past"); + } + + AlarmManager alarmManager = + (AlarmManager) context.getSystemService(Context.ALARM_SERVICE); + if (alarmManager == null) { + throw new IllegalStateException("alarm_manager_unavailable"); + } + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S + && !alarmManager.canScheduleExactAlarms()) { + throw new SecurityException("exact_alarm_denied"); + } + + if (scheduleId != null && !scheduleId.isEmpty()) { + cancelByScheduleId(context, scheduleId); + } + + List alarms = loadAlarms(context); + String alarmId = UUID.randomUUID().toString(); + AlarmRecord record = new AlarmRecord( + alarmId, + scheduleId == null ? "" : scheduleId, + triggerAtMillis, + nextRequestCode(alarms), + title == null ? "" : title, + false + ); + + rearm(context, alarmManager, record); + + alarms.add(record); + saveAlarms(context, alarms); + return alarmId; + } + + /** @deprecated 请改用 {@link #schedule(Context, long, String, String)}。 */ + @Deprecated + public static String schedule(Context context, long triggerAtMillis, String title) { + return schedule(context, triggerAtMillis, title, ""); + } + + /** + * 系统重启 / 应用更新后重新挂上所有还没过期的持久化闹钟。 + * AlarmManager 的注册在这两种情况下都会被系统清空,但 SharedPreferences 里的记录还在; + * 不重新挂,用户在下次自己打开 App 之前不会再收到任何提醒。 + *

+ * 已经过期的记录直接从持久化列表里丢弃,不在这里补响——JS 侧 + * {@code LocalReminderApplication} 自己会在下次 rebuild 时把错过的提醒当到点处理, + * 这里再触发一次会导致同一条提醒响两次。 + */ + public static void rescheduleAfterBoot(Context context) { + AlarmManager alarmManager = + (AlarmManager) context.getSystemService(Context.ALARM_SERVICE); + if (alarmManager == null) { + return; + } + boolean canScheduleExact = android.os.Build.VERSION.SDK_INT + < android.os.Build.VERSION_CODES.S + || alarmManager.canScheduleExactAlarms(); + + long now = System.currentTimeMillis(); + List survivors = new ArrayList<>(); + for (AlarmRecord record : loadAlarms(context)) { + if (record.triggerAtMillis <= now || !canScheduleExact) { + continue; + } + rearm(context, alarmManager, record); + survivors.add(record); + } + saveAlarms(context, survivors); + } + + private static void rearm(Context context, AlarmManager alarmManager, AlarmRecord record) { + PendingIntent operation = buildAlarmBroadcastPendingIntent( + context, + record, + PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE + ); + Intent showIntent = context.getPackageManager() + .getLaunchIntentForPackage(context.getPackageName()); + if (showIntent == null) { + showIntent = new Intent(Intent.ACTION_MAIN) + .setPackage(context.getPackageName()); + } + showIntent.setData(alarmUri(record.alarmId)) + .putExtra(AlarmContract.EXTRA_ALARM_ID, record.alarmId) + .putExtra(AlarmContract.EXTRA_SCHEDULE_ID, record.scheduleId) + .putExtra(AlarmContract.EXTRA_TITLE, record.title) + .putExtra(AlarmContract.EXTRA_REQUEST_CODE, record.requestCode) + .addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_SINGLE_TOP); + PendingIntent showPendingIntent = PendingIntent.getActivity( + context, + record.requestCode, + showIntent, + PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE + ); + alarmManager.setAlarmClock( + new AlarmManager.AlarmClockInfo(record.triggerAtMillis, showPendingIntent), + operation + ); + } + + public static boolean cancel(Context context, String alarmId) { + if (alarmId == null || alarmId.isEmpty()) { + return false; + } + List alarms = loadAlarms(context); + int index = indexById(alarms, alarmId); + if (index < 0) { + return false; + } + + AlarmRecord record = alarms.get(index); + cancelPendingIntent(context, record); + alarms.remove(index); + saveAlarms(context, alarms); + return true; + } + + public static int cancelByScheduleId(Context context, String scheduleId) { + if (scheduleId == null || scheduleId.isEmpty()) { + return 0; + } + List alarms = loadAlarms(context); + List remaining = new ArrayList<>(); + int cancelled = 0; + for (AlarmRecord record : alarms) { + if (scheduleId.equals(record.scheduleId)) { + cancelPendingIntent(context, record); + cancelled += 1; + } else { + remaining.add(record); + } + } + if (cancelled > 0) { + saveAlarms(context, remaining); + } + return cancelled; + } + + public static int cancelAll(Context context) { + List alarms = loadAlarms(context); + for (AlarmRecord record : alarms) { + cancelPendingIntent(context, record); + } + saveAlarms(context, new ArrayList()); + return alarms.size(); + } + + public static List loadAlarms(Context context) { + SharedPreferences preferences = + context.getSharedPreferences(AlarmContract.PREFS_NAME, Context.MODE_PRIVATE); + String serialized = preferences.getString(AlarmContract.ALARMS_KEY, "[]"); + List alarms = new ArrayList<>(); + try { + JSONArray array = new JSONArray(serialized); + for (int index = 0; index < array.length(); index++) { + Object value = array.get(index); + if (!(value instanceof JSONObject)) { + continue; + } + JSONObject object = (JSONObject) value; + long triggerAt = object.optLong("trigger_at", -1L); + int requestCode = object.optInt("request_code", -1); + if (triggerAt <= 0 || requestCode < 0) { + continue; + } + String alarmId = object.optString("alarm_id", ""); + boolean legacy = object.optBoolean("legacy", alarmId.isEmpty()); + if (alarmId.isEmpty()) { + alarmId = "legacy-" + requestCode; + } + alarms.add(new AlarmRecord( + alarmId, + object.optString("schedule_id", ""), + triggerAt, + requestCode, + object.optString("title", ""), + legacy + )); + } + } catch (JSONException ignored) { + alarms.clear(); + } + return alarms; + } + + static void removeAlarmRecord(Context context, String alarmId, int requestCode) { + List alarms = loadAlarms(context); + JSONArray remaining = new JSONArray(); + for (AlarmRecord alarm : alarms) { + boolean match; + if (!alarm.alarmId.isEmpty()) { + match = alarm.alarmId.equals(alarmId); + } else { + match = alarm.requestCode == requestCode; + } + if (match) { + continue; + } + try { + remaining.put(toJson(alarm)); + } catch (JSONException ignored) { + // 忽略单条序列化失败 + } + } + context.getSharedPreferences(AlarmContract.PREFS_NAME, Context.MODE_PRIVATE) + .edit() + .putString(AlarmContract.ALARMS_KEY, remaining.toString()) + .apply(); + } + + static Uri alarmUri(String alarmId) { + return Uri.parse( + AlarmContract.ALARM_URI_SCHEME + "://alarm/" + Uri.encode(alarmId) + ); + } + + static String scheduleIdForAlarm(Context context, String alarmId) { + if (alarmId == null || alarmId.isEmpty()) { + return ""; + } + for (AlarmRecord alarm : loadAlarms(context)) { + if (alarmId.equals(alarm.alarmId)) { + return alarm.scheduleId; + } + } + return ""; + } + + private static void cancelPendingIntent(Context context, AlarmRecord record) { + AlarmManager alarmManager = + (AlarmManager) context.getSystemService(Context.ALARM_SERVICE); + if (alarmManager == null) { + return; + } + PendingIntent operation = buildAlarmBroadcastPendingIntent( + context, + record, + PendingIntent.FLAG_NO_CREATE | PendingIntent.FLAG_IMMUTABLE + ); + if (operation != null) { + alarmManager.cancel(operation); + operation.cancel(); + } + } + + private static void saveAlarms(Context context, List alarms) { + JSONArray array = new JSONArray(); + for (AlarmRecord alarm : alarms) { + try { + array.put(toJson(alarm)); + } catch (JSONException ignored) { + // 忽略单条序列化失败 + } + } + context.getSharedPreferences(AlarmContract.PREFS_NAME, Context.MODE_PRIVATE) + .edit() + .putString(AlarmContract.ALARMS_KEY, array.toString()) + .apply(); + } + + private static JSONObject toJson(AlarmRecord alarm) throws JSONException { + JSONObject object = new JSONObject(); + object.put("alarm_id", alarm.alarmId); + object.put("schedule_id", alarm.scheduleId); + object.put("trigger_at", alarm.triggerAtMillis); + object.put("request_code", alarm.requestCode); + object.put("title", alarm.title); + object.put("legacy", alarm.legacy); + return object; + } + + private static PendingIntent buildAlarmBroadcastPendingIntent( + Context context, + AlarmRecord record, + int flags + ) { + Intent intent = new Intent(context, AlarmReceiver.class) + .setAction(AlarmContract.ACTION_FIRE_ALARM) + .putExtra(AlarmContract.EXTRA_ALARM_ID, record.alarmId) + .putExtra(AlarmContract.EXTRA_SCHEDULE_ID, record.scheduleId) + .putExtra(AlarmContract.EXTRA_REQUEST_CODE, record.requestCode) + .putExtra(AlarmContract.EXTRA_TITLE, record.title); + if (!record.legacy) { + intent.setData(alarmUri(record.alarmId)); + } + return PendingIntent.getBroadcast(context, record.requestCode, intent, flags); + } + + private static int nextRequestCode(List alarms) { + int requestCode = (int) (System.currentTimeMillis() & 0x7fffffff); + if (requestCode == 0) { + requestCode = 1; + } + while (containsRequestCode(alarms, requestCode)) { + requestCode = requestCode == Integer.MAX_VALUE ? 1 : requestCode + 1; + } + return requestCode; + } + + private static boolean containsRequestCode(List alarms, int requestCode) { + for (AlarmRecord alarm : alarms) { + if (alarm.requestCode == requestCode) { + return true; + } + } + return false; + } + + private static int indexById(List alarms, String alarmId) { + for (int index = 0; index < alarms.size(); index++) { + if (alarms.get(index).alarmId.equals(alarmId)) { + return index; + } + } + return -1; + } +} diff --git a/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/AlarmSoundService.java b/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/AlarmSoundService.java new file mode 100644 index 00000000..af58a492 --- /dev/null +++ b/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/AlarmSoundService.java @@ -0,0 +1,382 @@ +package com.timeflow.alarm; + +import android.app.Notification; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.app.ActivityOptions; +import android.app.Service; +import android.content.Context; +import android.content.Intent; +import android.content.pm.ServiceInfo; +import android.graphics.PixelFormat; +import android.media.AudioAttributes; +import android.media.MediaPlayer; +import android.net.Uri; +import android.os.Build; +import android.os.Handler; +import android.os.IBinder; +import android.os.Looper; +import android.provider.Settings; +import android.view.Gravity; +import android.view.View; +import android.view.WindowManager; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.InputStream; + +public final class AlarmSoundService extends Service { + private static final long SPEECH_REPEAT_DELAY_MILLIS = 1_500L; + + private final Handler playbackHandler = new Handler(Looper.getMainLooper()); + private final Runnable replaySpeech = this::replaySpeech; + + private MediaPlayer mediaPlayer; + private boolean destroyed; + private File bundledSpeechFile; + private WindowManager overlayWindowManager; + private View overlayView; + private String alarmId; + private String scheduleId; + private String alarmTitle; + private int requestCode; + private boolean firedNotified; + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + requestCode = intent == null + ? 0 + : intent.getIntExtra(AlarmContract.EXTRA_REQUEST_CODE, 0); + alarmId = intent == null + ? null + : intent.getStringExtra(AlarmContract.EXTRA_ALARM_ID); + scheduleId = intent == null + ? null + : intent.getStringExtra(AlarmContract.EXTRA_SCHEDULE_ID); + alarmTitle = intent == null + ? null + : intent.getStringExtra(AlarmContract.EXTRA_TITLE); + if (alarmId == null || alarmId.isEmpty()) { + alarmId = "legacy-" + requestCode; + } + if (scheduleId == null || scheduleId.isEmpty()) { + scheduleId = AlarmScheduler.scheduleIdForAlarm(this, alarmId); + } + if (alarmTitle == null || alarmTitle.isEmpty()) { + alarmTitle = "日程提醒"; + } + + createNotificationChannel(); + Notification notification = buildNotification(alarmId, alarmTitle); + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + startForeground( + requestCode, + notification, + ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK + ); + } else { + startForeground(requestCode, notification); + } + removeFromSavedAlarms(); + if (!firedNotified) { + firedNotified = true; + AlarmNativeBridge.notifyFired(this, scheduleId, alarmId, alarmTitle); + } + showAlarmOverlay(alarmTitle); + if (mediaPlayer == null) { + startBundledSpeech(); + } + } catch (RuntimeException exception) { + stopSelf(); + } + return START_NOT_STICKY; + } + + @Override + public void onDestroy() { + destroyed = true; + playbackHandler.removeCallbacksAndMessages(null); + removeAlarmOverlay(); + releaseMediaPlayer(); + deleteCachedSpeechFile(); + NotificationManager manager = + (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); + if (manager != null) { + manager.cancel(requestCode); + } + super.onDestroy(); + } + + @Override + public IBinder onBind(Intent intent) { + return null; + } + + static void stop(Context context) { + context.stopService(new Intent(context, AlarmSoundService.class)); + } + + static void start( + Context context, + String alarmId, + String scheduleId, + int requestCode, + String title + ) { + Intent intent = new Intent(context, AlarmSoundService.class) + .putExtra(AlarmContract.EXTRA_ALARM_ID, alarmId) + .putExtra(AlarmContract.EXTRA_SCHEDULE_ID, scheduleId) + .putExtra(AlarmContract.EXTRA_REQUEST_CODE, requestCode) + .putExtra(AlarmContract.EXTRA_TITLE, title); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.startForegroundService(intent); + } else { + context.startService(intent); + } + } + + private Notification buildNotification(String alarmId, String title) { + Intent ringIntent = new Intent(this, RingActivity.class) + .setData(alarmUri(alarmId)) + .putExtra(AlarmContract.EXTRA_ALARM_ID, alarmId) + .putExtra(AlarmContract.EXTRA_SCHEDULE_ID, scheduleId) + .putExtra(AlarmContract.EXTRA_REQUEST_CODE, requestCode) + .putExtra(AlarmContract.EXTRA_TITLE, title) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK + | Intent.FLAG_ACTIVITY_MULTIPLE_TASK + | Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS); + PendingIntent fullScreenIntent = PendingIntent.getActivity( + this, + requestCode, + ringIntent, + PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE, + pendingIntentOptions() + ); + return new Notification.Builder(this, AlarmContract.CHANNEL_ID) + .setSmallIcon(android.R.drawable.ic_lock_idle_alarm) + .setContentTitle(title) + .setContentText("点击停止提醒") + .setCategory(Notification.CATEGORY_ALARM) + .setVisibility(Notification.VISIBILITY_PUBLIC) + .setPriority(Notification.PRIORITY_MAX) + .setOngoing(true) + .setAutoCancel(false) + .setFullScreenIntent(fullScreenIntent, true) + .build(); + } + + private Uri alarmUri(String value) { + return AlarmScheduler.alarmUri(value); + } + + private android.os.Bundle pendingIntentOptions() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + return null; + } + ActivityOptions options = ActivityOptions.makeBasic(); + options.setPendingIntentCreatorBackgroundActivityStartMode( + backgroundActivityStartMode() + ); + return options.toBundle(); + } + + private int backgroundActivityStartMode() { + if (Build.VERSION.SDK_INT >= 36) { + return ActivityOptions.MODE_BACKGROUND_ACTIVITY_START_ALLOW_ALWAYS; + } + return ActivityOptions.MODE_BACKGROUND_ACTIVITY_START_ALLOWED; + } + + private void createNotificationChannel() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + return; + } + NotificationManager manager = + (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); + if (manager == null || manager.getNotificationChannel(AlarmContract.CHANNEL_ID) != null) { + return; + } + NotificationChannel channel = new NotificationChannel( + AlarmContract.CHANNEL_ID, + "Timeflow", + NotificationManager.IMPORTANCE_HIGH + ); + channel.setDescription("日程闹钟提醒"); + channel.enableVibration(true); + channel.setSound(null, null); + manager.createNotificationChannel(channel); + } + + private void showAlarmOverlay(String title) { + if (overlayView != null + || Build.VERSION.SDK_INT < Build.VERSION_CODES.M + || !Settings.canDrawOverlays(this)) { + return; + } + + overlayWindowManager = (WindowManager) getSystemService(Context.WINDOW_SERVICE); + if (overlayWindowManager == null) { + return; + } + + View content = AlarmRingUi.build( + this, + title, + view -> { + long triggerAt = System.currentTimeMillis() + + AlarmContract.SNOOZE_MINUTES * 60_000L; + try { + AlarmScheduler.schedule(this, triggerAt, alarmTitle, scheduleId); + } catch (RuntimeException ignored) { + // ignore + } + AlarmNativeBridge.notifySnoozed(this, scheduleId, alarmId, alarmTitle); + removeAlarmOverlay(); + RingActivity.finishIfOpen(); + stopSelf(); + }, + view -> { + AlarmNativeBridge.notifyDismissed(this, scheduleId, alarmId, alarmTitle); + removeAlarmOverlay(); + RingActivity.finishIfOpen(); + stopSelf(); + } + ); + int windowType = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O + ? WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY + : WindowManager.LayoutParams.TYPE_SYSTEM_ALERT; + int windowFlags = WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN + | WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS + | WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON + | WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED + | WindowManager.LayoutParams.FLAG_DISMISS_KEYGUARD + | WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON + | WindowManager.LayoutParams.FLAG_FULLSCREEN; + WindowManager.LayoutParams params = new WindowManager.LayoutParams( + WindowManager.LayoutParams.MATCH_PARENT, + WindowManager.LayoutParams.MATCH_PARENT, + windowType, + windowFlags, + PixelFormat.OPAQUE + ); + params.gravity = Gravity.TOP | Gravity.START; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + params.layoutInDisplayCutoutMode = + WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES; + } + content.setSystemUiVisibility( + View.SYSTEM_UI_FLAG_LAYOUT_STABLE + | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN + | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION + | View.SYSTEM_UI_FLAG_FULLSCREEN + | View.SYSTEM_UI_FLAG_HIDE_NAVIGATION + | View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY + ); + + try { + overlayWindowManager.addView(content, params); + overlayView = content; + } catch (RuntimeException exception) { + overlayWindowManager = null; + } + } + + private void removeAlarmOverlay() { + if (overlayWindowManager != null && overlayView != null) { + try { + overlayWindowManager.removeViewImmediate(overlayView); + } catch (RuntimeException ignored) { + // 系统可能已移除悬浮窗。 + } + } + overlayView = null; + overlayWindowManager = null; + } + + private void startBundledSpeech() { + if (destroyed || mediaPlayer != null) { + return; + } + try { + bundledSpeechFile = new File(getCacheDir(), "alarm_prompt_edge.mp3"); + try (InputStream input = getAssets().open("alarm_prompt.mp3"); + FileOutputStream output = new FileOutputStream(bundledSpeechFile, false)) { + byte[] buffer = new byte[8_192]; + int count; + while ((count = input.read(buffer)) != -1) { + output.write(buffer, 0, count); + } + } + startAudioPlayback(bundledSpeechFile); + } catch (Exception exception) { + releaseMediaPlayer(); + } + } + + private void startAudioPlayback(File audioFile) { + if (destroyed || mediaPlayer != null || audioFile == null || !audioFile.isFile()) { + return; + } + try { + MediaPlayer player = new MediaPlayer(); + player.setAudioAttributes(new AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_ALARM) + .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) + .build()); + player.setDataSource(audioFile.getAbsolutePath()); + player.setVolume(1.0f, 1.0f); + player.setOnCompletionListener(completed -> + playbackHandler.postDelayed(replaySpeech, SPEECH_REPEAT_DELAY_MILLIS)); + player.setOnErrorListener((failed, what, extra) -> { + releaseMediaPlayer(); + return true; + }); + player.prepare(); + mediaPlayer = player; + player.start(); + } catch (Exception exception) { + releaseMediaPlayer(); + } + } + + private void replaySpeech() { + if (destroyed || mediaPlayer == null) { + return; + } + try { + mediaPlayer.seekTo(0); + mediaPlayer.start(); + } catch (IllegalStateException ignored) { + releaseMediaPlayer(); + } + } + + private void releaseMediaPlayer() { + playbackHandler.removeCallbacks(replaySpeech); + if (mediaPlayer == null) { + return; + } + mediaPlayer.setOnCompletionListener(null); + mediaPlayer.setOnErrorListener(null); + try { + mediaPlayer.stop(); + } catch (IllegalStateException ignored) { + // 播放器可能已结束或失败。 + } + mediaPlayer.release(); + mediaPlayer = null; + } + + private void deleteCachedSpeechFile() { + if (bundledSpeechFile != null) { + bundledSpeechFile.delete(); + } + } + + private void removeFromSavedAlarms() { + AlarmScheduler.removeAlarmRecord(this, alarmId, requestCode); + } + +} diff --git a/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/BootReceiver.java b/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/BootReceiver.java new file mode 100644 index 00000000..6af9dbf7 --- /dev/null +++ b/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/BootReceiver.java @@ -0,0 +1,21 @@ +package com.timeflow.alarm; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; + +/** + * 重新挂上系统重启 / 应用更新后被清空的 AlarmManager 注册。 + * MY_PACKAGE_REPLACED 覆盖应用更新场景——同样会清空 AlarmManager,行为跟重启一致。 + */ +public final class BootReceiver extends BroadcastReceiver { + @Override + public void onReceive(Context context, Intent intent) { + String action = intent == null ? null : intent.getAction(); + if (!Intent.ACTION_BOOT_COMPLETED.equals(action) + && !Intent.ACTION_MY_PACKAGE_REPLACED.equals(action)) { + return; + } + AlarmScheduler.rescheduleAfterBoot(context); + } +} diff --git a/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/DayRulerView.java b/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/DayRulerView.java new file mode 100644 index 00000000..fcdab9c9 --- /dev/null +++ b/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/DayRulerView.java @@ -0,0 +1,157 @@ +package com.timeflow.alarm; + +import android.animation.ValueAnimator; +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.provider.Settings; +import android.view.View; +import android.view.animation.AccelerateDecelerateInterpolator; + +import java.util.Calendar; + +/** + * 以「今天」为刻度尺:每小时一刻度,每六小时加长刻度, + * 青柠指针落在响铃那一分钟。已过去的刻度变淡,未到的刻度更实。 + * + * 这是提醒屏上唯一的结构装置,也是唯一标出「这次打断落在一天何处」的元素。 + */ +final class DayRulerView extends View { + private static final int HOURS_PER_DAY = 24; + private static final int HOURS_PER_QUARTER = 6; + private static final long BREATH_MILLIS = 1_700L; + private static final int ALPHA_AHEAD = 56; + private static final int ALPHA_SPENT = 23; + private static final float HALO_ALPHA_TIGHT = 0.30f; + private static final float HALO_ALPHA_WIDE = 0.08f; + private static final float BREATH_AT_REST = 0.4f; + + private final Paint tickPaint = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint needlePaint = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint haloPaint = new Paint(Paint.ANTI_ALIAS_FLAG); + + private final float hourTickLength; + private final float quarterTickLength; + private final float tickThickness; + private final float needleThickness; + private final float haloTightHeight; + private final float haloWideHeight; + private final float haloRadius; + private final boolean breathing; + + private ValueAnimator breathAnimator; + private float breath; + private float dayFraction; + + DayRulerView(Context context, int tickColor, int needleColor) { + super(context); + float density = context.getResources().getDisplayMetrics().density; + hourTickLength = 9 * density; + quarterTickLength = 17 * density; + tickThickness = 1.5f * density; + needleThickness = 3 * density; + haloTightHeight = 8 * density; + haloWideHeight = 16 * density; + haloRadius = 8 * density; + + tickPaint.setColor(tickColor); + needlePaint.setColor(needleColor); + haloPaint.setColor(needleColor); + + breathing = animatorsEnabled(context); + breath = breathing ? 0f : BREATH_AT_REST; + syncToClock(); + } + + /** 重新读取墙上时钟,使长响过程中指针仍与当前时刻同步。 */ + void syncToClock() { + Calendar now = Calendar.getInstance(); + int minutesIntoDay = now.get(Calendar.HOUR_OF_DAY) * 60 + now.get(Calendar.MINUTE); + dayFraction = minutesIntoDay / (float) (HOURS_PER_DAY * 60); + invalidate(); + } + + @Override + protected void onDraw(Canvas canvas) { + super.onDraw(canvas); + float height = getHeight(); + float width = getWidth(); + if (height <= 0 || width <= 0) { + return; + } + + float needleY = clampToTrack(height * dayFraction, height, needleThickness); + for (int hour = 0; hour <= HOURS_PER_DAY; hour++) { + float y = clampToTrack(height * hour / HOURS_PER_DAY, height, tickThickness); + float length = hour % HOURS_PER_QUARTER == 0 ? quarterTickLength : hourTickLength; + tickPaint.setAlpha(y < needleY ? ALPHA_SPENT : ALPHA_AHEAD); + drawBar(canvas, y, length, tickThickness, tickThickness, tickPaint); + } + + float haloHeight = haloTightHeight + (haloWideHeight - haloTightHeight) * breath; + float haloAlpha = HALO_ALPHA_TIGHT + (HALO_ALPHA_WIDE - HALO_ALPHA_TIGHT) * breath; + haloPaint.setAlpha(Math.round(haloAlpha * 255f)); + drawBar(canvas, needleY, width, haloHeight, haloRadius, haloPaint); + drawBar(canvas, needleY, width, needleThickness, needleThickness, needlePaint); + } + + @Override + protected void onAttachedToWindow() { + super.onAttachedToWindow(); + if (!breathing || breathAnimator != null) { + return; + } + breathAnimator = ValueAnimator.ofFloat(0f, 1f); + breathAnimator.setDuration(BREATH_MILLIS); + breathAnimator.setRepeatMode(ValueAnimator.REVERSE); + breathAnimator.setRepeatCount(ValueAnimator.INFINITE); + breathAnimator.setInterpolator(new AccelerateDecelerateInterpolator()); + breathAnimator.addUpdateListener(animator -> { + breath = (float) animator.getAnimatedValue(); + invalidate(); + }); + breathAnimator.start(); + } + + @Override + protected void onDetachedFromWindow() { + if (breathAnimator != null) { + breathAnimator.cancel(); + breathAnimator = null; + } + super.onDetachedFromWindow(); + } + + private static void drawBar( + Canvas canvas, + float centerY, + float length, + float thickness, + float radius, + Paint paint + ) { + canvas.drawRoundRect( + 0f, + centerY - thickness / 2f, + length, + centerY + thickness / 2f, + radius, + radius, + paint + ); + } + + /** 保证一天的首末刻度仍完整落在列内。 */ + private static float clampToTrack(float y, float height, float thickness) { + float inset = thickness / 2f; + return Math.min(Math.max(y, inset), height - inset); + } + + private static boolean animatorsEnabled(Context context) { + return Settings.Global.getFloat( + context.getContentResolver(), + Settings.Global.ANIMATOR_DURATION_SCALE, + 1f + ) > 0f; + } +} diff --git a/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/RingActivity.java b/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/RingActivity.java new file mode 100644 index 00000000..fdaace80 --- /dev/null +++ b/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/RingActivity.java @@ -0,0 +1,212 @@ +package com.timeflow.alarm; + +import android.app.AlarmManager; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.app.Activity; +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.os.Build; +import android.os.Bundle; +import android.view.View; +import android.view.Window; +import android.view.WindowInsetsController; +import android.view.WindowManager; + +import java.lang.ref.WeakReference; + +public final class RingActivity extends Activity { + private static WeakReference currentActivity = new WeakReference<>(null); + + private String alarmId; + private String scheduleId; + private String alarmTitle; + private int requestCode; + private boolean dismissNotified; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + currentActivity = new WeakReference<>(this); + requestCode = getIntent().getIntExtra(AlarmContract.EXTRA_REQUEST_CODE, 0); + alarmId = getIntent().getStringExtra(AlarmContract.EXTRA_ALARM_ID); + scheduleId = getIntent().getStringExtra(AlarmContract.EXTRA_SCHEDULE_ID); + alarmTitle = getIntent().getStringExtra(AlarmContract.EXTRA_TITLE); + if (alarmId == null || alarmId.isEmpty()) { + alarmId = "legacy-" + requestCode; + } + if (scheduleId == null || scheduleId.isEmpty()) { + scheduleId = AlarmScheduler.scheduleIdForAlarm(this, alarmId); + } + if (alarmTitle == null || alarmTitle.isEmpty()) { + alarmTitle = "日程提醒"; + } + makeVisibleOverLockScreen(); + matchSystemBarsToReminder(); + setContentView(buildContentView()); + removeFromSavedAlarms(); + AlarmSoundService.start( + this, + alarmId, + scheduleId, + requestCode, + alarmTitle + ); + } + + @Override + protected void onDestroy() { + if (currentActivity.get() == this) { + currentActivity.clear(); + } + super.onDestroy(); + } + + static void finishIfOpen() { + RingActivity activity = currentActivity.get(); + if (activity != null && !activity.isFinishing()) { + activity.finishAndRemoveTask(); + } + } + + private View buildContentView() { + return AlarmRingUi.build( + this, + alarmTitle, + view -> snoozeAndClose(), + view -> confirmAndClose() + ); + } + + private void makeVisibleOverLockScreen() { + Window window = getWindow(); + window.addFlags( + WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON + | WindowManager.LayoutParams.FLAG_DISMISS_KEYGUARD + | WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED + | WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON + ); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) { + setShowWhenLocked(true); + setTurnScreenOn(true); + } + } + + /** + * 提醒界面偏浅色,系统栏需使用深色图标; + * 否则深色模式下浅底上会出现白色图标。 + */ + private void matchSystemBarsToReminder() { + Window window = getWindow(); + window.setStatusBarColor(AlarmRingUi.topEdgeColor()); + window.setNavigationBarColor(AlarmRingUi.bottomEdgeColor()); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + WindowInsetsController controller = window.getInsetsController(); + if (controller != null) { + controller.setSystemBarsAppearance( + WindowInsetsController.APPEARANCE_LIGHT_STATUS_BARS + | WindowInsetsController.APPEARANCE_LIGHT_NAVIGATION_BARS, + WindowInsetsController.APPEARANCE_LIGHT_STATUS_BARS + | WindowInsetsController.APPEARANCE_LIGHT_NAVIGATION_BARS + ); + } + return; + } + window.getDecorView().setSystemUiVisibility( + View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR + | View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR + ); + } + + private void confirmAndClose() { + if (!dismissNotified) { + dismissNotified = true; + AlarmNativeBridge.notifyDismissed(this, scheduleId, alarmId, alarmTitle); + } + AlarmSoundService.stop(this); + cancelNotification(); + cancelAlarmPendingIntent(); + finishAndRemoveTask(); + } + + private void snoozeAndClose() { + if (!dismissNotified) { + dismissNotified = true; + long triggerAt = System.currentTimeMillis() + + AlarmContract.SNOOZE_MINUTES * 60_000L; + try { + AlarmScheduler.schedule(this, triggerAt, alarmTitle, scheduleId); + } catch (RuntimeException ignored) { + // 尽力重新挂闹钟;即使失败也通知 JS 落 snooze 状态。 + } + AlarmNativeBridge.notifySnoozed(this, scheduleId, alarmId, alarmTitle); + } + AlarmSoundService.stop(this); + cancelNotification(); + cancelAlarmPendingIntent(); + finishAndRemoveTask(); + } + + private void cancelNotification() { + NotificationManager manager = + (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); + if (manager != null) { + manager.cancel(requestCode); + } + } + + private void cancelAlarmPendingIntent() { + AlarmManager alarmManager = (AlarmManager) getSystemService(Context.ALARM_SERVICE); + if (alarmManager == null) { + return; + } + + Intent activityIntent = new Intent(this, RingActivity.class) + .setData(alarmUri(alarmId)); + PendingIntent activityPendingIntent = PendingIntent.getActivity( + this, + requestCode, + activityIntent, + PendingIntent.FLAG_NO_CREATE | PendingIntent.FLAG_IMMUTABLE + ); + Intent broadcastIntent = new Intent(this, AlarmReceiver.class) + .setAction(AlarmContract.ACTION_FIRE_ALARM); + if (!isLegacyAlarm()) { + broadcastIntent.setData(alarmUri(alarmId)); + } + PendingIntent broadcastPendingIntent = PendingIntent.getBroadcast( + this, + requestCode, + broadcastIntent, + PendingIntent.FLAG_NO_CREATE | PendingIntent.FLAG_IMMUTABLE + ); + if (activityPendingIntent != null) { + alarmManager.cancel(activityPendingIntent); + activityPendingIntent.cancel(); + } + if (broadcastPendingIntent != null) { + alarmManager.cancel(broadcastPendingIntent); + broadcastPendingIntent.cancel(); + } + } + + private Uri alarmUri(String value) { + return AlarmScheduler.alarmUri(value); + } + + private boolean isLegacyAlarm() { + return alarmId != null && alarmId.startsWith("legacy-"); + } + + private void removeFromSavedAlarms() { + AlarmScheduler.removeAlarmRecord(this, alarmId, requestCode); + } + + + @Override + public void onBackPressed() { + confirmAndClose(); + } + +} diff --git a/frontend/modules/timeflow-alarm/index.js b/frontend/modules/timeflow-alarm/index.js new file mode 100644 index 00000000..37e2f90e --- /dev/null +++ b/frontend/modules/timeflow-alarm/index.js @@ -0,0 +1,3 @@ +// 原生模块经 React Native autolinking(AlarmPackage)接入。 +// JS 侧通过 NativeModules.TimeflowAlarm 调用。 +module.exports = {}; diff --git a/frontend/modules/timeflow-alarm/package.json b/frontend/modules/timeflow-alarm/package.json new file mode 100644 index 00000000..92afe2da --- /dev/null +++ b/frontend/modules/timeflow-alarm/package.json @@ -0,0 +1,12 @@ +{ + "name": "timeflow-alarm", + "version": "1.0.0", + "description": "Native Android exact-alarm bridge for Timeflow", + "main": "index.js", + "license": "UNLICENSED", + "private": true, + "peerDependencies": { + "react": "*", + "react-native": "*" + } +} diff --git a/frontend/modules/timeflow-alarm/react-native.config.js b/frontend/modules/timeflow-alarm/react-native.config.js new file mode 100644 index 00000000..60b6f8ee --- /dev/null +++ b/frontend/modules/timeflow-alarm/react-native.config.js @@ -0,0 +1,12 @@ +module.exports = { + dependency: { + platforms: { + android: { + sourceDir: './android', + packageImportPath: 'import com.timeflow.alarm.AlarmPackage;', + packageInstance: 'new AlarmPackage()', + }, + ios: null, + }, + }, +}; diff --git a/frontend/modules/timeflow-baidu-location/.gitignore b/frontend/modules/timeflow-baidu-location/.gitignore new file mode 100644 index 00000000..a4a00d77 --- /dev/null +++ b/frontend/modules/timeflow-baidu-location/.gitignore @@ -0,0 +1,3 @@ +android/build/ +android/.gradle/ +*.iml diff --git a/frontend/modules/timeflow-baidu-location/README.md b/frontend/modules/timeflow-baidu-location/README.md new file mode 100644 index 00000000..7683c6d3 --- /dev/null +++ b/frontend/modules/timeflow-baidu-location/README.md @@ -0,0 +1,16 @@ +# timeflow-baidu-location + +Android 百度定位桥接(`LocationClient` 连续定位),**不使用 Google Geofencing**。 + +- 原生模块名:`TimeflowBaiduLocation` +- 事件:`TimeflowBaiduLocation`(latitude / longitude / accuracy / observedAt) +- 坐标系:`gcj02` +- AK 通过 Expo 插件写入 `com.baidu.lbsapi.API_KEY` + +## 控制台要求 + +Android AK 必须与包名 **`com.timeflow`** + 签名证书 **SHA1** 绑定,否则定位会失败(常见 locType 鉴权错误)。 + +## 应用侧 + +`NativeLocationMonitor` 订阅连续定位,用 Haversine 判断进出圈。 diff --git a/frontend/modules/timeflow-baidu-location/android/build.gradle b/frontend/modules/timeflow-baidu-location/android/build.gradle new file mode 100644 index 00000000..c330f520 --- /dev/null +++ b/frontend/modules/timeflow-baidu-location/android/build.gradle @@ -0,0 +1,39 @@ +apply plugin: 'com.android.library' +apply plugin: 'kotlin-android' + +def getExtOrDefault(name, defaultValue) { + return rootProject.ext.has(name) ? rootProject.ext.get(name) : defaultValue +} + +android { + namespace "com.timeflow.baidulocation" + + compileSdkVersion getExtOrDefault('compileSdkVersion', 35) + + defaultConfig { + minSdkVersion getExtOrDefault('minSdkVersion', 24) + targetSdkVersion getExtOrDefault('targetSdkVersion', 35) + } + + sourceSets { + main { + java.srcDirs = ['src/main/java'] + } + } + + lintOptions { + abortOnError false + } +} + +repositories { + mavenCentral() + google() +} + +dependencies { + implementation 'com.facebook.react:react-android' + implementation 'androidx.core:core-ktx:1.13.1' + // 仅定位 SDK,不引入 Google Geofencing。 + implementation 'com.baidu.lbsyun:BaiduMapSDK_Location:9.6.4' +} diff --git a/frontend/modules/timeflow-baidu-location/android/src/main/AndroidManifest.xml b/frontend/modules/timeflow-baidu-location/android/src/main/AndroidManifest.xml new file mode 100644 index 00000000..51e7a1b6 --- /dev/null +++ b/frontend/modules/timeflow-baidu-location/android/src/main/AndroidManifest.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + diff --git a/frontend/modules/timeflow-baidu-location/android/src/main/java/com/timeflow/baidulocation/BaiduLocationModule.kt b/frontend/modules/timeflow-baidu-location/android/src/main/java/com/timeflow/baidulocation/BaiduLocationModule.kt new file mode 100644 index 00000000..dd732dd7 --- /dev/null +++ b/frontend/modules/timeflow-baidu-location/android/src/main/java/com/timeflow/baidulocation/BaiduLocationModule.kt @@ -0,0 +1,191 @@ +package com.timeflow.baidulocation + +import android.util.Log +import com.baidu.location.BDAbstractLocationListener +import com.baidu.location.BDLocation +import com.baidu.location.LocationClient +import com.baidu.location.LocationClientOption +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 +import com.facebook.react.modules.core.DeviceEventManagerModule +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.TimeZone + +/** + * 百度连续定位桥:不使用 Google Geofencing。 + * 坐标系默认 gcj02,便于与国内常见选点坐标对齐后做 Haversine。 + */ +class BaiduLocationModule( + private val reactContext: ReactApplicationContext, +) : ReactContextBaseJavaModule(reactContext) { + + private var client: LocationClient? = null + private var updating = false + private var lastLocation: WritableMap? = null + + private val isoFormat = + SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US).apply { + timeZone = TimeZone.getTimeZone("UTC") + } + + private val listener = + object : BDAbstractLocationListener() { + override fun onReceiveLocation(location: BDLocation?) { + if (location == null) return + if (!isUsableLocation(location)) { + Log.w(NAME, "ignore locType=${location.locType}") + return + } + + val payload = Arguments.createMap() + payload.putDouble("latitude", location.latitude) + payload.putDouble("longitude", location.longitude) + payload.putDouble( + "accuracy", + if (location.radius > 0) location.radius.toDouble() else 0.0, + ) + payload.putString("observedAt", isoFormat.format(Date())) + payload.putInt("locType", location.locType) + lastLocation = copyMap(payload) + + if (reactContext.hasActiveReactInstance()) { + reactContext + .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java) + .emit(EVENT_LOCATION, payload) + } + } + } + + override fun getName(): String = NAME + + @ReactMethod + fun setAgreePrivacy(agree: Boolean, promise: Promise) { + try { + LocationClient.setAgreePrivacy(agree) + promise.resolve(true) + } catch (error: Exception) { + promise.reject("PRIVACY_FAILED", error.message, error) + } + } + + @ReactMethod + fun init(ak: String?, promise: Promise) { + try { + LocationClient.setAgreePrivacy(true) + if (client == null) { + client = LocationClient(reactContext.applicationContext) + client?.registerLocationListener(listener) + } + if (!ak.isNullOrBlank()) { + Log.i(NAME, "init akLength=${ak.length}") + } + promise.resolve(true) + } catch (error: Exception) { + promise.reject("INIT_FAILED", error.message, error) + } + } + + @ReactMethod + fun startUpdating(intervalMs: Double, promise: Promise) { + try { + LocationClient.setAgreePrivacy(true) + val locationClient = + client ?: LocationClient(reactContext.applicationContext).also { + it.registerLocationListener(listener) + client = it + } + + val span = intervalMs.toInt().coerceAtLeast(1000) + val option = LocationClientOption() + option.locationMode = LocationClientOption.LocationMode.Hight_Accuracy + option.setCoorType("gcj02") + option.setScanSpan(span) + option.isOpenGps = true + option.setIsNeedAddress(false) + option.setNeedNewVersionRgc(false) + locationClient.locOption = option + + if (!updating) { + locationClient.start() + updating = true + } else { + locationClient.restart() + } + promise.resolve(true) + } catch (error: Exception) { + promise.reject("START_FAILED", error.message, error) + } + } + + @ReactMethod + fun stopUpdating(promise: Promise) { + try { + client?.stop() + updating = false + promise.resolve(true) + } catch (error: Exception) { + promise.reject("STOP_FAILED", error.message, error) + } + } + + @ReactMethod + fun getCurrentPosition(promise: Promise) { + val cached = lastLocation + if (cached != null) { + promise.resolve(copyMap(cached)) + return + } + promise.resolve(null) + } + + @ReactMethod + fun addListener(eventName: String?) { + // RN event emitter bookkeeping. + } + + @ReactMethod + fun removeListeners(count: Double) { + // RN event emitter bookkeeping. + } + + override fun invalidate() { + try { + client?.unRegisterLocationListener(listener) + client?.stop() + } catch (_: Exception) { + } + client = null + updating = false + super.invalidate() + } + + companion object { + const val NAME = "TimeflowBaiduLocation" + const val EVENT_LOCATION = "TimeflowBaiduLocation" + + private fun isUsableLocation(location: BDLocation): Boolean { + if (location.latitude == 0.0 && location.longitude == 0.0) return false + // 常见成功:61 GPS、161 网络、66 离线;其它带有效坐标的也接受。 + return when (location.locType) { + BDLocation.TypeGpsLocation, + BDLocation.TypeNetWorkLocation, + BDLocation.TypeOffLineLocation, + BDLocation.TypeCacheLocation, + -> true + else -> location.radius > 0 + } + } + + private fun copyMap(source: WritableMap): WritableMap { + val copy = Arguments.createMap() + copy.merge(source) + return copy + } + } +} diff --git a/frontend/modules/timeflow-baidu-location/android/src/main/java/com/timeflow/baidulocation/BaiduLocationPackage.kt b/frontend/modules/timeflow-baidu-location/android/src/main/java/com/timeflow/baidulocation/BaiduLocationPackage.kt new file mode 100644 index 00000000..ba27aca8 --- /dev/null +++ b/frontend/modules/timeflow-baidu-location/android/src/main/java/com/timeflow/baidulocation/BaiduLocationPackage.kt @@ -0,0 +1,18 @@ +package com.timeflow.baidulocation + +import com.facebook.react.ReactPackage +import com.facebook.react.bridge.NativeModule +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.uimanager.ViewManager + +class BaiduLocationPackage : ReactPackage { + override fun createNativeModules(reactContext: ReactApplicationContext): List { + return listOf(BaiduLocationModule(reactContext)) + } + + override fun createViewManagers( + reactContext: ReactApplicationContext, + ): List> { + return emptyList() + } +} diff --git a/frontend/modules/timeflow-baidu-location/index.js b/frontend/modules/timeflow-baidu-location/index.js new file mode 100644 index 00000000..1d760182 --- /dev/null +++ b/frontend/modules/timeflow-baidu-location/index.js @@ -0,0 +1,3 @@ +// Native module is linked via React Native autolinking (BaiduLocationPackage). +// JS callers use NativeModules.TimeflowBaiduLocation from the app layer. +module.exports = {}; diff --git a/frontend/modules/timeflow-baidu-location/package.json b/frontend/modules/timeflow-baidu-location/package.json new file mode 100644 index 00000000..6ebff60e --- /dev/null +++ b/frontend/modules/timeflow-baidu-location/package.json @@ -0,0 +1,12 @@ +{ + "name": "timeflow-baidu-location", + "version": "1.0.0", + "description": "Native Android Baidu LocationClient bridge for Timeflow (no Google geofencing)", + "main": "index.js", + "license": "UNLICENSED", + "private": true, + "peerDependencies": { + "react": "*", + "react-native": "*" + } +} diff --git a/frontend/modules/timeflow-baidu-location/react-native.config.js b/frontend/modules/timeflow-baidu-location/react-native.config.js new file mode 100644 index 00000000..3be4ed84 --- /dev/null +++ b/frontend/modules/timeflow-baidu-location/react-native.config.js @@ -0,0 +1,12 @@ +module.exports = { + dependency: { + platforms: { + android: { + sourceDir: './android', + packageImportPath: 'import com.timeflow.baidulocation.BaiduLocationPackage;', + packageInstance: 'new BaiduLocationPackage()', + }, + ios: null, + }, + }, +}; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 052bc763..4d6b8c70 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -12,16 +12,21 @@ "@expo/metro-runtime": "~57.0.8", "@irvingouj/expo-audio-stream": "3.1.0", "expo": "~57.0.7", + "expo-audio": "~57.0.3", "expo-location": "~57.0.9", + "expo-notifications": "~57.0.10", "expo-secure-store": "~57.0.1", "expo-sqlite": "~57.0.1", "expo-status-bar": "~57.0.1", + "expo-task-manager": "~57.0.9", "react": "19.2.3", "react-dom": "19.2.3", "react-native": "0.86.0", "react-native-svg": "15.15.4", "react-native-web": "^0.21.2", - "rrule": "^2.8.1" + "rrule": "^2.8.1", + "timeflow-alarm": "file:modules/timeflow-alarm", + "timeflow-baidu-location": "file:modules/timeflow-baidu-location" }, "devDependencies": { "@testing-library/react-native": "13.3.3", @@ -45,6 +50,22 @@ "npm": ">=10.8.2 <11" } }, + "modules/timeflow-alarm": { + "version": "1.0.0", + "license": "UNLICENSED", + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, + "modules/timeflow-baidu-location": { + "version": "1.0.0", + "license": "UNLICENSED", + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -2590,22 +2611,25 @@ } }, "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", - "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.3.tgz", + "integrity": "sha512-UMduMbqO5s5zF2NkNacMT/yK5Y5QiKvWr2+50bzIIxFDwVJ2h49b+oyjaCGPhJxd2/gC2x39EHv/gHVuu36x2Q==", "dev": true, "license": "MIT", "optional": true, "dependencies": { "@tybys/wasm-util": "^0.10.3" }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=23.5.0" + }, "funding": { "type": "github", "url": "https://github.com/sponsors/Brooooooklyn" }, "peerDependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1" + "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", + "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" } }, "node_modules/@nolyfill/is-core-module": { @@ -4707,6 +4731,12 @@ "@babel/core": "^7.0.0" } }, + "node_modules/badgin": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/badgin/-/badgin-1.2.3.tgz", + "integrity": "sha512-NQGA7LcfCpSzIbGRbkgjgdWkjy7HI+Th5VLxTJfW5EeaAf3fnS+xWQaQOCYiny+q6QSvxqoSO04vCx+4u++EJw==", + "license": "MIT" + }, "node_modules/balanced-match": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", @@ -6888,6 +6918,55 @@ } } }, + "node_modules/expo-application": { + "version": "57.0.2", + "resolved": "https://registry.npmjs.org/expo-application/-/expo-application-57.0.2.tgz", + "integrity": "sha512-q31YwcXyymviAmdrtDfAg3Dld4VMxLCNAfgMHip7vZpPX4lzF/AfwsywqBJUAjQnJknzaNAkzqvMVjO5XmKYDA==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/expo-asset": { + "version": "57.0.10", + "resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-57.0.10.tgz", + "integrity": "sha512-32QpNkWlb8ftxq3ClAriwFXcZlpzuP7Qx4z3Gc9vhbAm1QZzGsCN+PwYt3fN8W46HBw5qaI6DSXcAyBlYzeVqA==", + "license": "MIT", + "dependencies": { + "@expo/image-utils": "^0.11.4", + "expo-constants": "~57.0.10" + }, + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } + }, + "node_modules/expo-audio": { + "version": "57.0.3", + "resolved": "https://registry.npmjs.org/expo-audio/-/expo-audio-57.0.3.tgz", + "integrity": "sha512-FzO0gnVmlrKmNoox7xc/795uNiuuqnYBovo2kgnNICDKJ0kDi1Y5UJjqX+NATxCelZcNv5BtWs3POkKJADhNCA==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "expo-asset": "*", + "react": "*", + "react-native": "*" + } + }, + "node_modules/expo-constants": { + "version": "57.0.10", + "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-57.0.10.tgz", + "integrity": "sha512-GCDXYEsloBfouMdT3BzoGhAkcLnYxEFNLQoSbNKIvIrD9FY5MmSeWuvRSveJdOiuydwQY2iH6hy020vTaQflCQ==", + "license": "MIT", + "dependencies": { + "@expo/env": "~2.4.2" + }, + "peerDependencies": { + "expo": "*", + "react-native": "*" + } + }, "node_modules/expo-location": { "version": "57.0.9", "resolved": "https://registry.npmjs.org/expo-location/-/expo-location-57.0.9.tgz", @@ -6945,6 +7024,24 @@ "react-native": "*" } }, + "node_modules/expo-notifications": { + "version": "57.0.10", + "resolved": "https://registry.npmjs.org/expo-notifications/-/expo-notifications-57.0.10.tgz", + "integrity": "sha512-Zrwwd2eGzSuk3LyD01P5QzhiE/oXNNWaUq/NLQnPoeo63Ek2R6sWA00b0o0rl47uIqdmMtuQUfWbXgJJyc3Uag==", + "license": "MIT", + "dependencies": { + "@expo/image-utils": "^0.11.4", + "abort-controller": "^3.0.0", + "badgin": "^1.1.5", + "expo-application": "~57.0.2", + "expo-constants": "~57.0.10" + }, + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } + }, "node_modules/expo-secure-store": { "version": "57.0.1", "resolved": "https://registry.npmjs.org/expo-secure-store/-/expo-secure-store-57.0.1.tgz", @@ -6988,6 +7085,19 @@ "react-native": "*" } }, + "node_modules/expo-task-manager": { + "version": "57.0.9", + "resolved": "https://registry.npmjs.org/expo-task-manager/-/expo-task-manager-57.0.9.tgz", + "integrity": "sha512-98L0EIexQkAxJ1GKPAev9rJcEJvZDfZOF8vmanvXisvfpC/Z0Rsel4vzV1bVldzXwQ4rMBu3V5C9evFJQMh1PA==", + "license": "MIT", + "dependencies": { + "unimodules-app-loader": "~57.0.1" + }, + "peerDependencies": { + "expo": "*", + "react-native": "*" + } + }, "node_modules/expo/node_modules/@expo/cli": { "version": "57.0.9", "resolved": "https://registry.npmjs.org/@expo/cli/-/cli-57.0.9.tgz", @@ -7247,34 +7357,6 @@ "node": ">=8" } }, - "node_modules/expo/node_modules/expo-asset": { - "version": "57.0.6", - "resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-57.0.6.tgz", - "integrity": "sha512-n3Yb1VxcP+BMRTyC4R1x2It4+m5EDkNXiVCHGWbnIREQUUkMs2Yeul7D5qfFWAYtIn2Z3hbGMndwU6Az1FPSEg==", - "license": "MIT", - "dependencies": { - "@expo/image-utils": "^0.11.3", - "expo-constants": "~57.0.6" - }, - "peerDependencies": { - "expo": "*", - "react": "*", - "react-native": "*" - } - }, - "node_modules/expo/node_modules/expo-constants": { - "version": "57.0.6", - "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-57.0.6.tgz", - "integrity": "sha512-OV+4XUshdO18TKNlo1cxUkXeJWgUOPgalvl8ofmc7kmPPHoyfz2hGJ94tyY/RND/GG5RREE+me9YHClNEzo+Ow==", - "license": "MIT", - "dependencies": { - "@expo/env": "~2.4.2" - }, - "peerDependencies": { - "expo": "*", - "react-native": "*" - } - }, "node_modules/expo/node_modules/expo-file-system": { "version": "57.0.1", "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-57.0.1.tgz", @@ -13842,6 +13924,14 @@ "integrity": "sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA==", "license": "MIT" }, + "node_modules/timeflow-alarm": { + "resolved": "modules/timeflow-alarm", + "link": true + }, + "node_modules/timeflow-baidu-location": { + "resolved": "modules/timeflow-baidu-location", + "link": true + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -14246,6 +14336,12 @@ "node": ">=4" } }, + "node_modules/unimodules-app-loader": { + "version": "57.0.1", + "resolved": "https://registry.npmjs.org/unimodules-app-loader/-/unimodules-app-loader-57.0.1.tgz", + "integrity": "sha512-wey5ChoJkCTq0j0JWdIMu2QB81vVrdhmrNAP14ZZ6WDslnZ7ff7Ezv8rMdEnVHaCKz3xK4mIVXbVU51xHgdyCA==", + "license": "MIT" + }, "node_modules/universalify": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index a8e99ea7..5bd00d59 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,16 +11,21 @@ "@expo/metro-runtime": "~57.0.8", "@irvingouj/expo-audio-stream": "3.1.0", "expo": "~57.0.7", + "expo-audio": "~57.0.3", "expo-location": "~57.0.9", + "expo-notifications": "~57.0.10", "expo-secure-store": "~57.0.1", "expo-sqlite": "~57.0.1", "expo-status-bar": "~57.0.1", + "expo-task-manager": "~57.0.9", "react": "19.2.3", "react-dom": "19.2.3", "react-native": "0.86.0", "react-native-svg": "15.15.4", "react-native-web": "^0.21.2", - "rrule": "^2.8.1" + "rrule": "^2.8.1", + "timeflow-alarm": "file:modules/timeflow-alarm", + "timeflow-baidu-location": "file:modules/timeflow-baidu-location" }, "devDependencies": { "@testing-library/react-native": "13.3.3", diff --git a/frontend/patches/@irvingouj+expo-audio-stream+3.1.0.patch b/frontend/patches/@irvingouj+expo-audio-stream+3.1.0.patch index 5c352fc2..6936bf65 100644 --- a/frontend/patches/@irvingouj+expo-audio-stream+3.1.0.patch +++ b/frontend/patches/@irvingouj+expo-audio-stream+3.1.0.patch @@ -1,3 +1,26 @@ +diff --git a/node_modules/@irvingouj/expo-audio-stream/android/build.gradle b/node_modules/@irvingouj/expo-audio-stream/android/build.gradle +index c3038e4..dde8fe9 100644 +--- a/node_modules/@irvingouj/expo-audio-stream/android/build.gradle ++++ b/node_modules/@irvingouj/expo-audio-stream/android/build.gradle +@@ -63,6 +63,18 @@ android { + kotlinOptions { + jvmTarget = JavaVersion.VERSION_11.majorVersion + } ++ } else { ++ // AGP>=8 时 javac 走 AGP 自己的默认值(17),但这个模块的 buildscript 单独 ++ // pin 了一份 Kotlin Gradle Plugin,没有显式 jvmTarget 就会用跑 Gradle 的 ++ // JDK(这台机器是 21)当目标,跟 javac 对不上,编译直接失败。 ++ compileOptions { ++ sourceCompatibility JavaVersion.VERSION_17 ++ targetCompatibility JavaVersion.VERSION_17 ++ } ++ ++ kotlinOptions { ++ jvmTarget = JavaVersion.VERSION_17.majorVersion ++ } + } + + namespace "expo.modules.audiostream" diff --git a/node_modules/@irvingouj/expo-audio-stream/android/src/main/java/expo/modules/audiostream/AudioRecorderManager.kt b/node_modules/@irvingouj/expo-audio-stream/android/src/main/java/expo/modules/audiostream/AudioRecorderManager.kt index 9ee227e..0373656 100644 --- a/node_modules/@irvingouj/expo-audio-stream/android/src/main/java/expo/modules/audiostream/AudioRecorderManager.kt diff --git a/frontend/plugins/withTimeflowAlarm.js b/frontend/plugins/withTimeflowAlarm.js new file mode 100644 index 00000000..aebc28b1 --- /dev/null +++ b/frontend/plugins/withTimeflowAlarm.js @@ -0,0 +1,29 @@ +const { AndroidConfig, createRunOncePlugin, withAndroidManifest } = require('expo/config-plugins'); + +const PACKAGE_NAME = 'timeflow-alarm'; +const PERMISSIONS = [ + 'android.permission.POST_NOTIFICATIONS', + 'android.permission.SCHEDULE_EXACT_ALARM', + 'android.permission.SYSTEM_ALERT_WINDOW', + 'android.permission.USE_FULL_SCREEN_INTENT', + 'android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS', + 'android.permission.VIBRATE', + 'android.permission.FOREGROUND_SERVICE', + 'android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK', +]; + +/** + * 保证应用级闹钟权限在 prebuild 后仍然保留。 + * 原生源码、AlarmPackage 自动链接与组件声明在 modules/timeflow-alarm + * (经 Android library manifest 合并)。 + */ +function withTimeflowAlarm(config) { + config = AndroidConfig.Permissions.withPermissions(config, PERMISSIONS); + config = withAndroidManifest(config, (config) => { + AndroidConfig.Permissions.ensurePermissions(config.modResults, PERMISSIONS); + return config; + }); + return config; +} + +module.exports = createRunOncePlugin(withTimeflowAlarm, PACKAGE_NAME, '1.0.0'); diff --git a/frontend/plugins/withTimeflowBaiduLocation.js b/frontend/plugins/withTimeflowBaiduLocation.js new file mode 100644 index 00000000..4a103523 --- /dev/null +++ b/frontend/plugins/withTimeflowBaiduLocation.js @@ -0,0 +1,59 @@ +const { AndroidConfig, createRunOncePlugin, withAndroidManifest } = require('expo/config-plugins'); + +const PACKAGE_NAME = 'timeflow-baidu-location'; +const PERMISSIONS = [ + 'android.permission.ACCESS_COARSE_LOCATION', + 'android.permission.ACCESS_FINE_LOCATION', + 'android.permission.ACCESS_BACKGROUND_LOCATION', + 'android.permission.ACCESS_WIFI_STATE', + 'android.permission.ACCESS_NETWORK_STATE', + 'android.permission.CHANGE_WIFI_STATE', + 'android.permission.INTERNET', + 'android.permission.FOREGROUND_SERVICE', + 'android.permission.FOREGROUND_SERVICE_LOCATION', +]; + +/** + * 注入百度定位 AK(com.baidu.lbsapi.API_KEY)与相关权限。 + * 原生 LocationClient / Service 在 modules/timeflow-baidu-location。 + * + * app.json: + * ["./plugins/withTimeflowBaiduLocation", { "apiKey": "YOUR_AK" }] + */ +function withTimeflowBaiduLocation(config, props = {}) { + const apiKey = typeof props.apiKey === 'string' ? props.apiKey.trim() : ''; + if (!apiKey) { + throw new Error( + 'withTimeflowBaiduLocation: missing apiKey. Pass { apiKey } in app.json plugins.', + ); + } + + config = AndroidConfig.Permissions.withPermissions(config, PERMISSIONS); + config = withAndroidManifest(config, (config) => { + AndroidConfig.Permissions.ensurePermissions(config.modResults, PERMISSIONS); + const app = AndroidConfig.Manifest.getMainApplicationOrThrow(config.modResults); + ensureMetaData(app, 'com.baidu.lbsapi.API_KEY', apiKey); + return config; + }); + return config; +} + +function ensureMetaData(application, name, value) { + if (!application['meta-data']) { + application['meta-data'] = []; + } + const list = application['meta-data']; + const existing = list.find((item) => item?.$?.['android:name'] === name); + if (existing) { + existing.$['android:value'] = value; + return; + } + list.push({ + $: { + 'android:name': name, + 'android:value': value, + }, + }); +} + +module.exports = createRunOncePlugin(withTimeflowBaiduLocation, PACKAGE_NAME, '1.0.0'); diff --git a/frontend/react-native.config.js b/frontend/react-native.config.js new file mode 100644 index 00000000..575aca2e --- /dev/null +++ b/frontend/react-native.config.js @@ -0,0 +1,12 @@ +const path = require('path'); + +module.exports = { + dependencies: { + 'timeflow-alarm': { + root: path.join(__dirname, 'modules/timeflow-alarm'), + }, + 'timeflow-baidu-location': { + root: path.join(__dirname, 'modules/timeflow-baidu-location'), + }, + }, +}; diff --git a/frontend/src/app/AppProviders.tsx b/frontend/src/app/AppProviders.tsx index 9fb78e24..62ee61fc 100644 --- a/frontend/src/app/AppProviders.tsx +++ b/frontend/src/app/AppProviders.tsx @@ -1,7 +1,8 @@ -import { type PropsWithChildren, useEffect } from 'react'; +import { type PropsWithChildren, useCallback, useEffect } from 'react'; import type { AuthController, AuthInvalidationCoordinator } from '../features/auth/application'; import { AuthProvider, useAuth } from '../features/auth/presentation/AuthProvider'; +import { useReminderPermissionsOnLaunch } from '../features/reminder'; import { AppServicesProvider } from './composition/AppServicesProvider'; import type { AppServices } from './composition/createAppServices'; @@ -19,26 +20,37 @@ export function AppProviders({ return ( - + {children} ); } -function AuthenticatedRuntime({ runtime }: { readonly runtime: AppServices['runtime'] }) { +function AuthenticatedRuntime({ services }: { readonly services: AppServices }) { const { viewState } = useAuth(); + const isAuthenticated = viewState.status === 'authenticated'; + + const onPermissionsUpdated = useCallback(() => { + void services.reminder.rebuild(); + }, [services]); + + useReminderPermissionsOnLaunch( + isAuthenticated ? services.reminderPorts.device : null, + isAuthenticated ? services.alertDialog : null, + onPermissionsUpdated, + ); useEffect(() => { - if (viewState.status !== 'authenticated') { + if (!isAuthenticated) { return; } - void runtime.start(); + void services.runtime.start(); return () => { - void runtime.stop(); + void services.runtime.stop(); }; - }, [runtime, viewState.status]); + }, [isAuthenticated, services]); return null; } diff --git a/frontend/src/app/AppRoot.tsx b/frontend/src/app/AppRoot.tsx index dcde474a..6df0e7a5 100644 --- a/frontend/src/app/AppRoot.tsx +++ b/frontend/src/app/AppRoot.tsx @@ -10,6 +10,7 @@ import { AuthenticatedVoiceTransport } from '../features/assistant/data/websocke import { LocalScheduleWriter } from '../features/assistant/data/local/LocalScheduleWriter'; import type { AuthController } from '../features/auth/application'; import { useAuth } from '../features/auth/presentation/AuthProvider'; +import type { SqliteLocalScheduleReader, SqliteReminderStateStore } from '../features/reminder'; import { SqliteScheduleClientService } from '../features/schedule/application'; import { ScheduleLocalRepository } from '../features/schedule/data'; import { RNAppStateProvider } from '../infrastructure/appState/RNAppStateProvider'; @@ -37,14 +38,22 @@ export function AppRoot({ invalidationCoordinator={authController ? undefined : services.auth.invalidationCoordinator} services={services} > - + ); } function AuthRoute({ + reminderState, + scheduleReader, webSocketClient, }: { + readonly reminderState: SqliteReminderStateStore; + readonly scheduleReader: SqliteLocalScheduleReader; readonly webSocketClient: AuthenticatedWebSocketClient; }) { const { retryInitialization, viewState } = useAuth(); @@ -77,6 +86,8 @@ function AuthRoute({ @@ -96,10 +107,14 @@ type ScheduleLoadState = function AuthenticatedScheduleRoute({ accountId, + reminderState, + scheduleReader, username, webSocketClient, }: { readonly accountId: string; + readonly reminderState: SqliteReminderStateStore; + readonly scheduleReader: SqliteLocalScheduleReader; readonly username: string; readonly webSocketClient: AuthenticatedWebSocketClient; }) { @@ -150,6 +165,24 @@ function AuthenticatedScheduleRoute({ [currentLoadState], ); + // 提醒引擎(LocalReminderApplication)在认证态一变化就 start(),早于这里数据库 + // 打开完成;SqliteLocalScheduleReader / SqliteReminderStateStore 都是整个 App + // 生命周期唯一一个实例,attach() 只是把它们接到这次登录/重试打开的仓储上。 + // reminderState 要先于 refresh() 触发的 rebuild 接上,不然 rebuild 读到的运行时 + // 状态还是空的,会把已经响过的提醒当成没响过又送一遍。 + useEffect(() => { + if (currentLoadState?.status !== 'ready') { + return; + } + reminderState.attach(currentLoadState.repository, accountId); + scheduleReader.attach(currentLoadState.repository, accountId); + void scheduleReader.refresh(); + return () => { + scheduleReader.detach(); + reminderState.detach(); + }; + }, [accountId, currentLoadState, reminderState, scheduleReader]); + // 语音这条连接复用应用唯一的 AuthenticatedWebSocketClient——握手、鉴权失效、 // 断线通知都由它统一处理,这里不再单独持有 access_token/device_id/wsUrl。 // 按住说话和免提通话共用同一批 capture/playback/location/appState 端口 @@ -166,11 +199,11 @@ function AuthenticatedScheduleRoute({ return { appState: new RNAppStateProvider(), capture: new ExpoAudioCapture(), - localScheduleWriter: new LocalScheduleWriter(currentLoadState.repository), + localScheduleWriter: new LocalScheduleWriter(currentLoadState.repository, scheduleReader), location: new ExpoLocationProvider(), playback: new ExpoAudioPlayback(), }; - }, [currentLoadState]); + }, [currentLoadState, scheduleReader]); const pushToTalkApplication = useMemo(() => { if (assistantDependencies === null) { diff --git a/frontend/src/app/composition/.gitkeep b/frontend/src/app/composition/.gitkeep deleted file mode 100644 index 8b137891..00000000 --- a/frontend/src/app/composition/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/frontend/src/app/composition/createAppServices.ts b/frontend/src/app/composition/createAppServices.ts index 66823c6c..5b0f0377 100644 --- a/frontend/src/app/composition/createAppServices.ts +++ b/frontend/src/app/composition/createAppServices.ts @@ -1,30 +1,41 @@ import { AppRuntime } from '../orchestration/AppRuntime'; import { createAuthRuntime, type AuthRuntime, type CreateAuthRuntimeOptions } from '../authRuntime'; import type { + AlertDialogPort, ReminderApplicationDependencies, ReminderApplicationPort, } from '../../features/reminder/application/interfaces'; +import { LocalReminderApplication } from '../../features/reminder/application'; import { - MockLocalScheduleReader, - MockReminderApplication, - MockReminderDispositionSync, - MockReminderStateStore, + LocalReminderDelivery, + LocalReminderDispositionSync, + LocalReminderRecovery, + NoopPopup, + SqliteLocalScheduleReader, + SqliteReminderStateStore, } from '../../features/reminder/data/local'; -import { MockAudioPlayback } from '../../infrastructure/audio'; -import { MockLocationMonitor } from '../../infrastructure/location'; +import { AlertReminderPresenter } from '../../features/reminder/presentation'; +import { ExpoAudioPlayback } from '../../infrastructure/audio'; +// NativeLocationMonitor(百度定位 SDK)保留在仓库里没删,只是这次没接进来——现在用的 +// 是 ExpoLocationMonitor(系统原生地理围栏),不依赖百度控制台那个 AK 注册。百度那个 +// Key 配置好之后如果想切回去,把下面这行 import 和 reminderPorts.location 换回来就行。 +import { ExpoLocationMonitor } from '../../infrastructure/location'; import { - MockPopup, - MockReminderRecovery, - MockReminderDelivery, - MockSystemNotification, - MockVibration, + ExpoSystemNotification, NativeAlarmScheduler, NativeDeviceCapability, + ReactNativeAlertDialog, + ReactNativeVibration, } from '../../infrastructure/notifications'; -import { MockTimeListener } from '../../shared/time'; -import { MockReminderPresenter } from '../../features/reminder/presentation'; +import { IntervalTimeListener } from '../../infrastructure/time'; import { ScheduleViewStore } from '../../features/schedule/presentation'; +export interface CreateAppServicesOptions { + readonly auth?: CreateAuthRuntimeOptions; + readonly schedules?: SqliteLocalScheduleReader; + readonly overrides?: Partial; +} + export type AppServices = { auth: AuthRuntime; protectedClient: AuthRuntime['protectedClient']; @@ -33,32 +44,47 @@ export type AppServices = { reminderPorts: ReminderApplicationDependencies; scheduleView: ScheduleViewStore; webSocketClient: AuthRuntime['webSocketClient']; + /** 需要 attach()/detach()/refresh() 时用这些具体类型;LocalReminderApplication 只经 reminderPorts 访问端口接口。 */ + schedules: SqliteLocalScheduleReader; + reminderState: SqliteReminderStateStore; + alertDialog: AlertDialogPort; }; -export interface CreateAppServicesOptions { - readonly auth?: CreateAuthRuntimeOptions; -} - /** 应用唯一组合根:认证传输、功能服务、生命周期和账号内存清理在此接线。 */ export function createAppServices(options: CreateAppServicesOptions = {}): AppServices { const auth = createAuthRuntime(options.auth); + const alertDialog = new ReactNativeAlertDialog(); + const schedules = options.schedules ?? new SqliteLocalScheduleReader(); + const reminderState = new SqliteReminderStateStore(); + const presenter = + (options.overrides?.presenter as AlertReminderPresenter | undefined) ?? + new AlertReminderPresenter(alertDialog); + + const { + schedules: _ignoredSchedules, + presenter: _ignoredPresenter, + ...restOverrides + } = options.overrides ?? {}; + const reminderPorts: ReminderApplicationDependencies = { - schedules: new MockLocalScheduleReader(), - time: new MockTimeListener(), - location: new MockLocationMonitor(), + time: new IntervalTimeListener(), + location: new ExpoLocationMonitor(), alarms: new NativeAlarmScheduler(), - delivery: new MockReminderDelivery(), - audio: new MockAudioPlayback(), + delivery: new LocalReminderDelivery(), + audio: new ExpoAudioPlayback(), device: new NativeDeviceCapability(), - presenter: new MockReminderPresenter(), - systemNotification: new MockSystemNotification(), - popup: new MockPopup(), - vibration: new MockVibration(), - recovery: new MockReminderRecovery(), - state: new MockReminderStateStore(), - dispositionSync: new MockReminderDispositionSync(), + systemNotification: new ExpoSystemNotification(), + popup: new NoopPopup(), + vibration: new ReactNativeVibration(), + recovery: new LocalReminderRecovery(), + state: reminderState, + dispositionSync: new LocalReminderDispositionSync(), + ...restOverrides, + schedules, + presenter, }; - const reminder = new MockReminderApplication(reminderPorts); + + const reminder = new LocalReminderApplication(reminderPorts); const scheduleView = new ScheduleViewStore(); const runtime = new AppRuntime([ { @@ -78,5 +104,8 @@ export function createAppServices(options: CreateAppServicesOptions = {}): AppSe reminderPorts, scheduleView, webSocketClient: auth.webSocketClient, + schedules, + reminderState, + alertDialog, }; } diff --git a/frontend/src/app/orchestration/.gitkeep b/frontend/src/app/orchestration/.gitkeep deleted file mode 100644 index 8b137891..00000000 --- a/frontend/src/app/orchestration/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/frontend/src/features/assistant/application/AssistantContinuousConversationService.ts b/frontend/src/features/assistant/application/AssistantContinuousConversationService.ts index 18e84ab6..5dd367f0 100644 --- a/frontend/src/features/assistant/application/AssistantContinuousConversationService.ts +++ b/frontend/src/features/assistant/application/AssistantContinuousConversationService.ts @@ -52,6 +52,8 @@ export class AssistantContinuousConversationService implements AssistantApplicat private streamId: string | null = null; /** 非 null 表示当前正处于 voice.tts.start 和 voice.tts.end/canceled 之间。 */ private currentAudioId: string | null = null; + /** 最近被打断的音频 id;服务端会在 canceled 后补发同 id 的 tts.end。 */ + private canceledAudioId: string | null = null; private streamStartedWaiter: ((conversationId: string) => void) | null = null; /** 跟 streamStartedWaiter 配对;传输层报错/连接掉线时用它让等待方结束,不然会永远卡住。 */ private streamStartRejecter: ((error: Error) => void) | null = null; @@ -81,6 +83,8 @@ export class AssistantContinuousConversationService implements AssistantApplicat * startStream() 内部有一个没人等的 await(配置原生播放器),如果紧跟着的 * 第一块音频不排在它后面,可能在原生侧还没配置完时就到达。 */ private playbackChain: Promise = Promise.resolve(); + /** 取消时递增,让已经排队但尚未执行的旧流操作失效。 */ + private playbackGeneration = 0; private readonly unsubscribeAppState: () => void; constructor( @@ -237,7 +241,7 @@ export class AssistantContinuousConversationService implements AssistantApplicat this.replyText = null; this.currentAudioId = null; this.notifyListeners(); - await this.deps.playback.stop().catch(() => {}); + await this.stopPlaybackImmediately(); } /** 用户点圆圈暂停/恢复。暂停期间空闲计时器照常跑——忘记恢复也会兜底挂断。 */ @@ -286,13 +290,22 @@ export class AssistantContinuousConversationService implements AssistantApplicat // (尤其冷启动 GPS)可能耗时数秒,放在连接之后拿会把这段时间算进握手预算, // 导致 hello 送达前就被服务端以 1008 断开。超时兜底,拿不到就不带,不阻塞握手。 let timeoutId: ReturnType | undefined; + let locationTimedOut = false; const sample = await Promise.race([ this.deps.location.getCurrentSample().catch(() => null), new Promise((resolve) => { - timeoutId = setTimeout(() => resolve(null), LOCATION_TIMEOUT_MS); + timeoutId = setTimeout(() => { + locationTimedOut = true; + resolve(null); + }, LOCATION_TIMEOUT_MS); }), ]); clearTimeout(timeoutId); + if (sample === null) { + console.warn('[location-search] voice handshake has no location', { + reason: locationTimedOut ? 'timeout' : 'unavailable', + }); + } // session.hello → session.ready 的握手已经在 transport.connect() 内部完成 // (共享的 AuthenticatedWebSocketClient 负责,voice_mode 已经绑定在这个 @@ -362,6 +375,8 @@ export class AssistantContinuousConversationService implements AssistantApplicat this.notifyListeners(); return; case 'voice.tts.start': + this.playbackGeneration += 1; + this.canceledAudioId = null; this.currentAudioId = message.audio_id; this.setState({ conversationId: message.conversation_id, phase: 'speaking' }); this.chainPlayback(() => @@ -372,7 +387,16 @@ export class AssistantContinuousConversationService implements AssistantApplicat ); return; case 'voice.tts.end': + // canceled 后服务端仍会补发同一条 tts.end;它不能收尾新流,也不能把 + // interrupted 状态提前改回 listening。 + if ( + (this.canceledAudioId !== null && message.audio_id === this.canceledAudioId) || + (this.currentAudioId !== null && message.audio_id !== this.currentAudioId) + ) { + return; + } this.currentAudioId = null; + this.canceledAudioId = null; this.chainPlayback(() => this.deps.playback.endStream()); this.setState({ conversationId: message.conversation_id, phase: 'listening' }); // 播报完成后给一个全新的窗口,对应"播报完成后进入短暂等待"——不用单独 @@ -380,10 +404,17 @@ export class AssistantContinuousConversationService implements AssistantApplicat this.armIdleTimer(); return; case 'voice.tts.canceled': - // 用户开口打断了正在播的回复:立刻丢掉播放端缓冲里还没放出来的音频, - // 而不是等 voice.tts.end(后面仍会补发,但语义已经不是"正常说完")。 + // 用户开口打断了正在播的回复:stop 必须绕过 playbackChain 立即执行, + // 否则已排队的 PCM 会先继续喂给原生播放器;旧队列随后由代次检查丢弃。 + if ( + this.currentAudioId !== null && + (message.audio_id === '' || message.audio_id !== this.currentAudioId) + ) { + return; + } + this.canceledAudioId = message.audio_id || this.currentAudioId; this.currentAudioId = null; - this.chainPlayback(() => this.deps.playback.stop()); + void this.stopPlaybackImmediately(); this.setState({ conversationId: message.conversation_id, phase: 'interrupted' }); return; case 'voice.session.end': @@ -422,9 +453,25 @@ export class AssistantContinuousConversationService implements AssistantApplicat } /** 把一次对原生播放模块的调用接到 playbackChain 末尾,保证上一次真正执行完 - * (不管成功与否)才轮到这一次。 */ + * (不管成功与否)才轮到这一次;取消后旧代次的操作会被跳过。 */ private chainPlayback(run: () => Promise): void { - this.playbackChain = this.playbackChain.then(run).catch(() => {}); + const generation = this.playbackGeneration; + this.playbackChain = this.playbackChain + .then(async () => { + if (generation !== this.playbackGeneration) { + return; + } + await run(); + }) + .catch(() => {}); + } + + /** 立即清空原生播放器,并把后续新操作排在 stop 完成之后。 */ + private async stopPlaybackImmediately(): Promise { + this.playbackGeneration += 1; + const stop = this.deps.playback.stop().catch(() => {}); + this.playbackChain = stop; + await stop; } private handleClose(event: { code: number; reason: string }): void { diff --git a/frontend/src/features/assistant/application/AssistantConversationService.ts b/frontend/src/features/assistant/application/AssistantConversationService.ts index c58bfbe7..da30af94 100644 --- a/frontend/src/features/assistant/application/AssistantConversationService.ts +++ b/frontend/src/features/assistant/application/AssistantConversationService.ts @@ -180,13 +180,22 @@ export class AssistantConversationService implements AssistantApplicationPort { // 清掉超时定时器,不然赢了比赛的那次调用还会留一个挂到 2s 之后才触发的 // 空转定时器(无功能影响,但测试环境里会被当成没清理干净的异步句柄)。 let timeoutId: ReturnType | undefined; + let locationTimedOut = false; const sample = await Promise.race([ this.deps.location.getCurrentSample().catch(() => null), new Promise((resolve) => { - timeoutId = setTimeout(() => resolve(null), LOCATION_TIMEOUT_MS); + timeoutId = setTimeout(() => { + locationTimedOut = true; + resolve(null); + }, LOCATION_TIMEOUT_MS); }), ]); clearTimeout(timeoutId); + if (sample === null) { + console.warn('[location-search] voice handshake has no location', { + reason: locationTimedOut ? 'timeout' : 'unavailable', + }); + } // session.hello → session.ready 的握手已经在 transport.connect() 内部完成 // (共享的 AuthenticatedWebSocketClient 负责),这里拿到的就是已经 ready 的连接。 @@ -288,6 +297,10 @@ export class AssistantConversationService implements AssistantApplicationPort { } private handleClose(event: { code: number; reason: string }): void { + // 从按住说话切到连续对话时,共享 WS 会因 voiceMode 不同而主动断开重连。 + // 旧连接若只把 unsubscribeConnection 置空却不执行,旧服务仍会订阅新连接的 + // TTS/PCM,并与连续对话服务把同一句话重复送进播放器。 + this.unsubscribeConnection?.(); this.connection = null; this.unsubscribeConnection = null; const message = event.reason || `连接已断开(${event.code})`; diff --git a/frontend/src/features/assistant/data/local/LocalScheduleWriter.ts b/frontend/src/features/assistant/data/local/LocalScheduleWriter.ts index b8b432ef..9133c088 100644 --- a/frontend/src/features/assistant/data/local/LocalScheduleWriter.ts +++ b/frontend/src/features/assistant/data/local/LocalScheduleWriter.ts @@ -1,3 +1,4 @@ +import type { SqliteLocalScheduleReader } from '../../../reminder'; import type { CloudScheduleRow, ScheduleLocalRepository } from '../../../schedule/data'; import type { LocalScheduleWriterPort } from '../../application/interfaces/LocalScheduleWriterPort'; import type { AppliedCommand } from '../../domain/ConversationTurn'; @@ -23,13 +24,19 @@ import type { AppliedCommand } from '../../domain/ConversationTurn'; * 同步,得等后端把 override 数据也传下来。 */ export class LocalScheduleWriter implements LocalScheduleWriterPort { - public constructor(private readonly repository: ScheduleLocalRepository) {} + public constructor( + private readonly repository: ScheduleLocalRepository, + private readonly scheduleReader?: SqliteLocalScheduleReader, + ) {} public async applyCommandResult(accountId: string, command: AppliedCommand): Promise { if (command.status !== 'applied' || !command.schedule) { return; } await this.repository.applyCloudSchedule(toCloudScheduleRow(accountId, command.schedule)); + // 提醒引擎读的是 SqliteLocalScheduleReader 的投影,不是这个仓储本身;写完 + // 必须主动刷新一次,不然新建/改动的日程要等下次 rebuild 才会被提醒引擎看到。 + await this.scheduleReader?.refresh(); } } diff --git a/frontend/src/features/reminder/application/LocalReminderApplication.ts b/frontend/src/features/reminder/application/LocalReminderApplication.ts index e8904c8a..0de4509f 100644 --- a/frontend/src/features/reminder/application/LocalReminderApplication.ts +++ b/frontend/src/features/reminder/application/LocalReminderApplication.ts @@ -7,8 +7,10 @@ import type { LocationMonitorEvent, LocationWatchHandle, AlarmScheduleReceipt, + AlarmNativeEvent, } from './interfaces'; import type { + DeliveryChannel, LocalReminderSchedule, LocationSample, ReminderDeliveryReceipt, @@ -21,6 +23,7 @@ import type { } from '../domain'; import { DEFAULT_SNOOZE_MINUTES } from '../domain'; import { evaluateGeofence, resolveGeofenceCenter, resolveWatchMode } from '../domain/geofence'; +import { resolveStrengthDeliveryPlan } from '../domain/strengthDelivery'; import { isSnoozeActive, isSnoozeExpired, @@ -50,9 +53,12 @@ export class LocalReminderApplication implements ReminderApplicationPort { private timeListenerId: string | null = null; private unsubscribePresenter: (() => void) | null = null; private unsubscribeSchedules: (() => void) | null = null; + private unsubscribeAlarms: (() => void) | null = null; private readonly registrations = new Map(); private readonly activeDeliveries = new Set(); private readonly deliverLocks = new Set(); + /** 原生已响铃、由原生 UI 承接展示的日程;JS 不再叠加弹窗/TTS。 */ + private readonly nativePresented = new Set(); private readonly inFlight = new Set>(); private opChain: Promise = Promise.resolve(); /** 每次 stop / 失败回滚自增;停机前开始的工作持有旧世代,重启后仍视为已取消。 */ @@ -161,6 +167,10 @@ export class LocalReminderApplication implements ReminderApplicationPort { this.unsubscribePresenter = this.dependencies.presenter.onAction((event) => { void this.handlePresentationAction(event.schedule_id, event.action); }); + this.unsubscribeAlarms = + this.dependencies.alarms.subscribe?.((event) => { + void this.handleNativeAlarmEvent(event); + }) ?? null; // IntervalTimeListener 不在 start 时同步打点;先挂上 listener id 再 rebuild。 const timeHandle = await this.dependencies.time.start( @@ -175,6 +185,12 @@ export class LocalReminderApplication implements ReminderApplicationPort { return; } + await this.hydrateNativeDispositions(); + if (!this.isLive(generation)) { + await this.stopInternal(); + return; + } + this.unsubscribeSchedules = this.dependencies.schedules.subscribe(() => { void this.enqueueRebuild(); }); @@ -209,6 +225,7 @@ export class LocalReminderApplication implements ReminderApplicationPort { } this.activeDeliveries.clear(); this.deliverLocks.clear(); + this.nativePresented.clear(); this.started = false; } @@ -217,6 +234,8 @@ export class LocalReminderApplication implements ReminderApplicationPort { this.unsubscribePresenter = null; this.unsubscribeSchedules?.(); this.unsubscribeSchedules = null; + this.unsubscribeAlarms?.(); + this.unsubscribeAlarms = null; } private async registerInternal( @@ -283,8 +302,22 @@ export class LocalReminderApplication implements ReminderApplicationPort { if (!this.isLive(generation)) return []; - const locationSchedules = active.filter((schedule) => schedule.schedule_type === 'location'); - const locationHandles = await this.dependencies.location.rebuild(locationSchedules, (event) => { + const locationTargets = active + .filter((schedule) => schedule.schedule_type === 'location') + .map((schedule) => { + const mode = resolveWatchMode(schedule); + const center = resolveGeofenceCenter(schedule, mode); + if (center == null) return null; + return { + schedule_id: schedule.id, + center, + radius_meters: schedule.geofence_radius_meters, + mode, + background: true, + }; + }) + .filter((target): target is NonNullable => target != null); + const locationHandles = await this.dependencies.location.rebuild(locationTargets, (event) => { void this.handleLocationMonitorEvent(event); }); const locationBySchedule = new Map( @@ -563,25 +596,58 @@ export class LocalReminderApplication implements ReminderApplicationPort { }); const request = toDeliveryRequest(schedule, trigger); - const receipt = await this.dependencies.delivery.deliver(request); - await this.dependencies.presenter.show(request); - await this.dependencies.systemNotification.show({ - notification_id: `reminder-${schedule.id}`, - title: schedule.title, - body: schedule.location_name ?? schedule.title, - }); - await this.dependencies.popup.show({ - popup_id: `reminder-${schedule.id}`, - title: schedule.title, - body: schedule.location_name ?? schedule.title, - }); - await this.dependencies.vibration.vibrate(); - - let audioReceipt = await this.dependencies.audio.playTts({ schedule_id: schedule.id }); - if (!audioReceipt.played) { - audioReceipt = await this.dependencies.audio.playLocalFallback({ - schedule_id: schedule.id, + const plan = resolveStrengthDeliveryPlan(request.strength); + const channels: DeliveryChannel[] = []; + let usedFallbackAudio = false; + let deliveryId = `delivery-${schedule.id}`; + + // 时间型日程只要排上了原生精确闹钟,响铃 UI 和声音就全权交给原生 + // RingActivity/AlarmSoundService;JS 这边再弹 Alert/放音会跟原生的全屏页 + // 抢事件,谁都关不掉谁。地点型日程没有原生等价物,走完整 JS 通道。 + const nativeAlarmOwnsRingUi = + schedule.schedule_type === 'time' && + this.registrations.get(schedule.id)?.alarm_id != null; + + // low=系统通知;medium=弹窗+短震动;high=弹窗+短震动+TTS(失败则本地音)。 + if (plan.useSystemNotification) { + const receipt = await this.dependencies.delivery.deliver(request); + deliveryId = receipt.delivery_id; + await this.dependencies.systemNotification.show({ + notification_id: `reminder-${schedule.id}`, + title: schedule.title, + body: schedule.location_name ?? schedule.title, }); + channels.push('system_notification'); + } + + if (plan.usePopup && !nativeAlarmOwnsRingUi) { + await this.dependencies.presenter.show(request); + channels.push('popup'); + } + + if (plan.useVibration) { + await this.dependencies.vibration.vibrate(); + channels.push('vibration'); + } + + let audioPlayed = false; + if (plan.useAudio && !nativeAlarmOwnsRingUi) { + let audioReceipt = await this.dependencies.audio.playTts({ schedule_id: schedule.id }); + if (!audioReceipt.played) { + audioReceipt = await this.dependencies.audio.playLocalFallback({ + schedule_id: schedule.id, + }); + } + audioPlayed = audioReceipt.played; + if (audioPlayed) { + channels.push(audioReceipt.used_local_fallback ? 'local_sound' : 'tts'); + } + usedFallbackAudio = audioReceipt.used_local_fallback; + } + + await this.cancelScheduledAlarm(schedule.id); + if (!plan.useAudio || audioPlayed) { + await this.dependencies.alarms.stopRinging?.(); } if (!this.isLive(generation)) { @@ -593,14 +659,11 @@ export class LocalReminderApplication implements ReminderApplicationPort { } return { - ...receipt, - channels: [ - ...receipt.channels, - 'popup', - 'vibration', - audioReceipt.used_local_fallback ? 'local_sound' : 'tts', - ], - used_fallback_audio: audioReceipt.used_local_fallback, + delivery_id: deliveryId, + schedule_id: schedule.id, + delivered_at: trigger.triggered_at, + channels, + used_fallback_audio: usedFallbackAudio, }; } catch (error) { if (!this.acceptingWork || this.isLive(generation)) { @@ -641,12 +704,83 @@ export class LocalReminderApplication implements ReminderApplicationPort { } await this.enqueueOp(() => this.snoozeInternal( - { schedule_id: scheduleId, snooze_minutes: DEFAULT_SNOOZE_MINUTES }, + { schedule_id: scheduleId, snooze_until: null, snooze_minutes: DEFAULT_SNOOZE_MINUTES }, generation, ), ); } + private async handleNativeAlarmEvent(event: AlarmNativeEvent): Promise { + if (!event.schedule_id) return; + if (event.type === 'fired') { + await this.acknowledgeNativeFire(event.schedule_id, event.at); + return; + } + if (event.type === 'snoozed') { + await this.snooze({ schedule_id: event.schedule_id, snooze_until: null }); + return; + } + await this.confirm(event.schedule_id, event.at); + } + + /** + * 原生已承接响铃 UI:只落 pending disposition,避免 JS 再弹 Alert/TTS。 + * handleTime 会因 pending / activeDeliveries 跳过,从而消除双通道连响。 + */ + private async acknowledgeNativeFire(scheduleId: string, firedAt: string): Promise { + const runtime = (await this.readRuntime(scheduleId)) ?? emptyRuntime(); + if ( + runtime.reminder_disposition_state === 'confirmed' || + runtime.reminder_disposition_state === 'pending' + ) { + this.nativePresented.add(scheduleId); + this.activeDeliveries.add(scheduleId); + return; + } + + this.nativePresented.add(scheduleId); + this.activeDeliveries.add(scheduleId); + await this.cancelScheduledAlarm(scheduleId); + await this.patchRuntime(scheduleId, { + ...runtime, + reminder_disposition_state: 'pending', + next_trigger_at: null, + disposition_updated_at: firedAt, + sync_status: 'pending', + }); + await this.dependencies.state.setDisposition(scheduleId, { + schedule_id: scheduleId, + state: 'pending', + updated_at: firedAt, + snoozed_until: null, + sync_status: 'pending', + }); + } + + private async hydrateNativeDispositions(): Promise { + const rows = await this.dependencies.alarms.consumeNativeDispositions?.(); + if (rows == null || rows.length === 0) return; + + for (const row of rows) { + if (row.state === 'confirmed') { + await this.confirm(row.schedule_id, row.updated_at); + continue; + } + if (row.state === 'snoozed') { + await this.snooze({ schedule_id: row.schedule_id, snooze_until: null }); + continue; + } + await this.acknowledgeNativeFire(row.schedule_id, row.updated_at); + } + } + + private async cancelScheduledAlarm(scheduleId: string): Promise { + const registration = this.registrations.get(scheduleId); + if (registration?.alarm_id == null) return; + await this.dependencies.alarms.cancel(registration.alarm_id); + registration.alarm_id = null; + } + private async handleLocationMonitorEvent(event: LocationMonitorEvent): Promise { const generation = this.generation; await this.track(this.runLocationMonitorEvent(event, generation)); diff --git a/frontend/src/features/reminder/application/index.ts b/frontend/src/features/reminder/application/index.ts index 6e87c456..63351bc7 100644 --- a/frontend/src/features/reminder/application/index.ts +++ b/frontend/src/features/reminder/application/index.ts @@ -1,5 +1,7 @@ export { LocalReminderApplication } from './LocalReminderApplication'; export type { + AlarmNativeDisposition, + AlarmNativeEvent, AlarmScheduleReceipt, AlarmScheduleRequest, AlarmSchedulerPort, @@ -13,9 +15,13 @@ export type { LocalTimeTick, LocationMonitorEvent, LocationMonitorPort, + LocationRebuildTarget, LocationWatchHandle, LocationWatchMode, LocationWatchRequest, + AlertDialogButton, + AlertDialogPort, + AlertDialogRequest, PopupPort, PopupReceipt, PopupRequest, @@ -24,6 +30,8 @@ export type { ReminderApplicationResult, ReminderConfirmedDisposition, ReminderDeliveryPort, + ReminderDeliveryReceipt, + ReminderDeliveryRequest, ReminderDispositionSyncPort, ReminderDispositionSyncReceipt, ReminderPresentationAction, diff --git a/frontend/src/features/reminder/application/interfaces/.gitkeep b/frontend/src/features/reminder/application/interfaces/.gitkeep deleted file mode 100644 index 8b137891..00000000 --- a/frontend/src/features/reminder/application/interfaces/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/frontend/src/features/reminder/application/interfaces/AlarmSchedulerPort.ts b/frontend/src/features/reminder/application/interfaces/AlarmSchedulerPort.ts index 17949c02..b7bb86fc 100644 --- a/frontend/src/features/reminder/application/interfaces/AlarmSchedulerPort.ts +++ b/frontend/src/features/reminder/application/interfaces/AlarmSchedulerPort.ts @@ -12,9 +12,30 @@ export type AlarmScheduleReceipt = { scheduled: boolean; }; +export type AlarmNativeEvent = { + type: 'fired' | 'dismissed' | 'snoozed'; + schedule_id: string; + alarm_id: string; + title: string; + at: string; +}; + +export type AlarmNativeDisposition = { + schedule_id: string; + alarm_id: string; + state: 'pending' | 'confirmed' | 'snoozed'; + updated_at: string; +}; + /** 原生闹钟映射边界;触发时间的选择留在应用层或领域层。 */ export interface AlarmSchedulerPort { schedule(request: AlarmScheduleRequest): Promise; cancel(alarmId: string | null): Promise<{ cancelled: boolean }>; rebuild(requests: readonly AlarmScheduleRequest[]): Promise; + /** 停止正在响铃的原生界面/音频(尽力而为)。 */ + stopRinging?(): Promise; + /** 订阅原生响铃/停铃事件;无原生桥时可不实现。 */ + subscribe?(listener: (event: AlarmNativeEvent) => void): () => void; + /** 拉取并清空进程外写入的处置状态(冷启动补水)。 */ + consumeNativeDispositions?(): Promise; } diff --git a/frontend/src/features/reminder/application/interfaces/DeviceCapabilityPort.ts b/frontend/src/features/reminder/application/interfaces/DeviceCapabilityPort.ts index 9ba6ae5c..1eb2b688 100644 --- a/frontend/src/features/reminder/application/interfaces/DeviceCapabilityPort.ts +++ b/frontend/src/features/reminder/application/interfaces/DeviceCapabilityPort.ts @@ -19,4 +19,6 @@ export interface DeviceCapabilityPort { getStatus(): Promise; requestPermission(permission: DevicePermission): Promise; openSettings(permission: DevicePermission): Promise; + /** 订阅应用回到前台;返回取消订阅函数。 */ + onAppActive(listener: () => void): () => void; } diff --git a/frontend/src/features/reminder/application/interfaces/LocationMonitorPort.ts b/frontend/src/features/reminder/application/interfaces/LocationMonitorPort.ts index 05711ee8..aa2dfa58 100644 --- a/frontend/src/features/reminder/application/interfaces/LocationMonitorPort.ts +++ b/frontend/src/features/reminder/application/interfaces/LocationMonitorPort.ts @@ -1,7 +1,19 @@ -import type { GeoPoint, LocalReminderSchedule, LocationSample } from '../../domain'; +import type { GeoPoint, LocationSample } from '../../domain'; export type LocationWatchMode = 'arrive' | 'return'; +/** + * 围栏重建目标:携带 watch 所需几何参数,避免 infrastructure 依赖完整领域日程, + * 同时保证冷启动 rebuild 能重新挂上系统围栏。 + */ +export type LocationRebuildTarget = { + schedule_id: string; + center: GeoPoint; + radius_meters: number; + mode: LocationWatchMode; + background: boolean; +}; + export type LocationWatchRequest = { schedule_id: string; center: GeoPoint; @@ -29,7 +41,7 @@ export interface LocationMonitorPort { ): Promise; unwatch(listenerId: string): Promise; rebuild( - schedules: readonly LocalReminderSchedule[], + targets: readonly LocationRebuildTarget[], listener: (event: LocationMonitorEvent) => void, ): Promise; getLastSample(): Promise; diff --git a/frontend/src/features/reminder/application/interfaces/NotificationChannels.ts b/frontend/src/features/reminder/application/interfaces/NotificationChannels.ts index 3c62b6e9..c9706ee5 100644 --- a/frontend/src/features/reminder/application/interfaces/NotificationChannels.ts +++ b/frontend/src/features/reminder/application/interfaces/NotificationChannels.ts @@ -34,3 +34,23 @@ export interface VibrationPort { vibrate(): Promise; stop(): Promise; } + +export type AlertDialogButton = { + text: string; + style?: 'default' | 'cancel' | 'destructive'; + onPress?: () => void; +}; + +export type AlertDialogRequest = { + title: string; + message: string; + buttons: readonly AlertDialogButton[]; + /** Android:返回键关闭时回调;未点按钮也要能结束等待。 */ + onDismiss?: () => void; + cancelable?: boolean; +}; + +/** 系统确认/选择对话框;由 infrastructure 适配,presentation 不直接依赖 RN Alert。 */ +export interface AlertDialogPort { + show(request: AlertDialogRequest): Promise; +} diff --git a/frontend/src/features/reminder/application/interfaces/ReminderApplicationPort.ts b/frontend/src/features/reminder/application/interfaces/ReminderApplicationPort.ts index 903b1dbd..5f0b4fe8 100644 --- a/frontend/src/features/reminder/application/interfaces/ReminderApplicationPort.ts +++ b/frontend/src/features/reminder/application/interfaces/ReminderApplicationPort.ts @@ -24,21 +24,11 @@ export type ReminderApplicationDependencies = { dispositionSync: import('./ReminderDispositionSyncPort').ReminderDispositionSyncPort; }; -/** - * 贪睡必须且只能提供一种目标时刻表达:绝对时间或相对分钟。 - * 两种字段互斥,避免实现层自行决定优先级,也禁止无目标时刻的请求。 - */ -export type ReminderSnoozeRequest = - | { - schedule_id: string; - snooze_until: string; - snooze_minutes?: never; - } - | { - schedule_id: string; - snooze_minutes: number; - snooze_until?: never; - }; +export type ReminderSnoozeRequest = { + schedule_id: string; + snooze_until: string | null; + snooze_minutes?: number | null; +}; export type ReminderApplicationResult = { accepted: boolean; diff --git a/frontend/src/features/reminder/application/interfaces/ReminderDeliveryPort.ts b/frontend/src/features/reminder/application/interfaces/ReminderDeliveryPort.ts index f13ba733..611712a6 100644 --- a/frontend/src/features/reminder/application/interfaces/ReminderDeliveryPort.ts +++ b/frontend/src/features/reminder/application/interfaces/ReminderDeliveryPort.ts @@ -1,5 +1,7 @@ import type { ReminderDeliveryReceipt, ReminderDeliveryRequest } from '../../domain'; +export type { ReminderDeliveryReceipt, ReminderDeliveryRequest }; + /** 通过与平台无关的展示边界送达提醒。 */ export interface ReminderDeliveryPort { deliver(request: ReminderDeliveryRequest): Promise; diff --git a/frontend/src/features/reminder/application/interfaces/index.ts b/frontend/src/features/reminder/application/interfaces/index.ts index ca99f198..788b9f5d 100644 --- a/frontend/src/features/reminder/application/interfaces/index.ts +++ b/frontend/src/features/reminder/application/interfaces/index.ts @@ -1,4 +1,6 @@ export type { + AlarmNativeDisposition, + AlarmNativeEvent, AlarmScheduleReceipt, AlarmScheduleRequest, AlarmSchedulerPort, @@ -17,11 +19,15 @@ export type { LocalScheduleReader } from './LocalScheduleReader'; export type { LocationMonitorEvent, LocationMonitorPort, + LocationRebuildTarget, LocationWatchHandle, LocationWatchMode, LocationWatchRequest, } from './LocationMonitorPort'; export type { + AlertDialogButton, + AlertDialogPort, + AlertDialogRequest, PopupPort, PopupReceipt, PopupRequest, @@ -36,7 +42,11 @@ export type { ReminderApplicationResult, ReminderSnoozeRequest, } from './ReminderApplicationPort'; -export type { ReminderDeliveryPort } from './ReminderDeliveryPort'; +export type { + ReminderDeliveryPort, + ReminderDeliveryReceipt, + ReminderDeliveryRequest, +} from './ReminderDeliveryPort'; export type { ReminderConfirmedDisposition, ReminderDispositionSyncPort, diff --git a/frontend/src/features/reminder/data/local/.gitkeep b/frontend/src/features/reminder/data/local/.gitkeep deleted file mode 100644 index 8b137891..00000000 --- a/frontend/src/features/reminder/data/local/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/frontend/src/features/reminder/data/local/InMemoryLocalScheduleReader.ts b/frontend/src/features/reminder/data/local/InMemoryLocalScheduleReader.ts new file mode 100644 index 00000000..fd175135 --- /dev/null +++ b/frontend/src/features/reminder/data/local/InMemoryLocalScheduleReader.ts @@ -0,0 +1,61 @@ +import type { LocalScheduleReader } from '../../application/interfaces'; +import type { LocalReminderSchedule } from '../../domain'; + +/** + * 进程内日程投影,供调用方写入后再由 LocalReminderApplication rebuild/start 接管。 + * 正式本地 DB 接入前作为默认 LocalScheduleReader。 + */ +export class InMemoryLocalScheduleReader implements LocalScheduleReader { + private readonly byId = new Map(); + private readonly listeners = new Set<(schedules: readonly LocalReminderSchedule[]) => void>(); + + list(): readonly LocalReminderSchedule[] { + return [...this.byId.values()]; + } + + async listReminderSchedules(): Promise { + return this.list(); + } + + async getReminderSchedule(scheduleId: string): Promise { + return this.byId.get(scheduleId) ?? null; + } + + subscribe(listener: (schedules: readonly LocalReminderSchedule[]) => void): () => void { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + } + + replaceAll(schedules: readonly LocalReminderSchedule[]): void { + this.byId.clear(); + for (const schedule of schedules) { + this.byId.set(schedule.id, schedule); + } + this.notify(); + } + + upsert(schedule: LocalReminderSchedule): void { + this.byId.set(schedule.id, schedule); + this.notify(); + } + + remove(scheduleId: string): void { + if (!this.byId.delete(scheduleId)) return; + this.notify(); + } + + clear(): void { + if (this.byId.size === 0) return; + this.byId.clear(); + this.notify(); + } + + private notify(): void { + const snapshot = this.list(); + for (const listener of this.listeners) { + listener(snapshot); + } + } +} diff --git a/frontend/src/features/reminder/data/local/LocalReminderAdapters.ts b/frontend/src/features/reminder/data/local/LocalReminderAdapters.ts new file mode 100644 index 00000000..604ca59a --- /dev/null +++ b/frontend/src/features/reminder/data/local/LocalReminderAdapters.ts @@ -0,0 +1,78 @@ +import type { + PopupPort, + PopupReceipt, + PopupRequest, + ReminderDeliveryPort, + ReminderDeliveryReceipt, + ReminderDeliveryRequest, + ReminderDispositionSyncPort, + ReminderDispositionSyncReceipt, + ReminderRecoveryPort, + ReminderRecoveryReceipt, + SystemNotificationPort, + SystemNotificationReceipt, + SystemNotificationRequest, + ReminderConfirmedDisposition, +} from '../../application/interfaces'; + +/** 送达记账:展示由 systemNotification / presenter 完成,此处只返回回执。 */ +export class LocalReminderDelivery implements ReminderDeliveryPort { + async deliver(request: ReminderDeliveryRequest): Promise { + return { + delivery_id: `delivery-${request.schedule_id}-${Date.now()}`, + schedule_id: request.schedule_id, + delivered_at: new Date().toISOString(), + channels: [], + used_fallback_audio: false, + }; + } + + async dismiss(_scheduleId: string): Promise { + return Promise.resolve(); + } +} + +/** 弹窗通道占位:提醒页由 ReminderPresenter 承接。 */ +export class NoopPopup implements PopupPort { + async show(request: PopupRequest): Promise { + return { popup_id: request.popup_id, visible: false }; + } + + async dismiss(_popupId: string): Promise { + return Promise.resolve(); + } +} + +/** 系统通知占位:接入 expo-notifications 前保持可替换端口。 */ +export class LocalSystemNotification implements SystemNotificationPort { + async show(request: SystemNotificationRequest): Promise { + return { notification_id: request.notification_id, shown: true }; + } + + async cancel(_notificationId: string): Promise { + return Promise.resolve(); + } +} + +/** 重启恢复占位:后续可接开机广播 / 精确闹钟重挂。 */ +export class LocalReminderRecovery implements ReminderRecoveryPort { + async registerForRestart(): Promise { + return { registered: true, recovery_id: `recovery-${Date.now()}` }; + } + + async restoreAfterRestart(): Promise { + return { registered: true, recovery_id: `recovery-${Date.now()}` }; + } +} + +/** 确认态本地受理:无网络时也返回 accepted,供后续 sync 替换。 */ +export class LocalReminderDispositionSync implements ReminderDispositionSyncPort { + async submitConfirmed( + disposition: ReminderConfirmedDisposition, + ): Promise { + return { + schedule_id: disposition.schedule_id, + accepted: true, + }; + } +} diff --git a/frontend/src/features/reminder/data/local/MockLocalScheduleReader.ts b/frontend/src/features/reminder/data/local/MockLocalScheduleReader.ts deleted file mode 100644 index 9b5942a4..00000000 --- a/frontend/src/features/reminder/data/local/MockLocalScheduleReader.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { LocalScheduleReader } from '../../application/interfaces'; -import type { LocalReminderSchedule } from '../../domain'; - -import { MOCK_REMINDER_SCHEDULES } from './mockReminderSchedules'; - -/** 代替本地数据库端口的固定只读适配器。 */ -export class MockLocalScheduleReader implements LocalScheduleReader { - readonly schedules = MOCK_REMINDER_SCHEDULES; - private readonly listeners = new Set<(schedules: readonly LocalReminderSchedule[]) => void>(); - - async listReminderSchedules(): Promise { - return this.schedules; - } - - async getReminderSchedule(scheduleId: string): Promise { - return this.schedules.find((schedule) => schedule.id === scheduleId) ?? null; - } - - subscribe(listener: (schedules: readonly LocalReminderSchedule[]) => void): () => void { - this.listeners.add(listener); - return () => { - this.listeners.delete(listener); - }; - } - - /** 测试或本地写入后通知订阅方;subscribe 本身不重放,避免与 start/rebuild 重入。 */ - notify(): void { - for (const listener of this.listeners) { - listener(this.schedules); - } - } -} diff --git a/frontend/src/features/reminder/data/local/MockReminderApplication.ts b/frontend/src/features/reminder/data/local/MockReminderApplication.ts deleted file mode 100644 index bc717c72..00000000 --- a/frontend/src/features/reminder/data/local/MockReminderApplication.ts +++ /dev/null @@ -1,105 +0,0 @@ -import type { - ReminderApplicationDependencies, - ReminderApplicationPort, - ReminderApplicationResult, - ReminderSnoozeRequest, -} from '../../application/interfaces'; -import type { - LocalReminderSchedule, - LocationSample, - ReminderDeliveryReceipt, - ReminderRegistration, - ReminderTrigger, -} from '../../domain'; - -const MOCK_DELIVERY: ReminderDeliveryReceipt = { - delivery_id: 'mock-delivery-001', - schedule_id: 'mock-schedule-time-001', - delivered_at: '2026-08-07T01:00:00.000Z', - channels: ['system_notification', 'popup', 'vibration'], - used_fallback_audio: false, -}; - -function registrationFor(schedule: LocalReminderSchedule): ReminderRegistration { - if (schedule.schedule_type === 'location') { - return { - schedule_id: schedule.id, - time_listener_id: null, - location_listener_id: `mock-location-listener-${schedule.id}`, - alarm_id: null, - }; - } - - return { - schedule_id: schedule.id, - time_listener_id: `mock-time-listener-${schedule.id}`, - location_listener_id: null, - alarm_id: `mock-alarm-${schedule.id}`, - }; -} - -/** 真实协调器完成前供应用外壳使用的应用门面。 */ -export class MockReminderApplication implements ReminderApplicationPort { - constructor(readonly dependencies: ReminderApplicationDependencies) {} - - async start(): Promise { - return Promise.resolve(); - } - - async stop(): Promise { - return Promise.resolve(); - } - - async register(schedule: LocalReminderSchedule): Promise { - return registrationFor(schedule); - } - - async rebuild(): Promise { - const schedules = await this.dependencies.schedules.listReminderSchedules(); - return schedules.map(registrationFor); - } - - async handleTime(_tick: { observed_at: string }): Promise { - return Promise.resolve(); - } - - async handleLocation(_sample: LocationSample): Promise { - return Promise.resolve(); - } - - async deliver(trigger: ReminderTrigger): Promise { - return { ...MOCK_DELIVERY, schedule_id: trigger.schedule_id }; - } - - async confirm(scheduleId: string, confirmedAt: string): Promise { - return { - accepted: true, - schedule_id: scheduleId, - disposition: { - schedule_id: scheduleId, - state: 'confirmed', - updated_at: confirmedAt, - snoozed_until: null, - sync_status: 'pending', - }, - }; - } - - async snooze(request: ReminderSnoozeRequest): Promise { - // 确定性 mock:绝对时间原样回传;相对分钟映射到固定 fixture 时刻。 - const snoozed_until = - 'snooze_minutes' in request ? '2026-08-07T01:10:00.000Z' : request.snooze_until; - - return { - accepted: true, - schedule_id: request.schedule_id, - disposition: { - schedule_id: request.schedule_id, - state: 'snoozed', - updated_at: '2026-08-07T01:00:00.000Z', - snoozed_until, - sync_status: 'pending', - }, - }; - } -} diff --git a/frontend/src/features/reminder/data/local/MockReminderDispositionSync.ts b/frontend/src/features/reminder/data/local/MockReminderDispositionSync.ts deleted file mode 100644 index 5a7ef1c5..00000000 --- a/frontend/src/features/reminder/data/local/MockReminderDispositionSync.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { - ReminderConfirmedDisposition, - ReminderDispositionSyncPort, - ReminderDispositionSyncReceipt, -} from '../../application/interfaces'; - -/** 最终确认状态网络同步回调的模拟实现。 */ -export class MockReminderDispositionSync implements ReminderDispositionSyncPort { - async submitConfirmed( - disposition: ReminderConfirmedDisposition, - ): Promise { - return { - schedule_id: disposition.schedule_id, - accepted: true, - }; - } -} diff --git a/frontend/src/features/reminder/data/local/MockReminderStateStore.ts b/frontend/src/features/reminder/data/local/MockReminderStateStore.ts deleted file mode 100644 index 187d26c4..00000000 --- a/frontend/src/features/reminder/data/local/MockReminderStateStore.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { ReminderStateStore } from '../../application/interfaces'; -import type { ReminderDisposition, ReminderRuntimeState } from '../../domain'; - -const MOCK_STATE: ReminderRuntimeState = { - reminder_disposition_state: null, - next_trigger_at: '2026-08-07T01:00:00.000Z', - snoozed_until: null, - geofence_armed: false, - disposition_updated_at: null, - sync_status: 'synced', - recorded_location: null, -}; - -/** 读取结果固定的本地状态空操作适配器,不打开本地数据库。 */ -export class MockReminderStateStore implements ReminderStateStore { - async read(_scheduleId: string): Promise { - return { ...MOCK_STATE }; - } - - async write(_scheduleId: string, _state: ReminderRuntimeState): Promise { - return Promise.resolve(); - } - - async setDisposition(_scheduleId: string, _disposition: ReminderDisposition): Promise { - return Promise.resolve(); - } -} diff --git a/frontend/src/features/reminder/data/local/SqliteLocalScheduleReader.ts b/frontend/src/features/reminder/data/local/SqliteLocalScheduleReader.ts new file mode 100644 index 00000000..b66ca3a6 --- /dev/null +++ b/frontend/src/features/reminder/data/local/SqliteLocalScheduleReader.ts @@ -0,0 +1,102 @@ +import type { ScheduleLocalRepository, LocalScheduleRow } from '../../../schedule/data'; +import type { LocalScheduleReader } from '../../application/interfaces'; +import type { LocalReminderSchedule, ReminderStrength } from '../../domain'; + +/** local_schedules 表没有这一列;地点提醒暂时统一按 200 米围栏处理。 */ +const DEFAULT_GEOFENCE_RADIUS_METERS = 200; + +type Target = { repository: ScheduleLocalRepository; accountId: string }; + +/** + * 真实本地日程数据源:读 SQLite `local_schedules`,供 LocalReminderApplication 使用。 + * + * createAppServices() 在认证/数据库就绪之前就要把这个类接进 reminderPorts,所以构造时 + * 不直接拿 repository/accountId——由调用方在数据库打开、账号确定后调用 attach(), + * 账号切换或登出时调用 detach()。整个应用生命周期内只有这一个实例, + * LocalReminderApplication.start() 对它的 subscribe() 只挂一次,重新绑定 target 不会 + * 让那次订阅失效。 + */ +export class SqliteLocalScheduleReader implements LocalScheduleReader { + private readonly listeners = new Set<(schedules: readonly LocalReminderSchedule[]) => void>(); + private target: Target | null = null; + + public attach(repository: ScheduleLocalRepository, accountId: string): void { + this.target = { repository, accountId }; + } + + public detach(): void { + this.target = null; + } + + public async listReminderSchedules(): Promise { + if (this.target === null) return []; + const rows = await this.target.repository.listSchedules(this.target.accountId); + return rows.map(toLocalReminderSchedule); + } + + public async getReminderSchedule(scheduleId: string): Promise { + if (this.target === null) return null; + const row = await this.target.repository.getSchedule(this.target.accountId, scheduleId); + return row === null ? null : toLocalReminderSchedule(row); + } + + public subscribe(listener: (schedules: readonly LocalReminderSchedule[]) => void): () => void { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + } + + /** attach() 之后或本地写入提交后调用:重新读取 SQLite 并通知订阅方触发 rebuild。 */ + public async refresh(): Promise { + const schedules = await this.listReminderSchedules(); + for (const listener of this.listeners) { + listener(schedules); + } + } +} + +function toLocalReminderSchedule(row: LocalScheduleRow): LocalReminderSchedule { + return { + id: row.id, + account_id: row.account_id, + title: row.title, + schedule_type: row.schedule_type, + schedule_kind: row.schedule_kind, + is_all_day: row.is_all_day === 1, + start_time: row.start_time, + end_time: row.end_time, + timezone: row.timezone, + recurrence_rule: row.recurrence_rule, + location_name: row.location_name, + latitude: row.latitude, + longitude: row.longitude, + geofence_radius_meters: DEFAULT_GEOFENCE_RADIUS_METERS, + reminder: + row.reminder_type === null + ? null + : { + reminder_type: row.reminder_type, + reminder_trigger_at: row.reminder_trigger_at, + reminder_offset_minutes: row.reminder_offset_minutes, + // reminder_type 非空时 reminder_strength 一定非空,由建表 CHECK 约束保证。 + reminder_strength: row.reminder_strength as ReminderStrength, + }, + runtime: { + reminder_disposition_state: row.reminder_disposition_state, + next_trigger_at: row.next_trigger_at, + snoozed_until: row.snoozed_until, + geofence_armed: row.geofence_armed === 1, + disposition_updated_at: row.disposition_updated_at, + sync_status: row.sync_status, + // local_schedules 没有单独记录到达点位的列,围栏到达位置暂不持久化。 + recorded_location: null, + }, + status: row.status, + // 表里没有独立的设备端 revision 列,用 cloud_revision 兼当;这个字段在 + // reminder 领域逻辑里当前也没被读取。 + revision: row.cloud_revision, + cloud_revision: row.cloud_revision, + updated_at: row.updated_at, + }; +} diff --git a/frontend/src/features/reminder/data/local/SqliteReminderStateStore.ts b/frontend/src/features/reminder/data/local/SqliteReminderStateStore.ts new file mode 100644 index 00000000..5f678c9a --- /dev/null +++ b/frontend/src/features/reminder/data/local/SqliteReminderStateStore.ts @@ -0,0 +1,136 @@ +import type { + LocalReminderRuntimeUpdate, + LocalScheduleRow, + ScheduleLocalRepository, +} from '../../../schedule/data'; +import { + floatingDateToLocalParts, + instantToZonedParts, + isValidIanaTimezone, + localPartsToFloatingDate, + zonedPartsToInstant, +} from '../../../schedule/domain/scheduleDateTime'; +import { + normalizeUtcUntilForFloatingRrule, + parseScheduleRrule, +} from '../../../schedule/domain/scheduleRecurrence'; +import type { ReminderStateStore } from '../../application/interfaces'; +import type { ReminderDisposition, ReminderRuntimeState } from '../../domain'; + +type Target = { repository: ScheduleLocalRepository; accountId: string }; + +/** + * 提醒运行时状态(响没响、确认没确认)的真实持久化:写 SQLite `local_schedules` 的 + * 运行时字段。跟 SqliteLocalScheduleReader 一样用 attach()/detach() 延迟绑定—— + * createAppServices() 构造时账号和仓储都还没有,整个 App 生命周期只有这一个实例。 + * + * 没有这个类之前用的是 MemoryReminderStateStore(纯内存),App 进程一死状态就丢, + * 已经响过的到点提醒下次启动会被当成全新的重新弹一遍。 + */ +export class SqliteReminderStateStore implements ReminderStateStore { + private target: Target | null = null; + + public attach(repository: ScheduleLocalRepository, accountId: string): void { + this.target = { repository, accountId }; + } + + public detach(): void { + this.target = null; + } + + public async read(scheduleId: string): Promise { + if (this.target === null) return null; + const row = await this.target.repository.getSchedule(this.target.accountId, scheduleId); + if (row === null) return null; + + // 重复日程的 occurrence 光标为空:要么是云端刚同步下来的新记录(从没落过库), + // 要么是上一次 occurrence 被 confirmInternal 消费后显式清空的(它就是靠置空 + // next_trigger_at 触发"这条该往前挪一格了")。resolveTimeTriggerAt() 对重复 + // 日程光标为空时明确返回 null,不会回退到系列 start_time,所以这里必须补上 + // 下一次发生时间,不然这条提醒永远不会再触发第二次。同时把上一轮的 + // disposition 状态一起重置,否则 canDeliver() 会因为它还是 'confirmed' 继续 + // 拦住这条全新的 occurrence。 + if (row.schedule_kind === 'recurring' && row.next_trigger_at === null) { + const nextOccurrence = nextRecurringOccurrenceAtOrAfter(row, new Date()); + if (nextOccurrence !== null) { + return { + reminder_disposition_state: null, + next_trigger_at: nextOccurrence, + snoozed_until: null, + geofence_armed: row.geofence_armed === 1, + disposition_updated_at: null, + sync_status: row.sync_status, + recorded_location: null, + }; + } + // 系列已经结束(RRULE 的 UNTIL/COUNT 用完了)或规则本身有问题:没有下一次 + // 发生时间可算,透传原始(空)状态,让上层照常按"这条排不上"处理。 + } + + return { + reminder_disposition_state: row.reminder_disposition_state, + next_trigger_at: row.next_trigger_at, + snoozed_until: row.snoozed_until, + geofence_armed: row.geofence_armed === 1, + disposition_updated_at: row.disposition_updated_at, + sync_status: row.sync_status, + // local_schedules 没有单独记录到达点位的列,跟 SqliteLocalScheduleReader 一致处理。 + recorded_location: null, + }; + } + + public async write(scheduleId: string, state: ReminderRuntimeState): Promise { + if (this.target === null) return; + await this.target.repository.updateReminderRuntime( + this.target.accountId, + scheduleId, + toRuntimeUpdate(state), + ); + } + + public async setDisposition(scheduleId: string, disposition: ReminderDisposition): Promise { + if (this.target === null) return; + const current = await this.read(scheduleId); + await this.target.repository.updateReminderRuntime(this.target.accountId, scheduleId, { + reminder_disposition_state: disposition.state, + next_trigger_at: current?.next_trigger_at ?? null, + snoozed_until: disposition.snoozed_until, + geofence_armed: (current?.geofence_armed ?? false) ? 1 : 0, + disposition_updated_at: disposition.updated_at, + sync_status: disposition.sync_status, + }); + } +} + +/** 重复日程从 now 起(含 now 本身)的下一次发生时间;规则用完/无效时返回 null。 */ +function nextRecurringOccurrenceAtOrAfter(row: LocalScheduleRow, now: Date): string | null { + if (row.start_time === null || row.recurrence_rule === null) return null; + if (!isValidIanaTimezone(row.timezone)) return null; + + try { + const floatingStart = localPartsToFloatingDate( + instantToZonedParts(new Date(row.start_time), row.timezone), + ); + const rule = parseScheduleRrule( + normalizeUtcUntilForFloatingRrule(row.recurrence_rule, row.timezone), + floatingStart, + ); + const floatingNow = localPartsToFloatingDate(instantToZonedParts(now, row.timezone)); + const nextFloating = rule.after(floatingNow, true); + if (nextFloating === null) return null; + return zonedPartsToInstant(floatingDateToLocalParts(nextFloating), row.timezone).toISOString(); + } catch { + return null; + } +} + +function toRuntimeUpdate(state: ReminderRuntimeState): LocalReminderRuntimeUpdate { + return { + reminder_disposition_state: state.reminder_disposition_state, + next_trigger_at: state.next_trigger_at, + snoozed_until: state.snoozed_until, + geofence_armed: state.geofence_armed ? 1 : 0, + disposition_updated_at: state.disposition_updated_at, + sync_status: state.sync_status, + }; +} diff --git a/frontend/src/features/reminder/data/local/index.ts b/frontend/src/features/reminder/data/local/index.ts index 4f8b255e..191e728b 100644 --- a/frontend/src/features/reminder/data/local/index.ts +++ b/frontend/src/features/reminder/data/local/index.ts @@ -1,6 +1,11 @@ -export { MockLocalScheduleReader } from './MockLocalScheduleReader'; -export { MockReminderApplication } from './MockReminderApplication'; -export { MockReminderDispositionSync } from './MockReminderDispositionSync'; -export { MockReminderStateStore } from './MockReminderStateStore'; export { MemoryReminderStateStore } from './MemoryReminderStateStore'; -export { MOCK_REMINDER_SCHEDULES } from './mockReminderSchedules'; +export { InMemoryLocalScheduleReader } from './InMemoryLocalScheduleReader'; +export { SqliteLocalScheduleReader } from './SqliteLocalScheduleReader'; +export { SqliteReminderStateStore } from './SqliteReminderStateStore'; +export { + LocalReminderDelivery, + LocalReminderDispositionSync, + LocalReminderRecovery, + LocalSystemNotification, + NoopPopup, +} from './LocalReminderAdapters'; diff --git a/frontend/src/features/reminder/data/local/mockReminderSchedules.ts b/frontend/src/features/reminder/data/local/mockReminderSchedules.ts deleted file mode 100644 index cc5de05e..00000000 --- a/frontend/src/features/reminder/data/local/mockReminderSchedules.ts +++ /dev/null @@ -1,73 +0,0 @@ -import type { LocalReminderSchedule } from '../../domain'; - -const MOCK_RUNTIME = { - reminder_disposition_state: null, - next_trigger_at: '2026-08-07T01:00:00.000Z', - snoozed_until: null, - geofence_armed: false, - disposition_updated_at: null, - sync_status: 'synced' as const, - recorded_location: null, -}; - -/** 接入本地数据库读取器前使用的固定快照。 */ -export const MOCK_REMINDER_SCHEDULES: readonly LocalReminderSchedule[] = [ - { - id: 'mock-schedule-time-001', - account_id: 'mock-account-001', - title: '项目例会', - schedule_type: 'time', - schedule_kind: 'once', - is_all_day: false, - start_time: '2026-08-07T01:15:00.000Z', - end_time: null, - timezone: 'Asia/Shanghai', - recurrence_rule: null, - location_name: '203 会议室', - latitude: null, - longitude: null, - geofence_radius_meters: 100, - reminder: { - reminder_type: 'before_start', - reminder_trigger_at: null, - reminder_offset_minutes: 15, - reminder_strength: 'medium', - }, - runtime: { ...MOCK_RUNTIME }, - status: 'active', - revision: 1, - cloud_revision: 1, - updated_at: '2026-08-06T09:00:00.000Z', - }, - { - id: 'mock-schedule-location-001', - account_id: 'mock-account-001', - title: '取车提醒', - schedule_type: 'location', - schedule_kind: 'once', - is_all_day: false, - start_time: null, - end_time: null, - timezone: 'Asia/Shanghai', - recurrence_rule: null, - location_name: '停车场', - latitude: 31.2304, - longitude: 121.4737, - geofence_radius_meters: 100, - reminder: { - reminder_type: 'return_to_recorded_location', - reminder_trigger_at: null, - reminder_offset_minutes: null, - reminder_strength: 'high', - }, - runtime: { - ...MOCK_RUNTIME, - next_trigger_at: null, - recorded_location: { latitude: 31.2304, longitude: 121.4737 }, - }, - status: 'active', - revision: 1, - cloud_revision: 1, - updated_at: '2026-08-06T09:00:00.000Z', - }, -]; diff --git a/frontend/src/features/reminder/domain/.gitkeep b/frontend/src/features/reminder/domain/.gitkeep deleted file mode 100644 index 8b137891..00000000 --- a/frontend/src/features/reminder/domain/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/frontend/src/features/reminder/domain/index.ts b/frontend/src/features/reminder/domain/index.ts index 86f87408..328cc015 100644 --- a/frontend/src/features/reminder/domain/index.ts +++ b/frontend/src/features/reminder/domain/index.ts @@ -35,3 +35,5 @@ export { resolveSnoozeUntil, resolveTimeTriggerAt, } from './timeWindow'; +export type { StrengthDeliveryPlan } from './strengthDelivery'; +export { resolveStrengthDeliveryPlan } from './strengthDelivery'; diff --git a/frontend/src/features/reminder/domain/reminder.ts b/frontend/src/features/reminder/domain/reminder.ts index c20b859d..58b4c215 100644 --- a/frontend/src/features/reminder/domain/reminder.ts +++ b/frontend/src/features/reminder/domain/reminder.ts @@ -73,12 +73,7 @@ export type LocalReminderSchedule = { }; export type ReminderTriggerReason = - | 'at_time' - | 'before_start' - | 'arrive_location' - | 'return_to_recorded_location' - | 'snooze_expired' - | 'mock'; + 'at_time' | 'before_start' | 'arrive_location' | 'return_to_recorded_location' | 'snooze_expired'; export type ReminderTrigger = { reminder_id: string; diff --git a/frontend/src/features/reminder/domain/strengthDelivery.ts b/frontend/src/features/reminder/domain/strengthDelivery.ts new file mode 100644 index 00000000..37a56ff1 --- /dev/null +++ b/frontend/src/features/reminder/domain/strengthDelivery.ts @@ -0,0 +1,35 @@ +import type { ReminderStrength } from './reminder'; + +/** 提醒强度对应的客户端送达通道组合。 */ +export type StrengthDeliveryPlan = { + useSystemNotification: boolean; + usePopup: boolean; + useVibration: boolean; + useAudio: boolean; +}; + +export function resolveStrengthDeliveryPlan(strength: ReminderStrength): StrengthDeliveryPlan { + switch (strength) { + case 'low': + return { + useSystemNotification: true, + usePopup: false, + useVibration: false, + useAudio: false, + }; + case 'medium': + return { + useSystemNotification: false, + usePopup: true, + useVibration: true, + useAudio: false, + }; + case 'high': + return { + useSystemNotification: false, + usePopup: true, + useVibration: true, + useAudio: true, + }; + } +} diff --git a/frontend/src/features/reminder/index.ts b/frontend/src/features/reminder/index.ts index 5548f3b5..3b376418 100644 --- a/frontend/src/features/reminder/index.ts +++ b/frontend/src/features/reminder/index.ts @@ -18,9 +18,27 @@ export type { ReminderTrigger, ReminderTriggerReason, ReminderType, + GeofenceTransition, + GeofenceWatchMode, + StrengthDeliveryPlan, +} from './domain'; +export { + DEFAULT_SNOOZE_MINUTES, + distanceMeters, + evaluateGeofence, + isSnoozeActive, + isSnoozeExpired, + isTimeWindowReached, + resolveEffectiveTriggerAt, + resolveGeofenceCenter, + resolveSnoozeUntil, + resolveStrengthDeliveryPlan, + resolveTimeTriggerAt, + resolveWatchMode, } from './domain'; -export { DEFAULT_SNOOZE_MINUTES } from './domain'; export type { + AlarmNativeDisposition, + AlarmNativeEvent, AlarmScheduleReceipt, AlarmScheduleRequest, AlarmSchedulerPort, @@ -34,9 +52,13 @@ export type { LocalTimeTick, LocationMonitorEvent, LocationMonitorPort, + LocationRebuildTarget, LocationWatchHandle, LocationWatchMode, LocationWatchRequest, + AlertDialogButton, + AlertDialogPort, + AlertDialogRequest, PopupPort, PopupReceipt, PopupRequest, @@ -64,11 +86,15 @@ export type { } from './application'; export { LocalReminderApplication } from './application'; export { - MockLocalScheduleReader, - MockReminderApplication, - MockReminderDispositionSync, - MockReminderStateStore, - MOCK_REMINDER_SCHEDULES, MemoryReminderStateStore, + InMemoryLocalScheduleReader, + SqliteLocalScheduleReader, + SqliteReminderStateStore, + LocalReminderDelivery, + LocalReminderDispositionSync, + LocalReminderRecovery, + LocalSystemNotification, + NoopPopup, } from './data/local'; -export { MockReminderPresenter, useReminderPermissionsOnLaunch } from './presentation'; +export { AlertReminderPresenter, useReminderPermissionsOnLaunch } from './presentation'; +export type { ReminderActionHandler, ReminderViewModel } from './presentation'; diff --git a/frontend/src/features/reminder/presentation/.gitkeep b/frontend/src/features/reminder/presentation/.gitkeep deleted file mode 100644 index 8b137891..00000000 --- a/frontend/src/features/reminder/presentation/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/frontend/src/features/reminder/presentation/AlertReminderPresenter.ts b/frontend/src/features/reminder/presentation/AlertReminderPresenter.ts new file mode 100644 index 00000000..a35306e9 --- /dev/null +++ b/frontend/src/features/reminder/presentation/AlertReminderPresenter.ts @@ -0,0 +1,76 @@ +import type { + ReminderPresentationAction, + ReminderPresentationReceipt, + ReminderPresenterPort, + AlertDialogPort, +} from '../application/interfaces'; +import type { ReminderDeliveryRequest } from '../domain'; + +const MESSAGE_BY_REASON: Record = { + arrive_location: '您已进入目标地点附近,请及时处理。', + return_to_recorded_location: '您已回到记录地点附近,请及时处理。', + at_time: '已到提醒时间,请及时处理。', + before_start: '日程即将开始,请及时处理。', + snooze_expired: '延后提醒时间已到,请及时处理。', +}; + +/** 提醒展示编排;平台 Alert 由注入的 AlertDialogPort 完成。 */ +export class AlertReminderPresenter implements ReminderPresenterPort { + private readonly actionListeners = new Set< + (event: { schedule_id: string; action: ReminderPresentationAction }) => void + >(); + private visibleScheduleId: string | null = null; + private readonly suppressed = new Set(); + + constructor(private readonly dialog: AlertDialogPort) {} + + async show(request: ReminderDeliveryRequest): Promise { + this.suppressed.delete(request.schedule_id); + this.visibleScheduleId = request.schedule_id; + const message = MESSAGE_BY_REASON[request.trigger.reason] ?? '日程提醒已触发,请及时处理。'; + + await this.dialog.show({ + title: request.title || '日程提醒', + message, + buttons: [ + { + text: '延后', + style: 'cancel', + onPress: () => this.emit(request.schedule_id, 'snooze'), + }, + { + text: '确认', + onPress: () => this.emit(request.schedule_id, 'confirm'), + }, + ], + }); + + return { + presentation_id: `alert-${request.schedule_id}`, + visible: true, + }; + } + + async hide(scheduleId: string): Promise { + this.suppressed.add(scheduleId); + if (this.visibleScheduleId === scheduleId) { + this.visibleScheduleId = null; + } + } + + onAction( + listener: (event: { schedule_id: string; action: ReminderPresentationAction }) => void, + ): () => void { + this.actionListeners.add(listener); + return () => { + this.actionListeners.delete(listener); + }; + } + + private emit(scheduleId: string, action: ReminderPresentationAction): void { + if (this.suppressed.has(scheduleId)) return; + for (const listener of this.actionListeners) { + listener({ schedule_id: scheduleId, action }); + } + } +} diff --git a/frontend/src/features/reminder/presentation/MockReminderPresenter.ts b/frontend/src/features/reminder/presentation/MockReminderPresenter.ts deleted file mode 100644 index 34bc0fc5..00000000 --- a/frontend/src/features/reminder/presentation/MockReminderPresenter.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type { - ReminderPresenterPort, - ReminderPresentationAction, - ReminderPresentationReceipt, -} from '../application/interfaces'; -import type { ReminderDeliveryRequest } from '../domain'; - -/** 应用级弹窗/悬浮层完成前使用的固定展示器。 */ -export class MockReminderPresenter implements ReminderPresenterPort { - async show(_request: ReminderDeliveryRequest): Promise { - return { - presentation_id: 'mock-presentation-001', - visible: true, - }; - } - - async hide(_scheduleId: string): Promise { - return Promise.resolve(); - } - - onAction( - _listener: (event: { schedule_id: string; action: ReminderPresentationAction }) => void, - ): () => void { - return () => undefined; - } -} diff --git a/frontend/src/features/reminder/presentation/index.ts b/frontend/src/features/reminder/presentation/index.ts index 90ea79ea..bd6e4fdc 100644 --- a/frontend/src/features/reminder/presentation/index.ts +++ b/frontend/src/features/reminder/presentation/index.ts @@ -1,3 +1,3 @@ -export { MockReminderPresenter } from './MockReminderPresenter'; +export { AlertReminderPresenter } from './AlertReminderPresenter'; export { useReminderPermissionsOnLaunch } from './useReminderPermissionsOnLaunch'; export type { ReminderActionHandler, ReminderViewModel } from './ReminderViewModel'; diff --git a/frontend/src/features/reminder/presentation/useReminderPermissionsOnLaunch.ts b/frontend/src/features/reminder/presentation/useReminderPermissionsOnLaunch.ts index a62a036d..ca37781d 100644 --- a/frontend/src/features/reminder/presentation/useReminderPermissionsOnLaunch.ts +++ b/frontend/src/features/reminder/presentation/useReminderPermissionsOnLaunch.ts @@ -1,9 +1,12 @@ import { useEffect, useRef } from 'react'; -import { Alert, AppState, type AppStateStatus, Platform } from 'react-native'; -import type { DeviceCapabilityPort, DevicePermission } from '../application/interfaces'; +import type { + AlertDialogPort, + DeviceCapabilityPort, + DevicePermission, +} from '../application/interfaces'; -const PERMISSION_ORDER: DevicePermission[] = [ +const ANDROID_ALARM_ORDER: DevicePermission[] = [ 'notifications', 'exact_alarm', 'overlay', @@ -11,6 +14,8 @@ const PERMISSION_ORDER: DevicePermission[] = [ 'battery_optimization', ]; +const LOCATION_ORDER: DevicePermission[] = ['location_foreground', 'location_background']; + const PERMISSION_PROMPTS: Partial> = { notifications: { title: '需要通知权限', @@ -32,31 +37,49 @@ const PERMISSION_PROMPTS: Partial = new Set(['notifications']); + +/** 精确闹钟没有系统授权框,只能跳设置页;先弹说明框会多一步点击,直接跳过去。 */ +const DIRECT_SETTINGS: ReadonlySet = new Set(['exact_alarm']); + +const LOCATION_PERMISSIONS: ReadonlySet = new Set([ + 'location_foreground', + 'location_background', +]); + /** - * 启动时逐项申请提醒相关权限;通过 DeviceCapabilityPort 访问平台, - * 不在 UI 层直接依赖 NativeModules。 + * 启动时逐项申请提醒相关权限;通过 DeviceCapabilityPort / AlertDialogPort 访问平台, + * 不在 UI 层直接依赖 react-native。 + * + * - Android(alarm 能力可用):闹钟相关权限 + 定位权限 + * - 其它平台 / alarm 不可用:仍申请定位权限,保证地点提醒链路可授权 + * + * @param onPermissionsUpdated 某项权限刚授权成功时回调(用于重建围栏/闹钟)。 */ -export function useReminderPermissionsOnLaunch(device: DeviceCapabilityPort | null): void { +export function useReminderPermissionsOnLaunch( + device: DeviceCapabilityPort | null, + dialog: AlertDialogPort | null, + onPermissionsUpdated?: () => void, +): void { const busyRef = useRef(false); const awaitingReturnRef = useRef(false); const skippedRef = useRef(new Set()); useEffect(() => { - if (Platform.OS !== 'android' || device == null) return; + if (device == null || dialog == null) return; let cancelled = false; - const timeouts = new Set>(); - - const schedule = (fn: () => void, ms: number) => { - const id = setTimeout(() => { - timeouts.delete(id); - if (cancelled) return; - fn(); - }, ms); - timeouts.add(id); - }; const runPrompt = () => { if (cancelled) return; @@ -66,90 +89,127 @@ export function useReminderPermissionsOnLaunch(device: DeviceCapabilityPort | nu }; const promptNext = async () => { - if (cancelled || busyRef.current) return; + if (busyRef.current || cancelled || awaitingReturnRef.current) return; busyRef.current = true; + let queueNext = false; try { const status = await device.getStatus(); - if (cancelled) return; - if (!status.supported) return; + if (status.platform === 'web' || status.platform === 'unknown') return; - const missing = PERMISSION_ORDER.find((permission) => { - if (skippedRef.current.has(permission)) return false; - return !status.permissions[permission]; - }); + const missing = nextMissingPermission(status); if (missing == null) return; const prompt = PERMISSION_PROMPTS[missing]; if (prompt == null) { skippedRef.current.add(missing); + queueNext = true; return; } - if (missing === 'notifications') { - let granted = false; - try { - granted = await device.requestPermission('notifications'); - } catch { - granted = false; + // 通知:直接系统授权框。 + if (DIRECT_REQUEST.has(missing)) { + const granted = await device.requestPermission(missing); + if (!granted) { + skippedRef.current.add(missing); + } else { + onPermissionsUpdated?.(); + } + queueNext = true; + return; + } + + // 精确闹钟没有系统授权框,只能跳设置页,先弹说明框只会多一次点击,直接跳过去。 + if (DIRECT_SETTINGS.has(missing)) { + awaitingReturnRef.current = true; + const opened = await device.openSettings(missing); + if (!opened) { + awaitingReturnRef.current = false; + skippedRef.current.add(missing); + queueNext = true; } - if (cancelled) return; - if (!granted) skippedRef.current.add('notifications'); - busyRef.current = false; - schedule(runPrompt, 350); return; } - const shouldAuthorize = await confirmAsync(prompt.title, prompt.message); - if (cancelled) return; + const shouldAuthorize = await confirmAsync(dialog, prompt.title, prompt.message); if (!shouldAuthorize) { skippedRef.current.add(missing); - } else { - let opened = false; - try { - opened = await device.openSettings(missing); - } catch { - opened = false; + queueNext = true; + return; + } + + // 定位:先等应用内说明框关掉,再申请系统权限;失败则跳转设置,避免“点了没反应还连弹”。 + if (LOCATION_PERMISSIONS.has(missing)) { + await delay(350); + const granted = await device.requestPermission(missing); + if (granted) { + onPermissionsUpdated?.(); + queueNext = true; + return; } - if (cancelled) return; - if (opened) { - awaitingReturnRef.current = true; - } else { + awaitingReturnRef.current = true; + const opened = await device.openSettings(missing); + if (!opened) { + awaitingReturnRef.current = false; skippedRef.current.add(missing); + queueNext = true; } + return; + } + + // 精确闹钟 / 悬浮窗等:跳转系统设置,回来后再继续。 + awaitingReturnRef.current = true; + const opened = await device.openSettings(missing); + if (!opened) { + awaitingReturnRef.current = false; + skippedRef.current.add(missing); + queueNext = true; } } finally { busyRef.current = false; } - if (!cancelled && !awaitingReturnRef.current) { - schedule(runPrompt, 200); + if (queueNext && !awaitingReturnRef.current) { + setTimeout(runPrompt, 250); } }; - const onAppStateChange = (state: AppStateStatus) => { - if (cancelled) return; - if (state !== 'active') return; + const nextMissingPermission = ( + status: Awaited>, + ): DevicePermission | null => { + if (status.platform === 'android' && status.supported) { + const alarmMissing = ANDROID_ALARM_ORDER.find((permission) => { + if (skippedRef.current.has(permission)) return false; + return !status.permissions[permission]; + }); + if (alarmMissing != null) return alarmMissing; + } + + return ( + LOCATION_ORDER.find((permission) => { + if (skippedRef.current.has(permission)) return false; + return !status.permissions[permission]; + }) ?? null + ); + }; + + const unsubscribe = device.onAppActive(() => { if (!awaitingReturnRef.current) return; awaitingReturnRef.current = false; - schedule(runPrompt, 300); - }; + onPermissionsUpdated?.(); + setTimeout(runPrompt, 300); + }); - schedule(runPrompt, 600); - const subscription = AppState.addEventListener('change', onAppStateChange); + const timer = setTimeout(runPrompt, 600); return () => { cancelled = true; - awaitingReturnRef.current = false; - for (const id of timeouts) { - clearTimeout(id); - } - timeouts.clear(); - subscription.remove(); + clearTimeout(timer); + unsubscribe(); }; - }, [device]); + }, [device, dialog, onPermissionsUpdated]); } -function confirmAsync(title: string, message: string): Promise { +function confirmAsync(dialog: AlertDialogPort, title: string, message: string): Promise { return new Promise((resolve) => { let settled = false; const finish = (value: boolean) => { @@ -157,14 +217,19 @@ function confirmAsync(title: string, message: string): Promise { settled = true; resolve(value); }; - Alert.alert( + void dialog.show({ title, message, - [ + cancelable: true, + onDismiss: () => finish(false), + buttons: [ { text: '暂不', style: 'cancel', onPress: () => finish(false) }, { text: '去授权', onPress: () => finish(true) }, ], - { cancelable: true, onDismiss: () => finish(false) }, - ); + }); }); } + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/frontend/src/infrastructure/audio/.gitkeep b/frontend/src/infrastructure/audio/.gitkeep deleted file mode 100644 index 8b137891..00000000 --- a/frontend/src/infrastructure/audio/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/frontend/src/infrastructure/audio/ExpoAudioPlayback.ts b/frontend/src/infrastructure/audio/ExpoAudioPlayback.ts new file mode 100644 index 00000000..548922e2 --- /dev/null +++ b/frontend/src/infrastructure/audio/ExpoAudioPlayback.ts @@ -0,0 +1,147 @@ +import type { + AudioPlaybackPort, + AudioPlaybackReceipt, + AudioPlaybackRequest, +} from '../../features/reminder/application/interfaces'; +import { buildAudioDataUri } from './audioDataUri'; + +// Metro 资源 id;高强度本地兜底铃声(与原生 AlarmSoundService 同源)。 +const LOCAL_ALARM_SOUND = require('../../../assets/sounds/alarm_prompt.mp3') as number; + +type AudioPlayerLike = { + pause: () => void; + replace: (source: string | number) => void; + play: () => void; + volume: number; + loop: boolean; +}; + +type ExpoAudioModule = { + createAudioPlayer: (source?: string | number | null) => AudioPlayerLike; + setAudioModeAsync: (mode: Record) => Promise; +}; + +/** + * 音频播放适配器: + * - TTS 有字节时播放并循环,直到 stop + * - 否则播放打包的 alarm_prompt.mp3(高强度本地兜底) + */ +export class ExpoAudioPlayback implements AudioPlaybackPort { + private player: AudioPlayerLike | null = null; + private activeScheduleId: string | null = null; + private modeReady: Promise | null = null; + + async isTtsAvailable(): Promise { + // TTS 字节管线尚未接入;有 data 时由 playTts 直接播放。 + return false; + } + + async playTts(request: AudioPlaybackRequest): Promise { + if (request.data == null || request.data.byteLength === 0) { + return { + playback_id: `tts-empty-${request.schedule_id}`, + played: false, + used_local_fallback: false, + }; + } + const played = await this.playBytes(request.schedule_id, request.data, request.format ?? 'wav'); + return { + playback_id: `tts-${request.schedule_id}`, + played, + used_local_fallback: false, + }; + } + + async playLocalFallback(request: AudioPlaybackRequest): Promise { + if (request.data != null && request.data.byteLength > 0) { + const played = await this.playBytes( + request.schedule_id, + request.data, + request.format ?? 'wav', + ); + return { + playback_id: `local-${request.schedule_id}`, + played, + used_local_fallback: true, + }; + } + + const played = await this.playBundledAlarm(request.schedule_id); + return { + playback_id: `local-bundled-${request.schedule_id}`, + played, + used_local_fallback: true, + }; + } + + async stop(scheduleId: string): Promise { + if (this.activeScheduleId !== scheduleId) return; + if (this.player != null) { + this.player.loop = false; + this.player.pause(); + } + this.activeScheduleId = null; + } + + private async playBytes(scheduleId: string, data: Uint8Array, format: string): Promise { + const expoAudio = await loadExpoAudio(); + if (expoAudio == null) return false; + + await this.ensureAudioMode(expoAudio); + const player = this.ensurePlayer(expoAudio); + player.pause(); + player.replace(buildAudioDataUri(data, format)); + player.loop = true; + player.volume = 1; + this.activeScheduleId = scheduleId; + player.play(); + return true; + } + + private async playBundledAlarm(scheduleId: string): Promise { + const expoAudio = await loadExpoAudio(); + if (expoAudio == null) return false; + + await this.ensureAudioMode(expoAudio); + const player = this.ensurePlayer(expoAudio); + player.pause(); + player.replace(LOCAL_ALARM_SOUND); + player.loop = true; + player.volume = 1; + this.activeScheduleId = scheduleId; + player.play(); + return true; + } + + private ensurePlayer(expoAudio: ExpoAudioModule): AudioPlayerLike { + if (this.player == null) { + this.player = expoAudio.createAudioPlayer(null); + } + return this.player; + } + + private async ensureAudioMode(expoAudio: ExpoAudioModule): Promise { + if (this.modeReady == null) { + this.modeReady = expoAudio + .setAudioModeAsync({ + allowsRecording: false, + interruptionMode: 'doNotMix', + playsInSilentMode: true, + shouldPlayInBackground: true, + shouldRouteThroughEarpiece: false, + }) + .catch(() => undefined); + } + await this.modeReady; + } +} + +async function loadExpoAudio(): Promise { + try { + const mod = await import('expo-audio'); + if (typeof mod.createAudioPlayer !== 'function') return null; + return mod as unknown as ExpoAudioModule; + } catch { + return null; + } +} diff --git a/frontend/src/infrastructure/audio/MockAudioPlayback.ts b/frontend/src/infrastructure/audio/MockAudioPlayback.ts deleted file mode 100644 index f59131bc..00000000 --- a/frontend/src/infrastructure/audio/MockAudioPlayback.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { - AudioPlaybackPort, - AudioPlaybackReceipt, - AudioPlaybackRequest, -} from '../../features/reminder/application/interfaces'; - -/** 固定音频能力:远程语音合成不可用,本地兜底音效可用。 */ -export class MockAudioPlayback implements AudioPlaybackPort { - async isTtsAvailable(): Promise { - return false; - } - - async playTts(_request: AudioPlaybackRequest): Promise { - return { - playback_id: 'mock-tts-playback-001', - played: false, - used_local_fallback: false, - }; - } - - async playLocalFallback(_request: AudioPlaybackRequest): Promise { - return { - playback_id: 'mock-local-sound-001', - played: true, - used_local_fallback: true, - }; - } - - async stop(_scheduleId: string): Promise { - return Promise.resolve(); - } -} diff --git a/frontend/src/infrastructure/audio/audioDataUri.ts b/frontend/src/infrastructure/audio/audioDataUri.ts new file mode 100644 index 00000000..94976c5d --- /dev/null +++ b/frontend/src/infrastructure/audio/audioDataUri.ts @@ -0,0 +1,39 @@ +const BASE64_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; + +const MIME_BY_FORMAT: Record = { + aac: 'audio/aac', + m4a: 'audio/mp4', + mp3: 'audio/mpeg', + mpeg: 'audio/mpeg', + oga: 'audio/ogg', + ogg: 'audio/ogg', + wav: 'audio/wav', + wave: 'audio/wav', +}; + +export function encodeBase64(bytes: Uint8Array): string { + let output = ''; + for (let index = 0; index < bytes.length; index += 3) { + const first = bytes[index] ?? 0; + const hasSecond = index + 1 < bytes.length; + const hasThird = index + 2 < bytes.length; + const second = hasSecond ? bytes[index + 1]! : 0; + const third = hasThird ? bytes[index + 2]! : 0; + const value = (first << 16) | (second << 8) | third; + + output += BASE64_ALPHABET[(value >>> 18) & 63]; + output += BASE64_ALPHABET[(value >>> 12) & 63]; + output += hasSecond ? BASE64_ALPHABET[(value >>> 6) & 63] : '='; + output += hasThird ? BASE64_ALPHABET[value & 63] : '='; + } + return output; +} + +export function buildAudioDataUri(bytes: Uint8Array, audioFormat: string): string { + const normalizedFormat = audioFormat.trim().toLowerCase().replace(/^\./, ''); + if (!/^[a-z0-9][a-z0-9.+-]{0,31}$/.test(normalizedFormat)) { + throw new Error('Unsupported reminder audio format'); + } + const mime = MIME_BY_FORMAT[normalizedFormat] ?? `audio/${normalizedFormat}`; + return `data:${mime};base64,${encodeBase64(bytes)}`; +} diff --git a/frontend/src/infrastructure/audio/index.ts b/frontend/src/infrastructure/audio/index.ts index 8cc9460f..48529eb5 100644 --- a/frontend/src/infrastructure/audio/index.ts +++ b/frontend/src/infrastructure/audio/index.ts @@ -1 +1,2 @@ -export { MockAudioPlayback } from './MockAudioPlayback'; +export { ExpoAudioPlayback } from './ExpoAudioPlayback'; +export { buildAudioDataUri, encodeBase64 } from './audioDataUri'; diff --git a/frontend/src/infrastructure/location/.gitkeep b/frontend/src/infrastructure/location/.gitkeep deleted file mode 100644 index 8b137891..00000000 --- a/frontend/src/infrastructure/location/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/frontend/src/infrastructure/location/ExpoLocationMonitor.ts b/frontend/src/infrastructure/location/ExpoLocationMonitor.ts new file mode 100644 index 00000000..12a2081f --- /dev/null +++ b/frontend/src/infrastructure/location/ExpoLocationMonitor.ts @@ -0,0 +1,253 @@ +import * as Location from 'expo-location'; +import { AppState, type AppStateStatus, type NativeEventSubscription } from 'react-native'; + +import type { + LocationMonitorEvent, + LocationMonitorPort, + LocationRebuildTarget, + LocationWatchHandle, + LocationWatchRequest, +} from '../../features/reminder/application/interfaces'; +import type { LocationSample } from '../../features/reminder/domain'; + +import { + GEOFENCE_TASK_NAME, + drainPendingGeofenceEvents, + subscribeGeofenceTaskEvents, + type GeofenceTaskPayload, +} from './geofenceTask'; +import type { LocationProvider } from './LocationProvider'; + +type ActiveWatch = { + listener_id: string; + request: LocationWatchRequest; + listener: (event: LocationMonitorEvent) => void; +}; + +/** ≈1.1km,足够超出任何合理的围栏半径,用来给 exit 事件合成一个"明显在圈外"的采样点。 */ +const OUTSIDE_OFFSET_DEGREES = 0.01; + +/** + * 基于 expo-location 系统原生地理围栏(Android GeofencingClient / iOS + * CLCircularRegion)的适配器:不依赖百度账号/Key,围栏进出判断交给系统,比 + * NativeLocationMonitor 那套连续定位轮询 + 应用侧 Haversine 更省电、后台也更可靠。 + * + * 系统只会在真正穿越边界时回调 enter/exit,不会在注册那一刻告诉你当前在圈内还是圈 + * 外,所以 watch() 里仍然主动取一次当前定位、强制送一条初始采样,让 + * LocalReminderApplication 的 armed 状态机能定出正确的起始值;之后的进出全靠 + * geofenceTask 的系统回调,不再轮询。 + * + * LocalReminderApplication.applyLocationSample() 不看这里传的 phase,只用 sample + * 坐标自己重新跑一遍 Haversine 判断,所以 enter/exit 时合成的坐标(区域中心 / + * 中心外一段距离)足够,不需要为了拿"真实"坐标再多发一次定位请求。 + */ +export class ExpoLocationMonitor implements LocationMonitorPort, LocationProvider { + private readonly watches = new Map(); + private readonly scheduleToListener = new Map(); + private lastSample: LocationSample | null = null; + private syncChain: Promise = Promise.resolve(); + private unsubscribeTask: (() => void) | null; + private readonly appStateSub: NativeEventSubscription; + + constructor() { + this.unsubscribeTask = subscribeGeofenceTaskEvents(this.handleTaskEvent); + this.appStateSub = AppState.addEventListener('change', this.handleAppState); + } + + async watch( + request: LocationWatchRequest, + listener: (event: LocationMonitorEvent) => void, + ): Promise { + const existingId = this.scheduleToListener.get(request.schedule_id); + if (existingId != null) { + await this.removeWatch(existingId, false); + } + + const listener_id = `location-${request.schedule_id}`; + this.watches.set(listener_id, { listener_id, request: { ...request }, listener }); + this.scheduleToListener.set(request.schedule_id, listener_id); + await this.enqueueSync(); + + const sample = await this.getCurrentSample(); + if (sample != null) { + listener({ schedule_id: request.schedule_id, sample, phase: 'inside' }); + } + await this.replayPendingEvents(); + + return { listener_id, schedule_id: request.schedule_id }; + } + + async unwatch(listenerId: string): Promise { + await this.removeWatch(listenerId, true); + } + + async rebuild( + targets: readonly LocationRebuildTarget[], + listener: (event: LocationMonitorEvent) => void, + ): Promise { + for (const listenerId of [...this.watches.keys()]) { + await this.removeWatch(listenerId, false); + } + + // 直接灌 Map,不逐个调用 watch():watch() 自己的 enqueueSync()+getCurrentSample() + // 是为单条增量注册设计的,N 个 target 各跑一次会把 startGeofencingAsync 的区域 + // 列表越注册越长(O(N²) 总区域数)、定位请求也打 N 次;这里全部灌完只同步一次、 + // 取一次定位,再扇给每个刚注册的 watch。 + const handles: LocationWatchHandle[] = []; + for (const target of targets) { + const listener_id = `location-${target.schedule_id}`; + this.watches.set(listener_id, { + listener_id, + request: { + schedule_id: target.schedule_id, + center: target.center, + radius_meters: target.radius_meters, + mode: target.mode, + background: target.background, + }, + listener, + }); + this.scheduleToListener.set(target.schedule_id, listener_id); + handles.push({ listener_id, schedule_id: target.schedule_id }); + } + await this.enqueueSync(); + + const sample = await this.getCurrentSample(); + if (sample != null) { + for (const handle of handles) { + listener({ schedule_id: handle.schedule_id, sample, phase: 'inside' }); + } + } + + // App 进程被杀掉期间,headless task 攒下的围栏事件在这里补上——watches/ + // scheduleToListener 刚灌好,handleTaskEvent 才查得到对应的 watch。 + await this.replayPendingEvents(); + + return handles; + } + + async getLastSample(): Promise { + return this.lastSample; + } + + async getCurrentSample(): Promise { + try { + const { status } = await Location.getForegroundPermissionsAsync(); + if (status !== 'granted') return this.lastSample; + const position = await Location.getCurrentPositionAsync({}); + const sample = toSample(position); + this.lastSample = sample; + return sample; + } catch { + return this.lastSample; + } + } + + dispose(): void { + this.unsubscribeTask?.(); + this.unsubscribeTask = null; + this.appStateSub.remove(); + void Location.hasStartedGeofencingAsync(GEOFENCE_TASK_NAME) + .then((started) => (started ? Location.stopGeofencingAsync(GEOFENCE_TASK_NAME) : undefined)) + .catch(() => undefined); + } + + private readonly handleAppState = (state: AppStateStatus): void => { + if (state !== 'active' || this.watches.size === 0) return; + void this.enqueueSync(); + }; + + /** 补上 headless task 期间(没有订阅者时)攒下的围栏事件,按正常路径重放一遍。 */ + private async replayPendingEvents(): Promise { + const pending = await drainPendingGeofenceEvents(); + for (const payload of pending) { + this.handleTaskEvent(payload); + } + } + + private readonly handleTaskEvent = (payload: GeofenceTaskPayload): void => { + const listenerId = this.scheduleToListener.get(payload.schedule_id); + const watch = listenerId != null ? this.watches.get(listenerId) : undefined; + if (watch == null) return; + + const sample: LocationSample = + payload.event === 'enter' + ? { + latitude: payload.latitude, + longitude: payload.longitude, + accuracy_meters: payload.radius, + observed_at: payload.observed_at, + } + : { + latitude: payload.latitude + OUTSIDE_OFFSET_DEGREES, + longitude: payload.longitude, + accuracy_meters: payload.radius, + observed_at: payload.observed_at, + }; + this.lastSample = sample; + watch.listener({ + schedule_id: watch.request.schedule_id, + sample, + phase: payload.event === 'enter' ? 'entered' : 'left', + }); + }; + + private async removeWatch(listenerId: string, sync: boolean): Promise { + const watch = this.watches.get(listenerId); + if (watch == null) return; + this.watches.delete(listenerId); + this.scheduleToListener.delete(watch.request.schedule_id); + if (sync) { + await this.enqueueSync(); + } + } + + private enqueueSync(): Promise { + this.syncChain = this.syncChain.then( + () => this.syncRegions(), + () => this.syncRegions(), + ); + return this.syncChain; + } + + private async syncRegions(): Promise { + if (this.watches.size === 0) { + const started = await Location.hasStartedGeofencingAsync(GEOFENCE_TASK_NAME).catch( + () => false, + ); + if (started) { + await Location.stopGeofencingAsync(GEOFENCE_TASK_NAME).catch(() => undefined); + } + return; + } + + const { status: foreground } = await Location.getForegroundPermissionsAsync(); + if (foreground !== 'granted') return; + const { status: background } = await Location.getBackgroundPermissionsAsync(); + if (background !== 'granted') return; + + const regions: Location.LocationRegion[] = [...this.watches.values()].map((watch) => ({ + identifier: watch.request.schedule_id, + latitude: watch.request.center.latitude, + longitude: watch.request.center.longitude, + radius: watch.request.radius_meters, + notifyOnEnter: true, + notifyOnExit: true, + })); + + try { + await Location.startGeofencingAsync(GEOFENCE_TASK_NAME, regions); + } catch { + // 系统围栏注册失败(比如权限被收回);下次 watch()/rebuild() 会再重试。 + } + } +} + +function toSample(position: Location.LocationObject): LocationSample { + return { + latitude: position.coords.latitude, + longitude: position.coords.longitude, + accuracy_meters: position.coords.accuracy ?? 0, + observed_at: new Date(position.timestamp).toISOString(), + }; +} diff --git a/frontend/src/infrastructure/location/ExpoLocationProvider.ts b/frontend/src/infrastructure/location/ExpoLocationProvider.ts index f9907df1..c0ce1b77 100644 --- a/frontend/src/infrastructure/location/ExpoLocationProvider.ts +++ b/frontend/src/infrastructure/location/ExpoLocationProvider.ts @@ -4,27 +4,76 @@ import type { LocationSample } from '../../features/reminder/domain'; import type { LocationProvider } from './LocationProvider'; +const LAST_KNOWN_MAX_AGE_MS = 60_000; +const LAST_KNOWN_REQUIRED_ACCURACY_METERS = 200; + /** - * 真实定位实现。权限被拒绝或定位失败都返回 null 而不是抛错——调用方(目前是 - * session.hello 的位置字段)拿不到就不带这个字段,不应该因为定位失败连不上。 + * 真实定位实现。语音握手优先使用短时间内的缓存位置,避免等待 GPS 冷启动;同时 + * 在后台刷新当前位置,让下一次连接获得更新的位置。权限被拒绝或定位失败都返回 + * null 而不是抛错,调用方拿不到就不带 session.hello 的位置字段,不影响连通性。 */ export class ExpoLocationProvider implements LocationProvider { + private freshSampleInFlight: Promise | null = null; + async getCurrentSample(): Promise { const { status } = await Location.requestForegroundPermissionsAsync(); if (status !== 'granted') { + console.warn('[location-search] foreground location permission is unavailable', { status }); return null; } + const cached = await this.getRecentSample(); + if (cached !== null) { + void this.getFreshSample(); + return cached; + } + + return this.getFreshSample(); + } + + private async getRecentSample(): Promise { try { - const position = await Location.getCurrentPositionAsync({}); - return { - accuracy_meters: position.coords.accuracy ?? 0, - latitude: position.coords.latitude, - longitude: position.coords.longitude, - observed_at: new Date(position.timestamp).toISOString(), - }; - } catch { + const position = await Location.getLastKnownPositionAsync({ + maxAge: LAST_KNOWN_MAX_AGE_MS, + requiredAccuracy: LAST_KNOWN_REQUIRED_ACCURACY_METERS, + }); + return position === null ? null : toSample(position); + } catch (error) { + console.warn('[location-search] failed to read cached location', { + errorType: error instanceof Error ? error.name : typeof error, + }); return null; } } + + private getFreshSample(): Promise { + if (this.freshSampleInFlight !== null) { + return this.freshSampleInFlight; + } + + const request = Location.getCurrentPositionAsync({}) + .then(toSample) + .catch((error) => { + console.warn('[location-search] failed to acquire current location', { + errorType: error instanceof Error ? error.name : typeof error, + }); + return null; + }); + this.freshSampleInFlight = request; + void request.finally(() => { + if (this.freshSampleInFlight === request) { + this.freshSampleInFlight = null; + } + }); + return request; + } +} + +function toSample(position: Location.LocationObject): LocationSample { + return { + accuracy_meters: position.coords.accuracy ?? 0, + latitude: position.coords.latitude, + longitude: position.coords.longitude, + observed_at: new Date(position.timestamp).toISOString(), + }; } diff --git a/frontend/src/infrastructure/location/LocationProvider.ts b/frontend/src/infrastructure/location/LocationProvider.ts index 84933700..723c463a 100644 --- a/frontend/src/infrastructure/location/LocationProvider.ts +++ b/frontend/src/infrastructure/location/LocationProvider.ts @@ -1,6 +1,6 @@ -import type { LocationSample } from '../../features/reminder/domain'; +import type { LocationObservation } from '../../contracts/reminder'; /** 获取一次当前位置样本的平台适配器。 */ export interface LocationProvider { - getCurrentSample(): Promise; + getCurrentSample(): Promise; } diff --git a/frontend/src/infrastructure/location/MockLocationMonitor.ts b/frontend/src/infrastructure/location/MockLocationMonitor.ts deleted file mode 100644 index b5fb4e65..00000000 --- a/frontend/src/infrastructure/location/MockLocationMonitor.ts +++ /dev/null @@ -1,57 +0,0 @@ -import type { - LocationMonitorEvent, - LocationMonitorPort, - LocationWatchHandle, - LocationWatchRequest, -} from '../../features/reminder/application/interfaces'; -import type { LocalReminderSchedule, LocationSample } from '../../features/reminder/domain'; - -import type { LocationProvider } from './LocationProvider'; - -const MOCK_SAMPLE: LocationSample = { - latitude: 31.2304, - longitude: 121.4737, - accuracy_meters: 12, - observed_at: '2026-08-07T01:00:00.000Z', -}; - -function isLocationSchedule(schedule: LocalReminderSchedule): boolean { - return schedule.schedule_type === 'location' && schedule.status === 'active'; -} - -/** 固定定位能力适配器,不访问平台定位接口。 */ -export class MockLocationMonitor implements LocationMonitorPort, LocationProvider { - async watch( - request: LocationWatchRequest, - _listener: (event: LocationMonitorEvent) => void, - ): Promise { - return { - listener_id: `mock-location-listener-${request.schedule_id}`, - schedule_id: request.schedule_id, - }; - } - - async unwatch(_listenerId: string): Promise { - return Promise.resolve(); - } - - async rebuild( - schedules: readonly LocalReminderSchedule[], - _listener: (event: LocationMonitorEvent) => void, - ): Promise { - return schedules.filter(isLocationSchedule).map((schedule) => ({ - listener_id: `mock-location-listener-${schedule.id}`, - schedule_id: schedule.id, - })); - } - - async getLastSample(): Promise { - return { ...MOCK_SAMPLE }; - } - - async getCurrentSample(): Promise { - return { ...MOCK_SAMPLE }; - } -} - -export { MOCK_SAMPLE as MOCK_LOCATION_SAMPLE }; diff --git a/frontend/src/infrastructure/location/NativeLocationMonitor.ts b/frontend/src/infrastructure/location/NativeLocationMonitor.ts new file mode 100644 index 00000000..323fc535 --- /dev/null +++ b/frontend/src/infrastructure/location/NativeLocationMonitor.ts @@ -0,0 +1,223 @@ +import { AppState, type AppStateStatus, type NativeEventSubscription } from 'react-native'; + +import type { + LocationMonitorEvent, + LocationMonitorPort, + LocationRebuildTarget, + LocationWatchHandle, + LocationWatchRequest, +} from '../../features/reminder/application/interfaces'; +import type { LocationSample } from '../../features/reminder/domain'; +import { distanceMeters } from '../../features/reminder/domain/geofence'; +import type { LocationObservation } from '../../contracts/reminder'; + +import type { LocationProvider } from './LocationProvider'; +import { + baiduGetCurrentPosition, + baiduInit, + baiduStartUpdating, + baiduStopUpdating, + isBaiduLocationAvailable, + subscribeBaiduLocation, + type BaiduLocationSample, +} from './native/BaiduLocationBridge'; + +type ActiveWatch = { + listener_id: string; + request: LocationWatchRequest; + listener: (event: LocationMonitorEvent) => void; + inside: boolean | null; +}; + +/** + * 基于百度定位 SDK 的围栏/定位适配器: + * - 连续定位采样(非 Google Geofencing) + * - 应用侧 Haversine 计算进出圈边沿 + */ +export class NativeLocationMonitor implements LocationMonitorPort, LocationProvider { + private readonly watches = new Map(); + private readonly scheduleToListener = new Map(); + private lastSample: LocationSample | null = null; + private syncChain: Promise = Promise.resolve(); + private unsubscribeLocation: (() => void) | null = null; + private started = false; + private readonly appStateSub: NativeEventSubscription; + + constructor() { + this.appStateSub = AppState.addEventListener('change', this.handleAppState); + } + + async watch( + request: LocationWatchRequest, + listener: (event: LocationMonitorEvent) => void, + ): Promise { + const existingId = this.scheduleToListener.get(request.schedule_id); + if (existingId != null) { + await this.removeWatch(existingId, false); + } + + const listener_id = `location-${request.schedule_id}`; + this.watches.set(listener_id, { + listener_id, + request: { ...request }, + listener, + inside: null, + }); + this.scheduleToListener.set(request.schedule_id, listener_id); + await this.enqueueSync(); + + const sample = await this.getCurrentSample(); + const watch = this.watches.get(listener_id); + if (sample != null && watch != null) { + this.emitForWatch(watch, sample, /*forcePhase*/ true); + } + + return { listener_id, schedule_id: request.schedule_id }; + } + + async unwatch(listenerId: string): Promise { + await this.removeWatch(listenerId, true); + } + + async rebuild( + targets: readonly LocationRebuildTarget[], + listener: (event: LocationMonitorEvent) => void, + ): Promise { + for (const listenerId of [...this.watches.keys()]) { + await this.removeWatch(listenerId, false); + } + await this.enqueueSync(); + + const handles: LocationWatchHandle[] = []; + for (const target of targets) { + handles.push( + await this.watch( + { + schedule_id: target.schedule_id, + center: target.center, + radius_meters: target.radius_meters, + mode: target.mode, + background: target.background, + }, + listener, + ), + ); + } + return handles; + } + + async getLastSample(): Promise { + return this.lastSample; + } + + async getCurrentSample(): Promise { + if (!isBaiduLocationAvailable()) return this.lastSample; + try { + if (!this.started) { + await baiduInit(null); + } + const current = await baiduGetCurrentPosition(); + if (current == null) return this.lastSample; + const sample = toSample(current); + this.lastSample = sample; + return sample; + } catch { + return this.lastSample; + } + } + + dispose(): void { + this.unsubscribeLocation?.(); + this.unsubscribeLocation = null; + this.appStateSub.remove(); + void baiduStopUpdating(); + this.started = false; + } + + private readonly handleAppState = (state: AppStateStatus): void => { + if (state !== 'active' || this.watches.size === 0) return; + void this.enqueueSync(); + }; + + private async removeWatch(listenerId: string, sync: boolean): Promise { + const watch = this.watches.get(listenerId); + if (watch == null) return; + this.watches.delete(listenerId); + this.scheduleToListener.delete(watch.request.schedule_id); + if (sync) { + await this.enqueueSync(); + } + } + + private enqueueSync(): Promise { + this.syncChain = this.syncChain.then( + () => this.syncMonitoring(), + () => this.syncMonitoring(), + ); + return this.syncChain; + } + + private async syncMonitoring(): Promise { + if (this.watches.size === 0) { + this.unsubscribeLocation?.(); + this.unsubscribeLocation = null; + if (this.started) { + await baiduStopUpdating(); + this.started = false; + } + return; + } + + if (!isBaiduLocationAvailable()) { + return; + } + + if (this.unsubscribeLocation == null) { + this.unsubscribeLocation = subscribeBaiduLocation((payload) => { + const sample = toSample(payload); + this.lastSample = sample; + for (const watch of this.watches.values()) { + this.emitForWatch(watch, sample, false); + } + }); + } + + await baiduInit(null); + await baiduStartUpdating(5_000); + this.started = true; + } + + private emitForWatch(watch: ActiveWatch, sample: LocationSample, forcePhase: boolean): void { + const inside = distanceMeters(watch.request.center, sample) <= watch.request.radius_meters; + const previous = watch.inside; + watch.inside = inside; + + let phase: LocationMonitorEvent['phase']; + if (previous == null || forcePhase) { + phase = inside ? 'inside' : 'outside'; + } else if (previous === inside) { + phase = inside ? 'inside' : 'outside'; + } else { + phase = inside ? 'entered' : 'left'; + } + + if (!forcePhase && previous === inside) { + return; + } + + watch.listener({ + schedule_id: watch.request.schedule_id, + sample, + phase, + }); + } +} + +function toSample(payload: BaiduLocationSample): LocationSample { + return { + latitude: payload.latitude, + longitude: payload.longitude, + accuracy_meters: payload.accuracy ?? 0, + observed_at: payload.observedAt || new Date().toISOString(), + }; +} diff --git a/frontend/src/infrastructure/location/geofenceTask.ts b/frontend/src/infrastructure/location/geofenceTask.ts new file mode 100644 index 00000000..4d1f3239 --- /dev/null +++ b/frontend/src/infrastructure/location/geofenceTask.ts @@ -0,0 +1,258 @@ +import * as Location from 'expo-location'; +import type { SQLiteDatabase } from 'expo-sqlite'; +import * as TaskManager from 'expo-task-manager'; + +export const GEOFENCE_TASK_NAME = 'timeflow-geofence'; + +export type GeofenceTaskPayload = { + schedule_id: string; + event: 'enter' | 'exit'; + latitude: number; + longitude: number; + radius: number; + observed_at: string; +}; + +type GeofenceTaskListener = (payload: GeofenceTaskPayload) => void; + +const listeners = new Set(); + +/** 没有订阅者时(App 进程已死,Expo 只把 JS 引擎拉起来跑这一个 headless task, + * AppRoot/ExpoLocationMonitor 那套 React 生命周期根本没启动),通知失败的事件落这里, + * 等真正的会话起来后由 drainPendingGeofenceEvents() 取走重放,而不是直接丢掉。 */ +const PENDING_EVENTS_KEY = 'timeflow-pending-geofence-events'; +const TIMEFLOW_DATABASE_NAME = 'timeflow.db'; + +type HeadlessScheduleRow = { + id: string; + title: string; + location_name: string | null; + schedule_type: string; + reminder_type: string | null; + reminder_disposition_state: string | null; + snoozed_until: string | null; + geofence_armed: number; + status: string; +}; + +/** 订阅系统围栏任务回调;须在应用入口尽早 import 本模块以完成 defineTask。 */ +export function subscribeGeofenceTaskEvents(listener: GeofenceTaskListener): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +/** 懒加载:expo-sqlite/kv-store 的默认导出是模块加载时就构造的单例,顶层 import + * 会在测试环境(没有真实原生模块)里直接抛错——这个仓库里原生相关的按需依赖 + * 一律走动态 import,参照 ExpoAudioPlayback.ts 的 loadExpoAudio()。 */ +async function loadStorage(): Promise { + try { + const mod = await import('expo-sqlite/kv-store'); + return mod.Storage; + } catch { + return null; + } +} + +/** 取出并清空 headless 期间攒下的围栏事件;调用方负责按订阅时的逻辑重放它们。 */ +export async function drainPendingGeofenceEvents(): Promise { + const storage = await loadStorage(); + if (storage == null) return []; + const raw = await storage.getItem(PENDING_EVENTS_KEY); + if (raw == null) return []; + await storage.removeItem(PENDING_EVENTS_KEY); + try { + const parsed: unknown = JSON.parse(raw); + return Array.isArray(parsed) ? (parsed as GeofenceTaskPayload[]) : []; + } catch { + return []; + } +} + +async function persistPendingEvent(payload: GeofenceTaskPayload): Promise { + const storage = await loadStorage(); + if (storage == null) return; + const raw = await storage.getItem(PENDING_EVENTS_KEY); + const pending: GeofenceTaskPayload[] = []; + if (raw != null) { + try { + const parsed: unknown = JSON.parse(raw); + if (Array.isArray(parsed)) pending.push(...(parsed as GeofenceTaskPayload[])); + } catch { + // 上一份坏了就当没有,不阻塞这次事件的持久化。 + } + } + pending.push(payload); + await storage.setItem(PENDING_EVENTS_KEY, JSON.stringify(pending)); +} + +async function emit(payload: GeofenceTaskPayload): Promise { + if (listeners.size === 0) { + let delivered = false; + try { + delivered = await deliverHeadlessGeofenceEvent(payload); + } catch { + delivered = false; + } + if (!delivered) { + await persistPendingEvent(payload); + } + return; + } + for (const listener of listeners) { + listener(payload); + } +} + +/** + * Deliver a notification while Expo has only started this headless task. + * + * AppRoot/LocalReminderApplication is not alive in this path, so the task must keep + * the same edge-triggered armed state in SQLite instead of notifying on every enter. + * Returning false leaves the event in the existing pending queue for replay when the + * normal application session starts. + */ +async function deliverHeadlessGeofenceEvent(payload: GeofenceTaskPayload): Promise { + const database = await openHeadlessDatabase(); + if (database == null) return false; + + if (payload.event === 'exit') { + await database.runAsync( + `UPDATE local_schedules + SET geofence_armed = 1 + WHERE id = ? + AND status = 'active' + AND schedule_type = 'location' + AND geofence_armed = 0`, + payload.schedule_id, + ); + return true; + } + + const schedule = await database.getFirstAsync( + `SELECT id, title, location_name, schedule_type, reminder_type, + reminder_disposition_state, snoozed_until, geofence_armed, status + FROM local_schedules + WHERE id = ?`, + payload.schedule_id, + ); + if ( + schedule == null || + schedule.status !== 'active' || + schedule.schedule_type !== 'location' || + schedule.geofence_armed !== 1 || + schedule.reminder_disposition_state === 'pending' || + schedule.reminder_disposition_state === 'confirmed' || + (schedule.reminder_disposition_state === 'snoozed' && + schedule.snoozed_until != null && + Date.parse(schedule.snoozed_until) > Date.parse(payload.observed_at)) + ) { + return true; + } + + const notifications = await loadNotifications(); + if (notifications == null) return false; + await ensureAndroidChannel(notifications); + notifications.setNotificationHandler({ + handleNotification: async () => ({ + shouldShowBanner: true, + shouldShowList: true, + shouldPlaySound: true, + shouldSetBadge: false, + }), + }); + + const notificationId = `reminder-${schedule.id}`; + await notifications.scheduleNotificationAsync({ + identifier: notificationId, + content: { + title: schedule.title || '日程提醒', + body: + schedule.reminder_type === 'return_to_recorded_location' + ? `您已回到${schedule.location_name ?? '记录地点'}附近,请及时处理。` + : `您已进入${schedule.location_name ?? '目标地点'}附近,请及时处理。`, + sound: 'default', + data: { schedule_id: schedule.id, reason: schedule.reminder_type ?? 'arrive_location' }, + }, + trigger: null, + }); + + await database.runAsync( + `UPDATE local_schedules + SET geofence_armed = 0, + reminder_disposition_state = 'pending', + next_trigger_at = NULL, + disposition_updated_at = ?, + sync_status = 'pending' + WHERE id = ? + AND status = 'active' + AND schedule_type = 'location' + AND geofence_armed = 1 + AND (reminder_disposition_state IS NULL OR reminder_disposition_state = 'snoozed')`, + payload.observed_at, + schedule.id, + ); + return true; +} + +async function openHeadlessDatabase(): Promise { + try { + const { openDatabaseAsync } = await import('expo-sqlite'); + return await openDatabaseAsync(TIMEFLOW_DATABASE_NAME); + } catch { + return null; + } +} + +async function loadNotifications(): Promise { + try { + return await import('expo-notifications'); + } catch { + return null; + } +} + +async function ensureAndroidChannel( + notifications: typeof import('expo-notifications'), +): Promise { + await notifications.setNotificationChannelAsync('timeflow-reminders', { + name: '日程提醒', + importance: notifications.AndroidImportance.DEFAULT, + vibrationPattern: [0, 180], + lightColor: '#D7F36A', + }); +} + +if (!TaskManager.isTaskDefined(GEOFENCE_TASK_NAME)) { + TaskManager.defineTask(GEOFENCE_TASK_NAME, async ({ data, error }) => { + if (error) return; + + const payload = data as + | { + eventType?: Location.GeofencingEventType; + region?: Location.LocationRegion; + } + | undefined; + const region = payload?.region; + if (region?.identifier == null) return; + + const eventType = payload?.eventType; + const event = + eventType === Location.GeofencingEventType.Enter + ? 'enter' + : eventType === Location.GeofencingEventType.Exit + ? 'exit' + : null; + if (event == null) return; + + await emit({ + schedule_id: region.identifier, + event, + latitude: region.latitude, + longitude: region.longitude, + radius: region.radius, + observed_at: new Date().toISOString(), + }); + }); +} diff --git a/frontend/src/infrastructure/location/index.ts b/frontend/src/infrastructure/location/index.ts index ef2d2b9d..1461aae2 100644 --- a/frontend/src/infrastructure/location/index.ts +++ b/frontend/src/infrastructure/location/index.ts @@ -1,2 +1,10 @@ -export { MockLocationMonitor, MOCK_LOCATION_SAMPLE } from './MockLocationMonitor'; +export { NativeLocationMonitor } from './NativeLocationMonitor'; +export { ExpoLocationMonitor } from './ExpoLocationMonitor'; export type { LocationProvider } from './LocationProvider'; +export { + isBaiduLocationAvailable, + baiduInit, + baiduStartUpdating, + baiduStopUpdating, + subscribeBaiduLocation, +} from './native/BaiduLocationBridge'; diff --git a/frontend/src/infrastructure/location/native/BaiduLocationBridge.ts b/frontend/src/infrastructure/location/native/BaiduLocationBridge.ts new file mode 100644 index 00000000..fb5e8ea9 --- /dev/null +++ b/frontend/src/infrastructure/location/native/BaiduLocationBridge.ts @@ -0,0 +1,83 @@ +import { NativeEventEmitter, NativeModules, Platform } from 'react-native'; + +export type BaiduLocationSample = { + latitude: number; + longitude: number; + accuracy: number; + observedAt: string; + locType?: number; +}; + +type TimeflowBaiduLocationNative = { + setAgreePrivacy: (agree: boolean) => Promise; + init: (ak: string | null) => Promise; + startUpdating: (intervalMs: number) => Promise; + stopUpdating: () => Promise; + getCurrentPosition: () => Promise; + addListener: (eventName: string) => void; + removeListeners: (count: number) => void; +}; + +const Native = NativeModules.TimeflowBaiduLocation as TimeflowBaiduLocationNative | undefined; + +function getNative(): TimeflowBaiduLocationNative | undefined { + return NativeModules.TimeflowBaiduLocation as TimeflowBaiduLocationNative | undefined; +} + +export function isBaiduLocationAvailable(): boolean { + return Platform.OS === 'android' && getNative() != null; +} + +export async function baiduSetAgreePrivacy(agree: boolean): Promise { + const native = getNative(); + if (native == null) return; + await native.setAgreePrivacy(agree); +} + +export async function baiduInit(ak: string | null = null): Promise { + const native = getNative(); + if (native == null) return false; + await native.setAgreePrivacy(true); + return native.init(ak); +} + +export async function baiduStartUpdating(intervalMs = 5_000): Promise { + const native = getNative(); + if (native == null) return false; + return native.startUpdating(intervalMs); +} + +export async function baiduStopUpdating(): Promise { + const native = getNative(); + if (native == null) return; + await native.stopUpdating(); +} + +export async function baiduGetCurrentPosition(): Promise { + const native = getNative(); + if (native == null) return null; + return native.getCurrentPosition(); +} + +export function subscribeBaiduLocation( + listener: (sample: BaiduLocationSample) => void, +): () => void { + const native = getNative(); + if (native == null || Platform.OS !== 'android') { + return () => {}; + } + const emitter = new NativeEventEmitter(native as never); + const sub = emitter.addListener('TimeflowBaiduLocation', (payload: BaiduLocationSample) => { + if ( + payload == null || + typeof payload.latitude !== 'number' || + typeof payload.longitude !== 'number' + ) { + return; + } + listener(payload); + }); + return () => sub.remove(); +} + +export { Native as TimeflowBaiduLocationNativeModule }; diff --git a/frontend/src/infrastructure/notifications/.gitkeep b/frontend/src/infrastructure/notifications/.gitkeep deleted file mode 100644 index 8b137891..00000000 --- a/frontend/src/infrastructure/notifications/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/frontend/src/infrastructure/notifications/ExpoSystemNotification.ts b/frontend/src/infrastructure/notifications/ExpoSystemNotification.ts new file mode 100644 index 00000000..5623c600 --- /dev/null +++ b/frontend/src/infrastructure/notifications/ExpoSystemNotification.ts @@ -0,0 +1,72 @@ +import type { + SystemNotificationPort, + SystemNotificationReceipt, + SystemNotificationRequest, +} from '../../features/reminder/application/interfaces'; + +type NotificationsModule = typeof import('expo-notifications'); + +let channelReady: Promise | null = null; + +async function loadNotifications(): Promise { + try { + return await import('expo-notifications'); + } catch { + return null; + } +} + +async function ensureAndroidChannel(Notifications: NotificationsModule): Promise { + if (channelReady != null) { + await channelReady; + return; + } + channelReady = (async () => { + await Notifications.setNotificationChannelAsync('timeflow-reminders', { + name: '日程提醒', + importance: Notifications.AndroidImportance.DEFAULT, + vibrationPattern: [0, 180], + lightColor: '#D7F36A', + }); + })(); + await channelReady; +} + +/** 基于 expo-notifications 的轻度提醒系统通知。 */ +export class ExpoSystemNotification implements SystemNotificationPort { + async show(request: SystemNotificationRequest): Promise { + const Notifications = await loadNotifications(); + if (Notifications == null) { + return { notification_id: request.notification_id, shown: false }; + } + + await ensureAndroidChannel(Notifications); + Notifications.setNotificationHandler({ + handleNotification: async () => ({ + shouldShowBanner: true, + shouldShowList: true, + shouldPlaySound: false, + shouldSetBadge: false, + }), + }); + + await Notifications.scheduleNotificationAsync({ + identifier: request.notification_id, + content: { + title: request.title, + body: request.body, + sound: false, + }, + trigger: null, + }); + + return { notification_id: request.notification_id, shown: true }; + } + + async cancel(notificationId: string): Promise { + const Notifications = await loadNotifications(); + if (Notifications == null) return; + await Notifications.dismissNotificationAsync(notificationId).catch(() => undefined); + await Notifications.cancelScheduledNotificationAsync(notificationId).catch(() => undefined); + } +} diff --git a/frontend/src/infrastructure/notifications/MockAlarmScheduler.ts b/frontend/src/infrastructure/notifications/MockAlarmScheduler.ts deleted file mode 100644 index 68b447cf..00000000 --- a/frontend/src/infrastructure/notifications/MockAlarmScheduler.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type { - AlarmScheduleReceipt, - AlarmScheduleRequest, - AlarmSchedulerPort, -} from '../../features/reminder/application/interfaces'; - -/** 固定闹钟适配器,始终标记为已调度(进程内占位 id)。 */ -export class MockAlarmScheduler implements AlarmSchedulerPort { - async schedule(request: AlarmScheduleRequest): Promise { - return { - alarm_id: `mock-alarm-${request.schedule_id}`, - schedule_id: request.schedule_id, - scheduled: true, - }; - } - - async cancel(_alarmId: string | null): Promise<{ cancelled: boolean }> { - return { cancelled: true }; - } - - async rebuild( - requests: readonly AlarmScheduleRequest[], - ): Promise { - return Promise.all(requests.map((request) => this.schedule(request))); - } -} diff --git a/frontend/src/infrastructure/notifications/MockDeviceCapability.ts b/frontend/src/infrastructure/notifications/MockDeviceCapability.ts deleted file mode 100644 index cda5590c..00000000 --- a/frontend/src/infrastructure/notifications/MockDeviceCapability.ts +++ /dev/null @@ -1,40 +0,0 @@ -import type { - DeviceCapabilityPort, - DeviceCapabilityStatus, - DevicePermission, -} from '../../features/reminder/application/interfaces'; - -const MOCK_PERMISSIONS: Readonly> = { - notifications: true, - exact_alarm: true, - overlay: false, - full_screen: true, - battery_optimization: false, - location_foreground: true, - location_background: false, -}; - -const MOCK_STATUS: DeviceCapabilityStatus = { - platform: 'android', - supported: true, - permissions: MOCK_PERMISSIONS, - background_execution: false, -}; - -/** 原生模块接入前使用的固定设备能力状态。 */ -export class MockDeviceCapability implements DeviceCapabilityPort { - async getStatus(): Promise { - return { ...MOCK_STATUS, permissions: { ...MOCK_STATUS.permissions } }; - } - - async requestPermission(permission: DevicePermission): Promise { - // 与 getStatus() 保持一致:返回该权限的固定值,不假装 grant 成功。 - return MOCK_PERMISSIONS[permission]; - } - - async openSettings(_permission: DevicePermission): Promise { - return true; - } -} - -export { MOCK_STATUS as MOCK_DEVICE_CAPABILITY_STATUS }; diff --git a/frontend/src/infrastructure/notifications/MockNotificationChannels.ts b/frontend/src/infrastructure/notifications/MockNotificationChannels.ts deleted file mode 100644 index d42c7a99..00000000 --- a/frontend/src/infrastructure/notifications/MockNotificationChannels.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { - PopupPort, - PopupReceipt, - PopupRequest, - SystemNotificationPort, - SystemNotificationReceipt, - SystemNotificationRequest, - VibrationPort, -} from '../../features/reminder/application/interfaces'; - -export class MockSystemNotification implements SystemNotificationPort { - async show(request: SystemNotificationRequest): Promise { - return { notification_id: request.notification_id, shown: true }; - } - - async cancel(_notificationId: string): Promise { - return Promise.resolve(); - } -} - -export class MockPopup implements PopupPort { - async show(request: PopupRequest): Promise { - return { popup_id: request.popup_id, visible: true }; - } - - async dismiss(_popupId: string): Promise { - return Promise.resolve(); - } -} - -export class MockVibration implements VibrationPort { - async vibrate(): Promise { - return Promise.resolve(); - } - - async stop(): Promise { - return Promise.resolve(); - } -} diff --git a/frontend/src/infrastructure/notifications/MockReminderDelivery.ts b/frontend/src/infrastructure/notifications/MockReminderDelivery.ts deleted file mode 100644 index f8115703..00000000 --- a/frontend/src/infrastructure/notifications/MockReminderDelivery.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type { ReminderDeliveryPort } from '../../features/reminder/application/interfaces'; -import type { - ReminderDeliveryReceipt, - ReminderDeliveryRequest, -} from '../../features/reminder/domain'; - -const MOCK_RECEIPT: ReminderDeliveryReceipt = { - delivery_id: 'mock-delivery-001', - schedule_id: 'mock-schedule-time-001', - delivered_at: '2026-08-07T01:00:00.000Z', - channels: ['system_notification'], - used_fallback_audio: false, -}; - -/** 系统通知送达使用的固定通知边界。 */ -export class MockReminderDelivery implements ReminderDeliveryPort { - async deliver(request: ReminderDeliveryRequest): Promise { - return { ...MOCK_RECEIPT, schedule_id: request.schedule_id }; - } - - async dismiss(_scheduleId: string): Promise { - return Promise.resolve(); - } -} - -export { MOCK_RECEIPT as MOCK_REMINDER_DELIVERY_RECEIPT }; diff --git a/frontend/src/infrastructure/notifications/MockReminderRecovery.ts b/frontend/src/infrastructure/notifications/MockReminderRecovery.ts deleted file mode 100644 index 34be1b00..00000000 --- a/frontend/src/infrastructure/notifications/MockReminderRecovery.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { - ReminderRecoveryPort, - ReminderRecoveryReceipt, -} from '../../features/reminder/application/interfaces'; - -/** 固定重启恢复适配器,后续可替换为安卓启动接收器。 */ -export class MockReminderRecovery implements ReminderRecoveryPort { - async registerForRestart(): Promise { - return { registered: true, recovery_id: 'mock-recovery-001' }; - } - - async restoreAfterRestart(): Promise { - return { registered: true, recovery_id: 'mock-recovery-001' }; - } -} diff --git a/frontend/src/infrastructure/notifications/NativeAlarmScheduler.ts b/frontend/src/infrastructure/notifications/NativeAlarmScheduler.ts index e2008346..bc8ad7b4 100644 --- a/frontend/src/infrastructure/notifications/NativeAlarmScheduler.ts +++ b/frontend/src/infrastructure/notifications/NativeAlarmScheduler.ts @@ -1,4 +1,6 @@ import type { + AlarmNativeDisposition, + AlarmNativeEvent, AlarmScheduleReceipt, AlarmScheduleRequest, AlarmSchedulerPort, @@ -7,10 +9,14 @@ import { isTimeflowAlarmAvailable, nativeAreAlarmPermissionsGranted, nativeCancelAlarm, + nativeCancelAllAlarms, + nativeConsumeAlarmDispositions, nativeScheduleAlarm, + nativeStopAlarmRinging, + subscribeNativeAlarmEvents, } from './native/TimeflowAlarmBridge'; -/** Android TimeflowAlarm 适配器;无法挂上时返回 scheduled: false。 */ +/** Android TimeflowAlarm 适配器;无法挂上时返回 scheduled=false。 */ export class NativeAlarmScheduler implements AlarmSchedulerPort { async schedule(request: AlarmScheduleRequest): Promise { if (!isTimeflowAlarmAvailable()) { @@ -22,44 +28,81 @@ export class NativeAlarmScheduler implements AlarmSchedulerPort { return unscheduled(request.schedule_id); } - try { - const ready = await nativeAreAlarmPermissionsGranted(); - if (!ready) { - return unscheduled(request.schedule_id); - } - - const alarmId = await nativeScheduleAlarm(triggerAtMillis, request.title); - if (alarmId == null || alarmId.length === 0) { - return unscheduled(request.schedule_id); - } + const ready = await nativeAreAlarmPermissionsGranted(); + if (!ready) { + return unscheduled(request.schedule_id); + } - return { - alarm_id: alarmId, - schedule_id: request.schedule_id, - scheduled: true, - }; - } catch { + const alarmId = await nativeScheduleAlarm(triggerAtMillis, request.title, request.schedule_id); + if (alarmId == null || alarmId.length === 0) { return unscheduled(request.schedule_id); } + + return { + alarm_id: alarmId, + schedule_id: request.schedule_id, + scheduled: true, + }; } async cancel(alarmId: string | null): Promise<{ cancelled: boolean }> { if (alarmId == null || alarmId.length === 0) { return { cancelled: false }; } - const cancelled = await nativeCancelAlarm(alarmId); - return { cancelled }; + await nativeCancelAlarm(alarmId); + return { cancelled: true }; } async rebuild( requests: readonly AlarmScheduleRequest[], ): Promise { + // 冷启动 registrations 为空时也要清掉 SharedPreferences 里的孤儿闹钟。 + await nativeCancelAllAlarms(); const receipts: AlarmScheduleReceipt[] = []; for (const request of requests) { receipts.push(await this.schedule(request)); } return receipts; } + + async stopRinging(): Promise { + await nativeStopAlarmRinging(); + } + + subscribe(listener: (event: AlarmNativeEvent) => void): () => void { + return subscribeNativeAlarmEvents((payload) => { + listener({ + type: payload.type, + schedule_id: payload.scheduleId, + alarm_id: payload.alarmId, + title: payload.title, + at: new Date(payload.atMillis || Date.now()).toISOString(), + }); + }); + } + + async consumeNativeDispositions(): Promise { + const rows = await nativeConsumeAlarmDispositions(); + return rows + .map((row) => { + const state = + row.state === 'confirmed' + ? 'confirmed' + : row.state === 'pending' + ? 'pending' + : row.state === 'snoozed' + ? 'snoozed' + : null; + if (state == null || !row.scheduleId) return null; + return { + schedule_id: row.scheduleId, + alarm_id: row.alarmId ?? '', + state, + updated_at: new Date(row.updatedAtMillis || Date.now()).toISOString(), + } satisfies AlarmNativeDisposition; + }) + .filter((row): row is AlarmNativeDisposition => row != null); + } } function unscheduled(scheduleId: string): AlarmScheduleReceipt { diff --git a/frontend/src/infrastructure/notifications/NativeDeviceCapability.ts b/frontend/src/infrastructure/notifications/NativeDeviceCapability.ts index b1ce7a66..e7d73d6d 100644 --- a/frontend/src/infrastructure/notifications/NativeDeviceCapability.ts +++ b/frontend/src/infrastructure/notifications/NativeDeviceCapability.ts @@ -1,4 +1,4 @@ -import { Platform } from 'react-native'; +import { AppState, Linking, Platform, type AppStateStatus } from 'react-native'; import type { DeviceCapabilityPort, @@ -22,22 +22,37 @@ const SETTINGS_KIND: Partial< notifications: 'app', }; -/** 基于 TimeflowAlarm 原生模块的设备权限适配器。 */ +/** 基于 TimeflowAlarm + expo-location 的设备权限适配器。 */ export class NativeDeviceCapability implements DeviceCapabilityPort { async getStatus(): Promise { const platform = toPlatform(); + const location = await readLocationPermissions(); + if (!isTimeflowAlarmAvailable()) { - return unsupportedStatus(platform); + return { + platform, + supported: false, + permissions: { + ...emptyPermissions(false), + location_foreground: location.foreground, + location_background: location.background, + }, + background_execution: false, + }; } - let status; - try { - status = await nativeGetAlarmPermissionStatus(); - } catch { - return unsupportedStatus(platform); - } + const status = await nativeGetAlarmPermissionStatus(); if (status == null) { - return unsupportedStatus(platform); + return { + platform, + supported: false, + permissions: { + ...emptyPermissions(false), + location_foreground: location.foreground, + location_background: location.background, + }, + background_execution: false, + }; } return { @@ -49,8 +64,8 @@ export class NativeDeviceCapability implements DeviceCapabilityPort { overlay: status.overlay, full_screen: status.fullScreen, battery_optimization: status.battery, - location_foreground: false, - location_background: false, + location_foreground: location.foreground, + location_background: location.background, }, background_execution: status.battery, }; @@ -58,22 +73,33 @@ export class NativeDeviceCapability implements DeviceCapabilityPort { async requestPermission(permission: DevicePermission): Promise { if (permission === 'notifications') { + return nativeRequestNotificationPermission(); + } + if (permission === 'location_foreground' || permission === 'location_background') { + return requestLocationPermission(permission); + } + return this.openSettings(permission); + } + + async openSettings(permission: DevicePermission): Promise { + if (permission === 'location_foreground' || permission === 'location_background') { try { - return await nativeRequestNotificationPermission(); + await Linking.openSettings(); + return true; } catch { return false; } } - return this.openSettings(permission); + const kind = SETTINGS_KIND[permission] ?? 'app'; + return nativeOpenAlarmPermissionSettings(kind); } - async openSettings(permission: DevicePermission): Promise { - const kind = SETTINGS_KIND[permission] ?? 'app'; - try { - return await nativeOpenAlarmPermissionSettings(kind); - } catch { - return false; - } + onAppActive(listener: () => void): () => void { + const onChange = (state: AppStateStatus) => { + if (state === 'active') listener(); + }; + const subscription = AppState.addEventListener('change', onChange); + return () => subscription.remove(); } } @@ -84,15 +110,6 @@ function toPlatform(): DeviceCapabilityStatus['platform'] { return 'unknown'; } -function unsupportedStatus(platform: DeviceCapabilityStatus['platform']): DeviceCapabilityStatus { - return { - platform, - supported: false, - permissions: emptyPermissions(false), - background_execution: false, - }; -} - function emptyPermissions(value: boolean): Readonly> { return { notifications: value, @@ -104,3 +121,61 @@ function emptyPermissions(value: boolean): Readonly { + try { + const Location = await import('expo-location'); + const foreground = await Location.getForegroundPermissionsAsync(); + const background = await Location.getBackgroundPermissionsAsync(); + return { + foreground: foreground.status === Location.PermissionStatus.GRANTED, + background: background.status === Location.PermissionStatus.GRANTED, + }; + } catch { + return { foreground: false, background: false }; + } +} + +async function requestLocationPermission( + permission: 'location_foreground' | 'location_background', +): Promise { + try { + const Location = await import('expo-location'); + if (permission === 'location_foreground') { + const current = await Location.getForegroundPermissionsAsync(); + if (current.status === Location.PermissionStatus.GRANTED) { + return true; + } + // 系统不再弹授权框时不要空等,交给上层 openSettings。 + if (current.canAskAgain === false) { + return false; + } + const result = await Location.requestForegroundPermissionsAsync(); + return result.status === Location.PermissionStatus.GRANTED; + } + + const foreground = await Location.getForegroundPermissionsAsync(); + if (foreground.status !== Location.PermissionStatus.GRANTED) { + if (foreground.canAskAgain === false) { + return false; + } + const requested = await Location.requestForegroundPermissionsAsync(); + if (requested.status !== Location.PermissionStatus.GRANTED) { + return false; + } + } + + const currentBackground = await Location.getBackgroundPermissionsAsync(); + if (currentBackground.status === Location.PermissionStatus.GRANTED) { + return true; + } + if (currentBackground.canAskAgain === false) { + return false; + } + + const background = await Location.requestBackgroundPermissionsAsync(); + return background.status === Location.PermissionStatus.GRANTED; + } catch { + return false; + } +} diff --git a/frontend/src/infrastructure/notifications/ReactNativeAlertDialog.ts b/frontend/src/infrastructure/notifications/ReactNativeAlertDialog.ts new file mode 100644 index 00000000..ac779b7a --- /dev/null +++ b/frontend/src/infrastructure/notifications/ReactNativeAlertDialog.ts @@ -0,0 +1,25 @@ +import { Alert } from 'react-native'; + +import type { + AlertDialogPort, + AlertDialogRequest, +} from '../../features/reminder/application/interfaces'; + +/** 系统 Alert 对话框适配器;presentation 通过 AlertDialogPort 使用,不直接依赖 RN。 */ +export class ReactNativeAlertDialog implements AlertDialogPort { + async show(request: AlertDialogRequest): Promise { + Alert.alert( + request.title, + request.message, + request.buttons.map((button) => ({ + text: button.text, + style: button.style, + onPress: button.onPress, + })), + { + cancelable: request.cancelable ?? true, + onDismiss: request.onDismiss, + }, + ); + } +} diff --git a/frontend/src/infrastructure/notifications/ReactNativeVibration.ts b/frontend/src/infrastructure/notifications/ReactNativeVibration.ts new file mode 100644 index 00000000..7bec320b --- /dev/null +++ b/frontend/src/infrastructure/notifications/ReactNativeVibration.ts @@ -0,0 +1,38 @@ +import { Platform, Vibration } from 'react-native'; + +import type { VibrationPort } from '../../features/reminder/application/interfaces'; + +/** 震动 / 间隔,确认或 teardown 前循环。 */ +const REPEAT_PATTERN = [0, 700, 350, 700]; + +/** React Native Vibration 适配器:提醒展示期间持续震动。 */ +export class ReactNativeVibration implements VibrationPort { + private iosTimer: ReturnType | null = null; + + async vibrate(): Promise { + this.clearIosTimer(); + Vibration.cancel(); + + if (Platform.OS === 'android') { + // 第二个参数 true = 按 pattern 循环,直到 cancel。 + Vibration.vibrate(REPEAT_PATTERN, true); + return; + } + + Vibration.vibrate(REPEAT_PATTERN); + this.iosTimer = setInterval(() => { + Vibration.vibrate(REPEAT_PATTERN); + }, 2_000); + } + + async stop(): Promise { + this.clearIosTimer(); + Vibration.cancel(); + } + + private clearIosTimer(): void { + if (this.iosTimer == null) return; + clearInterval(this.iosTimer); + this.iosTimer = null; + } +} diff --git a/frontend/src/infrastructure/notifications/index.ts b/frontend/src/infrastructure/notifications/index.ts index 2ad4c8f1..b34e52ac 100644 --- a/frontend/src/infrastructure/notifications/index.ts +++ b/frontend/src/infrastructure/notifications/index.ts @@ -1,16 +1,18 @@ -export { MockAlarmScheduler } from './MockAlarmScheduler'; export { NativeAlarmScheduler } from './NativeAlarmScheduler'; export { NativeDeviceCapability } from './NativeDeviceCapability'; -export { MockPopup, MockSystemNotification, MockVibration } from './MockNotificationChannels'; -export { MockReminderRecovery } from './MockReminderRecovery'; -export { MockReminderDelivery, MOCK_REMINDER_DELIVERY_RECEIPT } from './MockReminderDelivery'; -export { MockDeviceCapability, MOCK_DEVICE_CAPABILITY_STATUS } from './MockDeviceCapability'; +export { ReactNativeVibration } from './ReactNativeVibration'; +export { ReactNativeAlertDialog } from './ReactNativeAlertDialog'; +export { ExpoSystemNotification } from './ExpoSystemNotification'; export { isTimeflowAlarmAvailable, nativeAreAlarmPermissionsGranted, nativeCancelAlarm, + nativeCancelAllAlarms, + nativeConsumeAlarmDispositions, nativeGetAlarmPermissionStatus, nativeOpenAlarmPermissionSettings, nativeRequestNotificationPermission, nativeScheduleAlarm, + nativeStopAlarmRinging, + subscribeNativeAlarmEvents, } from './native/TimeflowAlarmBridge'; diff --git a/frontend/src/infrastructure/notifications/native/TimeflowAlarmBridge.ts b/frontend/src/infrastructure/notifications/native/TimeflowAlarmBridge.ts index d571a321..07550801 100644 --- a/frontend/src/infrastructure/notifications/native/TimeflowAlarmBridge.ts +++ b/frontend/src/infrastructure/notifications/native/TimeflowAlarmBridge.ts @@ -1,4 +1,4 @@ -import { NativeModules, Platform } from 'react-native'; +import { NativeEventEmitter, NativeModules, Platform } from 'react-native'; export type NativeAlarmPermissionStatus = { exactAlarm: boolean; @@ -8,66 +8,148 @@ export type NativeAlarmPermissionStatus = { battery: boolean; }; +export type NativeAlarmEventPayload = { + type: 'fired' | 'dismissed' | 'snoozed'; + scheduleId: string; + alarmId: string; + title: string; + atMillis: number; +}; + +export type NativeAlarmDispositionPayload = { + scheduleId: string; + alarmId: string; + state: string; + updatedAtMillis: number; +}; + type TimeflowAlarmNative = { - schedule: (triggerAtMillis: number, title?: string | null) => Promise<{ alarmId: string }>; + schedule: ( + triggerAtMillis: number, + title?: string | null, + scheduleId?: string | null, + ) => Promise<{ alarmId: string; scheduleId?: string }>; cancel: (alarmId: string) => Promise; + cancelAll: () => Promise; + stopRinging: () => Promise; + consumeNativeDispositions: () => Promise; getPermissionStatus: () => Promise; openPermissionSettings: ( kind: 'exactAlarm' | 'overlay' | 'fullScreen' | 'battery' | 'app', ) => Promise; requestNotificationPermission: () => Promise; + addListener?: (eventName: string) => void; + removeListeners?: (count: number) => void; }; -const NativeAlarm = NativeModules.TimeflowAlarm as TimeflowAlarmNative | undefined; +const EVENT_NAME = 'TimeflowAlarmEvent'; + +function getNativeAlarm(): TimeflowAlarmNative | undefined { + return NativeModules.TimeflowAlarm as TimeflowAlarmNative | undefined; +} export function isTimeflowAlarmAvailable(): boolean { - return Platform.OS === 'android' && NativeAlarm != null; + return Platform.OS === 'android' && getNativeAlarm() != null; } export async function nativeScheduleAlarm( triggerAtMillis: number, title: string, + scheduleId?: string, ): Promise { - if (!isTimeflowAlarmAvailable() || NativeAlarm == null) return null; + const native = getNativeAlarm(); + if (!isTimeflowAlarmAvailable() || native == null) return null; try { - const result = await NativeAlarm.schedule(triggerAtMillis, title); - const alarmId = result?.alarmId; - if (alarmId == null || alarmId.length === 0) return null; - return alarmId; + const result = await native.schedule(triggerAtMillis, title, scheduleId ?? ''); + return result.alarmId; } catch { return null; } } -export async function nativeCancelAlarm(alarmId: string | null | undefined): Promise { - if (!isTimeflowAlarmAvailable() || NativeAlarm == null || !alarmId) return false; +export async function nativeCancelAlarm(alarmId: string | null | undefined): Promise { + const native = getNativeAlarm(); + if (!isTimeflowAlarmAvailable() || native == null || !alarmId) return; try { - return Boolean(await NativeAlarm.cancel(alarmId)); + await native.cancel(alarmId); } catch { - return false; + // 尽力取消,忽略失败。 + } +} + +export async function nativeCancelAllAlarms(): Promise { + const native = getNativeAlarm(); + if (!isTimeflowAlarmAvailable() || native == null) return; + try { + await native.cancelAll(); + } catch { + // 尽力全部取消,忽略失败。 + } +} + +export async function nativeStopAlarmRinging(): Promise { + const native = getNativeAlarm(); + if (!isTimeflowAlarmAvailable() || native == null) return; + try { + await native.stopRinging(); + } catch { + // 尽力停铃,忽略失败。 + } +} + +export async function nativeConsumeAlarmDispositions(): Promise { + const native = getNativeAlarm(); + if (!isTimeflowAlarmAvailable() || native == null) return []; + try { + return await native.consumeNativeDispositions(); + } catch { + return []; } } export async function nativeGetAlarmPermissionStatus(): Promise { - if (!isTimeflowAlarmAvailable() || NativeAlarm == null) return null; - return NativeAlarm.getPermissionStatus(); + const native = getNativeAlarm(); + if (!isTimeflowAlarmAvailable() || native == null) return null; + return native.getPermissionStatus(); } export async function nativeOpenAlarmPermissionSettings( kind: 'exactAlarm' | 'overlay' | 'fullScreen' | 'battery' | 'app', ): Promise { - if (!isTimeflowAlarmAvailable() || NativeAlarm == null) return false; - return NativeAlarm.openPermissionSettings(kind); + const native = getNativeAlarm(); + if (!isTimeflowAlarmAvailable() || native == null) return false; + return native.openPermissionSettings(kind); } export async function nativeRequestNotificationPermission(): Promise { - if (!isTimeflowAlarmAvailable() || NativeAlarm == null) return false; - return NativeAlarm.requestNotificationPermission(); + const native = getNativeAlarm(); + if (!isTimeflowAlarmAvailable() || native == null) return false; + try { + return await native.requestNotificationPermission(); + } catch { + return false; + } } export async function nativeAreAlarmPermissionsGranted(): Promise { const status = await nativeGetAlarmPermissionStatus(); if (status == null) return false; - // 挂闹钟的最低要求:精确闹钟 + 通知;悬浮窗/全屏/电池影响展示,不阻塞调度。 return status.exactAlarm && status.notifications; } + +export function subscribeNativeAlarmEvents( + listener: (event: NativeAlarmEventPayload) => void, +): () => void { + const native = getNativeAlarm(); + if (!isTimeflowAlarmAvailable() || native == null) { + return () => undefined; + } + const emitter = new NativeEventEmitter(native as never); + const subscription = emitter.addListener(EVENT_NAME, (payload: NativeAlarmEventPayload) => { + if (payload?.type !== 'fired' && payload?.type !== 'dismissed' && payload?.type !== 'snoozed') { + return; + } + listener(payload); + }); + return () => subscription.remove(); +} diff --git a/frontend/src/infrastructure/time/IntervalTimeListener.ts b/frontend/src/infrastructure/time/IntervalTimeListener.ts new file mode 100644 index 00000000..5099d98e --- /dev/null +++ b/frontend/src/infrastructure/time/IntervalTimeListener.ts @@ -0,0 +1,38 @@ +import type { + LocalTimeTick, + TimeListenerHandle, + TimeListenerOptions, + TimeListenerPort, +} from '../../features/reminder/application/interfaces'; + +const DEFAULT_INTERVAL_MS = 30_000; + +/** 进程内周期时间观测,驱动 handleTime。 */ +export class IntervalTimeListener implements TimeListenerPort { + private readonly timers = new Map>(); + private sequence = 0; + + constructor(private readonly intervalMs: number = DEFAULT_INTERVAL_MS) {} + + async start( + listener: (tick: LocalTimeTick) => void, + _options?: TimeListenerOptions, + ): Promise { + const listenerId = `interval-time-listener-${++this.sequence}`; + const emit = () => { + listener({ observed_at: new Date().toISOString() }); + }; + // 不在 start 同步打首 tick,交给调用方完成 rebuild 后再自然周期触发。 + const timer = setInterval(emit, this.intervalMs); + this.timers.set(listenerId, timer); + return { listener_id: listenerId }; + } + + async stop(listenerId: string): Promise { + const timer = this.timers.get(listenerId); + if (timer != null) { + clearInterval(timer); + this.timers.delete(listenerId); + } + } +} diff --git a/frontend/src/infrastructure/time/index.ts b/frontend/src/infrastructure/time/index.ts new file mode 100644 index 00000000..299256c7 --- /dev/null +++ b/frontend/src/infrastructure/time/index.ts @@ -0,0 +1 @@ +export { IntervalTimeListener } from './IntervalTimeListener'; diff --git a/frontend/src/shared/time/.gitkeep b/frontend/src/shared/time/.gitkeep deleted file mode 100644 index 8b137891..00000000 --- a/frontend/src/shared/time/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/frontend/src/shared/time/MockClock.ts b/frontend/src/shared/time/MockClock.ts deleted file mode 100644 index edc7ff1c..00000000 --- a/frontend/src/shared/time/MockClock.ts +++ /dev/null @@ -1,12 +0,0 @@ -/** 用于确定性测试和模拟组合的最小时钟端口。 */ -export interface Clock { - nowIso(): string; -} - -export class MockClock implements Clock { - constructor(private readonly value = '2026-08-07T01:00:00.000Z') {} - - nowIso(): string { - return this.value; - } -} diff --git a/frontend/src/shared/time/MockTimeListener.ts b/frontend/src/shared/time/MockTimeListener.ts deleted file mode 100644 index e77e36ec..00000000 --- a/frontend/src/shared/time/MockTimeListener.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { - LocalTimeTick, - TimeListenerHandle, - TimeListenerOptions, - TimeListenerPort, -} from '../../features/reminder/application/interfaces'; - -/** 固定时间监听边界,替换前不会发出平台事件。 */ -export class MockTimeListener implements TimeListenerPort { - async start( - _listener: (tick: LocalTimeTick) => void, - _options?: TimeListenerOptions, - ): Promise { - return { listener_id: 'mock-time-listener-001' }; - } - - async stop(_listenerId: string): Promise { - return Promise.resolve(); - } -} diff --git a/frontend/src/shared/time/format.ts b/frontend/src/shared/time/format.ts new file mode 100644 index 00000000..71609c15 --- /dev/null +++ b/frontend/src/shared/time/format.ts @@ -0,0 +1,19 @@ +/** + * 无业务语义的时间工具(附录 B.5)。 + * 不承载日程/提醒触发规则。 + */ +export function toIsoUtc(date: Date = new Date()): string { + return date.toISOString(); +} + +export function formatLocalDateTime( + iso: string, + timeZone = 'Asia/Shanghai', + locale = 'zh-CN', +): string { + try { + return new Date(iso).toLocaleString(locale, { hour12: false, timeZone }); + } catch { + return iso; + } +} diff --git a/frontend/src/shared/time/index.ts b/frontend/src/shared/time/index.ts index 1924ce7a..00679748 100644 --- a/frontend/src/shared/time/index.ts +++ b/frontend/src/shared/time/index.ts @@ -1,3 +1 @@ -export { MockClock } from './MockClock'; -export type { Clock } from './MockClock'; -export { MockTimeListener } from './MockTimeListener'; +export { formatLocalDateTime, toIsoUtc } from './format'; diff --git a/frontend/src/types/.gitkeep b/frontend/src/types/.gitkeep deleted file mode 100644 index 8b137891..00000000 --- a/frontend/src/types/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/frontend/src/types/expo-audio.d.ts b/frontend/src/types/expo-audio.d.ts new file mode 100644 index 00000000..6638cb05 --- /dev/null +++ b/frontend/src/types/expo-audio.d.ts @@ -0,0 +1,16 @@ +declare module 'expo-audio' { + export function createAudioPlayer(source?: string | number | null): { + pause: () => void; + replace: (source: string | number) => void; + play: () => void; + volume: number; + loop: boolean; + }; + + export function setAudioModeAsync(mode: Record): Promise; +} + +declare module '*.mp3' { + const asset: number; + export default asset; +} diff --git a/frontend/tests/integration/localScheduleWriter.test.ts b/frontend/tests/integration/localScheduleWriter.test.ts new file mode 100644 index 00000000..5988a996 --- /dev/null +++ b/frontend/tests/integration/localScheduleWriter.test.ts @@ -0,0 +1,81 @@ +import initSqlJs, { type SqlJsStatic } from 'sql.js'; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { LocalScheduleWriter } from '../../src/features/assistant/data/local/LocalScheduleWriter'; +import type { AppliedCommand } from '../../src/features/assistant/domain/ConversationTurn'; +import { ScheduleLocalRepository } from '../../src/features/schedule/data'; +import { SqliteLocalScheduleReader } from '../../src/features/reminder/data/local/SqliteLocalScheduleReader'; +import { migrateScheduleDatabase } from '../../src/infrastructure/database/migrations'; +import { SqlJsExpoDatabase } from '../helpers/sqliteTestDatabase'; + +function appliedCommand(overrides: Partial = {}): AppliedCommand { + return { + operation: 'create_schedule', + status: 'applied', + schedule: { + id: 'schedule-a', + schedule_type: 'time', + schedule_kind: 'once', + title: 'Team sync', + is_all_day: false, + timezone: 'Asia/Shanghai', + start_time: '2026-08-12T07:00:00Z', + revision: 1, + }, + ...overrides, + }; +} + +describe('LocalScheduleWriter refresh wiring', () => { + let sql: SqlJsStatic; + let database: SqlJsExpoDatabase; + let repository: ScheduleLocalRepository; + + beforeAll(async () => { + sql = await initSqlJs(); + }); + + beforeEach(async () => { + database = new SqlJsExpoDatabase(new sql.Database()); + await migrateScheduleDatabase(database.asSQLiteDatabase()); + repository = new ScheduleLocalRepository(database.asSQLiteDatabase()); + }); + + afterEach(() => { + database.close(); + }); + + it('refreshes the attached schedule reader after a successful write', async () => { + const reader = new SqliteLocalScheduleReader(); + reader.attach(repository, 'account-a'); + const listener = vi.fn(); + reader.subscribe(listener); + + const writer = new LocalScheduleWriter(repository, reader); + await writer.applyCommandResult('account-a', appliedCommand()); + + expect(listener).toHaveBeenCalledTimes(1); + expect(await reader.getReminderSchedule('schedule-a')).toMatchObject({ title: 'Team sync' }); + }); + + it('does not refresh when the command was not applied', async () => { + const reader = new SqliteLocalScheduleReader(); + reader.attach(repository, 'account-a'); + const listener = vi.fn(); + reader.subscribe(listener); + + const writer = new LocalScheduleWriter(repository, reader); + await writer.applyCommandResult('account-a', appliedCommand({ status: 'rejected' })); + + expect(listener).not.toHaveBeenCalled(); + expect(await reader.getReminderSchedule('schedule-a')).toBeNull(); + }); + + it('works without a schedule reader wired in', async () => { + const writer = new LocalScheduleWriter(repository); + await expect(writer.applyCommandResult('account-a', appliedCommand())).resolves.toBeUndefined(); + + const stored = await repository.getSchedule('account-a', 'schedule-a'); + expect(stored?.title).toBe('Team sync'); + }); +}); diff --git a/frontend/tests/integration/sqliteLocalScheduleReader.test.ts b/frontend/tests/integration/sqliteLocalScheduleReader.test.ts new file mode 100644 index 00000000..79413277 --- /dev/null +++ b/frontend/tests/integration/sqliteLocalScheduleReader.test.ts @@ -0,0 +1,132 @@ +import initSqlJs, { type SqlJsStatic } from 'sql.js'; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { ScheduleLocalRepository, type CloudScheduleRow } from '../../src/features/schedule/data'; +import { SqliteLocalScheduleReader } from '../../src/features/reminder/data/local/SqliteLocalScheduleReader'; +import { migrateScheduleDatabase } from '../../src/infrastructure/database/migrations'; +import { SqlJsExpoDatabase } from '../helpers/sqliteTestDatabase'; + +function cloudSchedule(overrides: Partial = {}): CloudScheduleRow { + return { + id: 'schedule-a', + account_id: 'account-a', + schedule_type: 'time', + schedule_kind: 'once', + title: 'Original title', + is_all_day: 0, + start_time: '2026-08-12T07:00:00Z', + end_time: null, + timezone: 'Asia/Shanghai', + recurrence_rule: null, + location_name: null, + latitude: null, + longitude: null, + reminder_type: 'before_start', + reminder_trigger_at: null, + reminder_offset_minutes: 15, + reminder_strength: 'medium', + reminder_disposition_state: null, + status: 'active', + cloud_revision: 1, + updated_at: '2026-08-11T07:00:00Z', + ...overrides, + }; +} + +describe('SqliteLocalScheduleReader', () => { + let sql: SqlJsStatic; + let database: SqlJsExpoDatabase; + let repository: ScheduleLocalRepository; + let reader: SqliteLocalScheduleReader; + + beforeAll(async () => { + sql = await initSqlJs(); + }); + + beforeEach(async () => { + database = new SqlJsExpoDatabase(new sql.Database()); + await migrateScheduleDatabase(database.asSQLiteDatabase()); + repository = new ScheduleLocalRepository(database.asSQLiteDatabase()); + reader = new SqliteLocalScheduleReader(); + }); + + afterEach(() => { + database.close(); + }); + + it('returns nothing before attach()', async () => { + expect(await reader.listReminderSchedules()).toEqual([]); + expect(await reader.getReminderSchedule('schedule-a')).toBeNull(); + }); + + it('maps a stored row onto the reminder domain shape once attached', async () => { + await repository.applyCloudSchedule(cloudSchedule()); + reader.attach(repository, 'account-a'); + + const schedules = await reader.listReminderSchedules(); + expect(schedules).toHaveLength(1); + expect(schedules[0]).toMatchObject({ + id: 'schedule-a', + account_id: 'account-a', + title: 'Original title', + geofence_radius_meters: 200, + reminder: { + reminder_type: 'before_start', + reminder_offset_minutes: 15, + reminder_strength: 'medium', + }, + runtime: { + geofence_armed: false, + recorded_location: null, + sync_status: 'synced', + }, + status: 'active', + }); + + expect(await reader.getReminderSchedule('schedule-a')).toMatchObject({ id: 'schedule-a' }); + expect(await reader.getReminderSchedule('missing')).toBeNull(); + }); + + it('scopes reads to the attached account', async () => { + await repository.applyCloudSchedule(cloudSchedule()); + reader.attach(repository, 'account-b'); + + expect(await reader.listReminderSchedules()).toEqual([]); + }); + + it('stops returning data after detach()', async () => { + await repository.applyCloudSchedule(cloudSchedule()); + reader.attach(repository, 'account-a'); + expect(await reader.listReminderSchedules()).toHaveLength(1); + + reader.detach(); + + expect(await reader.listReminderSchedules()).toEqual([]); + }); + + it('notifies subscribers with a fresh read on refresh()', async () => { + reader.attach(repository, 'account-a'); + const listener = vi.fn(); + reader.subscribe(listener); + + await repository.applyCloudSchedule(cloudSchedule()); + await reader.refresh(); + + expect(listener).toHaveBeenCalledTimes(1); + const [notified] = listener.mock.calls[0] as [readonly { id: string }[]]; + expect(notified).toHaveLength(1); + expect(notified[0].id).toBe('schedule-a'); + }); + + it('stops notifying an unsubscribed listener', async () => { + reader.attach(repository, 'account-a'); + const listener = vi.fn(); + const unsubscribe = reader.subscribe(listener); + unsubscribe(); + + await repository.applyCloudSchedule(cloudSchedule()); + await reader.refresh(); + + expect(listener).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/tests/integration/sqliteReminderStateStore.test.ts b/frontend/tests/integration/sqliteReminderStateStore.test.ts new file mode 100644 index 00000000..018b057c --- /dev/null +++ b/frontend/tests/integration/sqliteReminderStateStore.test.ts @@ -0,0 +1,170 @@ +import initSqlJs, { type SqlJsStatic } from 'sql.js'; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { ScheduleLocalRepository, type CloudScheduleRow } from '../../src/features/schedule/data'; +import { SqliteReminderStateStore } from '../../src/features/reminder/data/local/SqliteReminderStateStore'; +import { migrateScheduleDatabase } from '../../src/infrastructure/database/migrations'; +import { SqlJsExpoDatabase } from '../helpers/sqliteTestDatabase'; + +const START_TIME = '2026-08-10T07:00:00Z'; // 2026-08-10 15:00 Asia/Shanghai (Monday) +const ONE_WEEK_MS = 7 * 24 * 60 * 60 * 1_000; + +function recurringSchedule(overrides: Partial = {}): CloudScheduleRow { + return { + id: 'schedule-a', + account_id: 'account-a', + schedule_type: 'time', + schedule_kind: 'recurring', + title: '周会', + is_all_day: 0, + start_time: START_TIME, + end_time: null, + timezone: 'Asia/Shanghai', + // Asia/Shanghai 没有 DST,整周推进不用担心跨夏令时的偏差。 + recurrence_rule: 'FREQ=WEEKLY;COUNT=4', + location_name: null, + latitude: null, + longitude: null, + reminder_type: null, + reminder_trigger_at: null, + reminder_offset_minutes: null, + reminder_strength: null, + reminder_disposition_state: null, + status: 'active', + cloud_revision: 1, + updated_at: '2026-08-09T00:00:00Z', + ...overrides, + }; +} + +describe('SqliteReminderStateStore', () => { + let sql: SqlJsStatic; + let database: SqlJsExpoDatabase; + let repository: ScheduleLocalRepository; + let store: SqliteReminderStateStore; + + beforeAll(async () => { + sql = await initSqlJs(); + }); + + beforeEach(async () => { + database = new SqlJsExpoDatabase(new sql.Database()); + await migrateScheduleDatabase(database.asSQLiteDatabase()); + repository = new ScheduleLocalRepository(database.asSQLiteDatabase()); + store = new SqliteReminderStateStore(); + store.attach(repository, 'account-a'); + }); + + afterEach(() => { + database.close(); + vi.useRealTimers(); + }); + + it('returns null before attach()', async () => { + const detached = new SqliteReminderStateStore(); + expect(await detached.read('schedule-a')).toBeNull(); + }); + + it('passes through a once schedule unchanged, including a null next_trigger_at', async () => { + await repository.applyCloudSchedule( + recurringSchedule({ schedule_kind: 'once', recurrence_rule: null }), + ); + + const runtime = await store.read('schedule-a'); + + expect(runtime?.next_trigger_at).toBeNull(); + expect(runtime?.reminder_disposition_state).toBeNull(); + }); + + it('leaves a recurring schedule alone once it already has an occurrence cursor', async () => { + await repository.applyCloudSchedule(recurringSchedule()); + await repository.updateReminderRuntime('account-a', 'schedule-a', { + reminder_disposition_state: 'pending', + next_trigger_at: '2026-08-17T07:00:00Z', + snoozed_until: null, + geofence_armed: 0, + disposition_updated_at: '2026-08-17T07:00:00Z', + sync_status: 'synced', + }); + + const runtime = await store.read('schedule-a'); + + expect(runtime?.next_trigger_at).toBe('2026-08-17T07:00:00Z'); + expect(runtime?.reminder_disposition_state).toBe('pending'); + }); + + it('computes the next occurrence and resets disposition when the cursor is null', async () => { + await repository.applyCloudSchedule(recurringSchedule()); + // confirmInternal 的实际写法:确认之后把 disposition 设成 confirmed,同时把 + // occurrence 光标清空,这就是它触发"该往前挪一格了"的方式。 + await repository.updateReminderRuntime('account-a', 'schedule-a', { + reminder_disposition_state: 'confirmed', + next_trigger_at: null, + snoozed_until: null, + geofence_armed: 0, + disposition_updated_at: '2026-08-10T07:00:00Z', + sync_status: 'synced', + }); + // "现在" 落在第 2 次和第 3 次发生之间:应该拿到第 3 次,不是回退到 start_time + // 或者停在第 2 次。 + vi.useFakeTimers(); + vi.setSystemTime(new Date(Date.parse(START_TIME) + ONE_WEEK_MS * 1.5)); + + const runtime = await store.read('schedule-a'); + + const expectedThirdOccurrence = new Date( + Date.parse(START_TIME) + ONE_WEEK_MS * 2, + ).toISOString(); + expect(runtime?.next_trigger_at).toBe(expectedThirdOccurrence); + // 新 occurrence 的 disposition 必须重置,否则 canDeliver() 会因为上一次是 + // confirmed 就永远拦住这条日程,重复提醒响一次之后就再也不会响了。 + expect(runtime?.reminder_disposition_state).toBeNull(); + expect(runtime?.disposition_updated_at).toBeNull(); + }); + + it('leaves the cursor null once the recurring series has run out of occurrences', async () => { + await repository.applyCloudSchedule(recurringSchedule()); + await repository.updateReminderRuntime('account-a', 'schedule-a', { + reminder_disposition_state: 'confirmed', + next_trigger_at: null, + snoozed_until: null, + geofence_armed: 0, + disposition_updated_at: '2026-08-31T07:00:00Z', + sync_status: 'synced', + }); + // COUNT=4:第 4 次之后系列结束,"现在"设在最后一次之后。 + vi.useFakeTimers(); + vi.setSystemTime(new Date(Date.parse(START_TIME) + ONE_WEEK_MS * 10)); + + const runtime = await store.read('schedule-a'); + + expect(runtime?.next_trigger_at).toBeNull(); + }); + + it('write() persists a runtime patch and setDisposition() preserves the current cursor', async () => { + await repository.applyCloudSchedule(recurringSchedule()); + await store.write('schedule-a', { + reminder_disposition_state: 'pending', + next_trigger_at: '2026-08-17T07:00:00Z', + snoozed_until: null, + geofence_armed: false, + disposition_updated_at: '2026-08-17T07:00:00Z', + sync_status: 'pending', + recorded_location: null, + }); + + await store.setDisposition('schedule-a', { + schedule_id: 'schedule-a', + state: 'confirmed', + updated_at: '2026-08-17T07:05:00Z', + snoozed_until: null, + sync_status: 'pending', + }); + + const stored = await repository.getSchedule('account-a', 'schedule-a'); + expect(stored?.reminder_disposition_state).toBe('confirmed'); + // setDisposition 本身不该动 occurrence 光标,只有 confirmInternal 自己那次 + // 显式的 write() 才会把它清空。 + expect(stored?.next_trigger_at).toBe('2026-08-17T07:00:00Z'); + }); +}); diff --git a/frontend/tests/unit/app/AppRoot.test.tsx b/frontend/tests/unit/app/AppRoot.test.tsx index 2368b47c..ce522c1a 100644 --- a/frontend/tests/unit/app/AppRoot.test.tsx +++ b/frontend/tests/unit/app/AppRoot.test.tsx @@ -9,7 +9,12 @@ import { openTimeflowDatabase } from '../../../src/infrastructure/database'; jest.mock('../../../src/infrastructure/database', () => ({ openTimeflowDatabase: jest.fn<() => Promise>().mockResolvedValue({}), })); -jest.mock('../../../src/features/schedule/data', () => ({ ScheduleLocalRepository: jest.fn() })); +jest.mock('../../../src/features/schedule/data', () => ({ + ScheduleLocalRepository: jest.fn().mockImplementation(() => ({ + getSchedule: jest.fn<() => Promise>().mockResolvedValue(null), + listSchedules: jest.fn<() => Promise>().mockResolvedValue([]), + })), +})); jest.mock('../../../src/features/schedule/application', () => ({ SqliteScheduleClientService: jest.fn(), })); diff --git a/frontend/tests/unit/features/assistant/application/AssistantContinuousConversationService.test.ts b/frontend/tests/unit/features/assistant/application/AssistantContinuousConversationService.test.ts index 221c66f6..814b8571 100644 --- a/frontend/tests/unit/features/assistant/application/AssistantContinuousConversationService.test.ts +++ b/frontend/tests/unit/features/assistant/application/AssistantContinuousConversationService.test.ts @@ -444,6 +444,137 @@ describe('AssistantContinuousConversationService', () => { service.dispose(); }); + it('stops immediately and drops queued chunks when TTS is canceled', async () => { + const fake = createFakeConnection(); + const deps = createDeps({ connection: fake.connection }); + const service = new AssistantContinuousConversationService({ accountId: 'acc_001' }, deps); + const calls: string[] = []; + let resolveFirst: () => void = () => {}; + (deps.playback.pushChunk as jest.Mock) + .mockImplementationOnce( + () => + new Promise((resolve) => { + calls.push('push-1'); + resolveFirst = resolve; + }), + ) + .mockImplementationOnce(async () => { + calls.push('push-2'); + }); + (deps.playback.stop as jest.Mock).mockImplementation(async () => { + calls.push('stop'); + }); + + await startListening(fake, service); + fake.emitMessage({ + audio_id: 'audio_001', + conversation_id: 'conv_001', + payload: { + format: 'pcm_s16le', + purpose: 'reply', + sample_rate_hz: 24000, + speech_text: '', + }, + type: 'voice.tts.start', + } as AssistantServerMessage); + await flushAsync(); + fake.emitAudioFrame(new ArrayBuffer(4)); + fake.emitAudioFrame(new ArrayBuffer(4)); + await flushAsync(); + + fake.emitMessage({ + audio_id: 'audio_001', + conversation_id: 'conv_001', + type: 'voice.tts.canceled', + } as AssistantServerMessage); + expect(calls).toEqual(['push-1', 'stop']); + + resolveFirst(); + await flushAsync(); + + expect(calls).toEqual(['push-1', 'stop']); + expect(service.getState()).toEqual({ conversationId: 'conv_001', phase: 'interrupted' }); + service.dispose(); + }); + + it('ignores the canceled stream end that arrives after interruption', async () => { + const fake = createFakeConnection(); + const deps = createDeps({ connection: fake.connection }); + const service = new AssistantContinuousConversationService({ accountId: 'acc_001' }, deps); + + await startListening(fake, service); + fake.emitMessage({ + audio_id: 'audio_001', + conversation_id: 'conv_001', + payload: { + format: 'pcm_s16le', + purpose: 'reply', + sample_rate_hz: 24000, + speech_text: '', + }, + type: 'voice.tts.start', + } as AssistantServerMessage); + fake.emitMessage({ + audio_id: 'audio_001', + conversation_id: 'conv_001', + type: 'voice.tts.canceled', + } as AssistantServerMessage); + fake.emitMessage({ + audio_id: 'audio_001', + conversation_id: 'conv_001', + type: 'voice.tts.end', + } as AssistantServerMessage); + await flushAsync(); + + expect(deps.playback.endStream).not.toHaveBeenCalled(); + expect(service.getState()).toEqual({ conversationId: 'conv_001', phase: 'interrupted' }); + service.dispose(); + }); + + it('does not stop a newer TTS when a late cancellation belongs to the old audio', async () => { + const fake = createFakeConnection(); + const deps = createDeps({ connection: fake.connection }); + const service = new AssistantContinuousConversationService({ accountId: 'acc_001' }, deps); + + await startListening(fake, service); + const startMessage = (audioId: string): AssistantServerMessage => + ({ + audio_id: audioId, + conversation_id: 'conv_001', + payload: { + format: 'pcm_s16le', + purpose: 'reply', + sample_rate_hz: 24000, + speech_text: '', + }, + type: 'voice.tts.start', + }) as AssistantServerMessage; + + fake.emitMessage(startMessage('audio_001')); + fake.emitMessage({ + audio_id: 'audio_001', + conversation_id: 'conv_001', + type: 'voice.tts.end', + } as AssistantServerMessage); + fake.emitMessage(startMessage('audio_002')); + fake.emitMessage({ + audio_id: 'audio_001', + conversation_id: 'conv_001', + type: 'voice.tts.canceled', + } as AssistantServerMessage); + // 兼容尚未升级的后端:旧实现会把这种晚到的取消发成空 id,不能把新流停掉。 + fake.emitMessage({ + audio_id: '', + conversation_id: 'conv_001', + type: 'voice.tts.canceled', + } as AssistantServerMessage); + await flushAsync(); + + expect(deps.playback.stop).not.toHaveBeenCalled(); + expect(service.getState()).toEqual({ conversationId: 'conv_001', phase: 'speaking' }); + service.dispose(); + }); + it('handleClose() unsubscribes from the shared connection before nulling it, even when a real disconnect races endTurn()', async () => { const fake = createFakeConnection(); const deps = createDeps({ connection: fake.connection }); diff --git a/frontend/tests/unit/features/assistant/application/AssistantConversationService.test.ts b/frontend/tests/unit/features/assistant/application/AssistantConversationService.test.ts index 86d743c9..02a266de 100644 --- a/frontend/tests/unit/features/assistant/application/AssistantConversationService.test.ts +++ b/frontend/tests/unit/features/assistant/application/AssistantConversationService.test.ts @@ -68,6 +68,9 @@ function createFakeConnection() { return { closeCalls, connection, + emitAudioFrame: (chunk: ArrayBuffer) => { + for (const handler of audioHandlers) handler(chunk); + }, emitClose: (event: { code: number; reason: string }) => { for (const handler of closeHandlers) handler(event); }, @@ -170,6 +173,34 @@ describe('AssistantConversationService', () => { expect(service.getState()).toEqual({ message: '连接已断开(1006)', phase: 'error' }); }); + it('unsubscribes the old Push-to-talk listeners after a mode-switch disconnect', async () => { + const fake = createFakeConnection(); + const deps = createDeps({ connection: fake.connection }); + const service = new AssistantConversationService({ accountId: 'acc_001' }, deps); + + await completeStreamStart(fake, service.startTurn()); + // AuthenticatedWebSocketClient 切到 continuous 时会关闭当前 push_to_talk 连接。 + fake.emitClose({ code: 1000, reason: '' }); + expect(fake.unsubscribeCalls).toEqual({ audio: 1, close: 1, message: 1 }); + + // 模拟新连接上的 TTS:旧服务绝不能再收到它,否则会与连续对话重叠播放。 + fake.emitMessage({ + audio_id: 'audio_002', + conversation_id: 'conv_002', + payload: { + format: 'pcm_s16le', + purpose: 'reply', + sample_rate_hz: 24000, + speech_text: '', + }, + type: 'voice.tts.start', + } as AssistantServerMessage); + fake.emitAudioFrame(new ArrayBuffer(4)); + + expect(deps.playback.startStream).not.toHaveBeenCalled(); + expect(deps.playback.pushChunk).not.toHaveBeenCalled(); + }); + it('endTurn does not hang waiting on a startTurn that never got voice.stream.started', async () => { const fake = createFakeConnection(); const deps = createDeps({ connection: fake.connection }); diff --git a/frontend/tests/unit/features/reminder/useReminderPermissionsOnLaunch.test.ts b/frontend/tests/unit/features/reminder/useReminderPermissionsOnLaunch.test.ts deleted file mode 100644 index 831b5244..00000000 --- a/frontend/tests/unit/features/reminder/useReminderPermissionsOnLaunch.test.ts +++ /dev/null @@ -1,310 +0,0 @@ -import { act, renderHook } from '@testing-library/react-native'; -import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals'; -import { Alert, AppState, Platform, type AlertButton } from 'react-native'; - -import type { - DeviceCapabilityPort, - DeviceCapabilityStatus, - DevicePermission, -} from '../../../../src/features/reminder/application/interfaces'; -import { useReminderPermissionsOnLaunch } from '../../../../src/features/reminder/presentation/useReminderPermissionsOnLaunch'; - -const ALERT_OPTIONS = expect.objectContaining({ - cancelable: true, - onDismiss: expect.any(Function), -}); - -function deniedPermissions(): Record { - return { - notifications: false, - exact_alarm: false, - overlay: false, - full_screen: false, - battery_optimization: false, - location_foreground: false, - location_background: false, - }; -} - -function grantedPermissions(): Record { - return { - notifications: true, - exact_alarm: true, - overlay: true, - full_screen: true, - battery_optimization: true, - location_foreground: true, - location_background: true, - }; -} - -type FakeDevice = DeviceCapabilityPort & { status: DeviceCapabilityStatus }; - -function createDevice( - status: Partial & Pick = { - platform: 'android', - }, -): FakeDevice { - const device: FakeDevice = { - status: { - platform: status.platform, - supported: status.supported ?? status.platform === 'android', - permissions: { ...deniedPermissions(), ...status.permissions }, - background_execution: status.background_execution ?? true, - }, - getStatus: jest.fn(async () => device.status), - requestPermission: jest.fn(async (permission: DevicePermission) => { - device.status = { - ...device.status, - permissions: { ...device.status.permissions, [permission]: true }, - }; - return true; - }), - openSettings: jest.fn(async () => true), - }; - return device; -} - -let alertButtons: readonly AlertButton[] = []; -let dismissAlertCallback: (() => void) | undefined; -const appStateListeners: ((state: string) => void)[] = []; - -async function flush(ms = 0): Promise { - await act(async () => { - if (ms > 0) { - jest.advanceTimersByTime(ms); - } - for (let index = 0; index < 8; index += 1) { - await Promise.resolve(); - } - }); -} - -async function press(label: string): Promise { - await act(async () => { - alertButtons.find((button) => button.text === label)?.onPress?.(); - await Promise.resolve(); - await Promise.resolve(); - }); -} - -async function dismissAlert(): Promise { - await act(async () => { - dismissAlertCallback?.(); - await Promise.resolve(); - await Promise.resolve(); - }); -} - -describe('useReminderPermissionsOnLaunch', () => { - beforeEach(() => { - alertButtons = []; - appStateListeners.length = 0; - Platform.OS = 'android'; - dismissAlertCallback = undefined; - jest.spyOn(Alert, 'alert').mockImplementation((_title, _message, buttons, options) => { - alertButtons = buttons ?? []; - dismissAlertCallback = options?.onDismiss; - }); - jest.spyOn(AppState, 'addEventListener').mockImplementation((_event, listener) => { - appStateListeners.push(listener as (state: string) => void); - return { remove: jest.fn() } as ReturnType; - }); - }); - - afterEach(() => { - jest.useRealTimers(); - jest.restoreAllMocks(); - }); - - it('does nothing off Android or without a device', async () => { - jest.useFakeTimers(); - const androidDevice = createDevice(); - Platform.OS = 'ios'; - renderHook(() => useReminderPermissionsOnLaunch(androidDevice)); - Platform.OS = 'android'; - renderHook(() => useReminderPermissionsOnLaunch(null)); - await flush(1_000); - expect(androidDevice.getStatus).not.toHaveBeenCalled(); - }); - - it('requests notifications directly without an explanation alert', async () => { - jest.useFakeTimers(); - const device = createDevice({ platform: 'android', supported: true }); - renderHook(() => useReminderPermissionsOnLaunch(device)); - await flush(600); - expect(Alert.alert).not.toHaveBeenCalled(); - expect(device.requestPermission).toHaveBeenCalledWith('notifications'); - }); - - it('asks before opening settings for exact alarm, then resumes on app active', async () => { - jest.useFakeTimers(); - const device = createDevice({ - platform: 'android', - supported: true, - permissions: { ...deniedPermissions(), notifications: true }, - }); - renderHook(() => useReminderPermissionsOnLaunch(device)); - await flush(600); - expect(Alert.alert).toHaveBeenCalledWith( - '需要精确闹钟权限', - expect.any(String), - expect.any(Array), - ALERT_OPTIONS, - ); - await press('去授权'); - expect(device.openSettings).toHaveBeenCalledWith('exact_alarm'); - expect(device.requestPermission).not.toHaveBeenCalled(); - - device.status = { - ...device.status, - permissions: { ...device.status.permissions, exact_alarm: true }, - }; - await act(async () => { - appStateListeners.forEach((listener) => listener('active')); - await Promise.resolve(); - }); - await flush(300); - expect(Alert.alert).toHaveBeenCalledWith( - '需要悬浮窗权限', - expect.any(String), - expect.any(Array), - ALERT_OPTIONS, - ); - }); - - it('skips a permission when the user declines the explanation alert', async () => { - jest.useFakeTimers(); - const device = createDevice({ - platform: 'android', - supported: true, - permissions: { ...deniedPermissions(), notifications: true }, - }); - renderHook(() => useReminderPermissionsOnLaunch(device)); - await flush(600); - await press('暂不'); - await flush(250); - expect(device.openSettings).not.toHaveBeenCalled(); - expect(Alert.alert).toHaveBeenCalledWith( - '需要悬浮窗权限', - expect.any(String), - expect.any(Array), - ALERT_OPTIONS, - ); - }); - - it('skips and continues when the explanation alert is dismissed', async () => { - jest.useFakeTimers(); - const device = createDevice({ - platform: 'android', - supported: true, - permissions: { ...deniedPermissions(), notifications: true }, - }); - renderHook(() => useReminderPermissionsOnLaunch(device)); - await flush(600); - await dismissAlert(); - await flush(250); - expect(device.openSettings).not.toHaveBeenCalled(); - expect(Alert.alert).toHaveBeenCalledWith( - '需要悬浮窗权限', - expect.any(String), - expect.any(Array), - ALERT_OPTIONS, - ); - }); - - it('skips notifications and continues when requestPermission rejects', async () => { - jest.useFakeTimers(); - const device = createDevice({ platform: 'android', supported: true }); - device.requestPermission = jest.fn(async () => { - throw new Error('native prompt failed'); - }); - renderHook(() => useReminderPermissionsOnLaunch(device)); - await flush(600); - expect(device.requestPermission).toHaveBeenCalledWith('notifications'); - await flush(350); - expect(Alert.alert).toHaveBeenCalledWith( - '需要精确闹钟权限', - expect.any(String), - expect.any(Array), - ALERT_OPTIONS, - ); - }); - - it('skips and continues when openSettings returns false', async () => { - jest.useFakeTimers(); - const device = createDevice({ - platform: 'android', - supported: true, - permissions: { ...deniedPermissions(), notifications: true }, - }); - device.openSettings = jest.fn(async () => false); - renderHook(() => useReminderPermissionsOnLaunch(device)); - await flush(600); - await press('去授权'); - expect(device.openSettings).toHaveBeenCalledWith('exact_alarm'); - await flush(200); - expect(Alert.alert).toHaveBeenCalledWith( - '需要悬浮窗权限', - expect.any(String), - expect.any(Array), - ALERT_OPTIONS, - ); - }); - - it('skips and continues when openSettings throws', async () => { - jest.useFakeTimers(); - const device = createDevice({ - platform: 'android', - supported: true, - permissions: { ...deniedPermissions(), notifications: true }, - }); - device.openSettings = jest.fn(async () => { - throw new Error('settings unavailable'); - }); - renderHook(() => useReminderPermissionsOnLaunch(device)); - await flush(600); - await press('去授权'); - await flush(200); - expect(Alert.alert).toHaveBeenCalledWith( - '需要悬浮窗权限', - expect.any(String), - expect.any(Array), - ALERT_OPTIONS, - ); - }); - - it('does not run delayed prompts after unmount', async () => { - jest.useFakeTimers(); - const device = createDevice(); - const { unmount } = renderHook(() => useReminderPermissionsOnLaunch(device)); - unmount(); - await flush(1_000); - expect(device.getStatus).not.toHaveBeenCalled(); - }); - - it('cancels pending follow-up prompts on unmount', async () => { - jest.useFakeTimers(); - const device = createDevice(); - const { unmount } = renderHook(() => useReminderPermissionsOnLaunch(device)); - await flush(600); - expect(device.getStatus).toHaveBeenCalledTimes(1); - unmount(); - await flush(1_000); - expect(device.getStatus).toHaveBeenCalledTimes(1); - expect(Alert.alert).not.toHaveBeenCalled(); - }); - - it('does not prompt when every permission is already granted', async () => { - jest.useFakeTimers(); - const device = createDevice({ - platform: 'android', - supported: true, - permissions: grantedPermissions(), - }); - renderHook(() => useReminderPermissionsOnLaunch(device)); - await flush(600); - expect(device.requestPermission).not.toHaveBeenCalled(); - expect(Alert.alert).not.toHaveBeenCalled(); - }); -}); diff --git a/frontend/tests/unit/infrastructure/notifications/nativeAlarmScheduler.test.ts b/frontend/tests/unit/infrastructure/notifications/nativeAlarmScheduler.test.ts deleted file mode 100644 index b9b2ff62..00000000 --- a/frontend/tests/unit/infrastructure/notifications/nativeAlarmScheduler.test.ts +++ /dev/null @@ -1,257 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals'; -import { NativeModules, Platform } from 'react-native'; - -import type { AlarmScheduleRequest } from '../../../../src/features/reminder/application/interfaces'; -import { NativeAlarmScheduler } from '../../../../src/infrastructure/notifications/NativeAlarmScheduler'; -import { - isTimeflowAlarmAvailable, - nativeAreAlarmPermissionsGranted, - nativeCancelAlarm, - nativeScheduleAlarm, -} from '../../../../src/infrastructure/notifications/native/TimeflowAlarmBridge'; - -jest.mock('react-native', () => { - const RN = jest.requireActual('react-native') as typeof import('react-native'); - RN.NativeModules.TimeflowAlarm = { - schedule: jest.fn(), - cancel: jest.fn(), - getPermissionStatus: jest.fn(), - openPermissionSettings: jest.fn(), - requestNotificationPermission: jest.fn(), - }; - return RN; -}); - -type NativeAlarmMock = { - schedule: jest.MockedFunction< - (triggerAtMillis: number, title?: string | null) => Promise<{ alarmId: string }> - >; - cancel: jest.MockedFunction<(alarmId: string) => Promise>; - getPermissionStatus: jest.MockedFunction< - () => Promise<{ - exactAlarm: boolean; - overlay: boolean; - fullScreen: boolean; - notifications: boolean; - battery: boolean; - }> - >; -}; - -const NOW = '2026-08-13T08:00:00.000Z'; -const FUTURE = '2026-08-13T09:00:00.000Z'; -const PAST = '2026-08-13T07:00:00.000Z'; - -const native = NativeModules.TimeflowAlarm as NativeAlarmMock; - -function request(overrides: Partial = {}): AlarmScheduleRequest { - return { - schedule_id: 'schedule-1', - trigger_at: FUTURE, - title: '晨会', - exact: true, - ...overrides, - }; -} - -function grantPermissions( - overrides: Partial<{ - exactAlarm: boolean; - overlay: boolean; - fullScreen: boolean; - notifications: boolean; - battery: boolean; - }> = {}, -) { - native.getPermissionStatus.mockResolvedValue({ - exactAlarm: true, - overlay: false, - fullScreen: false, - notifications: true, - battery: false, - ...overrides, - }); -} - -describe('TimeflowAlarmBridge and NativeAlarmScheduler', () => { - beforeEach(() => { - jest.useFakeTimers(); - jest.setSystemTime(new Date(NOW)); - Platform.OS = 'android'; - native.schedule.mockReset(); - native.cancel.mockReset(); - native.getPermissionStatus.mockReset(); - grantPermissions(); - native.schedule.mockResolvedValue({ alarmId: 'alarm-1' }); - native.cancel.mockResolvedValue(true); - }); - - afterEach(() => { - jest.useRealTimers(); - Platform.OS = 'android'; - }); - - it('reports the bridge unavailable off Android', () => { - Platform.OS = 'ios'; - expect(isTimeflowAlarmAvailable()).toBe(false); - }); - - it('returns unscheduled when the native module is unavailable', async () => { - Platform.OS = 'ios'; - const scheduler = new NativeAlarmScheduler(); - await expect(scheduler.schedule(request())).resolves.toEqual({ - alarm_id: '', - schedule_id: 'schedule-1', - scheduled: false, - }); - expect(native.getPermissionStatus).not.toHaveBeenCalled(); - expect(native.schedule).not.toHaveBeenCalled(); - }); - - it('returns unscheduled for an invalid trigger time', async () => { - const scheduler = new NativeAlarmScheduler(); - await expect(scheduler.schedule(request({ trigger_at: 'not-a-date' }))).resolves.toEqual({ - alarm_id: '', - schedule_id: 'schedule-1', - scheduled: false, - }); - expect(native.schedule).not.toHaveBeenCalled(); - }); - - it('returns unscheduled for a trigger at or before now', async () => { - const scheduler = new NativeAlarmScheduler(); - await expect(scheduler.schedule(request({ trigger_at: PAST }))).resolves.toEqual({ - alarm_id: '', - schedule_id: 'schedule-1', - scheduled: false, - }); - await expect(scheduler.schedule(request({ trigger_at: NOW }))).resolves.toEqual({ - alarm_id: '', - schedule_id: 'schedule-1', - scheduled: false, - }); - expect(native.schedule).not.toHaveBeenCalled(); - }); - - it('returns unscheduled when exact-alarm permission is missing', async () => { - grantPermissions({ exactAlarm: false }); - const scheduler = new NativeAlarmScheduler(); - await expect(scheduler.schedule(request())).resolves.toEqual({ - alarm_id: '', - schedule_id: 'schedule-1', - scheduled: false, - }); - expect(native.schedule).not.toHaveBeenCalled(); - }); - - it('returns unscheduled when notification permission is missing', async () => { - grantPermissions({ notifications: false }); - const scheduler = new NativeAlarmScheduler(); - await expect(scheduler.schedule(request())).resolves.toEqual({ - alarm_id: '', - schedule_id: 'schedule-1', - scheduled: false, - }); - expect(native.schedule).not.toHaveBeenCalled(); - }); - - it('does not require overlay, full-screen, or battery permission to schedule', async () => { - grantPermissions({ overlay: false, fullScreen: false, battery: false }); - await expect(nativeAreAlarmPermissionsGranted()).resolves.toBe(true); - const scheduler = new NativeAlarmScheduler(); - await expect(scheduler.schedule(request())).resolves.toEqual({ - alarm_id: 'alarm-1', - schedule_id: 'schedule-1', - scheduled: true, - }); - }); - - it('schedules a future alarm and returns scheduled: true', async () => { - const scheduler = new NativeAlarmScheduler(); - await expect(scheduler.schedule(request())).resolves.toEqual({ - alarm_id: 'alarm-1', - schedule_id: 'schedule-1', - scheduled: true, - }); - expect(native.schedule).toHaveBeenCalledWith(Date.parse(FUTURE), '晨会'); - }); - - it('maps a native schedule rejection to unscheduled', async () => { - native.schedule.mockRejectedValue(new Error('exact alarm denied')); - const scheduler = new NativeAlarmScheduler(); - await expect(scheduler.schedule(request())).resolves.toEqual({ - alarm_id: '', - schedule_id: 'schedule-1', - scheduled: false, - }); - await expect(nativeScheduleAlarm(Date.parse(FUTURE), '晨会')).resolves.toBeNull(); - }); - - it('maps an empty native alarm id to unscheduled', async () => { - native.schedule.mockResolvedValue({ alarmId: '' }); - const scheduler = new NativeAlarmScheduler(); - await expect(scheduler.schedule(request())).resolves.toEqual({ - alarm_id: '', - schedule_id: 'schedule-1', - scheduled: false, - }); - }); - - it('maps a permission-status rejection to unscheduled', async () => { - native.getPermissionStatus.mockRejectedValue(new Error('status unavailable')); - const scheduler = new NativeAlarmScheduler(); - await expect(scheduler.schedule(request())).resolves.toEqual({ - alarm_id: '', - schedule_id: 'schedule-1', - scheduled: false, - }); - expect(native.schedule).not.toHaveBeenCalled(); - }); - - it('forwards cancel true and false from the native module', async () => { - const scheduler = new NativeAlarmScheduler(); - native.cancel.mockResolvedValueOnce(true); - await expect(scheduler.cancel('alarm-1')).resolves.toEqual({ cancelled: true }); - native.cancel.mockResolvedValueOnce(false); - await expect(scheduler.cancel('alarm-1')).resolves.toEqual({ cancelled: false }); - expect(native.cancel).toHaveBeenCalledTimes(2); - }); - - it('returns cancelled: false when cancel is called with an empty id', async () => { - const scheduler = new NativeAlarmScheduler(); - await expect(scheduler.cancel(null)).resolves.toEqual({ cancelled: false }); - await expect(scheduler.cancel('')).resolves.toEqual({ cancelled: false }); - expect(native.cancel).not.toHaveBeenCalled(); - }); - - it('maps a native cancel rejection to cancelled: false', async () => { - native.cancel.mockRejectedValue(new Error('cancel failed')); - const scheduler = new NativeAlarmScheduler(); - await expect(scheduler.cancel('alarm-1')).resolves.toEqual({ cancelled: false }); - await expect(nativeCancelAlarm('alarm-1')).resolves.toBe(false); - }); - - it('rebuilds mixed requests in order', async () => { - native.schedule.mockResolvedValueOnce({ alarmId: 'alarm-ok' }).mockResolvedValueOnce({ - alarmId: 'alarm-later', - }); - const scheduler = new NativeAlarmScheduler(); - const receipts = await scheduler.rebuild([ - request({ schedule_id: 'ok' }), - request({ schedule_id: 'expired', trigger_at: PAST }), - request({ schedule_id: 'later', trigger_at: '2026-08-13T10:00:00.000Z', title: '午会' }), - ]); - expect(receipts).toEqual([ - { alarm_id: 'alarm-ok', schedule_id: 'ok', scheduled: true }, - { alarm_id: '', schedule_id: 'expired', scheduled: false }, - { alarm_id: 'alarm-later', schedule_id: 'later', scheduled: true }, - ]); - expect(native.schedule).toHaveBeenCalledTimes(2); - expect(native.schedule).toHaveBeenNthCalledWith(1, Date.parse(FUTURE), '晨会'); - expect(native.schedule).toHaveBeenNthCalledWith( - 2, - Date.parse('2026-08-13T10:00:00.000Z'), - '午会', - ); - }); -}); diff --git a/frontend/tests/unit/infrastructure/notifications/nativeDeviceCapability.test.ts b/frontend/tests/unit/infrastructure/notifications/nativeDeviceCapability.test.ts deleted file mode 100644 index a7860009..00000000 --- a/frontend/tests/unit/infrastructure/notifications/nativeDeviceCapability.test.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { beforeEach, describe, expect, it, jest } from '@jest/globals'; -import { Platform } from 'react-native'; - -import { NativeDeviceCapability } from '../../../../src/infrastructure/notifications/NativeDeviceCapability'; -import { - isTimeflowAlarmAvailable, - nativeGetAlarmPermissionStatus, - nativeOpenAlarmPermissionSettings, - nativeRequestNotificationPermission, -} from '../../../../src/infrastructure/notifications/native/TimeflowAlarmBridge'; - -jest.mock('../../../../src/infrastructure/notifications/native/TimeflowAlarmBridge', () => ({ - isTimeflowAlarmAvailable: jest.fn(), - nativeGetAlarmPermissionStatus: jest.fn(), - nativeOpenAlarmPermissionSettings: jest.fn(), - nativeRequestNotificationPermission: jest.fn(), -})); - -const available = isTimeflowAlarmAvailable as jest.MockedFunction; -const getStatus = nativeGetAlarmPermissionStatus as jest.MockedFunction< - typeof nativeGetAlarmPermissionStatus ->; -const openSettings = nativeOpenAlarmPermissionSettings as jest.MockedFunction< - typeof nativeOpenAlarmPermissionSettings ->; -const requestNotifications = nativeRequestNotificationPermission as jest.MockedFunction< - typeof nativeRequestNotificationPermission ->; - -describe('NativeDeviceCapability', () => { - beforeEach(() => { - Platform.OS = 'android'; - available.mockReset().mockReturnValue(true); - getStatus.mockReset(); - openSettings.mockReset().mockResolvedValue(true); - requestNotifications.mockReset().mockResolvedValue(true); - }); - - it('reports unsupported when the native module is unavailable', async () => { - available.mockReturnValue(false); - const device = new NativeDeviceCapability(); - await expect(device.getStatus()).resolves.toMatchObject({ - platform: 'android', - supported: false, - background_execution: false, - permissions: { - notifications: false, - exact_alarm: false, - overlay: false, - full_screen: false, - battery_optimization: false, - location_foreground: false, - location_background: false, - }, - }); - expect(getStatus).not.toHaveBeenCalled(); - }); - - it('reports unsupported when permission status is missing', async () => { - getStatus.mockResolvedValue(null); - const device = new NativeDeviceCapability(); - await expect(device.getStatus()).resolves.toMatchObject({ - supported: false, - background_execution: false, - }); - }); - - it('reports unsupported when the native status read rejects', async () => { - getStatus.mockRejectedValue(new Error('bridge unavailable')); - const device = new NativeDeviceCapability(); - await expect(device.getStatus()).resolves.toMatchObject({ - platform: 'android', - supported: false, - background_execution: false, - permissions: { - notifications: false, - exact_alarm: false, - overlay: false, - full_screen: false, - battery_optimization: false, - location_foreground: false, - location_background: false, - }, - }); - }); - - it('maps native permission flags onto the device port', async () => { - getStatus.mockResolvedValue({ - exactAlarm: true, - overlay: false, - fullScreen: true, - notifications: true, - battery: true, - }); - const device = new NativeDeviceCapability(); - await expect(device.getStatus()).resolves.toEqual({ - platform: 'android', - supported: true, - background_execution: true, - permissions: { - notifications: true, - exact_alarm: true, - overlay: false, - full_screen: true, - battery_optimization: true, - location_foreground: false, - location_background: false, - }, - }); - }); - - it('requests notification permission through the native prompt', async () => { - const device = new NativeDeviceCapability(); - await expect(device.requestPermission('notifications')).resolves.toBe(true); - expect(requestNotifications).toHaveBeenCalledTimes(1); - expect(openSettings).not.toHaveBeenCalled(); - }); - - it('returns false when the native notification permission request rejects', async () => { - requestNotifications.mockRejectedValue(new Error('prompt failed')); - const device = new NativeDeviceCapability(); - await expect(device.requestPermission('notifications')).resolves.toBe(false); - }); - - it('returns false when opening settings rejects', async () => { - openSettings.mockRejectedValue(new Error('intent failed')); - const device = new NativeDeviceCapability(); - await expect(device.openSettings('overlay')).resolves.toBe(false); - await expect(device.requestPermission('exact_alarm')).resolves.toBe(false); - }); - - it('opens the matching settings page for non-notification permissions', async () => { - const device = new NativeDeviceCapability(); - await expect(device.requestPermission('exact_alarm')).resolves.toBe(true); - await expect(device.openSettings('overlay')).resolves.toBe(true); - await expect(device.openSettings('full_screen')).resolves.toBe(true); - await expect(device.openSettings('battery_optimization')).resolves.toBe(true); - await expect(device.openSettings('location_foreground')).resolves.toBe(true); - expect(openSettings.mock.calls.map((call) => call[0])).toEqual([ - 'exactAlarm', - 'overlay', - 'fullScreen', - 'battery', - 'app', - ]); - }); -}); diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index db2da661..0d62f629 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -2,5 +2,6 @@ "extends": "./node_modules/expo/tsconfig.base.json", "compilerOptions": { "strict": true - } + }, + "exclude": ["scripts"] }