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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 32 additions & 1 deletion README.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ Say it, and it is scheduled. When the time comes, it keeps the promise.
- [Requirements](#requirements)
- [Getting started](#getting-started)
- [Pointing the app at a local API](#pointing-the-app-at-a-local-api)
- [Client reminder telemetry (Sentry)](#client-reminder-telemetry-sentry)
- [Enabling real voice](#enabling-real-voice)
- [Running the API on the host](#running-the-api-on-the-host)
- [Quality gates](#quality-gates)
Expand Down Expand Up @@ -191,6 +192,28 @@ A new username creates an account. An existing username verifies the password.

On a physical device, allow port `8000` through the host firewall, keep phone and computer on the same network, and make sure the API listens on `0.0.0.0` (Compose does). `EXPO_PUBLIC_DEVICE_ID` can stay `device_001`; it identifies the WebSocket session.

## Client reminder telemetry (Sentry)

Whether a reminder fires through the native or JS channel, whether it rang in the background, and which OEM permissions are missing, is recorded on the client in Sentry. Grafana dashboards still chart server Prometheus only. Leave the DSN unset to keep the SDK off; that is the default for local development.

Copy the DSN from the Sentry project **Settings → Client Keys (DSN)** into `frontend/.env` (do not commit that file):

```bash
# frontend/.env
EXPO_PUBLIC_SENTRY_DSN=https://<key>@o<org>.ingest.sentry.io/<project>
```

See [frontend/.env.example](frontend/.env.example). After changing `EXPO_PUBLIC_*`, rebuild the app (`npm run android`) so Expo bakes the value in.

Never put a real DSN in the README or commit it. Events use closed-enum tags only (manufacturer, permission, channel, outcome). They do not include account, session, transcript, schedule titles, or coordinates.

Optional and unrelated to sending events:

| Variable | Where | Role |
| --- | --- | --- |
| `SENTRY_ORG` / `SENTRY_PROJECT` | Build env | Enables the `@sentry/react-native` plugin for source maps. Unset keeps the plugin off; the DSN can still send events |
| `SENTRY_AUTH_TOKEN` | Root `.env` | Grafana Sentry datasource reads Issues; **not** the client DSN |

## Enabling real voice

Compose injects database, JWT, and CORS into the API container. It does **not** pass Aliyun keys, so `docker compose up` uses the stand-in agent in development.
Expand Down Expand Up @@ -292,7 +315,7 @@ There are three templates, depending on how you run things:
| --- | --- |
| [`.env.example`](.env.example) | `docker compose`: database, API, JWT, CORS, observability |
| [`backend/.env.example`](backend/.env.example) | Host `uvicorn`: database URL, JWT, voice, maps, tracing |
| [`frontend/.env.example`](frontend/.env.example) | Android client: API / WebSocket URLs, device ID |
| [`frontend/.env.example`](frontend/.env.example) | Android client: API / WebSocket URLs, device ID, optional `EXPO_PUBLIC_SENTRY_DSN` |

Compose interpolates the root `.env` for `${VAR}` substitution. The API container only receives the backend allowlist in `docker-compose.yml`; `GRAFANA_*` and `SENTRY_AUTH_TOKEN` go to Grafana only.

Expand All @@ -311,6 +334,14 @@ Compose interpolates the root `.env` for `${VAR}` substitution. The API containe
| `SENTRY_AUTH_TOKEN` | Grafana → sentry.io; not the app DSN |
| `TIMEFLOW_OTEL_EXPORTER_OTLP_ENDPOINT` | Empty disables traces; the overlay defaults to `http://tempo:4318` |

`frontend/.env` values the app reads:

| Variable | Notes |
| --- | --- |
| `EXPO_PUBLIC_API_URL` / `EXPO_PUBLIC_WS_URL` | See [Pointing the app at a local API](#pointing-the-app-at-a-local-api) |
| `EXPO_PUBLIC_DEVICE_ID` | WebSocket session id; `device_001` is fine in development |
| `EXPO_PUBLIC_SENTRY_DSN` | Optional; empty keeps the Sentry SDK off. Copy from Sentry Client Keys; do not commit |

## Cloud deploy and observability

CI on `main` publishes `ghcr.io/1024xengineer/timeflow-backend:latest`. Grafana, Prometheus, and Tempo stay official images; their config lives under [`observability/`](observability/README.md).
Expand Down
33 changes: 32 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ TimeFlow 是一款语音优先的个人日程助手,帮你用说话的方式
- [环境要求](#环境要求)
- [快速上手](#快速上手)
- [客户端如何连上本机 API](#客户端如何连上本机-api)
- [客户端提醒埋点(Sentry)](#客户端提醒埋点sentry)
- [开启真实语音](#开启真实语音)
- [本机开发后端](#本机开发后端)
- [质量检查](#质量检查)
Expand Down Expand Up @@ -191,6 +192,28 @@ npm start

真机还需要:电脑防火墙放行 `8000`,手机和电脑在同一网络,且后端监听 `0.0.0.0`(Compose 默认如此)。`EXPO_PUBLIC_DEVICE_ID` 可保持 `device_001`,用于 WebSocket 会话标识。

## 客户端提醒埋点(Sentry)

提醒走原生还是 JS 通道、后台是否响铃、缺哪些厂商权限,客户端记到 Sentry。服务端 Grafana 看板仍只画 Prometheus。未配置 DSN 时 SDK 保持关闭,日常开发可以不填。

在 Sentry 项目 **Settings → Client Keys (DSN)** 复制 DSN,写入 `frontend/.env`(该文件不要提交):

```bash
# frontend/.env
EXPO_PUBLIC_SENTRY_DSN=https://<key>@o<org>.ingest.sentry.io/<project>
```

模板见 [frontend/.env.example](frontend/.env.example)。改完 `EXPO_PUBLIC_*` 后需要重新编译客户端(`npm run android`),Expo 才会打进包。

不要把真实 DSN 写进 README 或提交到 Git。事件只带封闭枚举标签(厂商、权限、通道、结果),不上报账号、session、语音原文、日程标题或坐标。

可选、与上报无关:

| 变量 | 位置 | 作用 |
| --- | --- | --- |
| `SENTRY_ORG` / `SENTRY_PROJECT` | 构建环境 | 打开 `@sentry/react-native` 插件以上传 source map;不设则插件关闭,DSN 仍可上报 |
| `SENTRY_AUTH_TOKEN` | 仓库根 `.env` | Grafana Sentry 数据源读 Issues;**不是**客户端 DSN |

## 开启真实语音

Compose 默认只把数据库、JWT 和 CORS 注入 API 容器,**不会**带上阿里云密钥。因此 `docker compose up` 在 development 下走占位助手。
Expand Down Expand Up @@ -293,7 +316,7 @@ npm run test:coverage
| --- | --- |
| [`.env.example`](.env.example) | `docker compose`:数据库、API 端口、JWT、CORS、观测 |
| [`backend/.env.example`](backend/.env.example) | 本机 `uvicorn`:数据库 URL、JWT、语音、地图、追踪 |
| [`frontend/.env.example`](frontend/.env.example) | Android 客户端:API / WebSocket 地址、设备 ID |
| [`frontend/.env.example`](frontend/.env.example) | Android 客户端:API / WebSocket 地址、设备 ID、可选 `EXPO_PUBLIC_SENTRY_DSN` |

仓库根 `.env` 供 Compose 做 `${VAR}` 插值。API 容器只注入 `docker-compose.yml` 列出的后端变量;`GRAFANA_*` / `SENTRY_AUTH_TOKEN` 只进 Grafana。

Expand All @@ -312,6 +335,14 @@ npm run test:coverage
| `SENTRY_AUTH_TOKEN` | Grafana 读 sentry.io;不是 App 的 DSN |
| `TIMEFLOW_OTEL_EXPORTER_OTLP_ENDPOINT` | 空则不上报 trace;观测 overlay 默认 `http://tempo:4318` |

`frontend/.env` 里客户端会读取的项:

| 变量 | 说明 |
| --- | --- |
| `EXPO_PUBLIC_API_URL` / `EXPO_PUBLIC_WS_URL` | 见「客户端如何连上本机 API」 |
| `EXPO_PUBLIC_DEVICE_ID` | WebSocket 会话标识,开发可保持 `device_001` |
| `EXPO_PUBLIC_SENTRY_DSN` | 可选;空则 Sentry SDK 关闭。取值来自 Sentry 项目 Client Keys,不要提交 |

## 云上部署与观测

`main` 上的 CI 会把后端打成 `ghcr.io/1024xengineer/timeflow-backend:latest`。观测三件套是 Grafana / Prometheus / Tempo 官方镜像,配置在 [`observability/`](observability/README.md)。
Expand Down
5 changes: 5 additions & 0 deletions frontend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,8 @@
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

# Optional. Copy the DSN from Sentry → Settings → Client Keys.
# When unset, the Sentry SDK stays disabled (no client events).
# Do not commit frontend/.env. Do not put account, session, transcript, or schedule titles in events.
EXPO_PUBLIC_SENTRY_DSN=
16 changes: 16 additions & 0 deletions frontend/app.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,22 @@ module.exports = {
defaultChannel: 'timeflow-reminders',
},
],
...sentryPlugin(),
],
},
};

function sentryPlugin() {
if (!process.env.SENTRY_ORG || !process.env.SENTRY_PROJECT) {
return [];
}
return [
[
'@sentry/react-native',
{
organization: process.env.SENTRY_ORG,
project: process.env.SENTRY_PROJECT,
},
],
];
}
5 changes: 4 additions & 1 deletion frontend/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,11 @@ import './src/infrastructure/location/reminderGuardTask';

import { registerRootComponent } from 'expo';

import { initSentry, wrapRoot } from './src/infrastructure/observability/initSentry';
import App from './App';

initSentry();

// 注册根组件会向应用注册表登记主组件。
// 无论通过开发容器还是原生构建加载应用,它都会完成必要的运行环境设置。
registerRootComponent(App);
registerRootComponent(wrapRoot(App));
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ final class AlarmContract {
static final String PREFS_NAME = "timeflow_alarms";
static final String ALARMS_KEY = "pending_alarms";
static final String DISPOSITIONS_KEY = "native_dispositions";
/** 后台响铃失败(FGS 被拒等),JS 下次起来再上报 Sentry。不含日程 id。 */
static final String FIRE_ATTEMPTS_KEY = "native_fire_attempts";
static final String ALARM_URI_SCHEME = "timeflow-alarm";
/**
* 自启动/后台弹出界面没有标准 API 能查真实授权状态,这三个 key 只记录
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,33 @@ class AlarmModule(private val reactContext: ReactApplicationContext) :
}
}

@ReactMethod
fun peekNativeFireAttempts(promise: Promise) {
try {
val records = AlarmNativeBridge.peekFireAttempts(reactContext)
val array: WritableArray = Arguments.createArray()
for (record in records) {
val item = Arguments.createMap()
item.putString("result", record.result)
item.putDouble("atMillis", record.atMillis.toDouble())
array.pushMap(item)
}
promise.resolve(array)
} catch (error: Exception) {
promise.reject("PEEK_FIRE_ATTEMPTS_FAILED", error.message, error)
}
}

@ReactMethod
fun ackNativeFireAttempts(promise: Promise) {
try {
AlarmNativeBridge.ackFireAttempts(reactContext)
promise.resolve(true)
} catch (error: Exception) {
promise.reject("ACK_FIRE_ATTEMPTS_FAILED", error.message, error)
}
}

@ReactMethod
fun getPermissionStatus(promise: Promise) {
try {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,93 @@ private static List<JSONObject> loadDispositionObjects(Context context) {
return objects;
}

public static final class FireAttemptRecord {
public final String result;
public final long atMillis;

FireAttemptRecord(String result, long atMillis) {
this.result = result;
this.atMillis = atMillis;
}
}

public static final String RESULT_SERVICE_DENIED = "service_denied";
public static final String RESULT_PRESENT_FAILED = "present_failed";
public static final String RESULT_FALLBACK_NOTIFICATION = "fallback_notification";
private static final int MAX_FIRE_ATTEMPTS = 20;

public static void recordFireAttempt(Context context, String result) {
if (!RESULT_SERVICE_DENIED.equals(result)
&& !RESULT_PRESENT_FAILED.equals(result)
&& !RESULT_FALLBACK_NOTIFICATION.equals(result)) {
return;
}
JSONArray remaining = new JSONArray();
for (JSONObject object : loadFireAttemptObjects(context)) {
remaining.put(object);
}
try {
JSONObject next = new JSONObject();
next.put("result", result);
next.put("at", System.currentTimeMillis());
remaining.put(next);
} catch (JSONException ignored) {
return;
}
JSONArray trimmed = new JSONArray();
int start = Math.max(0, remaining.length() - MAX_FIRE_ATTEMPTS);
for (int index = start; index < remaining.length(); index++) {
try {
trimmed.put(remaining.getJSONObject(index));
} catch (JSONException ignored) {
// skip malformed rows
}
}
context.getSharedPreferences(AlarmContract.PREFS_NAME, Context.MODE_PRIVATE)
.edit()
.putString(AlarmContract.FIRE_ATTEMPTS_KEY, trimmed.toString())
.apply();
}

public static List<FireAttemptRecord> peekFireAttempts(Context context) {
List<FireAttemptRecord> records = new ArrayList<>();
for (JSONObject object : loadFireAttemptObjects(context)) {
String result = object.optString("result", "");
if (result.isEmpty()) continue;
records.add(new FireAttemptRecord(
result,
object.optLong("at", System.currentTimeMillis())
));
}
return records;
}

public static void ackFireAttempts(Context context) {
context.getSharedPreferences(AlarmContract.PREFS_NAME, Context.MODE_PRIVATE)
.edit()
.putString(AlarmContract.FIRE_ATTEMPTS_KEY, "[]")
.apply();
}

private static List<JSONObject> loadFireAttemptObjects(Context context) {
SharedPreferences preferences =
context.getSharedPreferences(AlarmContract.PREFS_NAME, Context.MODE_PRIVATE);
String serialized = preferences.getString(AlarmContract.FIRE_ATTEMPTS_KEY, "[]");
List<JSONObject> objects = 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) {
objects.add((JSONObject) value);
}
}
} catch (JSONException ignored) {
return new ArrayList<>();
}
return objects;
}

public static void stopRinging(Context context) {
AlarmSoundService.stop(context);
RingActivity.finishIfOpen();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ public void onReceive(Context context, Intent intent) {
// 原来这里完全没兜底,抛出去要么被系统吞掉、要么让这条广播直接崩溃退出,
// 日志里连个痕迹都留不下。
Log.w(TAG, "startForegroundService failed for alarmId=" + alarmId, exception);
AlarmNativeBridge.recordFireAttempt(context, AlarmNativeBridge.RESULT_SERVICE_DENIED);
postFallbackNotification(context, title);
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,7 @@ private void presentAlarm(AlarmContract.ExtractedExtras extras) {
// 走到这个 catch,整条链路就是"通知也没弹、声音也没放、震动也没震",
// 跟用户看到的现象完全对得上,但之前没有任何日志能证实。
Log.w(TAG, "presentAlarm failed for alarmId=" + alarmId, exception);
AlarmNativeBridge.recordFireAttempt(this, AlarmNativeBridge.RESULT_PRESENT_FAILED);
// 真的什么都没展示:之前这里完全没告诉 presentNow() 的调用方,JS 侧会把
// 服务启动请求本身当成"已展示",跳过通知兜底——用户彻底看不到任何东西。
AlarmModule.resolvePresentation(alarmId, false);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package com.timeflow.alarm;

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

import android.content.Context;
import android.os.Build;

import androidx.test.core.app.ApplicationProvider;

import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.RobolectricTestRunner;
import org.robolectric.annotation.Config;

import java.util.List;

@RunWith(RobolectricTestRunner.class)
@Config(sdk = Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
public class AlarmNativeBridgeFireAttemptTest {

private Context context;

@Before
public void setUp() {
context = ApplicationProvider.getApplicationContext();
AlarmNativeBridge.ackFireAttempts(context);
}

@Test
public void recordsAndPeeksClosedEnumResultsWithoutScheduleIds() {
AlarmNativeBridge.recordFireAttempt(context, AlarmNativeBridge.RESULT_SERVICE_DENIED);
AlarmNativeBridge.recordFireAttempt(context, "not-a-real-result");
AlarmNativeBridge.recordFireAttempt(context, AlarmNativeBridge.RESULT_PRESENT_FAILED);

List<AlarmNativeBridge.FireAttemptRecord> rows = AlarmNativeBridge.peekFireAttempts(context);
assertEquals(2, rows.size());
assertEquals(AlarmNativeBridge.RESULT_SERVICE_DENIED, rows.get(0).result);
assertEquals(AlarmNativeBridge.RESULT_PRESENT_FAILED, rows.get(1).result);
assertTrue(rows.get(0).atMillis > 0);
}

@Test
public void ackClearsPersistedFireAttempts() {
AlarmNativeBridge.recordFireAttempt(context, AlarmNativeBridge.RESULT_SERVICE_DENIED);
AlarmNativeBridge.ackFireAttempts(context);
assertTrue(AlarmNativeBridge.peekFireAttempts(context).isEmpty());
}
}
Loading
Loading