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/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/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/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/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"] }