feat: DSH编排基建首版(server/tests 88用例/P2插件/8020端口/PORT-NOTE/MOBILE_APP)

This commit is contained in:
2026-09-12 14:20:02 +08:00
commit f6acd20325
128 changed files with 24471 additions and 0 deletions
+66
View File
@@ -0,0 +1,66 @@
plugins {
id "com.android.application"
}
android {
namespace "com.dsh.sync"
compileSdk 34
defaultConfig {
applicationId "com.dsh.sync"
minSdk 24
targetSdk 34
versionCode 1
versionName "1.0.0"
}
signingConfigs {
release {
// Created by android/build.sh on first run. Without it Gradle falls
// back to the debug key so a plain `gradle assembleRelease` still
// produces an installable APK.
def store = file("dsh-release.jks")
if (store.exists()) {
storeFile store
storePassword "dshsync"
keyAlias "dsh"
keyPassword "dshsync"
}
}
}
buildTypes {
release {
minifyEnabled false
if (file("dsh-release.jks").exists()) {
signingConfig signingConfigs.release
}
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
lint {
abortOnError false
}
}
dependencies {
implementation "androidx.appcompat:appcompat:1.7.0"
implementation "androidx.webkit:webkit:1.11.0"
// appcompat pulls kotlin-stdlib 1.8.22 while a transitive androidx artifact
// still drags the 1.6.x jdk7/jdk8 split modules, which duplicate its
// classes. Align every kotlin-stdlib-* module on one version.
constraints {
implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.8.22") {
because "duplicate classes against kotlin-stdlib 1.8.22"
}
implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.8.22") {
because "duplicate classes against kotlin-stdlib 1.8.22"
}
}
}
Binary file not shown.
+40
View File
@@ -0,0 +1,40 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<application
android:allowBackup="false"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.DshSync"
android:usesCleartextTraffic="true">
<activity
android:name=".MainActivity"
android:configChanges="orientation|screenSize|keyboardHidden|screenLayout|smallestScreenSize|uiMode"
android:exported="true"
android:launchMode="singleTask"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- QR deep link: http(s)://<server>/app#invite=<code>.
The fragment is not matched by pathPattern (Android compares
only scheme/host/path), so MainActivity reads it from the raw
intent data instead. -->
<intent-filter android:autoVerify="false">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="http" android:pathPattern="/app.*" />
<data android:scheme="https" android:pathPattern="/app.*" />
</intent-filter>
</activity>
</application>
</manifest>
@@ -0,0 +1,306 @@
package com.dsh.sync;
import android.annotation.SuppressLint;
import android.app.Activity;
import android.app.DownloadManager;
import android.content.Intent;
import android.content.SharedPreferences;
import android.net.Uri;
import android.os.Bundle;
import android.os.Environment;
import android.text.TextUtils;
import android.view.KeyEvent;
import android.view.View;
import android.view.inputmethod.EditorInfo;
import android.webkit.CookieManager;
import android.webkit.ValueCallback;
import android.webkit.WebChromeClient;
import android.webkit.WebResourceRequest;
import android.webkit.WebSettings;
import android.webkit.WebView;
import android.webkit.WebViewClient;
import android.widget.Button;
import android.widget.EditText;
import android.widget.LinearLayout;
import android.widget.ProgressBar;
import android.widget.Toast;
import androidx.activity.OnBackPressedCallback;
import androidx.appcompat.app.AppCompatActivity;
import java.util.Locale;
/**
* WebView shell around the self-hosted dsh-sync PWA.
*
* The page itself is the whole UI — this class only supplies what a browser tab
* cannot: a persistent server address, a native file picker for plugin uploads,
* and handling of the QR deep link (…/app#invite=CODE), which it forwards into
* the page so the login form arrives pre-filled.
*/
public class MainActivity extends AppCompatActivity {
private static final String PREFS = "dsh";
private static final String KEY_SERVER = "server_url";
private static final String KEY_INVITE = "invite";
private static final int FILE_PICKER_REQUEST = 1001;
/** A phone-shaped UA keeps the PWA layout stable and marks the traffic. */
private static final String UA_SUFFIX = " DshSyncApp/1.0";
private WebView web;
private EditText urlBar;
private ProgressBar progress;
private LinearLayout errorBar;
private ValueCallback<Uri[]> fileCallback;
private SharedPreferences prefs;
@SuppressLint("SetJavaScriptEnabled")
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
prefs = getSharedPreferences(PREFS, MODE_PRIVATE);
web = findViewById(R.id.web);
urlBar = findViewById(R.id.url);
progress = findViewById(R.id.progress);
errorBar = findViewById(R.id.error_bar);
findViewById(R.id.go).setOnClickListener(v -> navigate(urlBar.getText().toString()));
findViewById(R.id.reload).setOnClickListener(v -> web.reload());
findViewById(R.id.retry).setOnClickListener(v -> {
errorBar.setVisibility(View.GONE);
web.reload();
});
urlBar.setOnEditorActionListener((v, actionId, event) -> {
boolean enter = actionId == EditorInfo.IME_ACTION_GO
|| (event != null && event.getKeyCode() == KeyEvent.KEYCODE_ENTER);
if (enter) {
navigate(urlBar.getText().toString());
return true;
}
return false;
});
WebSettings s = web.getSettings();
s.setJavaScriptEnabled(true);
s.setDomStorageEnabled(true); // the PWA keeps its token here
s.setDatabaseEnabled(true);
s.setUserAgentString(s.getUserAgentString() + UA_SUFFIX);
s.setMediaPlaybackRequiresUserGesture(false);
s.setBuiltInZoomControls(false);
s.setSupportZoom(false);
CookieManager.getInstance().setAcceptCookie(true);
CookieManager.getInstance().setAcceptThirdPartyCookies(web, false);
web.setWebViewClient(new WebViewClient() {
@Override
public boolean shouldOverrideUrlLoading(WebView v, WebResourceRequest req) {
Uri u = req.getUrl();
String scheme = u.getScheme() == null ? "" : u.getScheme().toLowerCase(Locale.ROOT);
// Keep the server in-app; hand anything else to the system.
if (scheme.equals("http") || scheme.equals("https")) {
if (isCurrentServer(u)) {
return false;
}
startActivity(new Intent(Intent.ACTION_VIEW, u));
return true;
}
return true;
}
@Override
public void onPageFinished(WebView v, String url) {
urlBar.setText(url);
errorBar.setVisibility(View.GONE);
}
@Override
public void onReceivedError(WebView v, WebResourceRequest req, android.webkit.WebResourceError err) {
if (req.isForMainFrame()) {
errorBar.setVisibility(View.VISIBLE);
}
}
});
// Without this, the download button on /apk does nothing: the bytes
// arrive (server logs a 200) but WebView has no handler to write them
// anywhere. Hand the job to the system DownloadManager instead.
web.setDownloadListener((url, userAgent, contentDisposition, mimetype, contentLength) -> {
try {
DownloadManager.Request req = new DownloadManager.Request(Uri.parse(url));
req.setMimeType(mimetype);
req.addRequestHeader("Cookie", CookieManager.getInstance().getCookie(url));
req.setNotificationVisibility(
DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED);
req.setDestinationInExternalPublicDir(
Environment.DIRECTORY_DOWNLOADS, nameFromUrl(url, contentDisposition));
DownloadManager dm = (DownloadManager) getSystemService(DOWNLOAD_SERVICE);
if (dm == null) {
throw new IllegalStateException("no DownloadManager");
}
dm.enqueue(req);
Toast.makeText(this, "开始下载,完成后在「下载」里安装", Toast.LENGTH_LONG).show();
} catch (Exception e) {
Toast.makeText(this, "下载失败: " + e.getMessage(), Toast.LENGTH_LONG).show();
}
});
web.setWebChromeClient(new WebChromeClient() {
@Override
public void onProgressChanged(WebView v, int p) {
progress.setProgress(p);
progress.setVisibility(p >= 100 ? View.GONE : View.VISIBLE);
}
@Override
public boolean onShowFileChooser(WebView v, ValueCallback<Uri[]> cb,
FileChooserParams params) {
if (fileCallback != null) {
fileCallback.onReceiveValue(null);
}
fileCallback = cb;
try {
startActivityForResult(params.createIntent(), FILE_PICKER_REQUEST);
} catch (Exception e) {
fileCallback = null;
Toast.makeText(MainActivity.this, "无法打开文件选择器: " + e.getMessage(),
Toast.LENGTH_LONG).show();
return false;
}
return true;
}
});
getOnBackPressedDispatcher().addCallback(this, new OnBackPressedCallback(true) {
@Override
public void handleOnBackPressed() {
if (web.canGoBack()) {
web.goBack();
} else {
setEnabled(false);
getOnBackPressedDispatcher().onBackPressed();
}
}
});
handleIntent(getIntent());
}
@Override
protected void onNewIntent(Intent intent) {
super.onNewIntent(intent);
setIntent(intent);
handleIntent(intent);
}
/** Open a QR deep link (or the saved server) and pass the invite into the page. */
private void handleIntent(Intent intent) {
Uri data = intent == null ? null : intent.getData();
if (data != null && data.getScheme() != null) {
String invite = data.getFragment();
if (invite != null && invite.startsWith("invite=")) {
prefs.edit().putString(KEY_INVITE, invite.substring("invite=".length())).apply();
}
// Hand the whole URL back to navigate() rather than rebuilding it as
// /app, so a deep link to /apk keeps its path and its fragment.
navigate(data.toString());
return;
}
navigate(prefs.getString(KEY_SERVER, ""));
}
/** Normalise whatever the user typed into a server origin and load /app. */
private void navigate(String input) {
String value = input == null ? "" : input.trim();
if (value.isEmpty() && TextUtils.isEmpty(prefs.getString(KEY_SERVER, ""))) {
// Nothing configured yet: let the user land on the page so it can
// still be pointed elsewhere later.
urlBar.requestFocus();
Toast.makeText(this, "先填服务端地址,例如 192.168.5.2:8020", Toast.LENGTH_LONG).show();
return;
}
if (value.isEmpty()) {
value = prefs.getString(KEY_SERVER, "");
}
if (!value.startsWith("http://") && !value.startsWith("https://")) {
value = "http://" + value;
}
Uri u = Uri.parse(value);
if (u.getAuthority() == null) {
Toast.makeText(this, "地址无效: " + input, Toast.LENGTH_LONG).show();
return;
}
String origin = u.getScheme() + "://" + u.getAuthority();
// Keep the path the caller asked for (/app, /apk, ...). Forcing /app here
// made the /apk deep link open the login page instead of the installer.
String path = u.getPath();
if (path == null || path.isEmpty() || path.equals("/")) {
path = "/app";
}
// The invite only means anything to the login page; /apk and friends
// must not inherit a fragment they never asked for.
String invite = prefs.getString(KEY_INVITE, "");
boolean wantsInvite = "/app".equals(path) && !invite.isEmpty();
String target = origin + path + (wantsInvite ? "#invite=" + invite : "");
prefs.edit().putString(KEY_SERVER, origin).apply();
urlBar.setText(origin + path);
errorBar.setVisibility(View.GONE);
web.loadUrl(target);
}
/** Pick a filename for a download: Content-Disposition first, then the URL. */
private static String nameFromUrl(String url, String contentDisposition) {
if (contentDisposition != null) {
for (String part : contentDisposition.split(";")) {
String p = part.trim();
if (p.regionMatches(true, 0, "filename=", 0, 9)) {
String name = p.substring(9).trim().replace("\"", "");
if (!name.isEmpty()) {
return name;
}
}
}
}
String last = Uri.parse(url).getLastPathSegment();
return (last == null || last.isEmpty()) ? "download.bin" : last;
}
private boolean isCurrentServer(Uri u) {
String saved = prefs.getString(KEY_SERVER, "");
if (saved.isEmpty()) {
return false;
}
Uri s = Uri.parse(saved);
return s.getHost() != null && s.getHost().equalsIgnoreCase(u.getHost())
&& s.getPort() == u.getPort();
}
@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
if (requestCode == FILE_PICKER_REQUEST) {
if (fileCallback != null) {
Uri[] result = null;
if (resultCode == Activity.RESULT_OK && data != null) {
if (data.getClipData() != null) {
int n = data.getClipData().getItemCount();
result = new Uri[n];
for (int i = 0; i < n; i++) {
result[i] = data.getClipData().getItemAt(i).getUri();
}
} else if (data.getData() != null) {
result = new Uri[]{data.getData()};
}
}
fileCallback.onReceiveValue(result);
fileCallback = null;
}
return;
}
super.onActivityResult(requestCode, resultCode, data);
}
}
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:fillColor="#0f1115" android:pathData="M0,0h108v108h-108z" />
</vector>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#4c8dff"
android:pathData="M54,22L78,38L78,70L54,86L30,70L30,38Z" />
<path
android:fillColor="#0f1115"
android:pathData="M46,44h8v20h-8zM58,44h4v8h-4zM58,56h4v8h-4z" />
</vector>
@@ -0,0 +1,101 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/bg"
android:orientation="vertical">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/card"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingStart="10dp"
android:paddingTop="8dp"
android:paddingEnd="10dp"
android:paddingBottom="8dp">
<EditText
android:id="@+id/url"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:background="@color/bg"
android:hint="@string/url_hint"
android:imeOptions="actionGo"
android:inputType="textUri"
android:maxLines="1"
android:paddingStart="10dp"
android:paddingEnd="10dp"
android:paddingTop="8dp"
android:paddingBottom="8dp"
android:textColor="@color/text"
android:textColorHint="@color/muted"
android:textSize="13sp" />
<Button
android:id="@+id/go"
style="?android:attr/borderlessButtonStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:minWidth="48dp"
android:text="@string/go"
android:textColor="@color/acc" />
<Button
android:id="@+id/reload"
style="?android:attr/borderlessButtonStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:minWidth="48dp"
android:contentDescription="@string/reload"
android:text="@string/reload"
android:textColor="@color/acc" />
</LinearLayout>
<ProgressBar
android:id="@+id/progress"
style="?android:attr/progressBarStyleHorizontal"
android:layout_width="match_parent"
android:layout_height="3dp"
android:max="100"
android:visibility="gone" />
<LinearLayout
android:id="@+id/error_bar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/card"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingStart="12dp"
android:paddingEnd="12dp"
android:paddingTop="10dp"
android:paddingBottom="10dp"
android:visibility="gone">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/load_failed"
android:textColor="@color/text"
android:textSize="13sp" />
<Button
android:id="@+id/retry"
style="?android:attr/borderlessButtonStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:minWidth="48dp"
android:text="@string/retry"
android:textColor="@color/acc" />
</LinearLayout>
<WebView
android:id="@+id/web"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1" />
</LinearLayout>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="bg">#0f1115</color>
<color name="card">#171a21</color>
<color name="text">#e6e8ee</color>
<color name="muted">#8b93a7</color>
<color name="acc">#4c8dff</color>
</resources>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">dsh 同步</string>
<string name="url_hint">服务端地址,如 192.168.5.2:8020</string>
<string name="go">前往</string>
<string name="reload">刷新</string>
<string name="retry">重试</string>
<string name="load_failed">打不开页面。检查手机与服务器是否在同一网络。</string>
</resources>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.DshSync" parent="Theme.AppCompat.NoActionBar">
<item name="android:windowBackground">@color/bg</item>
<item name="android:statusBarColor">@color/card</item>
<item name="android:navigationBarColor">@color/card</item>
</style>
</resources>
+3
View File
@@ -0,0 +1,3 @@
plugins {
id "com.android.application" version "8.5.2" apply false
}
+110
View File
@@ -0,0 +1,110 @@
#!/usr/bin/env bash
# Build dsh-sync.apk and publish it into the server's data/dist directory.
#
# bash android/build.sh
#
# Toolchain (override with env vars if yours differs):
# JAVA_HOME JDK 17
# ANDROID_HOME Android SDK with build-tools + platform 34
# GRADLE gradle 8.7 launcher
#
# First run needs network access: Gradle downloads the Android Gradle Plugin and
# androidx dependencies into ~/.gradle. Later builds work offline.
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
ANDROID_DIR="$ROOT/android"
DIST="$ROOT/data/dist"
TOOLS="${ANDROID_TOOLS:-/c/Users/12914/Desktop/android-tools}"
# Windows path form for the JVM. `cygpath -w` when available, else a manual
# /c/ -> C:\ rewrite, because the -w call itself is what fails here.
win_path() {
if command -v cygpath >/dev/null 2>&1; then cygpath -w "$1"; return; fi
local p="$1"
if [[ "$p" =~ ^/([a-zA-Z])/(.*)$ ]]; then
printf '%s:\\%s\n' "${BASH_REMATCH[1]^^}" "${BASH_REMATCH[2]//\//\\}"
else
printf '%s\n' "$p"
fi
}
# The ambient JAVA_HOME/ANDROID_HOME on this machine point at an unrelated
# project, so ours wins unless the caller explicitly overrides via the
# ANDROID_TOOLS/GRADLE_BIN knobs. A stale ANDROID_HOME fails the build with
# "SDK location not found".
export JAVA_HOME="$(win_path "$TOOLS/jdk17/jdk-17.0.20+8")"
export ANDROID_HOME="$(win_path "$TOOLS/sdk")"
unset ANDROID_SDK_ROOT
GRADLE_BIN="${GRADLE_BIN:-$TOOLS/gradle-8.7/bin/gradle}"
if [ ! -x "$GRADLE_BIN" ] && [ ! -f "$GRADLE_BIN.bat" ]; then
echo "gradle not found at $GRADLE_BIN (set GRADLE_BIN)" >&2
exit 1
fi
echo "JAVA_HOME = $JAVA_HOME"
echo "ANDROID_HOME = $ANDROID_HOME"
# AGP reads sdk.dir from here; it survives a shell with no ANDROID_HOME and is
# the authoritative answer when the ambient env points somewhere stale.
# Forward slashes on purpose: a Java .properties file treats `\U` in a Windows
# path as a unicode escape and silently mangles it.
echo "sdk.dir=$TOOLS/sdk" | tr -d '\r' > "$ANDROID_DIR/local.properties"
# Release keystore, generated once. Committed nowhere: this is a private LAN
# tool, and a stable key matters only so app updates install over each other.
KEYSTORE="$ANDROID_DIR/app/dsh-release.jks"
KEYTOOL="$(dirname "$JAVA_HOME")/bin/keytool"
[ -x "$KEYTOOL" ] || KEYTOOL="$JAVA_HOME/bin/keytool"
if [ ! -f "$KEYSTORE" ]; then
echo ">> generating release keystore"
"$KEYTOOL" -genkeypair -v \
-keystore "$KEYSTORE" \
-alias dsh -keyalg RSA -keysize 2048 -validity 10000 \
-storepass dshsync -keypass dshsync \
-dname "CN=dsh-sync, OU=team, O=dsh, L=, S=, C=CN" >/dev/null 2>&1
fi
echo ">> building"
cd "$ANDROID_DIR"
"$GRADLE_BIN" --no-daemon -q assembleRelease
APK_SRC="$ANDROID_DIR/app/build/outputs/apk/release/app-release.apk"
if [ ! -f "$APK_SRC" ]; then
APK_SRC="$ANDROID_DIR/app/build/outputs/apk/release/app-release-unsigned.apk"
fi
[ -f "$APK_SRC" ] || { echo "build produced no APK" >&2; exit 1; }
echo ">> publishing"
mkdir -p "$DIST"
cp "$APK_SRC" "$DIST/dsh-sync.apk"
# The version metadata the server hands to /apk, so the page can show what it
# is offering without unpacking the artifact.
VERSION="$(grep -oP 'versionName\s+"\K[^"]+' "$ANDROID_DIR/app/build.gradle" | head -1)"
CODE="$(grep -oP 'versionCode\s+\K[0-9]+' "$ANDROID_DIR/app/build.gradle" | head -1)"
SHA="$(sha256sum "$DIST/dsh-sync.apk" | cut -d' ' -f1)"
SIZE="$(stat -c %s "$DIST/dsh-sync.apk")"
BUILT="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
cat > "$DIST/dsh-sync.json" <<EOF
{
"version": "$VERSION",
"version_code": $CODE,
"sha256": "$SHA",
"size_bytes": $SIZE,
"built_at": "$BUILT",
"application_id": "com.dsh.sync",
"min_sdk": 24,
"target_sdk": 34
}
EOF
echo
echo "APK : $DIST/dsh-sync.apk"
echo "size : $SIZE bytes"
echo "sha256 : $SHA"
echo "version : $VERSION ($CODE)"
echo
echo "Install page: http://<server>:8020/apk"
+162
View File
@@ -0,0 +1,162 @@
#!/usr/bin/env bash
# Drive the dsh-sync app inside MuMu Player 12 from the command line.
#
# bash android/emulator.sh start # boot the VM, wait for adb
# bash android/emulator.sh install # build + install + launch
# bash android/emulator.sh shot [out.png] # capture just the app's window
# bash android/emulator.sh tap X Y # tap in APP coordinates (0..1080 x 0..1920)
# bash android/emulator.sh text "..." # type into the focused field
# bash android/emulator.sh key <keyevent> # e.g. 66 = Enter, 4 = Back
# bash android/emulator.sh deeplink <url> # http://ip:8020/app#invite=<code>
# bash android/emulator.sh ui # dump visible text from the WebView
# bash android/emulator.sh log [n]
#
# Two MuMu behaviours drive the design here, both learned the hard way:
#
# 1. Every app gets its OWN virtual display, and the logical display id is
# re-assigned on every boot (0, then 3, then 6, then 8, then 11 across one
# session). `adb shell input tap` without `-d` goes to display 0, i.e. the
# launcher — taps appear to do nothing while the app is visibly on screen.
# Every input call below resolves the app's current display and passes -d.
#
# 2. `screencap` cannot capture the app's display: the compositor refuses with
# "Status: -2" for MuMu's app displays. Screenshots therefore come from the
# host window (see the CUA path) or from `screencap -a`; this script shells
# out to the window capture for `shot`.
set -uo pipefail
MUMU="${MUMU_HOME:-/c/Program Files/Netease/MuMu}"
ADB="$MUMU/nx_main/adb.exe"
MANAGER="$MUMU/nx_main/MuMuManager.exe"
SERIAL="${MUMU_SERIAL:-127.0.0.1:16384}"
PKG="com.dsh.sync"
APK="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/data/dist/dsh-sync.apk"
# Git Bash rewrites /sdcard/... into a Windows path; this disables that.
export MSYS_NO_PATHCONV=1
adb_() { "$ADB" -s "$SERIAL" "$@"; }
ensure_serial() {
if ! adb_ get-state >/dev/null 2>&1; then
"$ADB" connect "$SERIAL" >/dev/null 2>&1
fi
}
# Logical display currently hosting our activity, or empty.
#
# The imeInputTarget line names whichever window holds the input focus, so it
# must be filtered down to our own package first — otherwise the launcher's
# line matches and every tap lands on display 0.
app_display() {
adb_ shell dumpsys window windows 2>/dev/null | tr -d '\r' |
grep -E "imeInputTarget in display#" |
grep -F "$PKG" |
grep -oE "display# ?[0-9]+" | grep -oE "[0-9]+$" | head -1
}
require_app() {
local d; d="$(app_display)"
if [ -z "$d" ]; then
echo "app is not on screen — run: $0 start" >&2
exit 1
fi
printf '%s' "$d"
}
case "${1:-}" in
start)
"$MANAGER" control -v 0 launch >/dev/null 2>&1
for i in $(seq 1 40); do
sleep 5
ensure_serial
if [ "$(adb_ shell getprop sys.boot_completed 2>/dev/null | tr -d '\r')" = "1" ]; then
echo "booted after $((i * 5))s"
# A stale proxy silently blackholes every WebView request (Chromium
# honours the system proxy; netcat does not, so the network looks fine).
adb_ shell settings delete global http_proxy >/dev/null 2>&1
adb_ shell settings delete global global_http_proxy_host >/dev/null 2>&1
adb_ shell settings delete global global_http_proxy_port >/dev/null 2>&1
echo "proxy cleared"
exit 0
fi
done
echo "boot timed out" >&2; exit 1
;;
install)
[ -f "$APK" ] || { echo "missing $APK — run android/build.sh first" >&2; exit 1; }
adb_ install -r "$APK" | tail -1
adb_ shell am start -n "$PKG/.MainActivity" >/dev/null
echo launched
;;
deeplink)
# Explicit component: Android only offers a bare http URL to browsers, so an
# implicit VIEW intent never reaches this app.
adb_ shell am start -n "$PKG/.MainActivity" \
-a android.intent.action.VIEW -d "$2" >/dev/null
echo "sent $2"
;;
tap)
d="$(require_app)"
adb_ shell input -d "$d" tap "$2" "$3"
;;
text)
d="$(require_app)"
adb_ shell input -d "$d" text "$2"
;;
key)
d="$(require_app)"
adb_ shell input -d "$d" keyevent "$2"
;;
swipe)
d="$(require_app)"
adb_ shell input -d "$d" swipe "$2" "$3" "$4" "$5" "${6:-300}"
;;
ui)
# dumpsys window gives the WebView's text without needing a screenshot.
d="$(require_app)"
adb_ shell uiautomator dump //sdcard/_ui.xml >/dev/null 2>&1
adb_ shell cat //sdcard/_ui.xml 2>/dev/null |
tr '>' '\n' | grep -oE 'text="[^"]+"' | sed 's/text=//' | tr -d '"'
;;
shot)
# screencap refuses MuMu's app displays, so capture through the host window.
out="${2:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/_mumu_shot.png}"
"$MUMU/nx_main/MuMuManager.exe" >/dev/null 2>&1
powershell -NoProfile -Command "
Add-Type -AssemblyName System.Drawing,System.Windows.Forms
\$b = [System.Windows.Forms.Screen]::PrimaryScreen.Bounds
\$bmp = New-Object System.Drawing.Bitmap \$b.Width, \$b.Height
\$g = [System.Drawing.Graphics]::FromImage(\$bmp)
\$g.CopyFromScreen(\$b.Location, [System.Drawing.Point]::Empty, \$b.Size)
\$bmp.Save('$out')
" >/dev/null 2>&1
[ -s "$out" ] && echo "$out" || { echo "capture failed" >&2; exit 1; }
;;
status)
ensure_serial
echo "serial : $SERIAL"
echo "boot : $(adb_ shell getprop sys.boot_completed 2>/dev/null | tr -d '\r')"
echo "app display : $(app_display)"
echo "app pid : $(adb_ shell pidof $PKG 2>/dev/null | tr -d '\r')"
echo "proxy : $(adb_ shell settings get global http_proxy 2>/dev/null | tr -d '\r')"
echo "foreground : $(adb_ shell dumpsys activity activities 2>/dev/null | tr -d '\r' | grep -m1 topResumedActivity | sed 's/.*u0 //')"
;;
log)
adb_ logcat -d -t "${2:-80}" 2>/dev/null | grep -iE "dsh|chromium|AndroidRuntime|FATAL" | tail -25
;;
*)
sed -n '2,22p' "$0"
;;
esac
+3
View File
@@ -0,0 +1,3 @@
org.gradle.jvmargs=-Xmx1536m -Dfile.encoding=UTF-8
android.useAndroidX=true
android.nonTransitiveRClass=true
+1
View File
@@ -0,0 +1 @@
sdk.dir=/c/Users/12914/Desktop/android-tools/sdk
+16
View File
@@ -0,0 +1,16 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
}
}
rootProject.name = "dsh-sync"
include ":app"