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>