import fs from "node:fs"; import path from "node:path"; import { spawnSync } from "node:child_process"; export interface SmaliPatch { targetFile: string; search?: string; replace?: string; injectFile?: string; } interface TranslationConfig { backend?: "argos" | "libre"; source?: string; target?: string; url?: string; apiKeyEnv?: string; } interface XmlPatch { targetFile: string; search: string; replace: string; } interface ResourceConfig { translation?: TranslationConfig; overlay?: string; assetOverlay?: string; xmlPatches?: XmlPatch[]; } interface GeneratedSmaliConfig { sourceDir: string; } export interface PatchConfig { manifest: { packagePrefix: string; ordinarySignature?: boolean; }; resources?: ResourceConfig; generatedSmali?: GeneratedSmaliConfig; smaliPatches?: SmaliPatch[]; } const cjk = /[\u3400-\u9fff]/; const preferredTranslations: Readonly> = { " 搜索...": "Search", 按修改时间: "Modified", 按名称: "Name", "背景:": "Background", 全部: "All", 书架: "Library", 关闭: "Close", 取消: "Cancel", "加载中...": "Loading", 去设置WLAN: "Wi-Fi settings", 同步: "Sync", 删除: "Delete", 更新: "Update", 清空: "Clear", 测试: "Test", 标题: "Title", 搜索历史: "Search history", "搜索...": "Search", 暂无数据: "No items", 暂无小部件: "No widgets", 暂无文件: "No files", 暂无搜索结果: "No results", 没有网络: "No network", 新增首页小部件: "Add home widget", 编辑首页小部件: "Edit home widget", 添加小部件: "Add widget", 点击添加小部件: "Add widget", 点击新建笔记: "New note", 笔记: "Notes", 笔记名称: "Note name", 笔记更新: "Notes update", 修改名称: "Rename", 重命名: "Rename", 知道了: "Done", 所有笔记加密密码相同: "Use one password for all notes", 关闭密码: "Disable password", 继续: "Continue", "请输入4~8位数字": "Enter 4 to 8 digits", "共 8 项": "8 items", "录音文件名称录...": "Recording name", "请按名称或主题关键词搜索书籍,笔记": "Search books and notes", 支持搜索多看的在线书籍: "Search online books", 请输入书名或作者名称关键词: "Search by title or author", 阅读数据: "Reading data", 首页: "Home", 优化配置: "Optimize", 应用: "Apps", 卸载: "Uninstall", 请使用手机扫描查看: "Scan with your phone", 阅读分享: "Share reading", 打开多看: "Open Duokan", "将PDF文档放至Books或者Download文件夹内\n就能在此显示哦~": "Put PDF files in Books or Download to see them here", 最近浏览: "Recent", 本地PDF: "Local PDFs", 多看: "Duokan", 今日阅读: "Reading today", 全网排行: "Rankings", 全网阅读数据: "Reading statistics", 分钟: "minutes", 名: "rank", "点击登录系统账号,开始统计": "Sign in to track reading", 目标完成: "Goal reached", 近七天阅读: "Last 7 days", 查看最近文档: "Recent documents", "作者:": "Author", 加入书架: "Add to library", 已在书架: "In library", 文件夹: "Folder", "移动到…./文件夹1/文件夹2/文件夹3/文件夹4/文件夹5/文件夹6/文件夹7/文件夹8/文件夹9/文件夹10": "Move to folder", }; const signaturePermissions = new Set([ "android.permission.CHANGE_CONFIGURATION", "android.permission.DELETE_PACKAGES", "android.permission.FORCE_STOP_PACKAGES", "android.permission.INTERACT_ACROSS_USERS", "android.permission.MOUNT_UNMOUNT_FILESYSTEM", "android.permission.READ_LOGS", "android.permission.WRITE_SECURE_SETTINGS", ]); export function applyConfiguredTransforms( workDir: string, config: PatchConfig, projectRoot: string, ): void { const allowed = new Set([ "manifest", "resources", "generatedSmali", "smaliPatches", ]); const unsupported = Object.keys(config).filter((key) => !allowed.has(key)); if (unsupported.length) throw new Error( `Unsupported patch sections: ${unsupported.join(", ")}. Code patches must be Smali patches.`, ); if (!config.manifest?.packagePrefix) throw new Error("manifest.packagePrefix is required"); renameManifestPackage( workDir, config.manifest.packagePrefix, Boolean(config.manifest.ordinarySignature), ); if (config.resources?.translation) createTranslatedStringOverlay( workDir, config.resources.translation, projectRoot, ); if (config.resources?.overlay) applyResourceOverlay(workDir, config.resources.overlay, projectRoot); if (config.resources?.assetOverlay) applyAssetOverlay(workDir, config.resources.assetOverlay, projectRoot); applyXmlPatches(workDir, config.resources?.xmlPatches ?? []); applyGeneratedSmali(workDir, config.generatedSmali, projectRoot); applySmaliPatches(workDir, config.smaliPatches ?? [], projectRoot); } function applyGeneratedSmali( workDir: string, config: GeneratedSmaliConfig | undefined, projectRoot: string, ): void { if (!config) return; if ( path.isAbsolute(config.sourceDir) || config.sourceDir.split(/[\\/]/).includes("..") ) throw new Error("Generated Smali source must remain inside the repository"); const source = path.join(projectRoot, config.sourceDir); if (!fs.existsSync(source)) throw new Error(`Generated Smali source not found: ${config.sourceDir}`); const destination = path.join( workDir, "smali", "rip", "crit", "ms", "moannotes", "nativebridge", ); fs.mkdirSync(destination, { recursive: true }); fs.cpSync(source, destination, { recursive: true, force: true }); console.log(` -> Applied generated Smali ${config.sourceDir}`); } function applyAssetOverlay( workDir: string, overlay: string, projectRoot: string, ): void { if (path.isAbsolute(overlay) || overlay.split(/[\\/]/).includes("..")) throw new Error("Asset overlay path must remain inside the repository"); const source = path.join(projectRoot, overlay); if (!fs.existsSync(source)) throw new Error(`Asset overlay not found: ${overlay}`); fs.cpSync(source, path.join(workDir, "assets"), { recursive: true, force: true, }); console.log(` -> Applied asset overlay ${overlay}`); } function applyResourceOverlay( workDir: string, overlay: string, projectRoot: string, ): void { if (path.isAbsolute(overlay) || overlay.split(/[\\/]/).includes("..")) throw new Error("Resource overlay path must remain inside the repository"); const source = path.join(projectRoot, overlay); if (!fs.existsSync(source)) throw new Error(`Resource overlay not found: ${overlay}`); fs.cpSync(source, path.join(workDir, "res"), { recursive: true, force: true, }); console.log(` -> Applied resource overlay ${overlay}`); } function applyXmlPatches(workDir: string, patches: XmlPatch[]): void { for (const [index, patch] of patches.entries()) { if ( path.isAbsolute(patch.targetFile) || patch.targetFile.split(/[\\/]/).includes("..") || !patch.targetFile.startsWith("res/") ) throw new Error(`XML patch ${index + 1} has an unsafe target path`); const target = path.join(workDir, patch.targetFile); if (!fs.existsSync(target)) throw new Error( `XML patch ${index + 1} target is missing: ${patch.targetFile}`, ); const source = fs.readFileSync(target, "utf8"); const count = source.split(patch.search).length - 1; if (count !== 1) throw new Error( `XML patch ${index + 1} expected one match, found ${count}`, ); fs.writeFileSync(target, source.replace(patch.search, patch.replace)); } if (patches.length) console.log(` -> Applied ${patches.length} XML patches`); } function renameManifestPackage( workDir: string, prefix: string, ordinarySignature: boolean, ): void { const manifestPath = path.join(workDir, "AndroidManifest.xml"); let xml = fs.readFileSync(manifestPath, "utf8"); const match = xml.match(/\bpackage="([^"]+)"/); if (!match) throw new Error("Manifest package attribute was not found"); const original = match[1]!; // A prefix identical to (or already containing) the original package // means "keep the stock package": the tablet breaks when the installer // namespace changes, so never double-prefix (com.moan.notes must stay // com.moan.notes). const renamed = original === prefix || original.startsWith(`${prefix}.`) ? original : `${prefix}.${original}`; xml = xml.replace(`package="${original}"`, `package="${renamed}"`); // Provider authorities belong to the installation namespace, not the // signing namespace. A renamed package must always receive a renamed // authority or Android rejects it while the OEM package is still present. xml = xml.replaceAll( `android:authorities="${original}`, `android:authorities="${renamed}`, ); if (ordinarySignature) { xml = xml.replace(/\s+android:sharedUserId="[^"]+"/, ""); xml = xml.replace( //g, (element, permission: string) => signaturePermissions.has(permission) ? "" : element, ); console.log(" -> Removed shared system UID and signature-only permissions"); } fs.writeFileSync(manifestPath, xml); const apktoolPath = path.join(workDir, "apktool.yml"); if (fs.existsSync(apktoolPath)) { const yaml = fs .readFileSync(apktoolPath, "utf8") .replace( /renameManifestPackage:.*$/m, `renameManifestPackage: ${renamed}`, ); fs.writeFileSync(apktoolPath, yaml); } console.log(` -> Manifest package ${original} renamed to ${renamed}`); } function createTranslatedStringOverlay( workDir: string, config: TranslationConfig, projectRoot: string, ): void { const sourcePath = path.join(workDir, "res", "values", "strings.xml"); if (!fs.existsSync(sourcePath)) return; const source = fs.readFileSync(sourcePath, "utf8"); const entries: Array<{ name: string; attributes: string; value: string }> = []; const pattern = /]*)>([\s\S]*?)<\/string>/g; for (const match of source.matchAll(pattern)) { if (cjk.test(match[3]!)) entries.push({ name: match[1]!, attributes: match[2]!, value: match[3]!, }); } if (entries.length) { const translations = translateTexts( entries.map((entry) => entry.value), config, projectRoot, ); const strings = entries.map( (entry, index) => ` ${escapeXmlText(translations[index]!)}`, ); const target = config.target ?? "en"; const destination = path.join( workDir, "res", `values-${target}`, "strings.xml", ); fs.mkdirSync(path.dirname(destination), { recursive: true }); fs.writeFileSync( destination, `\n\n${strings.join("\n")}\n\n`, ); console.log( ` -> Added ${strings.length} ${target} translations through ${config.backend ?? "argos"}`, ); } createTranslatedLayoutOverlays(workDir, config, projectRoot); } function translateTexts( texts: string[], config: TranslationConfig, projectRoot: string, ): string[] { if (!texts.length) return []; const translations = texts.map((text) => preferredTranslations[text]); const unresolved = texts .map((text, index) => ({ text, index })) .filter(({ index }) => translations[index] === undefined); if (!unresolved.length) return translations as string[]; const apiKey = config.apiKeyEnv ? process.env[config.apiKeyEnv] : undefined; const request = JSON.stringify({ texts: unresolved.map(({ text }) => text), backend: config.backend ?? "argos", source: config.source ?? "zh", target: config.target ?? "en", url: config.url, apiKey, }); const bridge = path.join(projectRoot, "translator", "bridge.py"); const localPython = path.join( projectRoot, ".venv-translator", "bin", "python", ); const python = process.env.PYTHON ?? (fs.existsSync(localPython) ? localPython : "python3"); const result = spawnSync(python, [bridge], { input: request, encoding: "utf8", maxBuffer: 16 * 1024 * 1024, env: { ...process.env, ARGOS_PACKAGES_DIR: process.env.ARGOS_PACKAGES_DIR ?? path.join(projectRoot, ".argos-packages"), XDG_DATA_HOME: path.join(projectRoot, ".argos-data"), XDG_CONFIG_HOME: path.join(projectRoot, ".argos-config"), XDG_CACHE_HOME: path.join(projectRoot, ".argos-cache"), }, }); if (result.status !== 0) throw new Error(`Translation bridge failed: ${result.stderr.trim()}`); const response = JSON.parse(result.stdout) as { translations: string[] }; if ( !Array.isArray(response.translations) || response.translations.length !== unresolved.length ) throw new Error("Translation bridge returned an invalid result count"); for (const [index, translation] of response.translations.entries()) translations[unresolved[index]!.index] = translation; return translations as string[]; } function createTranslatedLayoutOverlays( workDir: string, config: TranslationConfig, projectRoot: string, ): void { const resourceRoot = path.join(workDir, "res"); const target = config.target ?? "en"; const attributePattern = /\bandroid:(text|hint|contentDescription)="([^"]*)"/g; const records: Array<{ destination: string; content: string; values: string[]; }> = []; for (const directory of fs .readdirSync(resourceRoot) .filter((name) => /^layout(?:-.+)?$/.test(name))) { const sourceDirectory = path.join(resourceRoot, directory); for (const file of fs .readdirSync(sourceDirectory) .filter((name) => name.endsWith(".xml"))) { const source = path.join(sourceDirectory, file); const content = fs.readFileSync(source, "utf8"); const values = Array.from(content.matchAll(attributePattern)) .map((match) => match[2]!) .filter((value) => cjk.test(value)); if (!values.length) continue; const qualifier = directory.slice("layout".length); records.push({ destination: path.join( resourceRoot, `layout-${target}${qualifier}`, file, ), content, values, }); } } if (!records.length) return; const translations = translateTexts( records.flatMap((record) => record.values), config, projectRoot, ); let translationIndex = 0; for (const record of records) { const translated = record.content.replace( attributePattern, (match, attribute: string, value: string) => { if (!cjk.test(value)) return match; const text = translations[translationIndex++]!; return `android:${attribute}="${escapeXmlAttribute(text)}"`; }, ); fs.mkdirSync(path.dirname(record.destination), { recursive: true }); fs.writeFileSync(record.destination, translated); } console.log(` -> Added ${translationIndex} ${target} layout translations`); } function escapeXmlText(value: string): string { return value .replace(/&(?!#\d+;|#x[0-9A-Fa-f]+;|\w+;)/g, "&") .replace(/<(?!\/?[A-Za-z][^>]*>)/g, "<") .replace(/'/g, "\\'"); } function escapeXmlAttribute(value: string): string { return escapeXmlText(value).replaceAll('"', """); } function applySmaliPatches( workDir: string, patches: SmaliPatch[], projectRoot: string, ): void { const smaliDirs = fs .readdirSync(workDir) .filter((name) => /^smali(?:_classes\d+)?$/.test(name)) .sort(); for (const [index, patch] of patches.entries()) { if ( path.isAbsolute(patch.targetFile) || patch.targetFile.split(/[\\/]/).includes("..") ) throw new Error(`Smali patch ${index + 1} has an unsafe target path`); if (!patch.targetFile.endsWith(".smali")) throw new Error(`Smali patch ${index + 1} targets a non-Smali file`); const existing = smaliDirs .map((dir) => path.join(workDir, dir, patch.targetFile)) .find(fs.existsSync); const target = existing ?? path.join(workDir, smaliDirs[0] ?? "smali", patch.targetFile); if (patch.injectFile) { const source = path.resolve(projectRoot, patch.injectFile); if (!source.endsWith(".smali") || !fs.existsSync(source)) throw new Error( `Smali injection source not found for patch ${index + 1}`, ); fs.mkdirSync(path.dirname(target), { recursive: true }); fs.copyFileSync(source, target); continue; } if ( patch.search === undefined || patch.replace === undefined || !fs.existsSync(target) ) throw new Error( `Smali patch ${index + 1} is incomplete or its target is missing`, ); const content = fs.readFileSync(target, "utf8"); const occurrences = content.split(patch.search).length - 1; if (occurrences !== 1) throw new Error( `Smali patch ${index + 1} expected one match in ${patch.targetFile}, found ${occurrences}`, ); fs.writeFileSync(target, content.replace(patch.search, patch.replace)); } if (patches.length) console.log(` -> Applied ${patches.length} Smali patches`); }