dsh-go-android-app 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 keyboard3
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,106 @@
1
- # Temporary Holding Version
1
+ # dsh-go-android-app
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ `dsh-go-android-app` 是给 DSH Go 的 DeepSeek Harness 用的插件。把想做的 Android 应用告诉 Agent,先在手机上点图标预览,确认后生成签名 APK。装好的应用可以从手机桌面独立打开。
4
+
5
+ ## 快速开始
6
+
7
+ 以下步骤适用于 DSH Go 1.0.13 的本机引擎(DeepSeek Harness `0.2.2-rc.1`,Node.js 24.9 或更新版本)。先在 DSH Go 完成本机引擎初始化并配置可用的模型。
8
+
9
+ ### 安装插件
10
+
11
+ 插件还在等待 DSH Go 市场收录,现阶段可以直接安装 npm 的发布包。[查看上架进度](https://github.com/ccch1mneyyy/dsh-go-market/pull/39)
12
+
13
+ 把下面这条命令发给 DSH Go 本机聊天里的 Agent,请它执行。也可以在 DSH Go 的本机终端直接运行。弹出审批时,核对命令后选「仅允许一次」。
14
+
15
+ ```sh
16
+ dsh plugin --profile web add https://registry.npmjs.org/dsh-go-android-app/-/dsh-go-android-app-0.1.1.tgz
17
+ ```
18
+
19
+ 安装成功后,在 DSH Go 的「引擎」页重启本机引擎,让插件生效。[DSH Go 主机与安装指南](https://github.com/ccch1mneyyy/dsh-Go/blob/v1.0.13/docs/plugins/hosts.md)
20
+
21
+ ### 做第一个应用
22
+
23
+ 在 DSH Go 新开一个本机聊天,选择工作区,然后直接说你想做什么。
24
+
25
+ > 我想做一个叫「随身跑记」的跑步计时器。能开始、暂停和结束计时,并记住每次跑了多久。
26
+
27
+ 1. 在同一台手机上点聊天里的「打开手机桌面预览」,再点 **随身跑记** 的图标。可以回到聊天继续提修改意见。
28
+ 2. 满意后在同一聊天里回复「确认打包」。首次构建可能需要确认下载 Android 工具和接受 SDK 许可,见[配置构建环境](#配置构建环境)。
29
+ 3. 打开 Agent 返回的 APK 文件卡或链接,按 Android 安装器的提示安装,再从手机桌面打开应用。
30
+
31
+ ## 从想法到 APK
32
+
33
+ 下面是「随身跑记」的完整实战案例。它在 Android 手机上用 DSH Go 构建并安装,另外接入了 GPS 和 Health Connect。当时发给 DSH Go 的需求如下。
34
+
35
+ > 直接在这台真机做一个中文原生 Android App「随身跑记」。需求:从 Health Connect 经用户授权读取今日总步数和最近跑步记录;用户点击开始后用手机 GPS 记录用时、距离和平均配速,结束后保存到本机并在 App 里查看。
36
+
37
+ 1. 先请 DSH Go 用插件预览「随身跑记」的离线界面。打开手机桌面预览,点橙色的 **随身跑记** 图标进入应用预览。
38
+ 2. 确认界面后,在同一 DSH Go 会话里要求 **用 Android SDK 构建并签名随身跑记的原生 APK**。打开 APK,在 Android 安装器确认安装。
39
+ 3. 打开随身跑记,按系统提示授权后开始跑步。结束时保存记录,可以回看用时、距离、平均配速和轨迹。
40
+
41
+ | 手机桌面预览 | 点图标进入应用预览 | 安装后记录跑步 |
42
+ | --- | --- | --- |
43
+ | <img src="docs/images/runlog-launcher.webp" width="230" alt="手机桌面预览上的橙色随身跑记图标"> | <img src="docs/images/runlog-preview.webp" width="230" alt="从桌面图标打开的随身跑记浏览器预览"> | <img src="docs/images/runlog-recording.webp" width="230" alt="随身跑记的跑步计时、距离、平均配速和轨迹"> |
44
+
45
+ 桌面预览由插件生成,点开的是原 APK 中的页面。浏览器预览中的健康数据是示例数据。安装后的跑步画面来自 Android 14 模拟器,轨迹使用模拟定位。Health Connect 和 GPS 由 DSH Go 在原生构建阶段接入,目前尚未接入插件模板。
46
+
47
+ ## 配置构建环境
48
+
49
+ DSH Go 的 ARM64 PRoot 环境如果使用 glibc 2.38 或更新版本,可以在第一次确认构建时自动配置。请使用能显示审批提示的会话权限,例如「工作区内修改」。确认框会提示下载缺少的工具并接受 [Android SDK 许可](https://developer.android.com/studio/terms)。
50
+
51
+ 插件会复用已有的 JDK 和 SDK,从固定的上游地址补齐缺少的组件。空环境首次下载约 330 MB。安装前验证下载大小和 SHA-256,试编译并验证一个临时 APK 后保存配置。以后构建复用已配置的工具。[工具链清单](lib/toolchain-sources.json)
52
+
53
+ 也可以在 DSH Go 终端提前配置。默认 DSH Home 是 `~/.dsh`,如果你的安装位置不同,请调整 `PLUGIN`。
54
+
55
+ ```sh
56
+ PLUGIN="${DSH_HOME:-$HOME/.dsh}/profiles/web/node_modules/dsh-go-android-app"
57
+ node "$PLUGIN/bin/dsh-go-android-app.js" setup --accept-android-sdk-license
58
+ node "$PLUGIN/bin/dsh-go-android-app.js" doctor
59
+ ```
60
+
61
+ 已有工具链时,可用 `configure` 指定实际路径。需要 JDK 17 或更新版本、Android API 34 的 `android.jar`、本机架构的 `aapt2` 和 `zipalign`,以及 `d8.jar` 和 `apksigner.jar`。
62
+
63
+ ```sh
64
+ node "$PLUGIN/bin/dsh-go-android-app.js" configure \
65
+ /path/to/jdk /path/to/build-tools-bin /path/to/android.jar /path/to/tool-jars
66
+ ```
67
+
68
+ 标准桌面 SDK 布局也可通过 `JAVA_HOME` 和 `ANDROID_HOME` 定位 Build Tools 34.0.0 及 API 34。配置和本地签名密钥保存在 `~/.local/share/dsh-go-app/`,可用 `DSH_GO_APP_HOME` 指定目录。
69
+
70
+ ## 查阅 Android API
71
+
72
+ `dsh_app_android_docs` 先从 `developer.android.com` 读取 API 参考。访问失败或正文缺少目标声明时,再尝试 `developer.android.google.cn` 的同路径和锚点,并返回实际来源。为插件项目传入 `projectId`,可以用当前构建 SDK 的 `android.jar` 核对平台 API 签名。AndroidX 用法按项目解析出的依赖版本核对,权限和运行条件查对应的官方文档。
73
+
74
+ ## 插件工作流
75
+
76
+ 1. Harness 先用 `dsh_app_doctor` 检查所需能力,再创建 `dsh-apps/<项目 ID>/`,生成 `index.html` 和桌面图标 `icon.json`。需要三轴陀螺仪时,声明 `gyroscope` 就会选中 `gyroscope-webview`,由 Android 原生读取角速度,WebView 显示数据。相机、定位和 Health Connect 等会列为当前模板不支持的能力。
77
+ 2. 在 Harness 聊天里点「打开手机桌面预览」,再点 App 图标进入应用。没有图标的项目会直接提供应用预览入口。使用 DSH Go 的本机引擎时,请在这台手机上打开。看完后回复修改意见或确认打包。链接有效期为 15 分钟,过期后回复「刷新预览」。陀螺仪的实时数据与硬件可用性可在目标设备安装 APK 后验证。
78
+ 3. 确认打包后,插件检查页面、图标和模板是否与预览一致,构建并验证 APK。如果当前会话禁止弹出审批,切换到「工作区内修改」等会显示审批提示的模式再试。有 `present` 时,在聊天里点 APK 文件卡打开 Android 安装器。其他客户端可使用返回的文件链接。
79
+ 4. 要更新应用,继续使用原项目。保留 `app.json`、`dist/` 和原签名密钥,插件会递增版本号并使用同一包名和签名,支持覆盖安装。
80
+
81
+ 桌面图标预览会在模拟手机壁纸上显示 App 名称和圆角图标,点图标即可打开同一版本的应用预览。图标会打包为 Android 自适应图标,最终轮廓由设备的启动器决定。旧项目可以在原目录补上 `icon.json`,再预览和打包。
82
+
83
+ 插件使用 Android Activity 和 WebView 运行单文件 HTML、CSS、JavaScript,支持 `localStorage`。陀螺仪模板在前台采样,通过页面同源的 `GET /gyroscope.json` 提供 X/Y/Z 角速度(rad/s)和开机后的采样时间。GPS、Health Connect、通用 Gradle 项目和自动设备测试属于后续能力。生成 APK 使用本地开发签名,适合直接安装试用。
84
+
85
+ ## 从源码安装
86
+
87
+ 在仓库根目录执行 `npm pack`。若在电脑上打包,先把生成的 `.tgz` 文件传到手机,再在 DSH Go 的本机终端运行下列命令。把路径和文件名换成实际的打包结果。
88
+
89
+ ```sh
90
+ dsh plugin --profile web add /absolute/path/to/dsh-go-android-app-0.1.1.tgz
91
+ ```
92
+
93
+ 安装后重启 DSH Go 的本机引擎。[DSH Go 插件开发指南](https://github.com/ccch1mneyyy/dsh-Go/blob/v1.0.13/docs/plugins/from-zero.md)
94
+
95
+ ## 从旧名称迁移
96
+
97
+ 旧包名是 `dsh-go-app`。在 DSH Go 本机终端移除旧包,再安装新包,然后重启本机引擎。
98
+
99
+ ```sh
100
+ dsh plugin --profile web remove dsh-go-app
101
+ dsh plugin --profile web add https://registry.npmjs.org/dsh-go-android-app/-/dsh-go-android-app-0.1.1.tgz
102
+ ```
103
+
104
+ 继续使用原工作区里的项目即可。新包沿用 `~/.local/share/dsh-go-app/` 中的工具链配置和签名密钥,以及 `DSH_GO_APP_HOME` 等已有环境变量,便于继续构建和覆盖安装已有应用。
105
+
106
+ **开发预览。** 已验证桌面 Harness 加载插件和签名构建,以及 Android 模拟器覆盖安装后保留应用数据。另在一台 Android 16 真机上通过 DSH Go 完成了陀螺仪模板选择、页面生成、预览、签名、安装和实时采样。
@@ -0,0 +1,64 @@
1
+ #!/usr/bin/env node
2
+ import { buildApk, configureToolchain } from '../lib/android.js'
3
+ import { createPreviewManager, createProject, readProject } from '../lib/project.js'
4
+ import { inspectToolchain, setupToolchain } from '../lib/setup.js'
5
+
6
+ const help = `dsh-go-android-app doctor
7
+ dsh-go-android-app setup --accept-android-sdk-license
8
+ dsh-go-android-app configure JAVA_HOME SDK_TOOLS ANDROID_JAR TOOL_JARS
9
+ dsh-go-android-app create PROJECT_ID "App name"
10
+ dsh-go-android-app preview PROJECT_ID
11
+ dsh-go-android-app build PROJECT_ID PREVIEW_SHA256`
12
+
13
+ async function main() {
14
+ const [command, ...args] = process.argv.slice(2)
15
+ const workspace = process.cwd()
16
+ switch (command) {
17
+ case 'doctor':
18
+ if (args.length === 0) {
19
+ const { ready, detail } = await inspectToolchain()
20
+ if (!ready) throw new Error(detail)
21
+ return { ready: true }
22
+ }
23
+ break
24
+ case 'setup':
25
+ if (args.length === 0 || (args.length === 1 && args[0] === '--accept-android-sdk-license')) {
26
+ return setupToolchain({ acceptAndroidSdkLicense: args.length === 1 })
27
+ }
28
+ break
29
+ case 'configure':
30
+ if (args.length === 4) {
31
+ const [javaHome, sdkTools, androidJar, toolJars] = args
32
+ return configureToolchain({ javaHome, sdkTools, androidJar, toolJars })
33
+ }
34
+ break
35
+ case 'create':
36
+ if (args.length === 2) return createProject(workspace, args[0], args[1])
37
+ break
38
+ case 'preview':
39
+ if (args.length === 1) {
40
+ const preview = createPreviewManager()
41
+ const { url, iconUrl, sha256 } = await preview.open(await readProject(workspace, args[0]))
42
+ process.once('SIGINT', () => preview.close())
43
+ process.once('SIGTERM', () => preview.close())
44
+ return { url, iconUrl, sha256 }
45
+ }
46
+ break
47
+ case 'build':
48
+ if (args.length === 2) return buildApk(await readProject(workspace, args[0]), args[1])
49
+ break
50
+ case undefined:
51
+ case '--help':
52
+ console.log(help)
53
+ return
54
+ }
55
+ throw new Error(help)
56
+ }
57
+
58
+ try {
59
+ const output = await main()
60
+ if (output) console.log(JSON.stringify(output, null, 2))
61
+ } catch (error) {
62
+ console.error(error.message)
63
+ process.exitCode = 1
64
+ }
@@ -0,0 +1,3 @@
1
+ - insert:
2
+ - id: dsh-go-android-app
3
+ name: dsh-go-android-app
@@ -0,0 +1,75 @@
1
+ package app.dshgo.shell;
2
+
3
+ import android.hardware.Sensor;
4
+ import android.hardware.SensorEvent;
5
+ import android.hardware.SensorEventListener;
6
+ import android.hardware.SensorManager;
7
+ import android.webkit.WebResourceRequest;
8
+ import android.webkit.WebResourceResponse;
9
+ import java.io.ByteArrayInputStream;
10
+ import java.nio.charset.StandardCharsets;
11
+ import java.util.Collections;
12
+
13
+ public final class GyroscopeActivity extends MainActivity implements SensorEventListener {
14
+ private static final String SAMPLE_URL = PAGE_ORIGIN + "/gyroscope.json";
15
+ private static final String INACTIVE = "{\"status\":\"inactive\"}";
16
+
17
+ // The listener runs on the main thread. WebView reads an immutable snapshot on its worker.
18
+ private volatile String snapshot = INACTIVE;
19
+ private SensorManager activeManager;
20
+
21
+ @Override
22
+ protected void onResume() {
23
+ super.onResume();
24
+ SensorManager manager = getSystemService(SensorManager.class);
25
+ Sensor sensor = manager == null ? null : manager.getDefaultSensor(Sensor.TYPE_GYROSCOPE);
26
+ if (sensor == null) {
27
+ snapshot = "{\"status\":\"unavailable\"}";
28
+ return;
29
+ }
30
+ snapshot = "{\"status\":\"waiting\"}";
31
+ if (manager.registerListener(this, sensor, SensorManager.SENSOR_DELAY_UI)) {
32
+ activeManager = manager;
33
+ } else {
34
+ snapshot = "{\"status\":\"error\"}";
35
+ }
36
+ }
37
+
38
+ @Override
39
+ protected void onPause() {
40
+ if (activeManager != null) {
41
+ activeManager.unregisterListener(this);
42
+ activeManager = null;
43
+ }
44
+ snapshot = INACTIVE;
45
+ super.onPause();
46
+ }
47
+
48
+ @Override
49
+ public void onSensorChanged(SensorEvent event) {
50
+ if (activeManager == null) return;
51
+ float x = event.values[0];
52
+ float y = event.values[1];
53
+ float z = event.values[2];
54
+ if (!Float.isFinite(x) || !Float.isFinite(y) || !Float.isFinite(z)) return;
55
+ snapshot = "{\"status\":\"active\",\"x\":" + x + ",\"y\":" + y + ",\"z\":" + z
56
+ + ",\"timestampNanos\":\"" + event.timestamp + "\"}";
57
+ }
58
+
59
+ @Override
60
+ public void onAccuracyChanged(Sensor sensor, int accuracy) {
61
+ }
62
+
63
+ @Override
64
+ String pagePolicy() {
65
+ return super.pagePolicy() + "; connect-src " + SAMPLE_URL;
66
+ }
67
+
68
+ @Override
69
+ WebResourceResponse localResource(WebResourceRequest request) {
70
+ if (!"GET".equals(request.getMethod()) || !SAMPLE_URL.equals(request.getUrl().toString())) return null;
71
+ return new WebResourceResponse("application/json", "UTF-8", 200, "OK",
72
+ Collections.singletonMap("Cache-Control", "no-store"),
73
+ new ByteArrayInputStream(snapshot.getBytes(StandardCharsets.UTF_8)));
74
+ }
75
+ }
@@ -0,0 +1,89 @@
1
+ package app.dshgo.shell;
2
+
3
+ import android.app.Activity;
4
+ import android.content.res.AssetManager;
5
+ import android.os.Bundle;
6
+ import android.webkit.WebResourceRequest;
7
+ import android.webkit.WebResourceResponse;
8
+ import android.webkit.WebSettings;
9
+ import android.webkit.WebView;
10
+ import android.webkit.WebViewClient;
11
+ import java.io.ByteArrayInputStream;
12
+ import java.io.IOException;
13
+ import java.util.Collections;
14
+
15
+ public class MainActivity extends Activity {
16
+ static final String PAGE_ORIGIN = "https://appassets.androidplatform.net";
17
+ private static final String PAGE = PAGE_ORIGIN + "/index.html";
18
+ private static final String PAGE_POLICY =
19
+ "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; "
20
+ + "img-src data:; base-uri 'none'; form-action 'none'; frame-ancestors 'none'";
21
+ private WebView webView;
22
+
23
+ @Override
24
+ public void onCreate(Bundle state) {
25
+ super.onCreate(state);
26
+ webView = new WebView(this);
27
+ WebSettings settings = webView.getSettings();
28
+ settings.setJavaScriptEnabled(true);
29
+ settings.setDomStorageEnabled(true);
30
+ settings.setAllowFileAccess(false);
31
+ settings.setAllowContentAccess(false);
32
+ settings.setMixedContentMode(WebSettings.MIXED_CONTENT_NEVER_ALLOW);
33
+ webView.setWebViewClient(new AppPageClient(this));
34
+ setContentView(webView);
35
+ webView.loadUrl(PAGE);
36
+ }
37
+
38
+ String pagePolicy() {
39
+ return PAGE_POLICY;
40
+ }
41
+
42
+ WebResourceResponse localResource(WebResourceRequest request) {
43
+ return null;
44
+ }
45
+
46
+ private static final class AppPageClient extends WebViewClient {
47
+ private final MainActivity activity;
48
+ private final AssetManager assets;
49
+
50
+ AppPageClient(MainActivity activity) {
51
+ this.activity = activity;
52
+ this.assets = activity.getAssets();
53
+ }
54
+
55
+ @Override
56
+ public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) {
57
+ return true;
58
+ }
59
+
60
+ @Override
61
+ public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceRequest request) {
62
+ if (request.isForMainFrame() && PAGE.equals(request.getUrl().toString())) {
63
+ try {
64
+ return new WebResourceResponse("text/html", "UTF-8", 200, "OK",
65
+ Collections.singletonMap("Content-Security-Policy", activity.pagePolicy()),
66
+ assets.open("index.html"));
67
+ } catch (IOException unavailable) {
68
+ return emptyResponse(404, "Not Found");
69
+ }
70
+ }
71
+ if (!request.isForMainFrame()) {
72
+ WebResourceResponse resource = activity.localResource(request);
73
+ if (resource != null) return resource;
74
+ }
75
+ return emptyResponse(403, "Forbidden");
76
+ }
77
+ }
78
+
79
+ private static WebResourceResponse emptyResponse(int status, String reason) {
80
+ return new WebResourceResponse("text/plain", "UTF-8", status, reason,
81
+ Collections.emptyMap(), new ByteArrayInputStream(new byte[0]));
82
+ }
83
+
84
+ @Override
85
+ protected void onDestroy() {
86
+ if (webView != null) webView.destroy();
87
+ super.onDestroy();
88
+ }
89
+ }
@@ -0,0 +1,68 @@
1
+ <!doctype html>
2
+ <html lang="zh-CN">
3
+ <meta charset="utf-8">
4
+ <meta name="viewport" content="width=device-width, initial-scale=1">
5
+ <title>{{APP_NAME}}</title>
6
+ <style>
7
+ body { margin: 0; background: #f5f8f6; color: #19332b; font: 1rem system-ui, sans-serif; }
8
+ main { max-width: 32rem; margin: 2rem auto; padding: 1.5rem; }
9
+ h1 { margin: 0 0 1rem; }
10
+ p { line-height: 1.5; }
11
+ dl { padding: 1rem 1.25rem; border-radius: .75rem; background: white; }
12
+ dl div { display: flex; justify-content: space-between; align-items: baseline; padding: .6rem 0; }
13
+ dd { margin: 0; font-size: 1.5rem; font-variant-numeric: tabular-nums; }
14
+ </style>
15
+ <main>
16
+ <h1>{{APP_NAME}}</h1>
17
+ <p id="status" role="status">正在准备采样</p>
18
+ <dl aria-label="三轴角速度,单位弧度每秒">
19
+ <div><dt>X 轴</dt><dd id="x">—</dd></div>
20
+ <div><dt>Y 轴</dt><dd id="y">—</dd></div>
21
+ <div><dt>Z 轴</dt><dd id="z">—</dd></div>
22
+ </dl>
23
+ <p>角速度单位为 rad/s。转动手机,观察各轴的读数。</p>
24
+ <p id="timestamp"></p>
25
+ </main>
26
+ <script>
27
+ const status = document.getElementById('status')
28
+ const timestamp = document.getElementById('timestamp')
29
+ const axes = ['x', 'y', 'z']
30
+ const messages = {
31
+ active: '正在读取原生陀螺仪',
32
+ waiting: '正在等待陀螺仪数据',
33
+ inactive: '采样已暂停',
34
+ unavailable: '这台设备没有可用的陀螺仪',
35
+ error: '无法读取陀螺仪',
36
+ }
37
+
38
+ function render(sample) {
39
+ const message = messages[sample.status] || messages.error
40
+ if (status.textContent !== message) status.textContent = message
41
+ const active = sample.status === 'active'
42
+ for (const axis of axes) {
43
+ document.getElementById(axis).textContent = active ? sample[axis].toFixed(4) : '—'
44
+ }
45
+ timestamp.textContent = active ? '采样时间(开机后 ns)' + sample.timestampNanos : ''
46
+ }
47
+
48
+ async function refresh() {
49
+ try {
50
+ if (!document.hidden) {
51
+ const response = await fetch('/gyroscope.json', { cache: 'no-store' })
52
+ if (!response.ok) throw new Error('Sensor reading unavailable')
53
+ render(await response.json())
54
+ }
55
+ } catch {
56
+ render({ status: 'error' })
57
+ } finally {
58
+ setTimeout(refresh, 150)
59
+ }
60
+ }
61
+
62
+ if (location.origin === 'https://appassets.androidplatform.net') {
63
+ refresh()
64
+ } else {
65
+ status.textContent = '浏览器预览。安装 APK 后可读取真机数据。'
66
+ }
67
+ </script>
68
+ </html>
@@ -0,0 +1,134 @@
1
+ import { execFile } from 'node:child_process'
2
+ import { isAbsolute, join } from 'node:path'
3
+ import { promisify } from 'node:util'
4
+ import { formatFetchOutput } from '@deepseek-ai/dsh-tool-web'
5
+ import { DEFAULT_USER_AGENT, HttpFetchProvider } from '@deepseek-ai/dsh-web-fetch-http'
6
+ import { toolchainCandidates } from './android.js'
7
+
8
+ const run = promisify(execFile)
9
+ const hosts = ['developer.android.com', 'developer.android.google.cn']
10
+ const maxExcerpt = 12_000
11
+ const identifier = /^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*$/
12
+ const docsWeb = new HttpFetchProvider({
13
+ // Devsite puts a large navigation tree before the reference article.
14
+ maxResponseBytes: 8_000_000,
15
+ maxBodyChars: 8_000_000,
16
+ timeoutMs: 25_000,
17
+ maxRedirects: 3,
18
+ userAgent: DEFAULT_USER_AGENT,
19
+ })
20
+
21
+ function normalizeAnchor(value) {
22
+ try {
23
+ value = decodeURIComponent(value)
24
+ } catch {
25
+ // A literal percent sign can appear in an HTML heading ID.
26
+ }
27
+ return value.replaceAll('&lt;', '<').replaceAll('&gt;', '>').replaceAll('&amp;', '&').replace(/\s/g, '')
28
+ }
29
+
30
+ function findSection(body, symbol, fragment) {
31
+ if (body.kind === 'text') {
32
+ if (fragment) return undefined
33
+ const heading = [...body.content.matchAll(/^#{1,6}\s+([^\r\n]+)$/gm)].find((match) => match[1].replaceAll('`', '').trim() === symbol)
34
+ if (!heading) return undefined
35
+ return { content: body.content.slice(heading.index, heading.index + maxExcerpt), kind: 'text' }
36
+ }
37
+
38
+ const start = body.content.indexOf('<div class="devsite-article-body')
39
+ if (start === -1) return undefined
40
+ const end = body.content.indexOf('</devsite-article>', start)
41
+ const article = body.content.slice(start, end === -1 ? undefined : end)
42
+ const headings = [...article.matchAll(/<h([1-6])\b([^>]*)>([\s\S]*?)<\/h\1>/gi)]
43
+ const matches = []
44
+ for (const [index, heading] of headings.entries()) {
45
+ const anchor = heading[2].match(/\bid=["']([^"']+)["']/i)?.[1]
46
+ const label = heading[3].replace(/<[^>]*>/g, '').trim()
47
+ const named = label === symbol || label.endsWith('.' + symbol)
48
+ const matched = named && (!fragment || (anchor && normalizeAnchor(anchor) === fragment))
49
+ if (!matched) continue
50
+ const next = headings[index + 1]?.index ?? article.length
51
+ matches.push(article.slice(heading.index, Math.min(next, heading.index + maxExcerpt)))
52
+ if (fragment || matches.length === 3) break
53
+ }
54
+ if (!matches.length) return undefined
55
+ return { content: matches.join('\n'), kind: 'html' }
56
+ }
57
+
58
+ export async function lookupAndroidReference({ url, api }, signal, web = docsWeb) {
59
+ const requested = new URL(url)
60
+ if (requested.protocol !== 'https:' || !hosts.includes(requested.host)
61
+ || requested.username || requested.password || !requested.pathname.startsWith('/reference/')) {
62
+ throw new Error('Supply an HTTPS Android API reference URL on developer.android.com or developer.android.google.cn.')
63
+ }
64
+ if (!identifier.test(api) || api.length > 160) throw new Error('Supply an API class, method, or field name.')
65
+ const symbol = api.split('.').at(-1)
66
+ const fragment = normalizeAnchor(requested.hash.slice(1))
67
+ const language = requested.searchParams.get('hl')
68
+ requested.search = ''
69
+ if (language && /^[a-z]{2,3}(?:-[a-zA-Z]{2,4})?$/.test(language)) requested.searchParams.set('hl', language)
70
+ const attempts = []
71
+ for (const host of hosts) {
72
+ signal?.throwIfAborted()
73
+ requested.hostname = host
74
+ const source = requested.href
75
+ try {
76
+ const page = await web.fetch({ url: source }, signal)
77
+ const finalUrl = new URL(page.url)
78
+ if (page.statusCode < 200 || page.statusCode >= 300) {
79
+ attempts.push(`${source} returned HTTP ${page.statusCode}.`)
80
+ continue
81
+ }
82
+ if (finalUrl.protocol !== 'https:' || !hosts.includes(finalUrl.host) || !finalUrl.pathname.startsWith('/reference/')) {
83
+ attempts.push(`${source} redirected outside the official Android reference hosts.`)
84
+ continue
85
+ }
86
+ const section = findSection(page.body, symbol, fragment)
87
+ if (!section) {
88
+ const result = page.truncated ? 'The response was incomplete' : 'No matching reference section was found'
89
+ attempts.push(`${source}: ${result} for ${api}.`)
90
+ continue
91
+ }
92
+ finalUrl.hash = requested.hash
93
+ const excerpt = formatFetchOutput({ ...page, url: finalUrl.href, body: section }, maxExcerpt)
94
+ return { found: true, source: finalUrl.href, excerpt, attempts }
95
+ } catch (error) {
96
+ signal?.throwIfAborted()
97
+ attempts.push(`${source}: ${String(error.message).slice(0, 250)}`)
98
+ }
99
+ }
100
+ return { found: false, attempts }
101
+ }
102
+
103
+ export async function verifyPlatformDeclaration(url, api, signal) {
104
+ const path = new URL(url).pathname.replace(/^\/reference\/(?:kotlin\/)?/, '')
105
+ if (!path.startsWith('android/')) return undefined
106
+ const className = path.replaceAll('/', '.')
107
+ if (!identifier.test(className)) return undefined
108
+ let config
109
+ try {
110
+ config = await toolchainCandidates()
111
+ } catch (error) {
112
+ return `Could not read the project SDK configuration: ${String(error.message).slice(0, 250)}`
113
+ }
114
+ const { javaHome, androidJar } = config
115
+ if (!javaHome || !androidJar || !isAbsolute(javaHome) || !isAbsolute(androidJar)) {
116
+ return 'The project SDK is unavailable. Verify the signature after configuring the build tools.'
117
+ }
118
+ try {
119
+ const { stdout } = await run(join(javaHome, 'bin', 'javap'), ['-public', '-classpath', androidJar, className], {
120
+ signal, timeout: 5_000, maxBuffer: 256 * 1024,
121
+ })
122
+ const symbol = api.split('.').at(-1)
123
+ const declaration = symbol === className.split('.').at(-1)
124
+ ? /\b(class|interface|enum)\b/
125
+ : new RegExp(`\\b${RegExp.escape(symbol)}(?=\\(|\\s*[;=])`)
126
+ const lines = stdout.split('\n').map((line) => line.trim()).filter((line) => declaration.test(line))
127
+ return lines.length
128
+ ? `Public declarations in ${androidJar}:\n${lines.slice(0, 6).join('\n')}`
129
+ : `No direct declaration matched in ${androidJar}. Check inherited members and exact overloads.`
130
+ } catch (error) {
131
+ signal?.throwIfAborted()
132
+ return `Could not verify ${className} against ${androidJar}: ${String(error.stderr || error.message).trim().slice(0, 250)}`
133
+ }
134
+ }