feat: DSH编排基建首版(server/tests 88用例/P2插件/8020端口/PORT-NOTE/MOBILE_APP)
This commit is contained in:
@@ -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.
@@ -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>
|
||||
@@ -0,0 +1,3 @@
|
||||
plugins {
|
||||
id "com.android.application" version "8.5.2" apply false
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
org.gradle.jvmargs=-Xmx1536m -Dfile.encoding=UTF-8
|
||||
android.useAndroidX=true
|
||||
android.nonTransitiveRClass=true
|
||||
@@ -0,0 +1 @@
|
||||
sdk.dir=/c/Users/12914/Desktop/android-tools/sdk
|
||||
@@ -0,0 +1,16 @@
|
||||
pluginManagement {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
dependencyResolutionManagement {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.name = "dsh-sync"
|
||||
include ":app"
|
||||
Reference in New Issue
Block a user