package com.xuqm.update.service; 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; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; import java.io.IOException; import java.io.InputStream; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.nio.file.StandardCopyOption; import java.security.DigestInputStream; import java.security.MessageDigest; import java.util.Map; import java.util.Locale; import java.util.HexFormat; import java.util.Optional; import java.util.UUID; 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; @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; } 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); } 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()); } 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("= 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 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 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(); } public record StoredRnBundle(String bundlePath, String md5) {} }