release: v1.0.2
加固:release 构建 + R8 混淆,清理无用资源,修启动链路
This commit is contained in:
1 parent
5b85408f17
commit
967e93af98
29 files changed
+1640
-1490
No files matched your search
@@ -11,8 +11,9 @@ android {
|
||||
applicationId "com.maipulse.app"
|
||||
minSdk 24
|
||||
targetSdk 34
|
||||
versionCode 1
|
||||
versionName "1.0"
|
||||
// 和网页里的 APP_VERSION 对齐,升级时一起改
|
||||
versionCode 102
|
||||
versionName "1.0.2"
|
||||
|
||||
ndk {
|
||||
abiFilters "arm64-v8a"
|
||||
@@ -20,7 +21,7 @@ android {
|
||||
|
||||
// Chaquopy 16 的 DSL 挂在 android.defaultConfig 下面
|
||||
python {
|
||||
buildPython "C:/Users/20558/AppData/Local/Programs/Python/Python38/python.exe"
|
||||
buildPython "python"
|
||||
pip {
|
||||
install "flask"
|
||||
install "requests"
|
||||
@@ -33,6 +34,12 @@ android {
|
||||
// 而 zipalign 之后我们要用 apksigner 重签。两边不是同一个 key 的话,
|
||||
// 出来的包签名不一样,用户必须卸载才能覆盖安装。
|
||||
signingConfigs {
|
||||
release {
|
||||
storeFile file("../maipulse-debug.keystore")
|
||||
storePassword "android"
|
||||
keyAlias "androiddebugkey"
|
||||
keyPassword "android"
|
||||
}
|
||||
debug {
|
||||
storeFile file("../maipulse-debug.keystore")
|
||||
storePassword "android"
|
||||
@@ -46,7 +53,13 @@ android {
|
||||
signingConfig signingConfigs.debug
|
||||
}
|
||||
release {
|
||||
minifyEnabled false
|
||||
// 出正式包:关调试标志,开 R8 去死代码 + 混淆 Java 侧
|
||||
signingConfig signingConfigs.release
|
||||
debuggable false
|
||||
minifyEnabled true
|
||||
shrinkResources true
|
||||
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'),
|
||||
'proguard-rules.pro'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,10 +69,25 @@ android {
|
||||
}
|
||||
|
||||
androidResources {
|
||||
// 这几类是已经压过的/要被 mmap 的,再压反而慢
|
||||
noCompress 'json', 'png'
|
||||
}
|
||||
|
||||
lint {
|
||||
// release 构建默认跑 lintVital,它会联网拉 lint-gradle 依赖。
|
||||
// 这条链断了整包就出不来,而 lint 结果对发版没有决定意义,直接关掉。
|
||||
checkReleaseBuilds false
|
||||
abortOnError false
|
||||
}
|
||||
|
||||
packagingOptions {
|
||||
jniLibs {
|
||||
// 原生库直接从 APK 里 mmap,不再解压一份到 /data
|
||||
useLegacyPackaging = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation 'androidx.core:core:1.12.0'
|
||||
}
|
||||
}
|
||||
@@ -5,19 +5,23 @@
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:allowBackup="false"
|
||||
android:fullBackupContent="false"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:label="maipulse"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:usesCleartextTraffic="true"
|
||||
android:hardwareAccelerated="true"
|
||||
android:largeHeap="true"
|
||||
android:theme="@style/AppTheme">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:configChanges="orientation|screenSize|keyboardHidden"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden|uiMode"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
android:launchMode="singleTask">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
|
||||
@@ -7,24 +7,32 @@ import android.content.BroadcastReceiver;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.IntentFilter;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.content.res.AssetManager;
|
||||
import android.graphics.Color;
|
||||
import android.graphics.Typeface;
|
||||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.os.Environment;
|
||||
import android.provider.Settings;
|
||||
import android.util.Log;
|
||||
import android.util.TypedValue;
|
||||
import android.view.Gravity;
|
||||
import android.view.KeyEvent;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.view.Window;
|
||||
import android.view.WindowManager;
|
||||
import android.webkit.JavascriptInterface;
|
||||
import android.webkit.ValueCallback;
|
||||
import android.webkit.WebChromeClient;
|
||||
import android.webkit.WebResourceRequest;
|
||||
import android.webkit.WebSettings;
|
||||
import android.webkit.WebView;
|
||||
import android.webkit.WebChromeClient;
|
||||
import android.webkit.WebViewClient;
|
||||
import android.widget.FrameLayout;
|
||||
import android.widget.ImageView;
|
||||
import android.widget.LinearLayout;
|
||||
import android.widget.TextView;
|
||||
import android.widget.Toast;
|
||||
|
||||
@@ -45,54 +53,144 @@ import java.net.Socket;
|
||||
/**
|
||||
* maipulse 的 Android 外壳。
|
||||
*
|
||||
* 流程:复制 assets/app -> filesDir/app(assets 只读,Flask 要写 logs 与缓存),
|
||||
* 起 Chaquopy 跑 Flask 绑 127.0.0.1:8787,端口通了再把 WebView 指过去。
|
||||
* 启动顺序:
|
||||
* 1. 先画原生启动页(图标 + 名字 + 状态文字),保证点开立刻有东西
|
||||
* 2. 后台线程把 assets/app 复制到 filesDir/app(assets 只读,Flask 要写 logs)
|
||||
* 3. 回到主线程装载 Chaquopy(Python.start 有主线程要求)
|
||||
* 4. 后台线程起 Flask,另一个线程轮询 127.0.0.1:8787
|
||||
* 5. 端口通了再加载 WebView,等 onPageFinished 把启动页淡出、网页淡入
|
||||
*
|
||||
* 额外给了网页一个 Maipulse.installApk(url):走系统 DownloadManager 下 APK,
|
||||
* 下完弹安装器,这样更新不用让用户自己去找文件。
|
||||
* 用户导入的机台配置(astral/config.py 和 astral/api/MaimaiNet.py)在资源重建时会被保留。
|
||||
*/
|
||||
public class MainActivity extends Activity {
|
||||
|
||||
private static final String TAG = "maipulse";
|
||||
private static final int PORT = 8787;
|
||||
private static final String HOME = "http://127.0.0.1:" + PORT + "/";
|
||||
private static final String BG = "#EEF2F9";
|
||||
|
||||
/* 启动页 */
|
||||
private View splash;
|
||||
private TextView splashTip;
|
||||
private TextView splashRetry;
|
||||
|
||||
private volatile WebView web;
|
||||
private volatile boolean firstPageDone = false;
|
||||
private volatile boolean loadRequested = false;
|
||||
|
||||
private WebView web;
|
||||
private TextView tip;
|
||||
private static final int REQ_PICK_FILE = 1001;
|
||||
private ValueCallback<Uri[]> filePathCallback;
|
||||
private long pendingDownloadId = -1;
|
||||
private BroadcastReceiver downloadReceiver;
|
||||
private long lastBackPress = 0;
|
||||
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
|
||||
setupSystemBars();
|
||||
buildUi();
|
||||
|
||||
// 关掉 WebView 远程调试(debug 包里默认是可调试的,能被 chrome://inspect 接管)
|
||||
try {
|
||||
WebView.setWebContentsDebuggingEnabled(false);
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
|
||||
registerDownloadReceiver();
|
||||
|
||||
// web.post 保证首帧先画出来,再进到会阻塞的主线程初始化
|
||||
web.post(this::prepareAndBoot);
|
||||
}
|
||||
|
||||
/* ---------------- 系统栏 ---------------- */
|
||||
|
||||
private void setupSystemBars() {
|
||||
Window w = getWindow();
|
||||
w.addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS);
|
||||
w.setStatusBarColor(Color.parseColor(BG));
|
||||
w.setNavigationBarColor(Color.WHITE);
|
||||
|
||||
int flags = View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR;
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
flags |= View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR;
|
||||
}
|
||||
w.getDecorView().setSystemUiVisibility(flags);
|
||||
}
|
||||
|
||||
/* ---------------- 界面 ---------------- */
|
||||
|
||||
private int dp(float v) {
|
||||
return Math.round(TypedValue.applyDimension(
|
||||
TypedValue.COMPLEX_UNIT_DIP, v, getResources().getDisplayMetrics()));
|
||||
}
|
||||
|
||||
private void buildUi() {
|
||||
FrameLayout root = new FrameLayout(this);
|
||||
root.setBackgroundColor(Color.parseColor("#EEF2F9"));
|
||||
root.setBackgroundColor(Color.parseColor(BG));
|
||||
|
||||
web = new WebView(this);
|
||||
web.setBackgroundColor(Color.parseColor("#EEF2F9"));
|
||||
web.setBackgroundColor(Color.parseColor(BG));
|
||||
web.setAlpha(0f);
|
||||
web.setOverScrollMode(View.OVER_SCROLL_NEVER);
|
||||
web.setVerticalScrollBarEnabled(false);
|
||||
web.setHorizontalScrollBarEnabled(false);
|
||||
web.setLongClickable(false);
|
||||
web.setHapticFeedbackEnabled(false);
|
||||
|
||||
WebSettings s = web.getSettings();
|
||||
s.setJavaScriptEnabled(true);
|
||||
s.setDomStorageEnabled(true);
|
||||
s.setAllowFileAccess(true);
|
||||
s.setDatabaseEnabled(true);
|
||||
s.setMediaPlaybackRequiresUserGesture(false);
|
||||
s.setMixedContentMode(WebSettings.MIXED_CONTENT_ALWAYS_ALLOW);
|
||||
s.setCacheMode(WebSettings.LOAD_DEFAULT);
|
||||
// 禁掉双指缩放与双击放大(网页里能放大就是这里没设)
|
||||
// 只通过 http://127.0.0.1 访问本地页面,不需要 file:// 与 content://
|
||||
s.setAllowFileAccess(false);
|
||||
s.setAllowContentAccess(false);
|
||||
s.setJavaScriptCanOpenWindowsAutomatically(false);
|
||||
s.setGeolocationEnabled(false);
|
||||
s.setSavePassword(false);
|
||||
s.setSupportZoom(false);
|
||||
s.setBuiltInZoomControls(false);
|
||||
s.setDisplayZoomControls(false);
|
||||
s.setTextZoom(100);
|
||||
// 长按不再弹出「复制/分享」这类浏览器菜单
|
||||
web.setLongClickable(false);
|
||||
web.setHapticFeedbackEnabled(false);
|
||||
web.setWebViewClient(new WebViewClient());
|
||||
s.setMixedContentMode(WebSettings.MIXED_CONTENT_ALWAYS_ALLOW);
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
s.setSafeBrowsingEnabled(false);
|
||||
}
|
||||
|
||||
web.setWebViewClient(new WebViewClient() {
|
||||
@Override
|
||||
public boolean shouldOverrideUrlLoading(WebView v, WebResourceRequest req) {
|
||||
Uri u = req.getUrl();
|
||||
String host = u.getHost();
|
||||
if (host == null) return true;
|
||||
// 本地服务内部跳转放行,其余一律甩给系统浏览器——不然点外链就变成浏览器了
|
||||
if ("127.0.0.1".equals(host) || "localhost".equals(host)) return false;
|
||||
try {
|
||||
startActivity(new Intent(Intent.ACTION_VIEW, u));
|
||||
} catch (Exception e) {
|
||||
Toast.makeText(MainActivity.this, "没有能打开该链接的应用", Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPageFinished(WebView v, String url) {
|
||||
if (firstPageDone) return;
|
||||
firstPageDone = true;
|
||||
showApp();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onReceivedError(WebView v, WebResourceRequest req, android.webkit.WebResourceError err) {
|
||||
if (req.isForMainFrame()) {
|
||||
Log.e(TAG, "page error: " + err.getDescription());
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 网页里的 <input type=file> 走 WebChromeClient.onShowFileChooser,挂在
|
||||
// WebViewClient 上编译不过,必须单独设一个 chrome client。
|
||||
web.setWebChromeClient(new WebChromeClient() {
|
||||
@Override
|
||||
public boolean onShowFileChooser(WebView view, ValueCallback<Uri[]> callback,
|
||||
@@ -112,19 +210,85 @@ public class MainActivity extends Activity {
|
||||
});
|
||||
web.addJavascriptInterface(new Bridge(), "Maipulse");
|
||||
|
||||
tip = new TextView(this);
|
||||
tip.setText("正在启动服务…");
|
||||
tip.setTextColor(Color.parseColor("#5A6A82"));
|
||||
tip.setTextSize(15);
|
||||
tip.setPadding(48, 48, 48, 48);
|
||||
|
||||
root.addView(web, new FrameLayout.LayoutParams(-1, -1));
|
||||
root.addView(tip, new FrameLayout.LayoutParams(-1, -1));
|
||||
splash = buildSplash();
|
||||
root.addView(splash, new FrameLayout.LayoutParams(-1, -1));
|
||||
setContentView(root);
|
||||
}
|
||||
|
||||
registerDownloadReceiver();
|
||||
new Thread(this::bootPython, "maipulse-boot").start();
|
||||
new Thread(this::waitAndLoad, "maipulse-wait").start();
|
||||
/** 原生启动页:图标 + 名字 + 一句状态 + 重试按钮(启动失败才出现) */
|
||||
private View buildSplash() {
|
||||
LinearLayout box = new LinearLayout(this);
|
||||
box.setOrientation(LinearLayout.VERTICAL);
|
||||
box.setGravity(Gravity.CENTER);
|
||||
box.setBackgroundColor(Color.parseColor(BG));
|
||||
box.setClickable(true); // 吃掉点击,防止启动过程中点穿到下面的 WebView
|
||||
|
||||
ImageView icon = new ImageView(this);
|
||||
try {
|
||||
icon.setImageResource(R.mipmap.ic_launcher);
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
LinearLayout.LayoutParams ip = new LinearLayout.LayoutParams(dp(72), dp(72));
|
||||
ip.bottomMargin = dp(18);
|
||||
box.addView(icon, ip);
|
||||
|
||||
TextView title = new TextView(this);
|
||||
title.setText("maipulse");
|
||||
title.setTextSize(22);
|
||||
title.setTypeface(Typeface.DEFAULT_BOLD);
|
||||
title.setTextColor(Color.parseColor("#1B1C21"));
|
||||
title.setLetterSpacing(0.06f);
|
||||
box.addView(title, new LinearLayout.LayoutParams(-2, -2));
|
||||
|
||||
splashTip = new TextView(this);
|
||||
splashTip.setText("正在启动…");
|
||||
splashTip.setTextSize(13);
|
||||
splashTip.setTextColor(Color.parseColor("#6B7280"));
|
||||
LinearLayout.LayoutParams tp = new LinearLayout.LayoutParams(-2, -2);
|
||||
tp.topMargin = dp(10);
|
||||
box.addView(splashTip, tp);
|
||||
|
||||
splashRetry = new TextView(this);
|
||||
splashRetry.setText("点这里重试");
|
||||
splashRetry.setTextSize(14);
|
||||
splashRetry.setTextColor(Color.parseColor("#D6336C"));
|
||||
splashRetry.setPadding(dp(18), dp(10), dp(18), dp(10));
|
||||
splashRetry.setVisibility(View.GONE);
|
||||
splashRetry.setOnClickListener(v -> {
|
||||
splashRetry.setVisibility(View.GONE);
|
||||
splashTip.setText("正在重试…");
|
||||
web.post(this::prepareAndBoot);
|
||||
});
|
||||
LinearLayout.LayoutParams rp = new LinearLayout.LayoutParams(-2, -2);
|
||||
rp.topMargin = dp(16);
|
||||
box.addView(splashRetry, rp);
|
||||
|
||||
return box;
|
||||
}
|
||||
|
||||
private void tip(final String text) {
|
||||
runOnUiThread(() -> {
|
||||
if (splashTip != null) splashTip.setText(text);
|
||||
});
|
||||
}
|
||||
|
||||
private void showApp() {
|
||||
if (web != null) web.animate().alpha(1f).setDuration(240).start();
|
||||
if (splash != null) {
|
||||
splash.animate().alpha(0f).setDuration(240).withEndAction(() -> {
|
||||
if (splash != null) splash.setVisibility(View.GONE);
|
||||
}).start();
|
||||
}
|
||||
}
|
||||
|
||||
private void fail(final String msg) {
|
||||
Log.e(TAG, msg);
|
||||
runOnUiThread(() -> {
|
||||
if (splash != null) splash.setAlpha(1f);
|
||||
if (splashTip != null) splashTip.setText(msg);
|
||||
if (splashRetry != null) splashRetry.setVisibility(View.VISIBLE);
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------------- 网页可调的接口 ---------------- */
|
||||
@@ -155,8 +319,11 @@ public class MainActivity extends Activity {
|
||||
try {
|
||||
File dir = getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS);
|
||||
if (dir != null) {
|
||||
for (File f : dir.listFiles()) {
|
||||
if (f.getName().endsWith(".apk")) f.delete();
|
||||
File[] old = dir.listFiles();
|
||||
if (old != null) {
|
||||
for (File f : old) {
|
||||
if (f.getName().endsWith(".apk")) f.delete();
|
||||
}
|
||||
}
|
||||
}
|
||||
DownloadManager.Request req = new DownloadManager.Request(Uri.parse(url));
|
||||
@@ -199,7 +366,6 @@ public class MainActivity extends Activity {
|
||||
try {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
|
||||
&& !getPackageManager().canRequestPackageInstalls()) {
|
||||
// 没有「安装未知应用」权限,跳设置页让用户开
|
||||
Intent s = new Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
|
||||
Uri.parse("package:" + getPackageName()));
|
||||
startActivity(s);
|
||||
@@ -217,76 +383,157 @@ public class MainActivity extends Activity {
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------- Python 启动 ---------------- */
|
||||
/* ---------------- 启动流程 ---------------- */
|
||||
|
||||
private void bootPython() {
|
||||
try {
|
||||
File files = getFilesDir();
|
||||
File appDir = new File(files, "app");
|
||||
File stamp = new File(files, "asset.rev");
|
||||
// 内置资源版本号:打包时由 build_apk.ps1 写进 assets/app/asset.rev,
|
||||
// 每次打包都会变,所以数据更新后一定能触发重建(手写常量太容易忘)。
|
||||
String want = "";
|
||||
try (InputStream in = getAssets().open("app/asset.rev")) {
|
||||
byte[] b = new byte[128];
|
||||
int n = in.read(b);
|
||||
if (n > 0) want = new String(b, 0, n, "UTF-8").trim();
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
String have = "";
|
||||
/**
|
||||
* 主线程入口:先把资源复制这一坨挪到后台,再回主线程做 Python.start()。
|
||||
* Chaquopy 要求 Python.start() 在主线程调用,所以这里不能整个丢进子线程。
|
||||
*/
|
||||
private void prepareAndBoot() {
|
||||
tip("正在准备资源…");
|
||||
new Thread(() -> {
|
||||
try {
|
||||
if (stamp.exists()) {
|
||||
byte[] b = new byte[(int) stamp.length()];
|
||||
try (InputStream in = new FileInputStream(stamp)) {
|
||||
int n = in.read(b);
|
||||
if (n > 0) have = new String(b, 0, n, "UTF-8").trim();
|
||||
}
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
prepareAssets();
|
||||
} catch (Throwable t) {
|
||||
fail("资源解包失败:" + t);
|
||||
return;
|
||||
}
|
||||
boolean broken = !appDir.exists()
|
||||
|| !new File(appDir, "app.py").exists()
|
||||
|| !new File(appDir, "data/master.json").exists();
|
||||
if (broken || want.isEmpty() || !want.equals(have)) {
|
||||
tip("正在释放内置资源");
|
||||
// 用户导入的机台配置要保住,其余整目录重建(否则旧资源永远不更新)
|
||||
byte[] conf = null;
|
||||
File confFile = new File(new File(appDir, "astral"), "config.py");
|
||||
if (confFile.exists() && confFile.length() > 0) {
|
||||
conf = new byte[(int) confFile.length()];
|
||||
try (InputStream in = new FileInputStream(confFile)) {
|
||||
int n = in.read(conf);
|
||||
if (n <= 0) conf = null;
|
||||
}
|
||||
}
|
||||
deleteTree(appDir);
|
||||
copyAssetDir("app", appDir);
|
||||
if (conf != null) {
|
||||
File cf = new File(new File(appDir, "astral"), "config.py");
|
||||
File parent = cf.getParentFile();
|
||||
if (parent != null && !parent.exists()) parent.mkdirs();
|
||||
try (OutputStream out = new FileOutputStream(cf)) {
|
||||
out.write(conf);
|
||||
}
|
||||
}
|
||||
try (OutputStream out = new FileOutputStream(stamp)) {
|
||||
out.write(want.getBytes("UTF-8"));
|
||||
}
|
||||
}
|
||||
tip("正在加载 Python 运行时…");
|
||||
runOnUiThread(() -> {
|
||||
tip("正在加载运行时…");
|
||||
// 让这句提示先渲染出来,再进阻塞的 Python.start()
|
||||
web.postDelayed(this::startPython, 60);
|
||||
});
|
||||
}, "mp-assets").start();
|
||||
}
|
||||
|
||||
private void startPython() {
|
||||
try {
|
||||
if (!Python.isStarted()) {
|
||||
Python.start(new AndroidPlatform(this));
|
||||
}
|
||||
tip("正在启动本地服务…");
|
||||
Python.getInstance().getModule("android_boot").callAttr("boot", files.getAbsolutePath());
|
||||
} catch (Throwable t) {
|
||||
Log.e(TAG, "boot failed", t);
|
||||
tip("启动失败:" + t);
|
||||
fail("运行时装载失败:" + t);
|
||||
return;
|
||||
}
|
||||
tip("正在启动本地服务…");
|
||||
new Thread(this::bootFlask, "mp-flask").start();
|
||||
new Thread(this::waitAndLoad, "mp-wait").start();
|
||||
}
|
||||
|
||||
/** assets/app -> filesDir/app;内置资源版本号变了就整目录重建,但保住用户导入的配置 */
|
||||
private void prepareAssets() throws IOException {
|
||||
File files = getFilesDir();
|
||||
File appDir = new File(files, "app");
|
||||
File stamp = new File(files, "asset.rev");
|
||||
|
||||
String want = "";
|
||||
try (InputStream in = getAssets().open("app/asset.rev")) {
|
||||
byte[] b = new byte[128];
|
||||
int n = in.read(b);
|
||||
if (n > 0) want = new String(b, 0, n, "UTF-8").trim();
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
|
||||
String have = "";
|
||||
try {
|
||||
if (stamp.exists()) {
|
||||
byte[] b = new byte[(int) stamp.length()];
|
||||
try (InputStream in = new FileInputStream(stamp)) {
|
||||
int n = in.read(b);
|
||||
if (n > 0) have = new String(b, 0, n, "UTF-8").trim();
|
||||
}
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
|
||||
boolean broken = !appDir.exists()
|
||||
|| !new File(appDir, "app.py").exists()
|
||||
|| !new File(appDir, "data/master.json").exists();
|
||||
|
||||
if (!broken && !want.isEmpty() && want.equals(have)) return;
|
||||
|
||||
tip("正在释放内置资源");
|
||||
// 用户填的机台身份写在这两个文件里,重建前先捞出来,重建后写回去
|
||||
byte[] confAstral = readFile(new File(new File(appDir, "astral"), "config.py"));
|
||||
byte[] confNet = readFile(new File(new File(appDir, "astral/api"), "MaimaiNet.py"));
|
||||
|
||||
deleteTree(appDir);
|
||||
copyAssetDir("app", appDir);
|
||||
|
||||
writeFile(new File(new File(appDir, "astral"), "config.py"), confAstral);
|
||||
writeFile(new File(new File(appDir, "astral/api"), "MaimaiNet.py"), confNet);
|
||||
|
||||
try (OutputStream out = new FileOutputStream(stamp)) {
|
||||
out.write(want.getBytes("UTF-8"));
|
||||
}
|
||||
}
|
||||
|
||||
private void tip(final String text) {
|
||||
runOnUiThread(() -> tip.setText(text));
|
||||
private byte[] readFile(File f) {
|
||||
if (f == null || !f.exists() || f.length() <= 0) return null;
|
||||
try (InputStream in = new FileInputStream(f)) {
|
||||
byte[] b = new byte[(int) f.length()];
|
||||
int n = in.read(b);
|
||||
if (n <= 0) return null;
|
||||
if (n < b.length) {
|
||||
byte[] c = new byte[n];
|
||||
System.arraycopy(b, 0, c, 0, n);
|
||||
return c;
|
||||
}
|
||||
return b;
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private void writeFile(File f, byte[] data) throws IOException {
|
||||
if (data == null || f == null) return;
|
||||
File parent = f.getParentFile();
|
||||
if (parent != null && !parent.exists() && !parent.mkdirs()) {
|
||||
throw new IOException("mkdirs failed: " + parent);
|
||||
}
|
||||
try (OutputStream out = new FileOutputStream(f)) {
|
||||
out.write(data);
|
||||
}
|
||||
}
|
||||
|
||||
private void bootFlask() {
|
||||
try {
|
||||
Python.getInstance().getModule("android_boot")
|
||||
.callAttr("boot", getFilesDir().getAbsolutePath());
|
||||
} catch (Throwable t) {
|
||||
Log.e(TAG, "flask exited", t);
|
||||
fail("本地服务异常退出:" + t);
|
||||
}
|
||||
}
|
||||
|
||||
private void waitAndLoad() {
|
||||
if (loadRequested) return;
|
||||
// 最多等 90 秒。Chaquopy 首次装载 + 资源解包在低端机上确实会慢。
|
||||
for (int i = 0; i < 450; i++) {
|
||||
if (web == null) return;
|
||||
try (Socket sock = new Socket()) {
|
||||
sock.connect(new InetSocketAddress("127.0.0.1", PORT), 300);
|
||||
loadRequested = true;
|
||||
runOnUiThread(() -> {
|
||||
if (web != null) {
|
||||
web.loadUrl(HOME);
|
||||
// 兜底:页面迟迟不触发 onPageFinished 也不能一直卡在启动页
|
||||
web.postDelayed(() -> {
|
||||
if (!firstPageDone) showApp();
|
||||
}, 15000);
|
||||
}
|
||||
});
|
||||
return;
|
||||
} catch (IOException ignored) {
|
||||
}
|
||||
try {
|
||||
Thread.sleep(200);
|
||||
} catch (InterruptedException e) {
|
||||
return;
|
||||
}
|
||||
if (i == 25) tip("还在启动,稍等…");
|
||||
}
|
||||
fail("本地服务没能在 90 秒内启动");
|
||||
}
|
||||
|
||||
private void deleteTree(File f) {
|
||||
@@ -316,7 +563,6 @@ public class MainActivity extends Activity {
|
||||
}
|
||||
|
||||
private void copyAssetFile(String src, File dst) throws IOException {
|
||||
// 不跳过已存在文件:整目录重建后旧的残留必须被覆盖
|
||||
File parent = dst.getParentFile();
|
||||
if (parent != null && !parent.exists() && !parent.mkdirs()) {
|
||||
throw new IOException("mkdirs failed: " + parent);
|
||||
@@ -329,25 +575,7 @@ public class MainActivity extends Activity {
|
||||
}
|
||||
}
|
||||
|
||||
private void waitAndLoad() {
|
||||
for (int i = 0; i < 200; i++) {
|
||||
try (Socket sock = new Socket()) {
|
||||
sock.connect(new InetSocketAddress("127.0.0.1", PORT), 300);
|
||||
runOnUiThread(() -> {
|
||||
tip.setVisibility(View.GONE);
|
||||
web.loadUrl("http://127.0.0.1:" + PORT + "/");
|
||||
});
|
||||
return;
|
||||
} catch (IOException ignored) {
|
||||
try {
|
||||
Thread.sleep(200);
|
||||
} catch (InterruptedException e) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
tip("服务没有起来,看看日志");
|
||||
}
|
||||
/* ---------------- 生命周期 ---------------- */
|
||||
|
||||
@Override
|
||||
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
|
||||
@@ -375,8 +603,23 @@ public class MainActivity extends Activity {
|
||||
|
||||
@Override
|
||||
public boolean onKeyDown(int keyCode, KeyEvent event) {
|
||||
if (keyCode == KeyEvent.KEYCODE_BACK && web != null && web.canGoBack()) {
|
||||
web.goBack();
|
||||
if (keyCode == KeyEvent.KEYCODE_BACK) {
|
||||
// 启动页还在 = 还没进主界面,直接交给系统
|
||||
if (splash != null && splash.getVisibility() == View.VISIBLE
|
||||
&& splash.getAlpha() > 0.5f) {
|
||||
return super.onKeyDown(keyCode, event);
|
||||
}
|
||||
if (web != null && web.canGoBack()) {
|
||||
web.goBack();
|
||||
return true;
|
||||
}
|
||||
long now = System.currentTimeMillis();
|
||||
if (now - lastBackPress < 2000) {
|
||||
finish();
|
||||
} else {
|
||||
lastBackPress = now;
|
||||
Toast.makeText(this, "再按一次退出", Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return super.onKeyDown(keyCode, event);
|
||||
@@ -389,6 +632,8 @@ public class MainActivity extends Activity {
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
if (web != null) {
|
||||
ViewGroup p = (ViewGroup) web.getParent();
|
||||
if (p != null) p.removeView(web);
|
||||
web.destroy();
|
||||
web = null;
|
||||
}
|
||||
|
||||
@@ -3,13 +3,33 @@
|
||||
|
||||
为什么不在 assets 里直接 import:app.py 顶层会 os.makedirs(logs),
|
||||
assets 是只读的,必须用 Java 侧复制出来的可写副本。
|
||||
|
||||
为什么要先探一次端口:Activity 退出后进程可能还在(Flask 线程是常驻的),
|
||||
用户再点图标进来时 boot() 会被再调用一次。这时 8787 已经有服务在跑,
|
||||
直接 return 让外壳去连既有服务,比抛 "Address already in use" 干净。
|
||||
"""
|
||||
import os
|
||||
import socket
|
||||
import sys
|
||||
|
||||
PORT = 8787
|
||||
|
||||
|
||||
def _port_in_use():
|
||||
s = socket.socket()
|
||||
s.settimeout(0.3)
|
||||
try:
|
||||
s.connect(("127.0.0.1", PORT))
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
finally:
|
||||
try:
|
||||
s.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def boot(files_dir):
|
||||
base = os.path.join(files_dir, "app")
|
||||
if base not in sys.path:
|
||||
@@ -21,6 +41,10 @@ def boot(files_dir):
|
||||
os.environ.setdefault("MAIPULSE_PORT", str(PORT))
|
||||
os.chdir(base)
|
||||
|
||||
# 上一次的 Flask 还活着就直接复用,别再起一个
|
||||
if _port_in_use():
|
||||
return "already running"
|
||||
|
||||
import app as maipulse_app # noqa: E402
|
||||
# 绑定 127.0.0.1:只有本机 WebView 能访问
|
||||
maipulse_app.app.run(host="127.0.0.1", port=PORT, debug=False,
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- allowBackup 已经是 false,这里再堵一层:任何域名都不参与云备份和换机迁移。
|
||||
配置里有机台身份(Keychip / 店铺 / 密钥),不该跟着账号漂到别的设备上。 -->
|
||||
<data-extraction-rules>
|
||||
<cloud-backup>
|
||||
<exclude domain="root" path="." />
|
||||
<exclude domain="file" path="." />
|
||||
<exclude domain="database" path="." />
|
||||
<exclude domain="sharedpref" path="." />
|
||||
<exclude domain="external" path="." />
|
||||
</cloud-backup>
|
||||
<device-transfer>
|
||||
<exclude domain="root" path="." />
|
||||
<exclude domain="file" path="." />
|
||||
<exclude domain="database" path="." />
|
||||
<exclude domain="sharedpref" path="." />
|
||||
<exclude domain="external" path="." />
|
||||
</device-transfer>
|
||||
</data-extraction-rules>
|
||||
Reference in new issue
Block a user