2026-04-28 21:05:06 +08:00
|
|
|
package com.xuqm.update.service;
|
|
|
|
|
|
2026-04-29 12:33:25 +08:00
|
|
|
import com.xuqm.update.model.AppPackageInspectResult;
|
|
|
|
|
import com.xuqm.update.model.RnBundleInspectResult;
|
|
|
|
|
import net.dongliu.apk.parser.ApkFile;
|
|
|
|
|
import net.dongliu.apk.parser.bean.ApkMeta;
|
2026-04-28 21:05:06 +08:00
|
|
|
import org.springframework.beans.factory.annotation.Value;
|
|
|
|
|
import org.springframework.stereotype.Service;
|
|
|
|
|
import org.springframework.web.multipart.MultipartFile;
|
|
|
|
|
|
|
|
|
|
import java.io.IOException;
|
2026-04-29 12:33:25 +08:00
|
|
|
import java.io.InputStream;
|
|
|
|
|
import java.nio.charset.StandardCharsets;
|
2026-04-28 21:05:06 +08:00
|
|
|
import java.nio.file.Files;
|
|
|
|
|
import java.nio.file.Path;
|
|
|
|
|
import java.nio.file.Paths;
|
2026-04-29 12:33:25 +08:00
|
|
|
import java.nio.file.StandardCopyOption;
|
2026-04-28 21:05:06 +08:00
|
|
|
import java.security.DigestInputStream;
|
|
|
|
|
import java.security.MessageDigest;
|
2026-04-29 12:33:25 +08:00
|
|
|
import java.util.Map;
|
|
|
|
|
import java.util.Locale;
|
2026-04-28 21:05:06 +08:00
|
|
|
import java.util.HexFormat;
|
2026-04-29 12:33:25 +08:00
|
|
|
import java.util.Optional;
|
2026-04-28 21:05:06 +08:00
|
|
|
import java.util.UUID;
|
2026-04-29 12:33:25 +08:00
|
|
|
import java.util.zip.ZipEntry;
|
|
|
|
|
import java.util.zip.ZipFile;
|
|
|
|
|
import javax.xml.parsers.DocumentBuilderFactory;
|
|
|
|
|
import org.w3c.dom.Document;
|
|
|
|
|
import org.w3c.dom.Element;
|
|
|
|
|
import org.w3c.dom.Node;
|
|
|
|
|
import org.w3c.dom.NodeList;
|
2026-04-28 21:05:06 +08:00
|
|
|
|
|
|
|
|
@Service
|
|
|
|
|
public class UpdateAssetService {
|
|
|
|
|
|
|
|
|
|
@Value("${update.upload-dir:/tmp/xuqm-update}")
|
|
|
|
|
private String uploadDir;
|
|
|
|
|
|
|
|
|
|
@Value("${update.base-url:https://update.dev.xuqinmin.com}")
|
|
|
|
|
private String baseUrl;
|
|
|
|
|
|
|
|
|
|
public String storeAppPackage(MultipartFile apkFile) throws IOException {
|
|
|
|
|
if (apkFile == null || apkFile.isEmpty()) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
String filename = UUID.randomUUID() + "_" + apkFile.getOriginalFilename();
|
|
|
|
|
Path dir = Paths.get(uploadDir, "apk");
|
|
|
|
|
Files.createDirectories(dir);
|
|
|
|
|
Path dest = dir.resolve(filename);
|
|
|
|
|
apkFile.transferTo(dest.toFile());
|
|
|
|
|
return baseUrl + "/files/apk/" + filename;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-29 12:33:25 +08:00
|
|
|
public AppPackageInspectResult inspectAppPackage(MultipartFile packageFile) throws Exception {
|
|
|
|
|
String fileName = Optional.ofNullable(packageFile != null ? packageFile.getOriginalFilename() : null)
|
|
|
|
|
.orElse("");
|
|
|
|
|
String normalized = fileName.toLowerCase(Locale.ROOT);
|
|
|
|
|
if (packageFile == null || packageFile.isEmpty()) {
|
|
|
|
|
return new AppPackageInspectResult(platformFromFileName(fileName), null, null, null, fileName, false);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Path temp = Files.createTempFile("xuqm-package-inspect-", suffixFor(fileName));
|
|
|
|
|
try {
|
|
|
|
|
try (InputStream in = packageFile.getInputStream()) {
|
|
|
|
|
Files.copy(in, temp, StandardCopyOption.REPLACE_EXISTING);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (normalized.endsWith(".apk")) {
|
|
|
|
|
return inspectApk(temp, fileName);
|
|
|
|
|
}
|
|
|
|
|
if (normalized.endsWith(".ipa")) {
|
|
|
|
|
return inspectIpa(temp, fileName);
|
|
|
|
|
}
|
|
|
|
|
return new AppPackageInspectResult(platformFromFileName(fileName), null, null, null, fileName, false);
|
|
|
|
|
} finally {
|
|
|
|
|
Files.deleteIfExists(temp);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public RnBundleInspectResult inspectRnBundle(MultipartFile bundle) throws Exception {
|
|
|
|
|
String fileName = Optional.ofNullable(bundle != null ? bundle.getOriginalFilename() : null)
|
|
|
|
|
.orElse("");
|
|
|
|
|
if (bundle == null || bundle.isEmpty()) {
|
|
|
|
|
return new RnBundleInspectResult(null, null, null, null, fileName, false);
|
|
|
|
|
}
|
|
|
|
|
return inspectRnBundleName(fileName);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-28 21:05:06 +08:00
|
|
|
public StoredRnBundle storeRnBundle(String appId, String platform, String moduleId, MultipartFile bundle) throws Exception {
|
|
|
|
|
if (bundle == null || bundle.isEmpty()) {
|
|
|
|
|
throw new IllegalArgumentException("bundle file is required");
|
|
|
|
|
}
|
|
|
|
|
String filename = moduleId + "." + platform.toLowerCase() + ".bundle";
|
|
|
|
|
Path dir = Paths.get(uploadDir, "rn", appId, platform.toLowerCase(), moduleId);
|
|
|
|
|
Files.createDirectories(dir);
|
|
|
|
|
Path dest = dir.resolve(filename);
|
|
|
|
|
|
|
|
|
|
String md5 = computeMd5(bundle);
|
|
|
|
|
bundle.transferTo(dest.toFile());
|
|
|
|
|
return new StoredRnBundle(dest.toAbsolutePath().toString(), md5);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private String computeMd5(MultipartFile file) throws Exception {
|
|
|
|
|
MessageDigest digest = MessageDigest.getInstance("MD5");
|
|
|
|
|
try (DigestInputStream dis = new DigestInputStream(file.getInputStream(), digest)) {
|
|
|
|
|
byte[] buf = new byte[8192];
|
|
|
|
|
while (dis.read(buf) != -1) {
|
|
|
|
|
// read fully
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return HexFormat.of().formatHex(digest.digest());
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-29 12:33:25 +08:00
|
|
|
private AppPackageInspectResult inspectApk(Path file, String fileName) throws Exception {
|
|
|
|
|
try (ApkFile apk = new ApkFile(file.toFile())) {
|
|
|
|
|
ApkMeta meta = apk.getApkMeta();
|
|
|
|
|
Integer versionCode = meta.getVersionCode() == null ? null : meta.getVersionCode().intValue();
|
|
|
|
|
return new AppPackageInspectResult(
|
|
|
|
|
"ANDROID",
|
|
|
|
|
blankToNull(meta.getPackageName()),
|
|
|
|
|
blankToNull(meta.getVersionName()),
|
|
|
|
|
versionCode,
|
|
|
|
|
fileName,
|
|
|
|
|
true);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private AppPackageInspectResult inspectIpa(Path file, String fileName) throws Exception {
|
|
|
|
|
try (ZipFile zipFile = new ZipFile(file.toFile())) {
|
|
|
|
|
ZipEntry entry = zipFile.stream()
|
|
|
|
|
.filter(e -> e.getName().endsWith("Info.plist"))
|
|
|
|
|
.findFirst()
|
|
|
|
|
.orElse(null);
|
|
|
|
|
if (entry != null) {
|
|
|
|
|
byte[] data = zipFile.getInputStream(entry).readAllBytes();
|
|
|
|
|
String text = new String(data, StandardCharsets.UTF_8);
|
|
|
|
|
if (text.contains("<plist")) {
|
|
|
|
|
var plist = parsePlistXml(text);
|
|
|
|
|
return new AppPackageInspectResult(
|
|
|
|
|
"IOS",
|
|
|
|
|
blankToNull(plist.get("CFBundleIdentifier")),
|
|
|
|
|
blankToNull(plist.get("CFBundleShortVersionString")),
|
|
|
|
|
parseInteger(plist.get("CFBundleVersion")),
|
|
|
|
|
fileName,
|
|
|
|
|
true);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return new AppPackageInspectResult("IOS", null, null, null, fileName, false);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private RnBundleInspectResult inspectRnBundleName(String fileName) {
|
|
|
|
|
String baseName = stripExtension(fileName);
|
|
|
|
|
String[] parts = baseName.split("__");
|
|
|
|
|
if (parts.length >= 4) {
|
|
|
|
|
return new RnBundleInspectResult(
|
|
|
|
|
blankToNull(parts[0]),
|
|
|
|
|
platformFromToken(parts[1]),
|
|
|
|
|
blankToNull(parts[2]),
|
|
|
|
|
blankToNull(parts[3]),
|
|
|
|
|
fileName,
|
|
|
|
|
true);
|
|
|
|
|
}
|
|
|
|
|
return new RnBundleInspectResult(
|
|
|
|
|
null,
|
|
|
|
|
platformFromFileName(fileName),
|
|
|
|
|
null,
|
|
|
|
|
null,
|
|
|
|
|
fileName,
|
|
|
|
|
false);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private Map<String, String> parsePlistXml(String xml) throws Exception {
|
|
|
|
|
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
|
|
|
|
|
factory.setNamespaceAware(false);
|
|
|
|
|
factory.setExpandEntityReferences(false);
|
|
|
|
|
Document document = factory.newDocumentBuilder().parse(new java.io.ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8)));
|
|
|
|
|
NodeList dictNodes = document.getElementsByTagName("dict");
|
|
|
|
|
if (dictNodes.getLength() == 0) {
|
|
|
|
|
return Map.of();
|
|
|
|
|
}
|
|
|
|
|
Element dict = (Element) dictNodes.item(0);
|
|
|
|
|
Map<String, String> values = new java.util.LinkedHashMap<>();
|
|
|
|
|
Node child = dict.getFirstChild();
|
|
|
|
|
String currentKey = null;
|
|
|
|
|
while (child != null) {
|
|
|
|
|
if (child.getNodeType() == Node.ELEMENT_NODE) {
|
|
|
|
|
String nodeName = child.getNodeName();
|
|
|
|
|
if ("key".equals(nodeName)) {
|
|
|
|
|
currentKey = child.getTextContent();
|
|
|
|
|
} else if (currentKey != null && ("string".equals(nodeName) || "integer".equals(nodeName))) {
|
|
|
|
|
values.put(currentKey, child.getTextContent());
|
|
|
|
|
currentKey = null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
child = child.getNextSibling();
|
|
|
|
|
}
|
|
|
|
|
return values;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private String platformFromFileName(String fileName) {
|
|
|
|
|
String lower = Optional.ofNullable(fileName).orElse("").toLowerCase(Locale.ROOT);
|
|
|
|
|
if (lower.contains("ios") || lower.endsWith(".ipa")) {
|
|
|
|
|
return "IOS";
|
|
|
|
|
}
|
|
|
|
|
return "ANDROID";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private String platformFromToken(String token) {
|
|
|
|
|
if (token == null) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
String normalized = token.trim().toUpperCase(Locale.ROOT);
|
|
|
|
|
return switch (normalized) {
|
|
|
|
|
case "ANDROID", "IOS" -> normalized;
|
|
|
|
|
case "A", "ANDROIDSDK" -> "ANDROID";
|
|
|
|
|
case "I", "IOSSDK" -> "IOS";
|
|
|
|
|
default -> normalized.isBlank() ? null : normalized;
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private String stripExtension(String fileName) {
|
|
|
|
|
if (fileName == null) return "";
|
|
|
|
|
int idx = fileName.lastIndexOf('.');
|
|
|
|
|
return idx > 0 ? fileName.substring(0, idx) : fileName;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private String suffixFor(String fileName) {
|
|
|
|
|
if (fileName == null) return ".tmp";
|
|
|
|
|
int idx = fileName.lastIndexOf('.');
|
|
|
|
|
return idx > 0 ? fileName.substring(idx) : ".tmp";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private Integer parseInteger(String value) {
|
|
|
|
|
if (value == null || value.isBlank()) return null;
|
|
|
|
|
try {
|
|
|
|
|
return Integer.valueOf(value.trim());
|
|
|
|
|
} catch (NumberFormatException e) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private String blankToNull(String value) {
|
|
|
|
|
return value == null || value.isBlank() ? null : value.trim();
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-28 21:05:06 +08:00
|
|
|
public record StoredRnBundle(String bundlePath, String md5) {}
|
|
|
|
|
}
|