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>
|
||||
Reference in New Issue
Block a user