2026-04-28 21:05:06 +08:00
|
|
|
package com.xuqm.update.service;
|
|
|
|
|
|
2026-04-29 19:08:13 +08:00
|
|
|
import com.fasterxml.jackson.databind.JsonNode;
|
|
|
|
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
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;
|
2026-04-29 17:35:52 +08:00
|
|
|
import org.springframework.http.ContentDisposition;
|
2026-04-28 21:05:06 +08:00
|
|
|
import org.springframework.stereotype.Service;
|
|
|
|
|
import org.springframework.web.multipart.MultipartFile;
|
2026-04-29 17:35:52 +08:00
|
|
|
import org.slf4j.Logger;
|
|
|
|
|
import org.slf4j.LoggerFactory;
|
2026-04-28 21:05:06 +08:00
|
|
|
|
|
|
|
|
import java.io.IOException;
|
2026-04-29 12:33:25 +08:00
|
|
|
import java.io.InputStream;
|
2026-04-29 17:35:52 +08:00
|
|
|
import java.net.HttpURLConnection;
|
|
|
|
|
import java.net.URI;
|
|
|
|
|
import java.net.URL;
|
2026-04-29 12:33:25 +08:00
|
|
|
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 {
|
|
|
|
|
|
2026-04-29 17:35:52 +08:00
|
|
|
private static final Logger log = LoggerFactory.getLogger(UpdateAssetService.class);
|
2026-04-29 19:08:13 +08:00
|
|
|
private final ObjectMapper objectMapper;
|
2026-04-29 17:35:52 +08:00
|
|
|
|
2026-04-28 21:05:06 +08:00
|
|
|
@Value("${update.upload-dir:/tmp/xuqm-update}")
|
|
|
|
|
private String uploadDir;
|
|
|
|
|
|
|
|
|
|
@Value("${update.base-url:https://update.dev.xuqinmin.com}")
|
|
|
|
|
private String baseUrl;
|
|
|
|
|
|
2026-04-29 19:08:13 +08:00
|
|
|
public UpdateAssetService(ObjectMapper objectMapper) {
|
|
|
|
|
this.objectMapper = objectMapper;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-28 21:05:06 +08:00
|
|
|
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 17:35:52 +08:00
|
|
|
public AppPackageInspectResult inspectAppPackage(String packageUrl) throws Exception {
|
|
|
|
|
if (packageUrl == null || packageUrl.isBlank()) {
|
|
|
|
|
return new AppPackageInspectResult(null, null, null, null, null, false);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
RemotePackage remote = downloadRemotePackage(packageUrl, true);
|
|
|
|
|
try {
|
|
|
|
|
return inspectDownloadedPackage(remote.path(), remote.fileName());
|
|
|
|
|
} finally {
|
|
|
|
|
Files.deleteIfExists(remote.path());
|
|
|
|
|
}
|
|
|
|
|
} catch (Exception ex) {
|
|
|
|
|
log.warn("Failed to inspect remote package {}, fallback to undetected result: {}", packageUrl, ex.getMessage());
|
|
|
|
|
return fallbackInspectResult(packageUrl);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
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("");
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-29 17:35:52 +08:00
|
|
|
return inspectDownloadedPackage(temp, fileName);
|
|
|
|
|
} catch (Exception ex) {
|
|
|
|
|
log.warn("Failed to inspect uploaded package {}, fallback to undetected result: {}", fileName, ex.getMessage());
|
|
|
|
|
return fallbackInspectResult(fileName);
|
2026-04-29 12:33:25 +08:00
|
|
|
} finally {
|
|
|
|
|
Files.deleteIfExists(temp);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-29 17:35:52 +08:00
|
|
|
public Path downloadRemotePackageToCache(String packageUrl) throws IOException {
|
|
|
|
|
return downloadRemotePackage(packageUrl, false).path();
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-29 12:33:25 +08:00
|
|
|
public RnBundleInspectResult inspectRnBundle(MultipartFile bundle) throws Exception {
|
|
|
|
|
String fileName = Optional.ofNullable(bundle != null ? bundle.getOriginalFilename() : null)
|
|
|
|
|
.orElse("");
|
|
|
|
|
if (bundle == null || bundle.isEmpty()) {
|
2026-04-29 15:46:40 +08:00
|
|
|
return new RnBundleInspectResult(null, null, null, null, null, fileName, false);
|
2026-04-29 12:33:25 +08:00
|
|
|
}
|
2026-04-29 19:08:13 +08:00
|
|
|
if (isZipBundle(fileName)) {
|
|
|
|
|
Path temp = Files.createTempFile("xuqm-rn-bundle-inspect-", suffixFor(fileName));
|
|
|
|
|
try {
|
|
|
|
|
try (InputStream in = bundle.getInputStream()) {
|
|
|
|
|
Files.copy(in, temp, StandardCopyOption.REPLACE_EXISTING);
|
|
|
|
|
}
|
|
|
|
|
RnBundleInspectResult zipped = inspectRnBundleZip(temp, fileName);
|
|
|
|
|
if (zipped.detected()) {
|
|
|
|
|
return zipped;
|
|
|
|
|
}
|
|
|
|
|
} finally {
|
|
|
|
|
Files.deleteIfExists(temp);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-29 12:33:25 +08:00
|
|
|
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");
|
|
|
|
|
}
|
2026-04-29 19:08:13 +08:00
|
|
|
String filename = resolveBundleFilename(moduleId, platform, bundle.getOriginalFilename());
|
2026-04-28 21:05:06 +08:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-29 17:35:52 +08:00
|
|
|
private AppPackageInspectResult inspectDownloadedPackage(Path file, String fileName) throws Exception {
|
|
|
|
|
String normalized = Optional.ofNullable(fileName).orElse("").toLowerCase(Locale.ROOT);
|
|
|
|
|
if (normalized.endsWith(".apk")) {
|
|
|
|
|
return inspectApk(file, fileName);
|
|
|
|
|
}
|
|
|
|
|
if (normalized.endsWith(".ipa")) {
|
|
|
|
|
return inspectIpa(file, fileName);
|
|
|
|
|
}
|
|
|
|
|
return new AppPackageInspectResult(platformFromFileName(fileName), null, null, null, fileName, false);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-29 12:33:25 +08:00
|
|
|
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]),
|
2026-04-29 15:46:40 +08:00
|
|
|
parts.length >= 5 ? blankToNull(parts[4]) : null,
|
2026-04-29 12:33:25 +08:00
|
|
|
fileName,
|
|
|
|
|
true);
|
|
|
|
|
}
|
|
|
|
|
return new RnBundleInspectResult(
|
|
|
|
|
null,
|
|
|
|
|
platformFromFileName(fileName),
|
|
|
|
|
null,
|
|
|
|
|
null,
|
2026-04-29 15:46:40 +08:00
|
|
|
null,
|
2026-04-29 12:33:25 +08:00
|
|
|
fileName,
|
|
|
|
|
false);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-29 19:08:13 +08:00
|
|
|
private RnBundleInspectResult inspectRnBundleZip(Path file, String fileName) throws Exception {
|
|
|
|
|
try (ZipFile zipFile = new ZipFile(file.toFile())) {
|
|
|
|
|
ZipEntry entry = zipFile.getEntry("rn-manifest.json");
|
|
|
|
|
if (entry == null) {
|
|
|
|
|
entry = zipFile.getEntry("manifest.json");
|
|
|
|
|
}
|
|
|
|
|
if (entry != null) {
|
|
|
|
|
try (InputStream in = zipFile.getInputStream(entry)) {
|
|
|
|
|
JsonNode node = objectMapper.readTree(in);
|
|
|
|
|
String moduleId = text(node, "moduleId");
|
|
|
|
|
String platform = text(node, "platform");
|
|
|
|
|
String version = firstText(node, "bundleVersion", "version");
|
|
|
|
|
String minCommonVersion = text(node, "minCommonVersion");
|
|
|
|
|
String packageName = text(node, "packageName");
|
|
|
|
|
return new RnBundleInspectResult(
|
|
|
|
|
moduleId,
|
|
|
|
|
platformFromToken(platform),
|
|
|
|
|
version,
|
|
|
|
|
minCommonVersion,
|
|
|
|
|
packageName,
|
|
|
|
|
fileName,
|
|
|
|
|
hasText(moduleId) && hasText(version) && hasText(platform));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return inspectRnBundleName(fileName);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-29 12:33:25 +08:00
|
|
|
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";
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-29 19:08:13 +08:00
|
|
|
private boolean isZipBundle(String fileName) {
|
|
|
|
|
String lower = Optional.ofNullable(fileName).orElse("").toLowerCase(Locale.ROOT);
|
|
|
|
|
return lower.endsWith(".zip") || lower.endsWith(".bundle.zip") || lower.endsWith(".tar.gz");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private String resolveBundleFilename(String moduleId, String platform, String originalFilename) {
|
|
|
|
|
String safeOriginal = Optional.ofNullable(originalFilename).orElse("").trim();
|
|
|
|
|
String ext = "";
|
|
|
|
|
int idx = safeOriginal.lastIndexOf('.');
|
|
|
|
|
if (idx > 0 && idx < safeOriginal.length() - 1) {
|
|
|
|
|
ext = safeOriginal.substring(idx);
|
|
|
|
|
}
|
|
|
|
|
if (!hasText(ext)) {
|
|
|
|
|
ext = ".zip";
|
|
|
|
|
}
|
|
|
|
|
return moduleId + "." + platform.toLowerCase(Locale.ROOT) + ext;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-29 17:35:52 +08:00
|
|
|
private RemotePackage downloadRemotePackage(String packageUrl, boolean tempFile) throws IOException {
|
|
|
|
|
HttpURLConnection connection = (HttpURLConnection) new URL(packageUrl).openConnection();
|
|
|
|
|
connection.setConnectTimeout(15_000);
|
|
|
|
|
connection.setReadTimeout(30_000);
|
|
|
|
|
connection.setInstanceFollowRedirects(true);
|
|
|
|
|
|
|
|
|
|
int status = connection.getResponseCode();
|
|
|
|
|
if (status >= 400) {
|
|
|
|
|
throw new IOException("Failed to download package: HTTP " + status);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
String contentType = Optional.ofNullable(connection.getContentType()).orElse("");
|
|
|
|
|
String fileName = resolveRemoteFileName(connection, packageUrl, contentType);
|
|
|
|
|
Path path = tempFile
|
|
|
|
|
? Files.createTempFile("xuqm-package-inspect-", suffixFor(fileName))
|
|
|
|
|
: buildRemoteCachePath(packageUrl, fileName, contentType);
|
|
|
|
|
|
|
|
|
|
if (!tempFile && Files.exists(path)) {
|
|
|
|
|
return new RemotePackage(path, fileName, contentType);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try (InputStream in = connection.getInputStream()) {
|
|
|
|
|
Files.copy(in, path, StandardCopyOption.REPLACE_EXISTING);
|
|
|
|
|
}
|
|
|
|
|
return new RemotePackage(path, fileName, contentType);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private Path buildRemoteCachePath(String packageUrl, String fileName, String contentType) throws IOException {
|
|
|
|
|
Path dir = Paths.get(uploadDir, "apk", "remote");
|
|
|
|
|
Files.createDirectories(dir);
|
|
|
|
|
String safeName = sanitizeFileName(fileName);
|
|
|
|
|
String cacheName = sha256Hex(packageUrl) + "_" + safeName;
|
|
|
|
|
if (!cacheName.contains(".") && hasText(contentType)) {
|
|
|
|
|
cacheName = cacheName + suffixByContentType(contentType);
|
|
|
|
|
}
|
|
|
|
|
return dir.resolve(cacheName);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private String resolveRemoteFileName(HttpURLConnection connection, String packageUrl, String contentType) {
|
|
|
|
|
String fileName = null;
|
|
|
|
|
String disposition = connection.getHeaderField("Content-Disposition");
|
|
|
|
|
if (hasText(disposition)) {
|
|
|
|
|
try {
|
|
|
|
|
fileName = ContentDisposition.parse(disposition).getFilename();
|
|
|
|
|
} catch (Exception ignored) {
|
|
|
|
|
fileName = null;
|
|
|
|
|
}
|
|
|
|
|
if (!hasText(fileName)) {
|
|
|
|
|
int idx = disposition.toLowerCase(Locale.ROOT).indexOf("filename=");
|
|
|
|
|
if (idx >= 0) {
|
|
|
|
|
fileName = disposition.substring(idx + 9).replace("\"", "").trim();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (!hasText(fileName)) {
|
|
|
|
|
String pathName = Paths.get(URI.create(packageUrl).getPath()).getFileName() != null
|
|
|
|
|
? Paths.get(URI.create(packageUrl).getPath()).getFileName().toString()
|
|
|
|
|
: null;
|
|
|
|
|
fileName = hasText(pathName) ? pathName : "downloaded";
|
|
|
|
|
}
|
|
|
|
|
if (!fileName.contains(".")) {
|
|
|
|
|
fileName = fileName + suffixByContentType(contentType);
|
|
|
|
|
}
|
|
|
|
|
return fileName;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private String suffixByContentType(String contentType) {
|
|
|
|
|
String lower = Optional.ofNullable(contentType).orElse("").toLowerCase(Locale.ROOT);
|
|
|
|
|
if (lower.contains("android.package-archive")) return ".apk";
|
|
|
|
|
if (lower.contains("ipa")) return ".ipa";
|
|
|
|
|
if (lower.contains("zip")) return ".zip";
|
|
|
|
|
return ".bin";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private String sanitizeFileName(String fileName) {
|
|
|
|
|
String safe = Optional.ofNullable(fileName).orElse("downloaded");
|
|
|
|
|
return safe.replaceAll("[\\\\/:*?\"<>|]", "_");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private String sha256Hex(String value) {
|
|
|
|
|
try {
|
|
|
|
|
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
|
|
|
|
return HexFormat.of().formatHex(digest.digest(value.getBytes(StandardCharsets.UTF_8)));
|
|
|
|
|
} catch (Exception e) {
|
|
|
|
|
throw new IllegalStateException("SHA-256 not available", e);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-29 12:33:25 +08:00
|
|
|
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-29 19:08:13 +08:00
|
|
|
private String text(JsonNode node, String field) {
|
|
|
|
|
if (node == null || !node.hasNonNull(field)) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
String value = node.get(field).asText();
|
|
|
|
|
return blankToNull(value);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private String firstText(JsonNode node, String primaryField, String fallbackField) {
|
|
|
|
|
String primary = text(node, primaryField);
|
|
|
|
|
return hasText(primary) ? primary : text(node, fallbackField);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-29 17:35:52 +08:00
|
|
|
private AppPackageInspectResult fallbackInspectResult(String source) {
|
|
|
|
|
String fileName = source;
|
|
|
|
|
try {
|
|
|
|
|
if (source != null && (source.startsWith("http://") || source.startsWith("https://"))) {
|
|
|
|
|
fileName = Optional.ofNullable(Paths.get(URI.create(source).getPath()).getFileName())
|
|
|
|
|
.map(Path::toString)
|
|
|
|
|
.orElse(source);
|
|
|
|
|
}
|
|
|
|
|
} catch (Exception ignored) {
|
|
|
|
|
fileName = source;
|
|
|
|
|
}
|
|
|
|
|
return new AppPackageInspectResult(
|
|
|
|
|
platformFromFileName(fileName),
|
|
|
|
|
null,
|
|
|
|
|
null,
|
|
|
|
|
null,
|
|
|
|
|
fileName,
|
|
|
|
|
false);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private boolean hasText(String value) {
|
|
|
|
|
return value != null && !value.isBlank();
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-28 21:05:06 +08:00
|
|
|
public record StoredRnBundle(String bundlePath, String md5) {}
|
2026-04-29 17:35:52 +08:00
|
|
|
|
|
|
|
|
private record RemotePackage(Path path, String fileName, String contentType) {}
|
2026-04-28 21:05:06 +08:00
|
|
|
}
|