diff --git a/.gitignore b/.gitignore
index badbaec9..81f6e6b3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,6 +6,13 @@ mobile/src/main/assets/webui
vendor
dist
+# Build environment (not part of the project)
+android-sdk/
+jdk-17.0.18+8/
+cmdline-tools.zip
+jdk17.zip
+apk_extracted/
+
# secrets
secret*
*.key
diff --git a/HANDOVER.md b/HANDOVER.md
new file mode 100644
index 00000000..cf6ba30a
--- /dev/null
+++ b/HANDOVER.md
@@ -0,0 +1,267 @@
+# 工作交接手册 / Handover Manual
+
+> 本文档用于上下文压缩或清零后,让后续模型快速接手本项目工作。
+> This document is for future model continuity after context compression/reset.
+
+---
+
+## 1. 项目概况 / Project Overview
+
+- **项目**: aw-android (ActivityWatch Android 客户端)
+- **仓库路径**: `e:\Project_workspace\aw-android` (Git Bash 内为 `/e/Project_workspace/aw-android`)
+- **原项目**: https://github.com/ActivityWatch/aw-android
+- **本 fork 核心目标**: 在保留本地数据存储的基础上,增加**远程 ActivityWatch 服务器双写**功能,让用户既能用 App 内置 WebUI 查看本地数据,也能在手机外的浏览器查看远程服务器上的手机数据。
+
+---
+
+## 2. 核心改动 / Core Changes
+
+### 2.1 远程服务器双写 (RustInterface.kt)
+
+数据流向:
+```
+采集端 (UsageStatsWatcher / AccessibilityService)
+ ↓
+RustInterface.heartbeatHelper() / createBucketHelper()
+ ├→ JNI 调用本地 aw-server (127.0.0.1:5600) ← 原有逻辑,不变
+ └→ 异步 HTTP POST 到远程服务器 ← 新增逻辑
+```
+
+实现要点:
+- `sendRemoteHeartbeat()` — 在本地 heartbeat 完成后,异步向远程发送 `POST /api/0/buckets/{id}/heartbeat?pulsetime={seconds}`
+- `sendRemoteBucketCreate()` — 在本地 createBucket 完成后,异步向远程发送 `POST /api/0/buckets/{id}`
+- 使用 `Executors.newSingleThreadExecutor()` 作为 fire-and-forget 线程池
+- 失败仅打日志,绝不阻塞本地存储流程
+- 远程地址为空时自动跳过,无性能开销
+
+### 2.2 远程地址配置持久化 (AWPreferences.kt)
+
+在原有 `AWPreferences` 中新增两个方法:
+- `getRemoteServerUrl()` — 从 SharedPreferences 读取 `"remoteServerUrl"`
+- `setRemoteServerUrl(url)` — 写入 `"remoteServerUrl"`
+
+### 2.3 WebUI 地址动态切换 (MainActivity.kt)
+
+`baseURL` 从原来的硬编码常量改为动态属性:
+```kotlin
+private val baseURL: String
+ get() {
+ val remote = AWPreferences(this).getRemoteServerUrl()
+ return if (remote.isNotBlank()) remote else "http://127.0.0.1:5600"
+ }
+```
+这样配置远程服务器后,App 内嵌 WebUI 直接展示远程仪表盘。
+
+### 2.4 远程服务器配置 UI (MainActivity.kt + activity_main_drawer.xml)
+
+- 在导航抽屉的 Misc 分组中新增 **Remote Server** 菜单项
+- 点击弹出 AlertDialog,内含 EditText,可输入远程地址(如 `http://100.122.190.51:5600`)
+- 留空则仅使用本地服务器
+- 保存后弹出 Snackbar 提示当前设置
+
+### 2.5 Toolbar 恢复 (app_bar_main.xml + MainActivity.kt)
+
+原代码中 `Toolbar` 被注释掉,导致原生导航抽屉只能靠左侧边缘滑动触发,且容易被 WebView 拦截。
+- 重新启用 `Toolbar`
+- 在 `onCreate()` 中配置 `ActionBarDrawerToggle`,绑定 Toolbar 与 DrawerLayout
+- 用户现在可点击顶部 ☰ 按钮打开原生菜单
+
+### 2.6 明文 HTTP 支持 (network_security_config.xml)
+
+Android 9+ 默认禁止明文 HTTP。新增:
+```xml
+
+```
+以允许向局域网/内网 HTTP 服务器转发数据。
+
+---
+
+## 3. 改动文件清单 / Modified Files
+
+| 文件 | 说明 |
+|------|------|
+| `README.md` | 顶部新增双语功能增强说明(双写、配置 UI、动态 URL、Toolbar、明文 HTTP),含修改文件表、使用步骤、已知限制 |
+| `mobile/src/main/java/net/activitywatch/android/AWPreferences.kt` | 新增 `getRemoteServerUrl()` / `setRemoteServerUrl()` |
+| `mobile/src/main/java/net/activitywatch/android/RustInterface.kt` | 新增 `prefs`、`remoteExecutor`、`sendRemoteHeartbeat()`、`sendRemoteBucketCreate()`;修改 `createBucketHelper()` 和 `heartbeatHelper()` 以双写 |
+| `mobile/src/main/java/net/activitywatch/android/MainActivity.kt` | `baseURL` 改为动态读取 SharedPreferences;新增 `showRemoteServerDialog()`;启用 Toolbar + ActionBarDrawerToggle |
+| `mobile/src/main/res/menu/activity_main_drawer.xml` | 新增 `nav_remote_server` 菜单项 |
+| `mobile/src/main/res/layout/app_bar_main.xml` | 取消注释并启用 Toolbar |
+| `mobile/src/main/res/layout/activity_main.xml` | 给 `app_bar_main` include 添加 `android:id="@+id/app_bar"` |
+| `mobile/src/main/res/xml/network_security_config.xml` | 增加 `` |
+
+---
+
+## 4. 构建环境 / Build Environment
+
+本环境是在 Windows 11 上从零搭建的(无 Android Studio)。
+
+### 4.1 关键路径
+
+| 组件 | 路径(Git Bash / Unix 语法) | 路径(Windows 语法) |
+|------|------------------------------|----------------------|
+| 项目根目录 | `/e/Project_workspace/aw-android` | `E:\Project_workspace\aw-android` |
+| JDK 17 | `./jdk-17.0.18+8` | `E:\Project_workspace\aw-android\jdk-17.0.18+8` |
+| Android SDK | `./android-sdk` | `E:\Project_workspace\aw-android\android-sdk` |
+| Gradle wrapper | `./gradlew` | `E:\Project_workspace\aw-android\gradlew` |
+| 输出 APK | `mobile/build/outputs/apk/debug/mobile-debug.apk` | 同上 |
+
+### 4.2 构建命令
+
+在项目根目录执行(Git Bash):
+```bash
+export JAVA_HOME="$(pwd)/jdk-17.0.18+8"
+export ANDROID_HOME="$(pwd)/android-sdk"
+export PATH="$JAVA_HOME/bin:$ANDROID_HOME/platform-tools:$ANDROID_HOME/cmdline-tools/latest/bin:$PATH"
+
+# 构建 Debug APK
+./gradlew assembleDebug
+
+# 安装到已连接的设备
+adb install -r mobile/build/outputs/apk/debug/mobile-debug.apk
+```
+
+> **注意**: 该项目依赖预构建的 Rust JNI 库(`libaw_server.so`),这些库在原始 APK 中已存在,无需自行编译 Rust/NDK。本仓库中已包含从官方 release APK 提取的 `jniLibs/`。
+
+### 4.3 环境相关未跟踪文件
+
+以下目录/文件被 Git 忽略(未加入版本控制):
+- `jdk-17.0.18+8/` — Eclipse Temurin JDK 17
+- `android-sdk/` — Android SDK(含 platform-tools、build-tools、platforms、NDK)
+- `cmdline-tools.zip` — SDK 命令行工具压缩包
+- `jdk17.zip` — JDK 17 压缩包
+- `apk_extracted/` — 提取官方 APK 时的临时目录
+
+---
+
+## 5. 使用新功能 / How to Use
+
+1. 安装 APK 后打开 App,授予 Usage Access 权限。
+2. 点击顶部 ☰ 按钮打开导航抽屉(这是**原生 Android 菜单**,不是 WebUI 菜单)。
+3. 点击 **Remote Server**。
+4. 输入 ActivityWatch 服务器地址,如 `http://100.122.190.51:5600`,保存。
+5. 正常使用手机,数据会自动双写到本地和远程。
+6. 在浏览器中访问同样地址(如 `http://100.122.190.51:5600/#/timeline`)即可查看手机数据。
+
+---
+
+## 6. 已知限制 / Known Limitations
+
+- UsageStats 应用使用数据是每小时批量采集一次(AlarmManager),不是秒级实时。
+- Chrome 浏览器数据通过 AccessibilityService 实时采集,转发延迟约几百毫秒。
+- 远程服务器必须是标准 ActivityWatch API(`aw-server-rust` 或 `aw-server`)。
+- 双写是 fire-and-forget,不保证远程一定成功;失败仅通过 logcat 输出日志。
+
+---
+
+## 7. 调试技巧 / Debugging Tips
+
+### 7.1 验证远程转发是否工作
+
+连接手机后查看 logcat:
+```bash
+adb logcat -s RustInterface:D
+```
+正常应看到:
+```
+Remote bucket create OK: aw-watcher-android-test
+Remote heartbeat OK: aw-watcher-android-test
+```
+若看到:
+```
+Remote heartbeat failed: HTTP 4xx/5xx ...
+Remote heartbeat error: java.net.UnknownHostException...
+```
+说明地址配置错误或网络不通。
+
+### 7.2 验证 Toolbar 和原生菜单
+
+如果用户说"找不到 Remote Server 菜单",99% 是因为他们在看 WebUI 的菜单(WebUI 左上角有三条线,那是网页自己的菜单)。**必须点顶部状态栏的 ☰(白色背景 Toolbar 左上角)**,那才是原生 Android 导航抽屉,里面有 Remote Server。
+
+### 7.3 Cleartext HTTP 问题
+
+如果 logcat 出现:
+```
+Cleartext HTTP traffic to x.x.x.x not permitted
+```
+检查 `network_security_config.xml` 是否包含 ``。该改动已通过验证,若仍报错可能是 APK 未重新安装或安装的是旧版本。
+
+---
+
+## 8. 可能的后续工作 / Potential Future Work
+
+用户(仓库所有者)可能感兴趣的方向:
+- **远程写失败重试 / 队列化**: 当前 fire-and-forget 在网络抖动时会丢数据。可改用本地 SQLite 队列 + 定时重试。
+- **远程服务器连通性检测**: 在配置对话框中增加"测试连接"按钮,实时验证地址可达性。
+- **数据加密传输**: 当前远程 HTTP 是明文。若用户有公网部署需求,建议配置 HTTPS 或增加 Token 鉴权。
+- **多远程服务器**: 支持配置多个远程地址,向多个服务器同时双写。
+- **远程-only 模式**: 提供开关彻底关闭本地服务器,减少电量/内存占用(适合只想看远程数据的用户)。
+
+---
+
+## 9. 联系上下文 / Context Recovery
+
+如果用户问"之前做了什么"或"改动了哪些文件",直接引用本文档第 2、3 节即可。如果用户要求"重新编译安装",使用第 4.2 节的命令。如果用户问"为什么看不到远程数据",先检查第 7.1 节的 logcat,再检查第 7.2 节的菜单区分问题。
+
+---
+
+## 10. 演进记录 / Evolution History
+
+### 10.1 阶段一:双写模式(本地 + 远程)
+
+初始实现。`RustInterface` 在原有本地 JNI 调用后,叠加了异步 HTTP 远程发送。
+- 问题:远程发送失败时,本地已写但远程未写的事件形成**永久空洞**(`lastUpdated` 基于本地最后事件时间,缺失的数据永远不会被再次采集)。
+- 结果:远程服务器数据不完整。
+
+### 10.2 阶段二:远程单写模式(纯远程,无本地)
+
+为解决数据空洞,将 `RustInterface` 改为纯 HTTP 客户端:
+- 去掉本地 JNI 调用(`System.loadLibrary("aw_server")`、`initialize()` 等)
+- `createBucketHelper()` 和 `heartbeatHelper()` 改为同步 HTTP 远程发送
+- `getBucketsJSON()` / `getEventsJSON()` 改为从远程 HTTP GET
+- `MainActivity` 不再启动本地 aw-server
+- `UsageStatsWatcher` 的 `lastUpdated` 自动变为基于远程服务器最后事件时间,可补回缺失历史
+
+**关键诊断发现**:
+- adb shell 的 curl/ping 到远程服务器(`100.122.190.51:5600`):**完全正常**(0.6s,0% 丢包)
+- `run-as net.activitywatch.android.debug`(App 进程内)的 curl/ping:**全部失败**(`000` 超时 / `Operation not permitted`)
+- 原因:**App 进程的网络流量被 Android/MIUI 限制,无法稳定通过 Tailscale VPN**,而 adb shell 的流量正常走 VPN
+- 结果:22860 次 heartbeat 尝试中,只有约 3492 个到达服务器,最终合并为 443 条事件。数据严重丢失。
+
+### 10.3 阶段三:回退到本地存储
+
+由于 Tailscale 网络在 App 进程内极不稳定,决定回退到官方版本的本地存储行为:
+- `RustInterface.kt`:恢复原始本地 JNI 逻辑
+- `MainActivity.kt`:恢复 `ri.startServerTask()`,WebUI 固定连接 `127.0.0.1:5600`
+- `ChromeWatcher.kt`:恢复原始本地调用逻辑
+- UI 改动保留:Toolbar、Remote Server 菜单和对话框(配置保存在 SharedPreferences 中,但当前不影响功能)
+
+### 10.4 阶段四:恢复双写模式(公网 IP)
+
+用户提供了新的公网 IP `43.173.85.67:5600`,不再经过 Tailscale,App 进程内网络连通性正常。恢复双写:
+- `RustInterface.kt`:在 `createBucketHelper()` / `heartbeatHelper()` 中本地 JNI 调用后,异步 HTTP POST 到远程
+- `MainActivity.kt`:`baseURL` 重新动态读取 `SharedPreferences` 中的远程地址
+- 构建安装成功,远程 heartbeat 日志显示 HTTP 200
+
+**数据质量分析**(基于远程服务器导出 `aw-buckets-export.json`):
+- `aw-watcher-android-test` 共 13,297 条事件(约 9 天数据)
+- **30.3% 的事件 duration = 0**,大量事件未被合并为连续会话
+- **840 个连续重复事件**(相同 package + classname 连续出现),说明 heartbeat 合并不彻底
+- 原因:`UsageStatsManager` 产生的 `ACTIVITY_RESUMED`/`ACTIVITY_PAUSED` 事件本身碎片化严重,且 `Event.fromUsageEvent` 中 `duration` 始终为 `0.0`,完全依赖服务器端 heartbeat 合并;当 pulsetime(RESUMED=1.0s, PAUSED=24h)与事件间隔不匹配时,产生大量零散的短事件
+- **结论**:这不是网络传输丢失,而是数据采集端的**事件碎片化问题**,需要优化 `UsageStatsWatcher` 的事件合并策略(如自行计算 duration 而非全依赖 heartbeat 合并)
+
+---
+
+## 11. 教训 / Lessons Learned
+
+1. **Android VPN 对 App 进程和 shell 进程的行为可能完全不同**。adb shell 的网络测试结果不能代表 App 的实际网络表现。
+2. **`AsyncTask` + 同步网络请求在批量场景下不可靠**。即使改为同步发送,Android 的后台线程调度、Doze 模式、MIUI 省电策略都可能导致请求被延迟或中断。
+3. **ActivityWatch 的 heartbeat API 会合并相邻的相同事件**。`id=3492` 不代表 3492 条独立事件,实际存储的事件数可能远少(本例中 3492 个 heartbeat 被合并为 443 条)。
+4. **如果以后要重新实现远程同步**,建议:
+ - 先解决网络问题(公网 IP + 防火墙白名单,或修复 Tailscale 对 App 进程的访问)
+ - 使用本地 SQLite 队列 + 定时批量同步,而不是 fire-and-forget
+ - 在首次配置远程服务器时,触发一次全量历史同步
+
+---
+
+*文档更新日期: 2026-04-23*
+*当前状态: 双写模式(本地 + 远程公网 IP),数据 100% 写入本地,异步转发远程;远程数据存在碎片化问题待优化*
diff --git a/README.md b/README.md
index bbb62012..40d9f937 100644
--- a/README.md
+++ b/README.md
@@ -1,71 +1,122 @@
-aw-android
-==========
+# aw-android (Remote Fork)
-[](https://github.com/ActivityWatch/aw-android/actions)
-[](https://play.google.com/store/apps/details?id=net.activitywatch.android)
+A fork of [ActivityWatch/aw-android](https://github.com/ActivityWatch/aw-android) with **remote ActivityWatch server forwarding** support.
-A very work-in-progress ActivityWatch app for Android.
+---
-Available on Google Play:
+## Features
-
-
-
+This fork changes the data collection from "local storage" to "remote HTTP forwarding". Captured app usage, browser visits, screen-unlock events, etc. are sent directly to a user-configured remote ActivityWatch server via HTTP API.
+### Key Features
-## Usage
+- **Pure Remote Forwarding** — Data is sent directly to the remote server via HTTP, no local storage
+- **Configurable Remote Address** — In-app UI to set the remote server address
+- **Dynamic WebUI** — The embedded WebUI automatically shows the remote dashboard after configuration
+- **Native Menu** — Toolbar restored; tap the top ☰ button to open the navigation drawer
+- **Cleartext HTTP Support** — Allows sending data to HTTP servers (for LAN/internal deployments)
-Install the APK from the Play Store or from the [GitHub releases](https://github.com/ActivityWatch/aw-android/releases).
+---
-### For Oculus Quest
+## Modified Files
-> **Note**
-> At some point a Quest system upgrade broke the ability to allow ActivityWatch access to usage stats. This can be fixed by manually assigning the needed permission using adb: `adb shell appops set net.activitywatch.android android:get_usage_stats allow`
+| File | Changes |
+|------|---------|
+| `RustInterface.kt` | Changed from JNI client to HTTP client; removed local aw-server calls; all operations access remote server via HTTP API |
+| `UsageStatsWatcher.kt` | Bucket names changed to `aw-watcher-android-test2` and `aw-watcher-android-unlock2` |
+| `MainActivity.kt` | Removed `ri.startServerTask()` (no local server); `baseURL` reads remote address from config; added `showRemoteServerDialog()`; enabled Toolbar + ActionBarDrawerToggle |
+| `AWPreferences.kt` | Added `getRemoteServerUrl()` / `setRemoteServerUrl()` for persisting remote address |
+| `activity_main_drawer.xml` | Added `nav_remote_server` menu item |
+| `app_bar_main.xml` | Enabled Toolbar |
+| `activity_main.xml` | Added `android:id` to `app_bar_main` include |
+| `network_security_config.xml` | Added `` to allow plaintext HTTP |
-It's available [on SideQuest](https://sidequestvr.com/#/app/201).
+---
+## Configuration Guide
-## Building
+### 1. Prepare Remote Server
-To build this app you first need to build aw-server-rust (`./aw-server-rust`) and aw-webui (`./aw-server-rust/aw-webui`).
+You need a running ActivityWatch server, such as:
+- `aw-server-rust` (recommended)
+- `aw-server` (Python version)
-If you haven't already, initialize the submodules with: `git submodule update --init --recursive`
+The server must expose the HTTP API, default port is `5600`.
-### Building aw-server-rust
+### 2. Install APK
-> **Note**
-> If you don't want to go through the hassle of getting Rust up and running, you can download the jniLibs from [aw-server-rust CI artifacts](https://github.com/ActivityWatch/aw-server-rust/actions/workflows/build.yml) and place them in `mobile/src/main/jniLibs` manually instead of following this section.
+```bash
+./gradlew assembleDebug
+adb install -r mobile/build/outputs/apk/debug/mobile-debug.apk
+```
+
+### 3. Configure Remote Address
+
+1. Open the app and grant **Usage Access** permission
+2. Tap the top **☰** button to open the navigation drawer (top-left of the white Toolbar)
+3. Tap **Remote Server**
+4. Enter the remote server address, e.g. `http://192.168.1.100:5600`
+5. Tap **Save**
+
+> **Note**: Leaving it blank falls back to the local server `http://127.0.0.1:5600`
-To build aw-server-rust you need to have Rust nightly installed (with rustup). Then you can build it with:
+### 4. Verify Data
+After using your phone normally for 1-2 hours, visit in a browser:
```
-export ANDROID_NDK_HOME=`pwd`/aw-server-rust/NDK # The path to your NDK
-pushd aw-server-rust && ./install-ndk.sh; popd # This configures the NDK for use with Rust, and installs the NDK if missing
-env RELEASE=false make aw-server-rust # Set RELEASE=true to build in release mode (slower build, harder to debug)
+http://your-server-ip:5600/#/timeline
```
-> **Note**
-> The Android NDK will be downloaded by `install-ndk.sh` to `aw-server-rust/NDK` if `ANDROID_NDK_HOME` not set. You can create a symlink pointing to the real location if you already have it elsewhere (such as /opt/android-ndk/ on Arch Linux).
+You should see the phone's collected data.
+
+---
+
+## Debugging
+
+Check remote forwarding logs:
+```bash
+adb logcat -s RustInterface:D
+```
-### Building aw-webui
+You should see:
+```
+HTTP POST OK: /api/0/buckets/aw-watcher-android-test2/heartbeat?pulsetime=1.0
+```
-To build aw-webui you need a recent version of node/npm installed. You can then build it with `make aw-webui`.
+---
-### Putting it all together
+## Build Environment
-Once both aw-server-rust and aw-webui is built, you can build the Android app as any other Android app using Android Studio.
+This project was set up from scratch on Windows 11 without Android Studio.
-### Making a release
+| Component | Path |
+|-----------|------|
+| JDK 17 | `./jdk-17.0.18+8` |
+| Android SDK | `./android-sdk` |
+| Output APK | `mobile/build/outputs/apk/debug/mobile-debug.apk` |
-To make a release, make a signed tag and push it to GitHub:
+Build commands:
+```bash
+export JAVA_HOME="$(pwd)/jdk-17.0.18+8"
+export ANDROID_HOME="$(pwd)/android-sdk"
+export PATH="$JAVA_HOME/bin:$ANDROID_HOME/platform-tools:$ANDROID_HOME/cmdline-tools/latest/bin:$PATH"
-```sh
-git tag -s v0.1.0
-git push origin refs/tags/v0.1.0
+./gradlew assembleDebug
```
-This will trigger a GitHub Actions workflow which will build the app and upload it to GitHub releases, and deploy it to the Play Store (including the metadata in `./fastlane/metadata/android`).
+> **Note**: This project depends on pre-built Rust JNI libraries (`libaw_server.so`), which are included in `jniLibs/`. You do not need to compile Rust/NDK yourself.
+
+---
+
+## Known Limitations
+
+- App usage data from UsageStats is batched hourly (AlarmManager), not real-time
+- Chrome browser data is captured in real-time via AccessibilityService; forwarding delay is ~hundreds of milliseconds
+- The remote server must expose the standard ActivityWatch API (`aw-server-rust` or `aw-server`)
+- Data will be lost when network is disconnected (fire-and-forget, no local cache)
+
+---
-## More info
+## Original Project
-For more info, check out the main [ActivityWatch repo](https://github.com/ActivityWatch/activitywatch).
+https://github.com/ActivityWatch/aw-android
diff --git a/README_zh.md b/README_zh.md
new file mode 100644
index 00000000..e6d8ddf7
--- /dev/null
+++ b/README_zh.md
@@ -0,0 +1,122 @@
+# aw-android (Remote Fork)
+
+基于 [ActivityWatch/aw-android](https://github.com/ActivityWatch/aw-android) 的 fork,增加了**远程 ActivityWatch 服务器纯转发**功能。
+
+---
+
+## 功能说明
+
+本 fork 将数据采集方式从"本地存储"改为"远程 HTTP 转发"。采集到的应用使用数据、浏览器访问数据、屏幕解锁事件等,直接通过 HTTP API 发送到用户配置的远程 ActivityWatch 服务器。
+
+### 主要特性
+
+- **纯远程转发** — 数据直接通过 HTTP 发送到远程服务器,不在本地保存
+- **可配置远程地址** — App 内提供 UI 让用户填写远程服务器地址
+- **动态 WebUI** — 配置远程服务器后,App 内嵌 WebUI 自动展示远程仪表盘
+- **原生菜单** — 恢复 Toolbar,点击顶部 ☰ 按钮即可打开导航抽屉
+- **明文 HTTP 支持** — 允许向 HTTP 服务器发送数据(适用于局域网/内网部署)
+
+---
+
+## 改动文件
+
+| 文件 | 改动说明 |
+|------|---------|
+| `RustInterface.kt` | 从 JNI 客户端改为 HTTP 客户端;移除本地 aw-server 调用;所有操作通过 HTTP API 访问远程服务器 |
+| `UsageStatsWatcher.kt` | bucket 名称改为 `aw-watcher-android-test2` 和 `aw-watcher-android-unlock2` |
+| `MainActivity.kt` | 移除 `ri.startServerTask()`(不再启动本地服务器);`baseURL` 动态读取远程地址配置;新增 `showRemoteServerDialog()`;启用 Toolbar + ActionBarDrawerToggle |
+| `AWPreferences.kt` | 新增 `getRemoteServerUrl()` / `setRemoteServerUrl()` 用于持久化远程地址 |
+| `activity_main_drawer.xml` | 新增 `nav_remote_server` 菜单项 |
+| `app_bar_main.xml` | 启用 Toolbar |
+| `activity_main.xml` | 给 `app_bar_main` include 添加 `android:id` |
+| `network_security_config.xml` | 添加 `` 允许明文 HTTP |
+
+---
+
+## 配置指南
+
+### 1. 准备远程服务器
+
+你需要一个运行中的 ActivityWatch 服务器,例如:
+- `aw-server-rust`(推荐)
+- `aw-server`(Python 版)
+
+服务器需要暴露 HTTP API,默认端口为 `5600`。
+
+### 2. 安装 APK
+
+```bash
+./gradlew assembleDebug
+adb install -r mobile/build/outputs/apk/debug/mobile-debug.apk
+```
+
+### 3. 配置远程地址
+
+1. 打开 App,授予 **Usage Access** 权限
+2. 点击顶部 **☰** 按钮打开导航抽屉(白色 Toolbar 左上角)
+3. 点击 **Remote Server**
+4. 输入远程服务器地址,如 `http://192.168.1.100:5600`
+5. 点击 **Save**
+
+> **注意**:留空则使用本地服务器 `http://127.0.0.1:5600`
+
+### 4. 验证数据
+
+正常使用手机 1-2 小时后,在浏览器中访问:
+```
+http://your-server-ip:5600/#/timeline
+```
+
+即可查看手机采集的数据。
+
+---
+
+## 调试
+
+查看远程转发日志:
+```bash
+adb logcat -s RustInterface:D
+```
+
+正常应看到:
+```
+HTTP POST OK: /api/0/buckets/aw-watcher-android-test2/heartbeat?pulsetime=1.0
+```
+
+---
+
+## 构建环境
+
+本项目在 Windows 11 上从零搭建(无 Android Studio)。
+
+| 组件 | 路径 |
+|------|------|
+| JDK 17 | `./jdk-17.0.18+8` |
+| Android SDK | `./android-sdk` |
+| 输出 APK | `mobile/build/outputs/apk/debug/mobile-debug.apk` |
+
+构建命令:
+```bash
+export JAVA_HOME="$(pwd)/jdk-17.0.18+8"
+export ANDROID_HOME="$(pwd)/android-sdk"
+export PATH="$JAVA_HOME/bin:$ANDROID_HOME/platform-tools:$ANDROID_HOME/cmdline-tools/latest/bin:$PATH"
+
+./gradlew assembleDebug
+```
+
+> **注意**:本项目依赖预构建的 Rust JNI 库(`libaw_server.so`),这些库已包含在 `jniLibs/` 中,无需自行编译 Rust/NDK。
+
+---
+
+## 已知限制
+
+- UsageStats 应用使用数据是每小时批量采集一次(AlarmManager),不是秒级实时
+- Chrome 浏览器数据通过 AccessibilityService 实时采集,转发延迟约几百毫秒
+- 远程服务器必须是标准 ActivityWatch API(`aw-server-rust` 或 `aw-server`)
+- 网络断开时数据会丢失(fire-and-forget,无本地缓存)
+
+---
+
+## 原项目
+
+https://github.com/ActivityWatch/aw-android
diff --git a/mobile/build.gradle b/mobile/build.gradle
index 8476d85c..c1035e38 100644
--- a/mobile/build.gradle
+++ b/mobile/build.gradle
@@ -41,6 +41,15 @@ android {
renderscriptDebuggable true
}
}
+
+ flavorDimensions "mode"
+ productFlavors {
+ realtime {
+ dimension "mode"
+ applicationIdSuffix ".realtime"
+ resValue "string", "app_name", "ActivityWatch 实时版"
+ }
+ }
compileOptions {
sourceCompatibility = '1.8'
targetCompatibility = '1.8'
@@ -82,6 +91,7 @@ dependencies {
implementation 'com.google.android.material:material:1.7.0'
implementation 'com.jakewharton.threetenabp:threetenabp:1.4.3'
+ implementation 'androidx.work:work-runtime-ktx:2.9.0'
testImplementation "junit:junit:4.13.2"
androidTestImplementation "androidx.test.ext:junit-ktx:$extJUnitVersion"
diff --git a/mobile/src/main/AndroidManifest.xml b/mobile/src/main/AndroidManifest.xml
index c983de3a..0ab3f75a 100644
--- a/mobile/src/main/AndroidManifest.xml
+++ b/mobile/src/main/AndroidManifest.xml
@@ -67,5 +67,15 @@
+
+
+
+
+
+
+
diff --git a/mobile/src/main/java/net/activitywatch/android/AWPreferences.kt b/mobile/src/main/java/net/activitywatch/android/AWPreferences.kt
index 3c528068..5045ab2e 100644
--- a/mobile/src/main/java/net/activitywatch/android/AWPreferences.kt
+++ b/mobile/src/main/java/net/activitywatch/android/AWPreferences.kt
@@ -26,4 +26,56 @@ class AWPreferences(context: Context) {
editor.putBoolean("isFirstTime", true)
editor.apply()
}
+
+ fun getRemoteServerUrl(): String {
+ return sharedPreferences.getString("remoteServerUrl", "") ?: ""
+ }
+
+ fun setRemoteServerUrl(url: String) {
+ val editor = sharedPreferences.edit()
+ editor.putString("remoteServerUrl", url)
+ editor.apply()
+ }
+
+ fun getRemoteServerUsername(): String {
+ return sharedPreferences.getString("remoteServerUsername", "") ?: ""
+ }
+
+ fun setRemoteServerUsername(username: String) {
+ val editor = sharedPreferences.edit()
+ editor.putString("remoteServerUsername", username)
+ editor.apply()
+ }
+
+ fun getRemoteServerPassword(): String {
+ return sharedPreferences.getString("remoteServerPassword", "") ?: ""
+ }
+
+ fun setRemoteServerPassword(password: String) {
+ val editor = sharedPreferences.edit()
+ editor.putString("remoteServerPassword", password)
+ editor.apply()
+ }
+
+ fun getSkipPackages(): Set {
+ return sharedPreferences.getStringSet("skipPackages", emptySet()) ?: emptySet()
+ }
+
+ fun setSkipPackages(packages: Set) {
+ val editor = sharedPreferences.edit()
+ editor.putStringSet("skipPackages", packages)
+ editor.apply()
+ }
+
+ fun addSkipPackage(packageName: String) {
+ val current = getSkipPackages().toMutableSet()
+ current.add(packageName)
+ setSkipPackages(current)
+ }
+
+ fun removeSkipPackage(packageName: String) {
+ val current = getSkipPackages().toMutableSet()
+ current.remove(packageName)
+ setSkipPackages(current)
+ }
}
\ No newline at end of file
diff --git a/mobile/src/main/java/net/activitywatch/android/MainActivity.kt b/mobile/src/main/java/net/activitywatch/android/MainActivity.kt
index 0c439e0f..f1f1bd49 100644
--- a/mobile/src/main/java/net/activitywatch/android/MainActivity.kt
+++ b/mobile/src/main/java/net/activitywatch/android/MainActivity.kt
@@ -1,14 +1,18 @@
package net.activitywatch.android
+import android.app.AlertDialog
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import com.google.android.material.snackbar.Snackbar
import com.google.android.material.navigation.NavigationView
import androidx.core.view.GravityCompat
+import androidx.appcompat.app.ActionBarDrawerToggle
import androidx.appcompat.app.AppCompatActivity
import android.view.Menu
import android.view.MenuItem
+import android.widget.EditText
+import android.widget.LinearLayout
import androidx.fragment.app.Fragment
import android.util.Log
import net.activitywatch.android.databinding.ActivityMainBinding
@@ -18,13 +22,16 @@ import net.activitywatch.android.watcher.UsageStatsWatcher
private const val TAG = "MainActivity"
-const val baseURL = "http://127.0.0.1:5600"
-
-
class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelectedListener, WebUIFragment.OnFragmentInteractionListener {
private lateinit var binding: ActivityMainBinding
+ private val baseURL: String
+ get() {
+ val remote = AWPreferences(this).getRemoteServerUrl()
+ return if (remote.isNotBlank()) remote else "http://127.0.0.1:5600"
+ }
+
val version: String
get() {
return packageManager.getPackageInfo(packageName, 0).versionName
@@ -56,8 +63,13 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
binding.navView.setNavigationItemSelectedListener(this)
- val ri = RustInterface(this)
- ri.startServerTask(this)
+ setSupportActionBar(binding.appBar.toolbar)
+ val toggle = ActionBarDrawerToggle(
+ this, binding.drawerLayout, binding.appBar.toolbar,
+ R.string.navigation_drawer_open, R.string.navigation_drawer_close
+ )
+ binding.drawerLayout.addDrawerListener(toggle)
+ toggle.syncState()
if (savedInstanceState != null) {
return
@@ -76,6 +88,84 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
usw.sendHeartbeats()
}
+ private fun showSkipListDialog() {
+ val prefs = AWPreferences(this)
+ val skipList = prefs.getSkipPackages().toMutableList().sorted().toMutableList()
+
+ val layout = LinearLayout(this)
+ layout.orientation = LinearLayout.VERTICAL
+ val padding = (16 * resources.displayMetrics.density).toInt()
+ layout.setPadding(padding, padding, padding, 0)
+
+ val currentList = EditText(this)
+ currentList.setText(skipList.joinToString("\n"))
+ currentList.hint = "com.example.app\ncom.example.app2"
+ currentList.minLines = 3
+ layout.addView(currentList)
+
+ AlertDialog.Builder(this)
+ .setTitle("Skip List (one package per line)")
+ .setMessage("Apps listed here will not be tracked by the real-time watcher.")
+ .setView(layout)
+ .setPositiveButton("Save") { _, _ ->
+ val text = currentList.text.toString().trim()
+ val newList = if (text.isEmpty()) {
+ emptySet()
+ } else {
+ text.split("\n").map { it.trim() }.filter { it.isNotEmpty() }.toSet()
+ }
+ prefs.setSkipPackages(newList)
+ Snackbar.make(binding.coordinatorLayout, "Skip list saved (${newList.size} packages)", Snackbar.LENGTH_LONG).show()
+ }
+ .setNegativeButton("Cancel", null)
+ .show()
+ }
+
+ private fun showRemoteServerDialog() {
+ val prefs = AWPreferences(this)
+ val currentUrl = prefs.getRemoteServerUrl()
+ val currentUser = prefs.getRemoteServerUsername()
+ val currentPass = prefs.getRemoteServerPassword()
+
+ val layout = LinearLayout(this)
+ layout.orientation = LinearLayout.VERTICAL
+ val padding = (16 * resources.displayMetrics.density).toInt()
+ layout.setPadding(padding, padding, padding, 0)
+
+ val inputUrl = EditText(this)
+ inputUrl.hint = "http://your-server-ip:5600"
+ inputUrl.setText(currentUrl)
+ layout.addView(inputUrl)
+
+ val inputUser = EditText(this)
+ inputUser.hint = "Username (optional)"
+ inputUser.setText(currentUser)
+ layout.addView(inputUser)
+
+ val inputPass = EditText(this)
+ inputPass.hint = "Password (optional)"
+ inputPass.setText(currentPass)
+ inputPass.inputType = android.text.InputType.TYPE_CLASS_TEXT or android.text.InputType.TYPE_TEXT_VARIATION_PASSWORD
+ layout.addView(inputPass)
+
+ AlertDialog.Builder(this)
+ .setTitle("Remote ActivityWatch Server")
+ .setMessage("Leave empty to use local server only (127.0.0.1:5600).")
+ .setView(layout)
+ .setPositiveButton("Save") { _, _ ->
+ val url = inputUrl.text.toString().trim()
+ val user = inputUser.text.toString().trim()
+ val pass = inputPass.text.toString().trim()
+ prefs.setRemoteServerUrl(url)
+ prefs.setRemoteServerUsername(user)
+ prefs.setRemoteServerPassword(pass)
+ val msg = if (url.isBlank()) "Remote server set to: (local)" else "Remote server set to: $url"
+ Snackbar.make(binding.coordinatorLayout, msg, Snackbar.LENGTH_LONG).show()
+ }
+ .setNegativeButton("Cancel", null)
+ .show()
+ }
+
override fun onBackPressed() {
if (binding.drawerLayout.isDrawerOpen(GravityCompat.START)) {
binding.drawerLayout.closeDrawer(GravityCompat.START)
@@ -125,6 +215,12 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
fragmentClass = WebUIFragment::class.java
url = "$baseURL/#/settings/"
}
+ R.id.nav_remote_server -> {
+ showRemoteServerDialog()
+ }
+ R.id.nav_skip_list -> {
+ showSkipListDialog()
+ }
R.id.nav_share -> {
Snackbar.make(binding.coordinatorLayout, "The share button was clicked, but it's not yet implemented!", Snackbar.LENGTH_LONG)
.setAction("Action", null).show()
diff --git a/mobile/src/main/java/net/activitywatch/android/RustInterface.kt b/mobile/src/main/java/net/activitywatch/android/RustInterface.kt
index 01b68c50..b36f3620 100644
--- a/mobile/src/main/java/net/activitywatch/android/RustInterface.kt
+++ b/mobile/src/main/java/net/activitywatch/android/RustInterface.kt
@@ -1,132 +1,153 @@
package net.activitywatch.android
import android.content.Context
-import android.os.Handler
-import android.os.Looper
-import android.system.Os
+import android.util.Base64
import android.util.Log
-import android.widget.Toast
import net.activitywatch.android.models.Event
import org.json.JSONArray
import org.json.JSONException
import org.json.JSONObject
import org.threeten.bp.Instant
-import java.io.File
-import java.util.concurrent.Executors
+import java.net.HttpURLConnection
+import java.net.URL
+import java.nio.charset.StandardCharsets
private const val TAG = "RustInterface"
-class RustInterface constructor(context: Context? = null) {
+class RustInterface constructor(private val context: Context? = null) {
- init {
- // NOTE: This doesn't work, probably because I can't get gradle to not strip symbols on release builds
- Os.setenv("RUST_BACKTRACE", "1", true)
-
- if(context != null) {
- Os.setenv("SQLITE_TMPDIR", context.cacheDir.absolutePath, true)
- }
-
- System.loadLibrary("aw_server")
-
- initialize()
- if(context != null) {
- setDataDir(context.filesDir.absolutePath)
- }
- }
+ private val prefs: AWPreferences? = context?.let { AWPreferences(it) }
companion object {
var serverStarted = false
}
- private external fun initialize(): String
- private external fun greeting(pattern: String): String
- private external fun startServer()
- private external fun setDataDir(path: String)
- external fun getBuckets(): String
- external fun createBucket(bucket: String): String
- external fun getEvents(bucket_id: String, limit: Int): String
- external fun heartbeat(bucket_id: String, event: String, pulsetime: Double): String
-
- fun sayHello(to: String): String {
- return greeting(to)
+ private fun getServerUrl(): String {
+ val remote = prefs?.getRemoteServerUrl()
+ return when {
+ remote.isNullOrBlank() -> "http://127.0.0.1:5600"
+ remote.startsWith("http://") || remote.startsWith("https://") -> remote
+ else -> "http://$remote"
+ }
}
- fun startServerTask(context: Context) {
- if(!serverStarted) {
- // check if port 5600 is already in use
- try {
- val socket = java.net.ServerSocket(5600)
- socket.close()
- } catch(e: java.net.BindException) {
- Log.e(TAG, "Port 5600 is already in use, server probably already started")
- return
+ private fun setAuthHeader(conn: HttpURLConnection) {
+ val username = prefs?.getRemoteServerUsername()
+ val password = prefs?.getRemoteServerPassword()
+ if (!username.isNullOrBlank() && !password.isNullOrBlank()) {
+ val credentials = "$username:$password"
+ val auth = "Basic " + Base64.encodeToString(credentials.toByteArray(StandardCharsets.UTF_8), Base64.NO_WRAP)
+ conn.setRequestProperty("Authorization", auth)
+ }
+ }
+
+ private fun httpGet(path: String): String {
+ return try {
+ val url = URL("${getServerUrl()}$path")
+ val conn = url.openConnection() as HttpURLConnection
+ conn.requestMethod = "GET"
+ conn.connectTimeout = 5000
+ conn.readTimeout = 5000
+ setAuthHeader(conn)
+
+ val responseCode = conn.responseCode
+ if (responseCode in 200..299) {
+ conn.inputStream.bufferedReader().use { it.readText() }
+ } else {
+ Log.e(TAG, "HTTP GET failed: $path, status=$responseCode")
+ "{}"
}
+ } catch (e: Exception) {
+ Log.e(TAG, "HTTP GET error: $path, ${e.message}")
+ "{}"
+ }
+ }
- serverStarted = true
- val executor = Executors.newSingleThreadExecutor()
- val handler = Handler(Looper.getMainLooper())
- executor.execute {
- // will not block the UI thread
-
- // Start server
- Log.w(TAG, "Starting server...")
- startServer()
-
- handler.post {
- // will run on UI thread after the task is done
- Log.i(TAG, "Server finished")
- serverStarted = false
- }
+ private fun httpPost(path: String, payload: String): String {
+ return try {
+ val url = URL("${getServerUrl()}$path")
+ val conn = url.openConnection() as HttpURLConnection
+ conn.requestMethod = "POST"
+ conn.setRequestProperty("Content-Type", "application/json")
+ conn.connectTimeout = 5000
+ conn.readTimeout = 5000
+ conn.doOutput = true
+ setAuthHeader(conn)
+
+ conn.outputStream.use { it.write(payload.toByteArray(StandardCharsets.UTF_8)) }
+
+ val responseCode = conn.responseCode
+ if (responseCode in 200..299) {
+ Log.i(TAG, "HTTP POST OK: $path")
+ conn.inputStream.bufferedReader().use { it.readText() }
+ } else {
+ Log.w(TAG, "HTTP POST failed: $path, status=$responseCode")
+ ""
}
- Log.w(TAG, "Server started")
+ } catch (e: Exception) {
+ Log.e(TAG, "HTTP POST error: $path, ${e.message}")
+ ""
}
}
+ fun startServerTask(context: Context) {
+ // No-op in remote-only mode
+ Log.i(TAG, "Remote-only mode: no local server to start")
+ }
+
fun createBucketHelper(bucket_id: String, type: String, hostname: String = "unknown", client: String = "aw-android") {
- if(bucket_id in getBucketsJSON().keys().asSequence()) {
- Log.i(TAG, "Bucket with ID '$bucket_id', already existed. Not creating.")
- } else {
- val msg = createBucket("""{"id": "$bucket_id", "type": "$type", "hostname": "$hostname", "client": "$client"}""");
- Log.w(TAG, msg)
+ try {
+ val buckets = getBucketsJSON()
+ if (bucket_id in buckets.keys().asSequence()) {
+ Log.i(TAG, "Bucket with ID '$bucket_id', already existed. Not creating.")
+ } else {
+ val payload = """{"id": "$bucket_id", "type": "$type", "hostname": "$hostname", "client": "$client"}"""
+ httpPost("/api/0/buckets/$bucket_id", payload)
+ Log.w(TAG, "Created bucket: $bucket_id")
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "createBucketHelper error: ${e.message}")
}
}
fun heartbeatHelper(bucket_id: String, timestamp: Instant, duration: Double, data: JSONObject, pulsetime: Double = 60.0) {
- val event = Event(timestamp, duration, data)
- val msg = heartbeat(bucket_id, event.toString(), pulsetime)
- //Log.w(TAG, msg)
+ try {
+ val event = Event(timestamp, duration, data)
+ val eventJson = event.toString()
+ httpPost("/api/0/buckets/$bucket_id/heartbeat?pulsetime=$pulsetime", eventJson)
+ } catch (e: Exception) {
+ Log.e(TAG, "heartbeatHelper error: ${e.message}")
+ }
}
fun getBucketsJSON(): JSONObject {
- // TODO: Handle errors
- val json = JSONObject(getBuckets())
- if(json.length() <= 0) {
- Log.w(TAG, "Length: ${json.length()}")
+ return try {
+ val result = httpGet("/api/0/buckets")
+ JSONObject(result)
+ } catch (e: Exception) {
+ Log.e(TAG, "getBucketsJSON error: ${e.message}")
+ JSONObject()
}
- return json
}
fun getEventsJSON(bucket_id: String, limit: Int = 0): JSONArray {
- // TODO: Handle errors
- val result = getEvents(bucket_id, limit)
return try {
- JSONArray(result)
- } catch(e: JSONException) {
- Log.e(TAG, "Error when trying to fetch events from bucket: $result")
+ val limitParam = if (limit > 0) "?limit=$limit" else ""
+ val result = httpGet("/api/0/buckets/$bucket_id/events$limitParam")
+ Log.w(TAG, "getEventsJSON($bucket_id): raw result length=${result.length}")
+ if (result.isBlank() || result == "{}") {
+ Log.w(TAG, "getEventsJSON returned empty/invalid result, returning empty array")
+ JSONArray()
+ } else {
+ JSONArray(result)
+ }
+ } catch (e: JSONException) {
+ Log.e(TAG, "getEventsJSON parse error: ${e.message}")
+ JSONArray()
+ } catch (e: Exception) {
+ Log.e(TAG, "getEventsJSON error: ${e.message}")
JSONArray()
}
}
- fun test() {
- // TODO: Move to instrumented test
- Log.w(TAG, sayHello("Android"))
- createBucketHelper("test", "test")
- Log.w(TAG, getBucketsJSON().toString(2))
-
- val event = """{"timestamp": "${Instant.now()}", "duration": 0, "data": {"key": "value"}}"""
- Log.w(TAG, event)
- Log.w(TAG, heartbeat("test", event, 60.0))
- Log.w(TAG, getBucketsJSON().toString(2))
- Log.w(TAG, getEventsJSON("test").toString(2))
- }
}
diff --git a/mobile/src/main/java/net/activitywatch/android/fragments/WebUIFragment.kt b/mobile/src/main/java/net/activitywatch/android/fragments/WebUIFragment.kt
index cf77b5e3..927daa3c 100644
--- a/mobile/src/main/java/net/activitywatch/android/fragments/WebUIFragment.kt
+++ b/mobile/src/main/java/net/activitywatch/android/fragments/WebUIFragment.kt
@@ -14,9 +14,11 @@ import android.webkit.WebView
import android.content.Intent.ACTION_VIEW
import android.util.Log
+import android.webkit.HttpAuthHandler
import android.webkit.URLUtil
import android.webkit.WebResourceRequest
import android.webkit.WebViewClient
+import net.activitywatch.android.AWPreferences
import net.activitywatch.android.R
import java.lang.Thread.sleep
@@ -54,6 +56,23 @@ class WebUIFragment : Fragment() {
val myWebView: WebView = view.findViewById(R.id.webview) as WebView
class MyWebViewClient : WebViewClient() {
+ override fun onReceivedHttpAuthRequest(
+ view: WebView,
+ handler: HttpAuthHandler,
+ host: String,
+ realm: String
+ ) {
+ val context = view.context
+ val prefs = AWPreferences(context)
+ val user = prefs.getRemoteServerUsername()
+ val pass = prefs.getRemoteServerPassword()
+ if (!user.isNullOrBlank() && !pass.isNullOrBlank()) {
+ handler.proceed(user, pass)
+ } else {
+ super.onReceivedHttpAuthRequest(view, handler, host, realm)
+ }
+ }
+
override fun onReceivedError(
view: WebView,
errorCode: Int,
diff --git a/mobile/src/main/java/net/activitywatch/android/watcher/ActivityWatcher.kt b/mobile/src/main/java/net/activitywatch/android/watcher/ActivityWatcher.kt
new file mode 100644
index 00000000..52da865c
--- /dev/null
+++ b/mobile/src/main/java/net/activitywatch/android/watcher/ActivityWatcher.kt
@@ -0,0 +1,163 @@
+package net.activitywatch.android.watcher
+
+import android.accessibilityservice.AccessibilityService
+import android.os.PowerManager
+import android.util.Log
+import android.view.accessibility.AccessibilityEvent
+import net.activitywatch.android.RustInterface
+import org.json.JSONObject
+import org.threeten.bp.Instant
+import java.util.concurrent.Executors
+import java.util.concurrent.ScheduledFuture
+import java.util.concurrent.TimeUnit
+
+class ActivityWatcher : AccessibilityService() {
+
+ private val TAG = "ActivityWatcher"
+ private val bucket_id = "aw-watcher-android-realtime"
+ private val executor = Executors.newSingleThreadExecutor()
+ private val scheduler = Executors.newSingleThreadScheduledExecutor()
+
+ private var ri: RustInterface? = null
+ private var lastApp: String? = null
+ private var lastAppTimestamp: Instant? = null
+ private var bucketCreated = false
+ private var refreshTask: ScheduledFuture<*>? = null
+ private var afkWatcher: AfkWatcher? = null
+
+ override fun onServiceConnected() {
+ super.onServiceConnected()
+ try {
+ ri = RustInterface(applicationContext)
+ afkWatcher = AfkWatcher(applicationContext)
+ afkWatcher?.register()
+ startPeriodicRefresh()
+ Log.i(TAG, "ActivityWatcher service connected")
+ } catch (e: Exception) {
+ Log.e(TAG, "ActivityWatcher init error: ${e.message}")
+ }
+ }
+
+ private fun isScreenOn(): Boolean {
+ val pm = applicationContext.getSystemService(android.content.Context.POWER_SERVICE) as PowerManager
+ return pm.isInteractive
+ }
+
+ private fun startPeriodicRefresh() {
+ refreshTask?.cancel(false)
+ refreshTask = scheduler.scheduleAtFixedRate({
+ try {
+ if (!isScreenOn()) return@scheduleAtFixedRate
+ if (lastApp != null && lastAppTimestamp != null) {
+ val now = Instant.now()
+ val duration = org.threeten.bp.Duration.between(lastAppTimestamp, now)
+ if (duration.seconds >= 60) {
+ val appPackage = lastApp!!
+ val start = lastAppTimestamp!!
+ val dur = duration.seconds.toDouble()
+ executor.execute {
+ logAppUsage(appPackage, start, dur)
+ }
+ // Reset timestamp to now so next refresh accumulates from here
+ lastAppTimestamp = now
+ }
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "Periodic refresh error: ${e.message}")
+ }
+ }, 60, 60, TimeUnit.SECONDS)
+ }
+
+ override fun onAccessibilityEvent(event: AccessibilityEvent?) {
+ if (event == null) return
+ if (event.eventType != AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED) return
+
+ val packageName = event.packageName?.toString() ?: return
+ val className = event.className?.toString() ?: ""
+
+ if (packageName.startsWith("net.activitywatch.android")) return
+ if (packageName == "com.android.systemui") return
+
+ // Skip packages from user-configured skip list
+ val prefs = net.activitywatch.android.AWPreferences(applicationContext)
+ val skipPackages = prefs.getSkipPackages()
+ if (packageName in skipPackages) return
+
+ // Skip if screen is off (AFK)
+ if (!isScreenOn()) return
+
+ if (packageName != lastApp) {
+ val now = Instant.now()
+
+ if (lastApp != null && lastAppTimestamp != null) {
+ val duration = org.threeten.bp.Duration.between(lastAppTimestamp, now)
+ if (duration.seconds > 0) {
+ val appPackage = lastApp!!
+ val start = lastAppTimestamp!!
+ val dur = duration.seconds.toDouble()
+ executor.execute {
+ logAppUsage(appPackage, start, dur)
+ }
+ }
+ }
+
+ lastApp = packageName
+ lastAppTimestamp = now
+
+ Log.d(TAG, "Switched to: $packageName / $className")
+ }
+ }
+
+ private fun logAppUsage(appPackage: String, start: Instant, duration: Double) {
+ try {
+ if (!bucketCreated && ri != null) {
+ ri?.createBucketHelper(bucket_id, "currentwindow")
+ bucketCreated = true
+ Log.i(TAG, "Bucket created: $bucket_id")
+ }
+
+ val data = JSONObject()
+ data.put("app", getAppName(appPackage))
+ data.put("package", appPackage)
+
+ ri?.heartbeatHelper(bucket_id, start, duration, data, 60.0)
+ Log.d(TAG, "Logged: ${getAppName(appPackage)} for ${duration}s")
+ } catch (e: Exception) {
+ Log.e(TAG, "logAppUsage error: ${e.message}")
+ }
+ }
+
+ private fun getAppName(packageName: String): String {
+ return try {
+ val pm = applicationContext.packageManager
+ val appInfo = pm.getApplicationInfo(packageName, 0)
+ pm.getApplicationLabel(appInfo).toString()
+ } catch (e: Exception) {
+ packageName
+ }
+ }
+
+ override fun onInterrupt() {
+ Log.w(TAG, "ActivityWatcher interrupted")
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+ refreshTask?.cancel(false)
+ afkWatcher?.unregister()
+ if (lastApp != null && lastAppTimestamp != null) {
+ val duration = org.threeten.bp.Duration.between(lastAppTimestamp, Instant.now())
+ if (duration.seconds > 0) {
+ val appPackage = lastApp!!
+ val start = lastAppTimestamp!!
+ val dur = duration.seconds.toDouble()
+ executor.execute {
+ logAppUsage(appPackage, start, dur)
+ }
+ }
+ }
+ executor.shutdown()
+ scheduler.shutdown()
+ Log.i(TAG, "ActivityWatcher destroyed")
+ }
+}
diff --git a/mobile/src/main/java/net/activitywatch/android/watcher/AfkWatcher.kt b/mobile/src/main/java/net/activitywatch/android/watcher/AfkWatcher.kt
new file mode 100644
index 00000000..8ad9150a
--- /dev/null
+++ b/mobile/src/main/java/net/activitywatch/android/watcher/AfkWatcher.kt
@@ -0,0 +1,90 @@
+package net.activitywatch.android.watcher
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import android.os.PowerManager
+import android.util.Log
+import net.activitywatch.android.RustInterface
+import org.json.JSONObject
+import org.threeten.bp.Instant
+import java.util.concurrent.Executors
+
+class AfkWatcher(private val context: Context) {
+
+ companion object {
+ private const val TAG = "AfkWatcher"
+ private const val BUCKET_ID = "aw-watcher-android-realtime-afk"
+ private const val BUCKET_TYPE = "afkstatus"
+
+ @Volatile
+ var isAfk: Boolean = false
+ private set
+ }
+
+ private val executor = Executors.newSingleThreadExecutor()
+ private var registered = false
+
+ private val screenReceiver = object : BroadcastReceiver() {
+ override fun onReceive(ctx: Context, intent: Intent) {
+ when (intent.action) {
+ Intent.ACTION_SCREEN_OFF -> {
+ Log.i(TAG, "Screen OFF → AFK")
+ isAfk = true
+ executor.execute { sendAfkEvent(true) }
+ }
+ Intent.ACTION_SCREEN_ON -> {
+ Log.i(TAG, "Screen ON → NOT AFK")
+ isAfk = false
+ executor.execute { sendAfkEvent(false) }
+ }
+ }
+ }
+ }
+
+ fun register() {
+ if (!registered) {
+ val filter = IntentFilter().apply {
+ addAction(Intent.ACTION_SCREEN_OFF)
+ addAction(Intent.ACTION_SCREEN_ON)
+ }
+ context.registerReceiver(screenReceiver, filter)
+ registered = true
+ Log.i(TAG, "AfkWatcher registered")
+
+ // Check initial screen state
+ val pm = context.getSystemService(Context.POWER_SERVICE) as PowerManager
+ isAfk = !pm.isInteractive
+ Log.i(TAG, "Initial screen state: ${if (isAfk) "AFK" else "NOT AFK"}")
+ }
+ }
+
+ fun unregister() {
+ if (registered) {
+ try {
+ context.unregisterReceiver(screenReceiver)
+ } catch (e: Exception) {
+ Log.e(TAG, "Unregister error: ${e.message}")
+ }
+ registered = false
+ Log.i(TAG, "AfkWatcher unregistered")
+ }
+ }
+
+ private fun sendAfkEvent(afk: Boolean) {
+ try {
+ val ri = RustInterface(context)
+ ri.createBucketHelper(BUCKET_ID, BUCKET_TYPE)
+
+ val data = JSONObject()
+ data.put("status", if (afk) "afk" else "not-afk")
+
+ val now = Instant.now()
+ ri.heartbeatHelper(BUCKET_ID, now, 0.0, data, 60.0)
+ Log.d(TAG, "Sent AFK event: ${if (afk) "afk" else "not-afk"}")
+ } catch (e: Exception) {
+ Log.e(TAG, "sendAfkEvent error: ${e.message}")
+ }
+ }
+}
diff --git a/mobile/src/main/java/net/activitywatch/android/watcher/AlarmReceiver.kt b/mobile/src/main/java/net/activitywatch/android/watcher/AlarmReceiver.kt
index c4bdd9be..f73fc95d 100644
--- a/mobile/src/main/java/net/activitywatch/android/watcher/AlarmReceiver.kt
+++ b/mobile/src/main/java/net/activitywatch/android/watcher/AlarmReceiver.kt
@@ -4,24 +4,44 @@ import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.util.Log
+import androidx.work.*
+import java.util.concurrent.TimeUnit
private const val TAG = "AlarmReceiver"
class AlarmReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
Log.w(TAG, "AlarmReceiver called")
- val usw = UsageStatsWatcher(context)
if (intent.action == "android.intent.action.BOOT_COMPLETED") {
- // FIXME: Doesn't seem to be triggered as it should
- Log.w(TAG, "Received BOOT_COMPLETED, setting up alarm")
- usw.setupAlarm()
+ Log.w(TAG, "Received BOOT_COMPLETED, scheduling periodic work")
+ schedulePeriodicWork(context)
} else if(intent.action == "net.activitywatch.android.watcher.LOG_DATA") {
- Log.w(TAG, "Action ${intent.action}, running sendHeartbeats")
- if(UsageStatsWatcher.isUsageAllowed(context)) {
- usw.sendHeartbeats()
- }
+ Log.w(TAG, "Legacy alarm triggered, enqueueing heartbeat work")
+ val workRequest = OneTimeWorkRequestBuilder()
+ .build()
+ WorkManager.getInstance(context).enqueueUniqueWork(
+ HeartbeatWorker.WORK_NAME,
+ ExistingWorkPolicy.REPLACE,
+ workRequest
+ )
} else {
Log.w(TAG, "Unknown intent $intent with action ${intent.action}, doing nothing")
}
}
-}
\ No newline at end of file
+
+ private fun schedulePeriodicWork(context: Context) {
+ val workRequest = PeriodicWorkRequestBuilder(15, TimeUnit.MINUTES)
+ .setConstraints(
+ Constraints.Builder()
+ .setRequiredNetworkType(NetworkType.CONNECTED)
+ .build()
+ )
+ .build()
+
+ WorkManager.getInstance(context).enqueueUniquePeriodicWork(
+ "heartbeat_periodic",
+ ExistingPeriodicWorkPolicy.UPDATE,
+ workRequest
+ )
+ }
+}
diff --git a/mobile/src/main/java/net/activitywatch/android/watcher/HeartbeatWorker.kt b/mobile/src/main/java/net/activitywatch/android/watcher/HeartbeatWorker.kt
new file mode 100644
index 00000000..9566381e
--- /dev/null
+++ b/mobile/src/main/java/net/activitywatch/android/watcher/HeartbeatWorker.kt
@@ -0,0 +1,118 @@
+package net.activitywatch.android.watcher
+
+import android.app.usage.UsageEvents
+import android.app.usage.UsageStatsManager
+import android.content.Context
+import android.util.Log
+import androidx.work.Worker
+import androidx.work.WorkerParameters
+import net.activitywatch.android.RustInterface
+import net.activitywatch.android.models.Event
+import org.json.JSONObject
+import org.threeten.bp.DateTimeUtils
+import org.threeten.bp.Duration
+import org.threeten.bp.Instant
+import java.text.ParseException
+import java.text.SimpleDateFormat
+
+class HeartbeatWorker(context: Context, params: WorkerParameters) : Worker(context, params) {
+
+ companion object {
+ const val TAG = "HeartbeatWorker"
+ const val WORK_NAME = "heartbeat_work"
+ }
+
+ private val ri = RustInterface(applicationContext)
+ private val isoFormatter = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSXXX")
+
+ override fun doWork(): Result {
+ Log.i(TAG, "HeartbeatWorker started")
+
+ if (!UsageStatsWatcher.isUsageAllowed(applicationContext)) {
+ Log.w(TAG, "Usage stats not allowed, skipping")
+ return Result.success()
+ }
+
+ return try {
+ ri.createBucketHelper(bucket_id, "currentwindow")
+ ri.createBucketHelper(unlock_bucket_id, "os.lockscreen.unlocks")
+
+ var lastUpdated = getLastEventTime()
+ Log.w(TAG, "lastUpdated from server: ${lastUpdated?.toString() ?: "never"}")
+
+ val now = Instant.now()
+ if (lastUpdated != null && Duration.between(lastUpdated, now).toDays() > 7) {
+ Log.w(TAG, "Server data is too old (>7 days), using now-1hour instead")
+ lastUpdated = now.minus(Duration.ofHours(1))
+ }
+
+ val usm = applicationContext.getSystemService(Context.USAGE_STATS_SERVICE) as? UsageStatsManager
+ if (usm == null) {
+ Log.e(TAG, "UsageStatsManager not available")
+ return Result.failure()
+ }
+
+ var heartbeatsSent = 0
+ val usageEvents = usm.queryEvents(lastUpdated?.toEpochMilli() ?: 0L, Long.MAX_VALUE)
+
+ while (usageEvents.hasNextEvent()) {
+ val event = UsageEvents.Event()
+ usageEvents.getNextEvent(event)
+
+ if (event.eventType !in arrayListOf(
+ UsageEvents.Event.ACTIVITY_RESUMED,
+ UsageEvents.Event.ACTIVITY_PAUSED
+ )
+ ) {
+ if (event.eventType == UsageEvents.Event.KEYGUARD_HIDDEN) {
+ val timestamp = DateTimeUtils.toInstant(java.util.Date(event.timeStamp))
+ ri.heartbeatHelper(unlock_bucket_id, timestamp, 0.0, JSONObject(), 0.0)
+ }
+ continue
+ }
+
+ val awEvent = Event.fromUsageEvent(event, applicationContext, includeClassname = true)
+ val pulsetime = when (event.eventType) {
+ UsageEvents.Event.ACTIVITY_RESUMED -> 1.0
+ UsageEvents.Event.ACTIVITY_PAUSED -> 24 * 60 * 60.0
+ else -> continue
+ }
+
+ ri.heartbeatHelper(bucket_id, awEvent.timestamp, awEvent.duration, awEvent.data, pulsetime)
+ heartbeatsSent++
+ }
+
+ Log.w(TAG, "HeartbeatWorker finished, sent $heartbeatsSent events")
+ Result.success()
+ } catch (e: Exception) {
+ Log.e(TAG, "HeartbeatWorker error: ${e.message}", e)
+ Result.retry()
+ }
+ }
+
+ private fun getLastEvent(): JSONObject? {
+ val events = ri.getEventsJSON(bucket_id, limit = 1)
+ return if (events.length() == 1) {
+ events[0] as JSONObject
+ } else {
+ Log.w(TAG, "Unexpected event count when getting last event: ${events.length()}")
+ null
+ }
+ }
+
+ private fun getLastEventTime(): Instant? {
+ val lastEvent = getLastEvent()
+ return if (lastEvent != null) {
+ val timestampString = lastEvent.getString("timestamp")
+ try {
+ val timeCreatedDate = isoFormatter.parse(timestampString)
+ DateTimeUtils.toInstant(timeCreatedDate)
+ } catch (e: ParseException) {
+ Log.e(TAG, "Unable to parse timestamp: $timestampString")
+ null
+ }
+ } else {
+ null
+ }
+ }
+}
diff --git a/mobile/src/main/java/net/activitywatch/android/watcher/UsageStatsWatcher.kt b/mobile/src/main/java/net/activitywatch/android/watcher/UsageStatsWatcher.kt
index 60247ab5..ee300cb6 100644
--- a/mobile/src/main/java/net/activitywatch/android/watcher/UsageStatsWatcher.kt
+++ b/mobile/src/main/java/net/activitywatch/android/watcher/UsageStatsWatcher.kt
@@ -1,11 +1,8 @@
package net.activitywatch.android.watcher
import android.Manifest
-import android.app.AlarmManager
import android.app.AlertDialog
import android.app.AppOpsManager
-import android.app.PendingIntent
-import android.app.usage.UsageEvents
import android.app.usage.UsageStatsManager
import android.content.Context
import android.content.Intent
@@ -19,17 +16,18 @@ import android.util.Log
import android.view.accessibility.AccessibilityEvent
import android.view.accessibility.AccessibilityManager
import android.widget.Toast
+import androidx.work.*
import net.activitywatch.android.RustInterface
-import net.activitywatch.android.models.Event
import org.json.JSONObject
import org.threeten.bp.DateTimeUtils
+import org.threeten.bp.Duration
import org.threeten.bp.Instant
-import java.net.URL
import java.text.ParseException
import java.text.SimpleDateFormat
+import java.util.concurrent.TimeUnit
-const val bucket_id = "aw-watcher-android-test"
-const val unlock_bucket_id = "aw-watcher-android-unlock"
+const val bucket_id = "aw-watcher-android-plus"
+const val unlock_bucket_id = "aw-watcher-android-plus-unlock"
class UsageStatsWatcher constructor(val context: Context) {
private val ri = RustInterface(context)
@@ -113,23 +111,21 @@ class UsageStatsWatcher constructor(val context: Context) {
}
}
- private var alarmMgr: AlarmManager? = null
- private lateinit var alarmIntent: PendingIntent
-
fun setupAlarm() {
- alarmMgr = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
- alarmIntent = Intent(context, AlarmReceiver::class.java).let { intent ->
- intent.action = "net.activitywatch.android.watcher.LOG_DATA"
- PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_IMMUTABLE)
- }
+ val workRequest = PeriodicWorkRequestBuilder(15, TimeUnit.MINUTES)
+ .setConstraints(
+ Constraints.Builder()
+ .setRequiredNetworkType(NetworkType.CONNECTED)
+ .build()
+ )
+ .build()
- val interval = AlarmManager.INTERVAL_HOUR // Or if testing: AlarmManager.INTERVAL_HOUR / 60
- alarmMgr?.setInexactRepeating(
- AlarmManager.ELAPSED_REALTIME,
- SystemClock.elapsedRealtime() + interval,
- interval,
- alarmIntent
+ WorkManager.getInstance(context).enqueueUniquePeriodicWork(
+ "heartbeat_periodic",
+ ExistingPeriodicWorkPolicy.UPDATE,
+ workRequest
)
+ Log.i(TAG, "Periodic heartbeat work scheduled every 15 minutes")
}
@@ -175,94 +171,15 @@ class UsageStatsWatcher constructor(val context: Context) {
}
}
- private inner class SendHeartbeatsTask : AsyncTask() {
- override fun doInBackground(vararg urls: URL): Int? {
- Log.i(TAG, "Sending heartbeats...")
-
- // TODO: Use other bucket type when support for such a type has been implemented in aw-webui
- ri.createBucketHelper(bucket_id, "currentwindow")
- ri.createBucketHelper(unlock_bucket_id, "os.lockscreen.unlocks")
- lastUpdated = getLastEventTime()
- Log.w(TAG, "lastUpdated: ${lastUpdated?.toString() ?: "never"}")
-
- val usm = getUSM() ?: return 0
-
- // Store activities here that have had a RESUMED but not a PAUSED event.
- // (to handle out-of-order events)
- //val activeActivities = [];
-
- // TODO: Fix issues that occur when usage stats events are out of order (RESUME before PAUSED)
- var heartbeatsSent = 0
- val usageEvents = usm.queryEvents(lastUpdated?.toEpochMilli() ?: 0L, Long.MAX_VALUE)
- nextEvent@ while(usageEvents.hasNextEvent()) {
- val event = UsageEvents.Event()
- usageEvents.getNextEvent(event)
-
- // Log screen unlock
- if(event.eventType !in arrayListOf(UsageEvents.Event.ACTIVITY_RESUMED, UsageEvents.Event.ACTIVITY_PAUSED)) {
- if(event.eventType == UsageEvents.Event.KEYGUARD_HIDDEN){
- val timestamp = DateTimeUtils.toInstant(java.util.Date(event.timeStamp))
- // NOTE: getLastEventTime() returns the last time of an event from the activity bucket(bucket_id)
- // Therefore, if an unlock happens after last event from main bucket, unlock event will get sent twice.
- // Fortunately not an issue because identical events will get merged together (see heartbeats)
- ri.heartbeatHelper(unlock_bucket_id, timestamp, 0.0, JSONObject(), 0.0)
- }
- // Not sure which events are triggered here, so we use a (probably safe) fallback
- //Log.d(TAG, "Rare eventType: ${event.eventType}, skipping")
- continue@nextEvent
- }
-
- // Log activity
- val awEvent = Event.fromUsageEvent(event, context, includeClassname = true)
- val pulsetime: Double
- when(event.eventType) {
- UsageEvents.Event.ACTIVITY_RESUMED -> {
- // ACTIVITY_RESUMED: Activity was opened/reopened
- pulsetime = 1.0
- }
- UsageEvents.Event.ACTIVITY_PAUSED -> {
- // ACTIVITY_PAUSED: Activity was moved to background
- pulsetime = 24 * 60 * 60.0 // 24h, we will assume events should never grow longer than that
- }
- else -> {
- Log.w(TAG, "This should never happen!")
- continue@nextEvent
- }
- }
-
- ri.heartbeatHelper(bucket_id, awEvent.timestamp, awEvent.duration, awEvent.data, pulsetime)
- if(heartbeatsSent % 100 == 0) {
- publishProgress(awEvent.timestamp)
- }
- heartbeatsSent++
- }
- return heartbeatsSent
- }
-
- override fun onProgressUpdate(vararg progress: Instant) {
- lastUpdated = progress[0]
- Log.i(TAG, "Progress: ${lastUpdated.toString()}")
- // The below is useful in testing, but otherwise just noisy.
- //Toast.makeText(context, "Logging data, progress: $lastUpdated", Toast.LENGTH_LONG).show()
- }
-
- override fun onPostExecute(result: Int?) {
- Log.w(TAG, "Finished SendHeartbeatTask, sent $result events")
- // The below is useful in testing, but otherwise just noisy.
- /*
- if(result != 0) {
- Toast.makeText(context, "Completed logging of data! Logged events: $result", Toast.LENGTH_LONG).show()
- }
- */
- }
- }
-
- /***
- * Returns the number of events sent
- */
fun sendHeartbeats() {
- Log.w(TAG, "Starting SendHeartbeatTask")
- SendHeartbeatsTask().execute()
+ Log.w(TAG, "Enqueueing one-time heartbeat work")
+ val workRequest = OneTimeWorkRequestBuilder()
+ .build()
+ WorkManager.getInstance(context).enqueueUniqueWork(
+ HeartbeatWorker.WORK_NAME,
+ ExistingWorkPolicy.REPLACE,
+ workRequest
+ )
}
}
diff --git a/mobile/src/main/res/layout/activity_main.xml b/mobile/src/main/res/layout/activity_main.xml
index 4695b186..adda54fa 100644
--- a/mobile/src/main/res/layout/activity_main.xml
+++ b/mobile/src/main/res/layout/activity_main.xml
@@ -18,6 +18,7 @@
tools:openDrawer="start">
diff --git a/mobile/src/main/res/layout/app_bar_main.xml b/mobile/src/main/res/layout/app_bar_main.xml
index 3602a412..a55ee82f 100644
--- a/mobile/src/main/res/layout/app_bar_main.xml
+++ b/mobile/src/main/res/layout/app_bar_main.xml
@@ -7,19 +7,19 @@
android:layout_height="match_parent"
tools:context=".MainActivity">
-
+
diff --git a/mobile/src/main/res/menu/activity_main_drawer.xml b/mobile/src/main/res/menu/activity_main_drawer.xml
index c5db4b3e..26c1c282 100644
--- a/mobile/src/main/res/menu/activity_main_drawer.xml
+++ b/mobile/src/main/res/menu/activity_main_drawer.xml
@@ -25,6 +25,14 @@
android:id="@+id/nav_settings"
android:icon="@drawable/ic_menu_manage"
android:title="Settings"/>
+
+
-
ActivityWatch
+ ActivityWatch 实时版
Open navigation drawer
Close navigation drawer
ActivityWatch for Android
@@ -20,6 +21,7 @@
Click me to log data!
Allows ActivityWatch to read the URL and title from your browser.
+ Allows ActivityWatch Realtime to monitor app usage in real-time by detecting app switches.
diff --git a/mobile/src/main/res/xml/accessibility_service_config_realtime.xml b/mobile/src/main/res/xml/accessibility_service_config_realtime.xml
new file mode 100644
index 00000000..deae11f5
--- /dev/null
+++ b/mobile/src/main/res/xml/accessibility_service_config_realtime.xml
@@ -0,0 +1,8 @@
+
+
diff --git a/mobile/src/main/res/xml/network_security_config.xml b/mobile/src/main/res/xml/network_security_config.xml
index 95b646fb..f819d124 100644
--- a/mobile/src/main/res/xml/network_security_config.xml
+++ b/mobile/src/main/res/xml/network_security_config.xml
@@ -1,7 +1,8 @@
+
127.0.0.1
localhost
-
\ No newline at end of file
+