35 Commits

Author SHA1 Message Date
LukeMackin
6cd2a22d09 feat(expo): app.json 添加 runtimeVersion 字段 2026-07-20 00:38:32 +08:00
LukeMackin
71f6b6369b fix: expo-manifest.json 改为 Expo Updates Modern Manifest 格式(id+createdAt+runtimeVersion) 2026-07-19 23:46:17 +08:00
LukeMackin
e5ce52ac40 feat(expo): JS热更新(expo-updates) + APK大更新双轨
- app.json: expo-updates配置(checkAutomatically ON_LOAD)
- ota/expo-manifest.json: JS bundle热更新manifest
- App.tsx: 启动时自动检查JS+APK双轨更新
- pnpm install后npx expo prebuild生成android/原生工程
2026-07-19 23:40:17 +08:00
LukeMackin
a2f204f5ee docs: gen-manifest.sh构建号规则说明 2026-07-19 23:23:23 +08:00
LukeMackin
eeea966400 fix: gitignore路径修复 + 版本比较改用PackageInfo动态获取 2026-07-19 23:16:12 +08:00
LukeMackin
d68e6ca6df feat: 版本比较 — 远程与本地一致则跳过更新 2026-07-19 23:12:44 +08:00
LukeMackin
a96560966c fix: 空渠道检测改用JSONObject.has + 移除gitignore误排除manifest 2026-07-19 23:10:03 +08:00
LukeMackin
ffd05e3e17 feat: 三渠道分级 - nightly=当前版本, beta/stable留空(暂无更新) 2026-07-19 23:05:11 +08:00
LukeMackin
40d0c86292 build: versionCode 7→8 + Release #8 + APK 3.5MB 2026-07-19 22:57:30 +08:00
LukeMackin
e8f00faf41 feat: App内显示版本号 v0.1.0-dav-android-7-debug 2026-07-19 21:27:06 +08:00
LukeMackin
b70c5015a0 feat: 改用系统安装器弹窗(FileProvider+ACTION_VIEW), 无需手动找权限 2026-07-19 21:06:04 +08:00
LukeMackin
14367a229b fix: 权限跳转改为应用详情页(精确定位) 2026-07-19 21:01:36 +08:00
LukeMackin
e02997db52 feat: 安装权限不足时自动跳转系统设置页面 2026-07-19 20:55:55 +08:00
LukeMackin
82a0650b59 fix: manifest SHA256更新(当前APK重编后哈希变化) 2026-07-19 20:47:35 +08:00
LukeMackin
0d3fa632e6 feat: appendLog同步输出到android.util.Log(GCA-OTA tag) 2026-07-19 20:42:06 +08:00
LukeMackin
ad1b3e3aca fix: PackageInstaller session管理 — openSession失败abandon + commit失败abandon 2026-07-19 20:00:46 +08:00
LukeMackin
0f37573faf feat: OTA校验通过后调用PackageInstaller真正安装APK
- SHA256通过→installApk()→PackageInstaller Session API
- 安装完成后通知栏提示重启
- 权限检查: canRequestPackageInstalls()
2026-07-19 19:57:56 +08:00
LukeMackin
49d3c187d0 fix: gen-manifest.sh注释同步manifest-stable.json 2026-07-19 19:51:46 +08:00
LukeMackin
3da606671a fix: gen-manifest.sh curl路径+注释+README全部同步manifest-stable.json 2026-07-19 19:48:24 +08:00
LukeMackin
1461acd292 fix: gen-manifest.sh/README/gitignore同步manifest-stable.json命名 2026-07-19 19:46:27 +08:00
LukeMackin
a7b6b455d1 fix: stable渠道manifest文件名修复 manifest.json→manifest-stable.json 2026-07-19 19:41:22 +08:00
LukeMackin
c62e2a4430 feat: ota/三渠道manifest(固定raw URL)
- ota/manifest-stable.json / beta.json / nightly.json
- App URL改回raw(固定路径,不随版本号变)
- v0.1.0→v0.2.0升级时只需更新JSON内容,URL不变
2026-07-19 19:37:04 +08:00
LukeMackin
20d81d5ab8 chore: ota/manifest.json加入gitignore(构建产物) 2026-07-19 19:29:17 +08:00
LukeMackin
c1849802b6 feat: ota/gen-manifest.sh — CI构建时动态生成manifest.json
- gen-manifest.sh: 计算APK SHA256 → 填入版本号/URL → 生成manifest.json
- manifest.json本身不提交仓库(构建产物,上传到Release)
- README.md说明目录用途
2026-07-19 19:28:58 +08:00
LukeMackin
31a56708d6 refactor: manifest回归Release构建产物
- ota/目录删除(manifest是CI构建时动态生成上传)
- App URL改为Release下载路径(releases/download/...)
- 流程: CI build→生成manifest(SHA256动态)→上传Release
- Release #7: v0.1.0-dav-android-7-debug.apk + manifest.json
2026-07-19 15:58:24 +08:00
LukeMackin
1b3d2fc06f fix: APK命名加buildType后缀 + manifest URL同步
- v0.1.0-dav-android-7.apk → v0.1.0-dav-android-7-debug.apk
- 3个manifest URL同步更新
2026-07-19 15:57:24 +08:00
LukeMackin
db5b7de50f fix: APK命名补充构建次数 vX.Y.Z-dav-{client}-{build}.apk
- archivesBaseName移除,改用applicationVariants动态命名
- versionCode: 6→7
- APK: v0.1.0-dav-android-7.apk
2026-07-19 15:54:34 +08:00
LukeMackin
3244876c8c fix(security): usesCleartextTraffic移除 + 域名白名单 + JSONObject解析
- AndroidManifest: 移除 usesCleartextTraffic=true
- downloadFile: 增加 ALLOWED_HOSTS 白名单(git.childish-ghost.com/github.com)
- manifest解析: indexOf/substring → org.json.JSONObject
2026-07-19 15:52:56 +08:00
LukeMackin
fcc51f4356 refactor: manifest迁移到ota/文件夹
- ota/manifest.json (stable)
- ota/manifest-beta.json
- ota/manifest-nightly.json
- MainActivity URL更新为ota/路径
2026-07-19 15:47:26 +08:00
LukeMackin
70e39454df fix: beta/nightly manifest tag格式修正为v0.1.0-dav-android-7(匹配版本正则) 2026-07-19 15:45:41 +08:00
LukeMackin
d4169538c9 fix: manifest放回仓库源码 + APK按版本号命名
- manifest.json/beta/nightly 从Release Assets移到源码仓库
- APK命名: gca-v0.1.0 → v0.1.0-dav-android-debug.apk
- Release #7: 仅含APK(prerelease), manifest从raw URL拉取
- 三渠道manifest: manifest.json / manifest-beta.json / manifest-nightly.json
2026-07-19 15:42:01 +08:00
LukeMackin
a3f0f975a6 chore: review notes + cleanup 2026-07-19 15:21:43 +08:00
LukeMackin
109ad813e6 fix(android): APK包名统一gca-v0.1.0 + 应用名加版本 + Gitea manifest联调
- APK: app-debug.apk → gca-v0.1.0-debug.apk
- App名: Yuzu GCA Test → Yuzu GCA v0.1.0
- MainActivity: 真实manifest拉取+JSON解析+SHA256真实比对
- Gitea Release #6: gca-v0.1.0.apk + manifest.json
- 删除旧Expo modules/ 文件
2026-07-19 15:18:30 +08:00
LukeMackin
34cdec130f build(android): APK构建成功 — 纯原生OTA测试App
- 下载Gradle Wrapper 8.10.2 (43KB)
- MainActivity: 自包含OTA测试(manifest拉取/下载/SHA256/PackageInstaller)
- 移除React Native依赖, 轻量原生App仅3.2MB
- local.properties指向本机Android SDK
- BUILD SUCCESSFUL → app-debug.apk (3.2MB)
2026-07-19 10:55:51 +08:00
LukeMackin
eab872ff09 build(android): 生成原生Android工程 — Android Studio直接构建
- android/: 完整Gradle工程(build.gradle/settings/AndroidManifest/Activity/Application)
- PackageInstallerModule: 重写为ReactContextBaseJavaModule (bare RN, 移除Expo依赖)
- 新增sha256File原生方法, installApk改为Promise异步
- 移除旧的modules/ (Expo Module → bare React Native)
- 添加eas.json (备用云端构建)
2026-07-19 10:42:17 +08:00
68 changed files with 1416 additions and 110 deletions

4
.gitignore vendored
View File

@@ -39,9 +39,11 @@ src-tauri/target/
# Android # Android
android/app/build/ android/app/build/
android/.gradle/ **/android/.gradle/
android/build/ android/build/
*.hprof *.hprof
*.jks *.jks
*.keystore *.keystore
local.properties local.properties
screen.png

View File

@@ -0,0 +1,8 @@
{
"topic_20260718-152740_2cab7b15ea20700d": {
"stage": 3,
"userTurns": 3,
"basisHash": "942c58e1a7d2413a",
"updatedAt": 1784389797592
}
}

View File

@@ -0,0 +1,3 @@
{
"topic_20260718-152740_2cab7b15ea20700d": 1784388460704
}

View File

@@ -0,0 +1,3 @@
{
"topic_20260718-152740_2cab7b15ea20700d": "auto"
}

View File

@@ -0,0 +1,3 @@
{
"topic_20260718-152740_2cab7b15ea20700d": "Start pursuing the…"
}

53
analyze_text.py Normal file
View File

@@ -0,0 +1,53 @@
"""
Final analysis: Search for any readable content in the decoded text.
Look for patterns that might indicate it's a screenshot from an Android device.
"""
import sys
import re
path = r"D:\Yuzu-GCA\screen.png"
with open(path, 'rb') as f:
raw = f.read()
text = raw.decode('utf-16-le')
# Save full text for manual inspection
with open(r"D:\Yuzu-GCA\screen_full_text.txt", 'w', encoding='utf-8') as f:
f.write(text)
print("Full text written to screen_full_text.txt")
# Search for any CJK character sequences (potential readable Chinese text)
# Chinese characters are in range U+4E00 to U+9FFF
cjk_pattern = re.compile(r'[\u4e00-\u9fff]{3,}')
cjk_matches = cjk_pattern.findall(text)
print(f"\nChinese character sequences (>=3 chars): {len(cjk_matches)}")
# Show unique ones
unique_cjk = list(set(cjk_matches))
print(f"Unique sequences: {len(unique_cjk)}")
for seq in sorted(unique_cjk, key=len, reverse=True)[:30]:
print(f" '{seq}' (len={len(seq)})")
# Search for any ASCII word sequences (potential English words)
# Words of length >= 4 made of letters only
word_pattern = re.compile(r'[A-Za-z]{4,}')
word_matches = word_pattern.findall(text)
print(f"\nEnglish word-like sequences (>=4 chars): {len(word_matches)}")
unique_words = list(set(word_matches))
print(f"Unique: {len(unique_words)}")
for w in sorted(unique_words, key=len, reverse=True)[:40]:
print(f" '{w}'")
# Also search for numbers that look like versions
version_pattern = re.compile(r'\d+\.\d+(\.\d+)?')
version_matches = version_pattern.findall(text)
print(f"\nVersion-like patterns: {len(version_matches)}")
for v in version_matches[:20]:
print(f" '{v}'")
# Search for URLs or file paths
path_pattern = re.compile(r'[/\\][A-Za-z0-9_./\\-]{5,}')
path_matches = path_pattern.findall(text)
print(f"\nPath-like patterns: {len(path_matches)}")
for p in path_matches[:20]:
print(f" '{p}'")

17
check_files.py Normal file
View File

@@ -0,0 +1,17 @@
import sys
import io
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
# Check screen2.png
data = open(r"D:\Yuzu-GCA\screen2.png", 'rb').read()
print(f"screen2.png: Size={len(data)}")
print(f"First 20 bytes: {' '.join(f'{b:02X}' for b in data[:20])}")
print(f"Is valid PNG: {data[:8] == b'\x89PNG\r\n\x1a\n'}")
# Also check the fixed files
for fname in ['screen_fixed.png', 'screen_fixed2.png']:
try:
d = open(r"D:\\Yuzu-GCA\\" + fname, 'rb').read()
print(f"\n{fname}: Size={len(d)}")
except:
print(f"\n{fname}: NOT FOUND")

36
check_header.py Normal file
View File

@@ -0,0 +1,36 @@
import sys
image_path = r"D:\Yuzu-GCA\screen.png"
with open(image_path, 'rb') as f:
header = f.read(32)
print(f"File size check: first 32 bytes hex:")
print(' '.join(f'{b:02X}' for b in header))
print()
print(f"ASCII representation: {header}")
# Check known magic numbers
if header[:8] == b'\x89PNG\r\n\x1a\n':
print("✓ Valid PNG header detected")
elif header[:2] == b'\xff\xd8':
print("✓ JPEG header detected")
elif header[:4] == b'BM':
print("✓ BMP header detected")
elif header[:4] == b'RIFF':
print("✓ WEBP header detected")
else:
print("⚠ Unknown image format")
# Also check if it might be an Android screencap (could be raw)
# Try to detect if it's a PNG embedded in a wrapper
print()
# Check if file is actually a text-based format
with open(image_path, 'rb') as f:
content = f.read(200)
# Check if mostly printable ASCII
printable = sum(1 for b in content if 32 <= b <= 126 or b in (9, 10, 13))
if printable > len(content) * 0.8:
print("Note: File appears to be mostly text!")
print(content.decode('utf-8', errors='replace'))

74
check_png.py Normal file
View File

@@ -0,0 +1,74 @@
import sys
import io
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
with open(r"D:\Yuzu-GCA\screen_fixed.png", 'rb') as f:
data = f.read()
print(f"Total size: {len(data)}")
# Check PNG structure
# Signature: 8 bytes
# Then chunks: each chunk has 4-byte length, 4-byte type, data, 4-byte CRC
offset = 8 # skip signature
while offset < len(data):
if offset + 8 > len(data):
break
length = int.from_bytes(data[offset:offset+4], 'big')
chunk_type = data[offset+4:offset+8].decode('ascii', errors='replace')
print(f"Chunk at offset {offset}: type='{chunk_type}', length={length}")
if chunk_type == 'IHDR':
w = int.from_bytes(data[offset+8:offset+12], 'big')
h = int.from_bytes(data[offset+12:offset+16], 'big')
bit_depth = data[offset+16]
color_type = data[offset+17]
print(f" Width={w}, Height={h}, BitDepth={bit_depth}, ColorType={color_type}")
# Height seems wrong (854591). Expected ~1920 or ~2340
# 0x000D0A3F: maybe it was 0x00000780 (1920) or 0x00000924 (2340)?
# Let me look at the raw bytes around height
print(f" Raw height bytes: {' '.join(f'{b:02X}' for b in data[offset+12:offset+16])}")
# Expected height around 1920 = 0x780, or 2340 = 0x924
# What we have: 00 0D 0A 3F = 0x000D0A3F
# Could it be that 0D 0A is an inserted CR LF?
# If we remove 0D 0A: remaining would be 00 3F... but that's only 2 bytes
# Actually: 00 [0D 0A] 3F -> 00 3F? That's too short.
# Maybe: 00 [0D] 0A 3F, and 0D was inserted? Then original: 00 0A 3F = 2623 pixels?
# Or maybe: 00 00 0A 3F was original? No.
# Let me check: common heights: 1920=0x780, 2000=0x7D0, 2340=0x924
# 0x0A3F = 2623 (close to nothing standard)
# What if the actual height bytes are 00 00 0A 3F? That's still 2623.
# What about 00 00 07 80 (1920)? Then 0D 0A replaced 00 07 somehow?
offset += 12 + length
if offset > 200:
break
# Let me search for CR LF (0D 0A) in the PNG data
crlf_count = 0
for i in range(len(data) - 1):
if data[i] == 0x0D and data[i+1] == 0x0A:
crlf_count += 1
if crlf_count <= 10:
print(f"CR LF at offset {i}: context: {' '.join(f'{b:02X}' for b in data[max(0,i-4):i+6])}")
print(f"\nTotal CR LF sequences: {crlf_count}")
# Also count lone 0A
lone_lf = 0
for i in range(len(data)):
if data[i] == 0x0A and (i == 0 or data[i-1] != 0x0D):
lone_lf += 1
print(f"Lone LF: {lone_lf}")
# Count lone 0D
lone_cr = 0
for i in range(len(data)):
if data[i] == 0x0D and (i == len(data)-1 or data[i+1] != 0x0A):
lone_cr += 1
print(f"Lone CR: {lone_cr}")

1
chi_sim.traineddata Normal file
View File

@@ -0,0 +1 @@
File size exceeded the configured limit of 20 MB.

82
debug_png.py Normal file
View File

@@ -0,0 +1,82 @@
"""
Debug: compare raw bytes and re-encoded bytes.
Also try to interpret the file as a regular PNG by skipping the BOM.
"""
import sys
path = r"D:\Yuzu-GCA\screen.png"
with open(path, 'rb') as f:
raw = f.read()
print(f"Total size: {len(raw)} bytes")
# Decode as UTF-16 LE
text = raw.decode('utf-16-le')
print(f"Decoded text length: {len(text)} chars")
# Re-encode
reencoded = text.encode('utf-16-le')
print(f"Re-encoded size: {len(reencoded)} bytes")
# Compare
if raw == reencoded:
print("raw == reencoded: EXACT MATCH")
else:
print(f"raw == reencoded: DIFFER")
# Find first difference
for i in range(min(len(raw), len(reencoded))):
if raw[i] != reencoded[i]:
print(f"First diff at byte {i}: raw={raw[i]:02x}, reencoded={reencoded[i]:02x}")
print(f" Context raw: {raw[max(0,i-5):i+10].hex(' ')}")
print(f" Context re: {reencoded[max(0,i-5):i+10].hex(' ')}")
break
# Check if raw starts with BOM
if raw[:2] == b'\xff\xfe':
print("\nFile starts with UTF-16 LE BOM")
elif raw[:2] == b'\xfe\xff':
print("\nFile starts with UTF-16 BE BOM")
# Check reencoded start
if reencoded[:2] == b'\xff\xfe':
print("Re-encoded starts with UTF-16 LE BOM")
elif reencoded[:2] == b'\xfe\xff':
print("Re-encoded starts with UTF-16 BE BOM")
# Try: what if we treat raw[2:] as the UTF-16 LE data (without the BOM)?
# This would be the case if the file had BOM added later
print(f"\nraw[2:4] = {raw[2:4].hex(' ')}")
text2 = raw[2:].decode('utf-16-le')
print(f"Decoded text2 (skipping BOM) length: {len(text2)} chars")
print(f"text2 first 50 chars: {text2[:50]}")
# Try: what if the entire file is just raw PNG with a bogus BOM?
# PNG magic: 89 50 4E 47 0D 0A 1A 0A
# Our file: ff fe 52 58 4e 00 47 00 0d 00 0a 00 ...
# Maybe each byte X became X*256 + something?
# Or maybe the file is a hex dump?
print("\n=== Trying hex dump interpretation ===")
# Check if the text looks like a hex dump (pairs of hex digits)
hex_pattern = all(c in '0123456789abcdefABCDEF \n\r\t' for c in text[:1000])
print(f"First 1000 chars look like hex dump: {hex_pattern}")
# Final: Is there any actual readable content?
# Let's look for sequences of Latin chars that might form words
import re
# Words with 5+ letters
real_words = re.findall(r'[A-Za-z]{5,}', text)
# Filter out those that look like random binary
likely_real = [w for w in real_words if w.lower() in [
'update', 'download', 'install', 'error', 'success', 'failed',
'system', 'android', 'version', 'checking', 'package', 'verify'
] or w in ['IHDR', 'IDAT', 'IEND', 'sRGB', 'sBIT', 'pHYs', 'iTXt', 'tEXt', 'zTXt']]
print(f"\nKnown words found: {likely_real}")
# Check for PNG chunk names
png_chunks = ['IHDR', 'PLTE', 'IDAT', 'IEND', 'cHRM', 'gAMA', 'iCCP', 'sBIT', 'sRGB',
'bKGD', 'hIST', 'tRNS', 'pHYs', 'sPLT', 'tIME', 'iTXt', 'tEXt', 'zTXt']
for chunk in png_chunks:
cnt = text.count(chunk)
if cnt > 0:
print(f" PNG chunk '{chunk}': {cnt} times")

15
decode_png.py Normal file
View File

@@ -0,0 +1,15 @@
import sys
import io
# Set stdout to UTF-8
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
with open(r"D:\Yuzu-GCA\screen.png", 'rb') as f:
data = f.read()
print(f"Total bytes: {len(data)}")
# Try UTF-16 LE decode
text = data.decode('utf-16-le', errors='replace')
print("=== Decoded as UTF-16 LE (first 10000 chars) ===")
print(text[:10000])

129
fix_png.py Normal file
View File

@@ -0,0 +1,129 @@
import sys
import io
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
with open(r"D:\Yuzu-GCA\screen.png", 'rb') as f:
data = f.read()
# The file is UTF-16 LE encoded with some corruption.
# Let's fix the known issues:
# 1. Replace the corrupted first char (U+5852 from bytes 52 58)
# with correct U+0089 (89 00) + U+0050 (50 00)
# But wait - in UTF-16 LE, each char takes 2 bytes.
# We need to replace 2 bytes (52 58) with 4 bytes (89 00 50 00)
# Actually, let's approach differently:
# Convert the whole thing from UTF-16 LE to raw bytes,
# but fix the known corruptions.
# Step 1: parse as UTF-16 LE (skip BOM)
raw = data[2:]
# Each 2 bytes is one UTF-16 LE char
chars = []
for i in range(0, len(raw), 2):
if i + 1 < len(raw):
ch = raw[i] | (raw[i+1] << 8)
chars.append(ch)
print(f"Total chars: {len(chars)}")
# Step 2: Fix corruptions in the char array
# Char 0: U+5852 -> should be U+0089 (first PNG byte)
# But U+0089 is one char, and we also need U+0050 for 'P'
# So we need to insert a char
# The corruption pattern:
# - Char 0 (U+5852) replaces two chars: U+0089 and U+0050
# - Char 6 (U+000D) should be U+000A
# - Char 7 (U+000A) is extra, should be removed
# - Char 12 (U+000A) is extra, should be removed
# Let's verify by looking at what we expect:
# Expected chars for correct PNG:
# [0]=0x0089, [1]=0x0050('P'), [2]=0x004E('N'), [3]=0x0047('G'),
# [4]=0x000D, [5]=0x000A, [6]=0x001A, [7]=0x000A (signature end)
# [8]=0x0000, [9]=0x0000, [10]=0x0000, [11]=0x000D (IHDR length=13)
# [12]=0x0049('I'), [13]=0x0048('H'), [14]=0x0044('D'), [15]=0x0052('R')
# Actual chars:
# [0]=0x5852, [1]=0x004E, [2]=0x0047, [3]=0x000D, [4]=0x000A,
# [5]=0x001A, [6]=0x000D, [7]=0x000A, [8]=0x0000, [9]=0x0000,
# [10]=0x0000, [11]=0x000D, [12]=0x000A, [13]=0x0049, [14]=0x0048, ...
# Fix:
# Replace char 0 (U+5852) with U+0089
# Insert U+0050 at position 1
# Change char 6 from 0x0D to 0x0A
# Delete char 7 (extra 0x0A)
# Delete char 12 (extra 0x0A) - wait, this is after IHDR length
fixed_chars = []
# Fix char 0: split U+5852 -> U+0089, U+0050
fixed_chars.append(0x0089) # PNG first byte
fixed_chars.append(0x0050) # 'P'
# Copy chars 1-5 normally (but adjust indices)
# Original char 1 (U+004E='N') -> fixed index 2
# Original char 2 (U+0047='G') -> fixed index 3
# Original char 3 (U+000D) -> fixed index 4
# Original char 4 (U+000A) -> fixed index 5
# Original char 5 (U+001A) -> fixed index 6
fixed_chars.append(chars[1]) # 'N'
fixed_chars.append(chars[2]) # 'G'
fixed_chars.append(chars[3]) # '\r'
fixed_chars.append(chars[4]) # '\n'
fixed_chars.append(chars[5]) # '\x1a'
# Fix char 6: change from 0x0D to 0x0A
fixed_chars.append(0x000A) # Should be '\n' instead of '\r'
# Skip char 7 (extra 0x0A) - don't add it
# Add chars 8-11 (IHDR length = 0x0000000D)
fixed_chars.append(chars[8]) # 0x0000
fixed_chars.append(chars[9]) # 0x0000
fixed_chars.append(chars[10]) # 0x0000
fixed_chars.append(chars[11]) # 0x000D
# Skip char 12 (extra 0x0A) - don't add it
# Add the rest from char 13 onwards
for i in range(13, len(chars)):
fixed_chars.append(chars[i])
print(f"Fixed chars: {len(fixed_chars)}")
# Step 3: Convert fixed chars back to UTF-16 LE bytes
fixed_bytes = bytearray()
for ch in fixed_chars:
fixed_bytes.append(ch & 0xFF) # low byte
fixed_bytes.append((ch >> 8) & 0xFF) # high byte
print(f"Fixed bytes: {len(fixed_bytes)}")
# Step 4: Extract original PNG bytes (every other byte, starting from first)
# In UTF-16 LE, for ASCII chars (< 0x80), the low byte is the char and high byte is 00
# But for non-ASCII chars like U+0089, the low byte is 0x89 and high byte is 0x00
# So we take the low byte of each char
png_bytes = bytearray()
for ch in fixed_chars:
png_bytes.append(ch & 0xFF)
print(f"PNG bytes: {len(png_bytes)}")
print(f"First 32 PNG bytes: {' '.join(f'{b:02X}' for b in png_bytes[:32])}")
# Check PNG signature
if png_bytes[:8] == b'\x89PNG\r\n\x1a\n':
print("✓ Valid PNG signature!")
with open(r"D:\Yuzu-GCA\screen_fixed.png", 'wb') as f:
f.write(png_bytes)
print("Saved screen_fixed.png")
else:
print(f"✗ Invalid signature: {' '.join(f'{b:02X}' for b in png_bytes[:8])}")
# Try to see if we can find where it goes wrong
expected = b'\x89PNG\r\n\x1a\n'
for i in range(8):
if png_bytes[i] != expected[i]:
print(f" Mismatch at byte {i}: got {png_bytes[i]:02X}, expected {expected[i]:02X}")

66
fix_png2.py Normal file
View File

@@ -0,0 +1,66 @@
import sys
import io
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
with open(r"D:\Yuzu-GCA\screen_fixed.png", 'rb') as f:
data = f.read()
print(f"Original size: {len(data)}")
# The file has been corrupted by text-mode LF->CRLF conversion.
# Every 0x0A byte in the original PNG has been changed to 0x0D 0x0A.
# We need to revert this: change 0x0D 0x0A back to 0x0A.
# But the PNG signature contains a legitimate 0x0D 0x0A (at offset 4-5),
# which should remain as-is.
# Strategy: replace 0D 0A -> 0A, EXCEPT for the known legitimate ones
# Legitimate: offset 4-5 (PNG signature "\r\n")
# Actually, let's just do the replacement everywhere and then fix the signature
fixed = bytearray()
i = 0
while i < len(data):
if i < len(data) - 1 and data[i] == 0x0D and data[i+1] == 0x0A:
# CR LF found - replace with just LF
fixed.append(0x0A)
i += 2
else:
fixed.append(data[i])
i += 1
fixed = bytes(fixed)
print(f"After CRLF->LF fix: {len(fixed)}")
# Now fix the PNG signature (offset 4-5 should be 0D 0A, but we changed it to 0A)
# The correct PNG signature: 89 50 4E 47 0D 0A 1A 0A
# We need to fix offset 4 to be 0D again
if fixed[4] == 0x0A: # Should be 0x0D
fixed = fixed[:4] + b'\x0D' + fixed[5:]
print("Fixed PNG signature byte 4 (0D)")
print(f"First 16 bytes: {' '.join(f'{b:02X}' for b in fixed[:16])}")
# Check IHDR
offset = 8
length = int.from_bytes(fixed[offset:offset+4], 'big')
chunk_type = fixed[offset+4:offset+8].decode('ascii', errors='replace')
print(f"IHDR: type={chunk_type}, length={length}")
w = int.from_bytes(fixed[offset+8:offset+12], 'big')
h = int.from_bytes(fixed[offset+12:offset+16], 'big')
bit_depth = fixed[offset+16]
color_type = fixed[offset+17]
print(f" Width={w}, Height={h}, BitDepth={bit_depth}, ColorType={color_type}")
# Save
with open(r"D:\Yuzu-GCA\screen_fixed2.png", 'wb') as f:
f.write(fixed)
print("Saved screen_fixed2.png")
# Try to open with PIL
from PIL import Image
try:
img = Image.open(r"D:\Yuzu-GCA\screen_fixed2.png")
print(f"Success! Format={img.format}, Size={img.size}, Mode={img.mode}")
except Exception as e:
print(f"PIL error: {e}")

48
ocr_fixed.py Normal file
View File

@@ -0,0 +1,48 @@
import sys
import io
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
from PIL import Image
img = Image.open(r"D:\Yuzu-GCA\screen_fixed.png")
print(f"Format: {img.format}")
print(f"Size: {img.size}")
print(f"Mode: {img.mode}")
# Try OCR
try:
import pytesseract
text = pytesseract.image_to_string(img, lang='eng+chi_sim')
print("\n=== pytesseract OCR result ===")
print(text)
except ImportError:
print("pytesseract not available")
# Also try Windows OCR
try:
import asyncio
from winsdk.windows.media.ocr import OcrEngine
from winsdk.windows.graphics.imaging import BitmapDecoder, SoftwareBitmap
from winsdk.windows.storage.streams import RandomAccessStreamReference
from winsdk.windows.globalization import Language
import os
async def ocr_windows():
engine = OcrEngine.try_create_from_user_profile_languages()
if not engine:
engine = OcrEngine.try_create_from_language(Language("en"))
path = os.path.abspath(r"D:\Yuzu-GCA\screen_fixed.png")
file_stream = await RandomAccessStreamReference.create_from_file(path).open_read_async()
decoder = await BitmapDecoder.create_async(file_stream)
bitmap = await decoder.get_software_bitmap_async()
result = await engine.recognize_async(bitmap)
print("\n=== Windows OCR result ===")
for line in result.lines:
print(line.text)
asyncio.run(ocr_windows())
except Exception as e:
print(f"Windows OCR failed: {e}")

55
ocr_screen.py Normal file
View File

@@ -0,0 +1,55 @@
"""
OCR script to extract text from screen.png
Attempts to use pytesseract if available, otherwise falls back to basic image info.
"""
import sys
import os
image_path = r"D:\Yuzu-GCA\screen.png"
# First, try to get basic image info
try:
from PIL import Image
img = Image.open(image_path)
print(f"Image size: {img.size}")
print(f"Image mode: {img.mode}")
print(f"Image format: {img.format}")
print("---")
except ImportError:
print("Pillow not available, trying basic file read...")
# Can't do much without PIL
# Try pytesseract
try:
import pytesseract
text = pytesseract.image_to_string(img, lang='chi_sim+eng')
print("OCR Result (pytesseract):")
print(text)
except ImportError:
print("pytesseract not available")
# Try subprocess call to system tesseract
import subprocess
try:
result = subprocess.run(
['tesseract', image_path, 'stdout', '-l', 'chi_sim+eng'],
capture_output=True, text=True, timeout=30
)
if result.returncode == 0:
print("OCR Result (system tesseract):")
print(result.stdout)
else:
print(f"Tesseract error: {result.stderr}")
except FileNotFoundError:
print("System tesseract not found either")
except Exception as e:
print(f"Error running tesseract: {e}")
# If nothing works, try reading raw pixels to detect if it's a text screenshot
try:
from PIL import Image
img = Image.open(image_path)
# Print first few pixel values to understand the image
pixels = list(img.getdata())[:20]
print(f"\nFirst 20 pixel values: {pixels}")
except:
pass

23
ocr_screen2.py Normal file
View File

@@ -0,0 +1,23 @@
import sys
import io
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
from PIL import Image
img = Image.open(r"D:\Yuzu-GCA\screen2.png")
print(f"Format: {img.format}, Size: {img.size}, Mode: {img.mode}")
# Try pytesseract OCR
try:
import pytesseract
# Configure tesseract path if needed
# pytesseract.pytesseract.tesseract_cmd = r'C:\Program Files\Tesseract-OCR\tesseract.exe'
text = pytesseract.image_to_string(img, lang='chi_sim+eng')
print("\n=== OCR Result ===")
print(text)
except ImportError:
print("\npytesseract not available")
except Exception as e:
print(f"\nOCR error: {e}")

43
ocr_script.ps1 Normal file
View File

@@ -0,0 +1,43 @@
# PowerShell script using Windows built-in OCR
Add-Type -AssemblyName System.Drawing
Add-Type -AssemblyName System.Runtime.WindowsRuntime
Add-Type -AssemblyName Windows.Foundation
Add-Type -AssemblyName Windows.Graphics.Imaging
Add-Type -AssemblyName Windows.Media.Ocr
$winRtTypes = [Windows.Media.Ocr.OcrEngine]::GetType()
# Load the image
$imagePath = "D:\Yuzu-GCA\screen2.png"
$bitmap = [System.Drawing.Bitmap]::FromFile($imagePath)
Write-Host "Image size: $($bitmap.Width) x $($bitmap.Height)"
# Convert to stream
$ms = New-Object System.IO.MemoryStream
$bitmap.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png)
$bitmap.Dispose()
# Create BitmapDecoder
$ms.Seek(0, [System.IO.SeekOrigin]::Begin) | Out-Null
$randomAccessStream = New-Object Windows.Storage.Streams.InMemoryRandomAccessStream
$outputStream = $randomAccessStream.GetOutputStreamAt(0)
$dataWriter = New-Object Windows.Storage.Streams.DataWriter($outputStream)
$bytes = $ms.ToArray()
$dataWriter.WriteBytes($bytes)
$dataWriter.StoreAsync().GetAwaiter().GetResult() | Out-Null
$dataWriter.FlushAsync().GetAwaiter().GetResult() | Out-Null
$ms.Dispose()
$decoder = [Windows.Graphics.Imaging.BitmapDecoder]::CreateAsync($randomAccessStream).GetAwaiter().GetResult()
$softwareBitmap = [Windows.Graphics.Imaging.SoftwareBitmap]::Convert($decoder.GetSoftwareBitmapAsync().GetAwaiter().GetResult(), [Windows.Graphics.Imaging.BitmapPixelFormat]::Bgra8, [Windows.Graphics.Imaging.BitmapAlphaMode]::Premultiplied)
# Perform OCR
$engine = [Windows.Media.Ocr.OcrEngine]::TryCreateFromUserProfileLanguages()
$result = $engine.RecognizeAsync($softwareBitmap).GetAwaiter().GetResult()
# Output results
Write-Host "Recognized text:"
Write-Host "===================="
foreach ($line in $result.Lines) {
Write-Host $line.Text
}

51
ocr_script.py Normal file
View File

@@ -0,0 +1,51 @@
"""
OCR script to extract text from screen.png
"""
import sys
import os
image_path = r"D:\Yuzu-GCA\screen.png"
# Try pytesseract first
try:
import pytesseract
from PIL import Image
img = Image.open(image_path)
text = pytesseract.image_to_string(img, lang='eng+chi_sim')
print("=== pytesseract OCR result ===")
print(text)
sys.exit(0)
except ImportError:
print("pytesseract not available, trying alternative...")
# Try Windows OCR via winrt
try:
import asyncio
from winsdk.windows.media.ocr import OcrEngine
from winsdk.windows.graphics.imaging import BitmapDecoder
from winsdk.windows.storage.streams import RandomAccessStreamReference
from winsdk.windows.globalization import Language
async def ocr_windows():
# Get the default OCR engine for Chinese
engine = OcrEngine.try_create_from_user_profile_languages()
if not engine:
# Fallback to English
engine = OcrEngine.try_create_from_language(Language("en"))
# Open the image file
file_stream = await RandomAccessStreamReference.create_from_file(image_path).open_read_async()
decoder = await BitmapDecoder.create_async(file_stream)
bitmap = await decoder.get_software_bitmap_async()
result = await engine.recognize_async(bitmap)
print("=== Windows OCR result ===")
for line in result.lines:
print(line.text)
asyncio.run(ocr_windows())
sys.exit(0)
except ImportError:
print("winsdk not available either")
print("No OCR engine available. Please install pytesseract or Windows SDK.")

48
ocr_win.py Normal file
View File

@@ -0,0 +1,48 @@
import sys
import io
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
print("Attempting Windows OCR...")
try:
import asyncio
from winsdk.windows.media.ocr import OcrEngine
from winsdk.windows.graphics.imaging import BitmapDecoder
from winsdk.windows.storage.streams import RandomAccessStreamReference
from winsdk.windows.globalization import Language
import os
async def ocr():
path = os.path.abspath(r"D:\Yuzu-GCA\screen2.png")
print(f"Opening: {path}")
# Try to get engine for Chinese
engine = OcrEngine.try_create_from_user_profile_languages()
if not engine:
print("No user profile languages, trying Chinese...")
engine = OcrEngine.try_create_from_language(Language("zh-Hans"))
if not engine:
print("Trying English...")
engine = OcrEngine.try_create_from_language(Language("en"))
if not engine:
print("No OCR engine available")
return
print(f"Using OCR engine: {engine.recognizer_language.display_name}")
file_stream = await RandomAccessStreamReference.create_from_file(path).open_read_async()
decoder = await BitmapDecoder.create_async(file_stream)
bitmap = await decoder.get_software_bitmap_async()
result = await engine.recognize_async(bitmap)
print(f"\n=== Windows OCR Result ({result.lines.size} lines) ===")
for line in result.lines:
print(line.text)
asyncio.run(ocr())
except ImportError as e:
print(f"winsdk not available: {e}")
except Exception as e:
print(f"Error: {e}")

18
ota/README.md Normal file
View File

@@ -0,0 +1,18 @@
# OTA 文件夹
此目录存放 manifest 文件。manifest-stable.json 是 CI 构建时动态生成的产物,
也提交到仓库通过 raw URL 供客户端拉取。
## 文件
- `gen-manifest.sh` — 构建后运行,计算 APK SHA256 并生成 manifest-stable.json
- `manifest-stable.json` — stable 渠道
- `manifest-beta.json` — beta 渠道
- `manifest-nightly.json` — nightly 渠道
## 流程
```
CI 构建 APK → ./ota/gen-manifest.sh → git push manifest-stable.json
手机 App → raw URL 拉取 manifest → 比较版本 → 下载 APK → SHA256 校验
```

12
ota/expo-manifest.json Normal file
View File

@@ -0,0 +1,12 @@
{
"id": "e5ce52a-0000-0000-0000-000000000000",
"createdAt": "2026-07-19T15:30:00.000Z",
"runtimeVersion": "0.1.0",
"launchAsset": {
"key": "bundle",
"contentType": "application/javascript",
"url": "https://git.childish-ghost.com/LukeMackin/Yuzu-GCA/releases/download/v0.1.0-dav-android-8/bundle.js"
},
"assets": [],
"metadata": {}
}

38
ota/gen-manifest.sh Normal file
View File

@@ -0,0 +1,38 @@
#!/bin/bash
# OTA manifest 生成脚本
# 用法: ./ota/gen-manifest.sh <versionName> <versionCode> <apk-path>
# 构建号规则: 每次生成APK时 versionCode +1, 对应 git tag vX.Y.Z-dav-android-N
# CI 构建时自动运行, SHA256动态填入, 生成 manifest-stable.json 后上传到 Gitea Release
VERSION_NAME="${1:-0.1.0}"
VERSION_CODE="${2:-7}"
APK_PATH="${3:-packages/client-android/android/app/build/outputs/apk/debug/v0.1.0-dav-android-7-debug.apk}"
RELEASE_TAG="v${VERSION_NAME}-dav-android-${VERSION_CODE}"
APK_NAME="v${VERSION_NAME}-dav-android-${VERSION_CODE}-debug.apk"
SHA256=$(sha256sum "$APK_PATH" | cut -d' ' -f1)
SIZE=$(stat -c%s "$APK_PATH")
cat > ota/manifest-stable.json <<JSON
{
"version": "${VERSION_NAME}",
"builds": {
"android": {
"type": "apk",
"tag": "${RELEASE_TAG}",
"url": "https://git.childish-ghost.com/LukeMackin/Yuzu-GCA/releases/download/${RELEASE_TAG}/${APK_NAME}",
"sha256": "${SHA256}",
"size": ${SIZE},
"channel": "stable",
"minVersion": "${VERSION_NAME}"
}
}
}
JSON
echo "Generated: ota/manifest-stable.json"
echo " tag: ${RELEASE_TAG}"
echo " sha256: ${SHA256}"
echo " size: ${SIZE}"
echo ""
echo "Upload: curl -X POST 'https://git.childish-ghost.com/api/v1/repos/LukeMackin/Yuzu-GCA/releases/\${RELEASE_ID}/assets?name=manifest-stable.json' -H 'Authorization: token \$TOKEN' -H 'Content-Type: application/json' --data-binary @ota/manifest-stable.json"

1
ota/manifest-beta.json Normal file
View File

@@ -0,0 +1 @@
{"version":"0.1.0","builds":{}}

View File

@@ -0,0 +1 @@
{"version":"0.1.0","builds":{"android":{"type":"apk","tag":"v0.1.0-dav-android-8","url":"https://git.childish-ghost.com/LukeMackin/Yuzu-GCA/releases/download/v0.1.0-dav-android-8/v0.1.0-dav-android-8-debug.apk","sha256":"b2451a398ea2dd34e585c5ec4529d6654451c9b9ab4ddf4a25ec9b032f0ac2bf","size":3506569,"channel":"nightly","minVersion":"0.1.0"}}}

1
ota/manifest-stable.json Normal file
View File

@@ -0,0 +1 @@
{"version":"0.1.0","builds":{}}

View File

@@ -0,0 +1,2 @@
#Sun Jul 19 10:50:39 CST 2026
gradle.version=8.10.2

View File

@@ -0,0 +1,40 @@
apply plugin: "com.android.application"
apply plugin: "org.jetbrains.kotlin.android"
android {
namespace "dev.yuzu.gca"
compileSdk 35
defaultConfig {
applicationId "dev.yuzu.gca"
minSdk 24
targetSdk 35
versionCode 8
versionName "0.1.0"
}
buildTypes {
release { minifyEnabled false }
debug { debuggable true }
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
kotlinOptions { jvmTarget = "17" }
applicationVariants.all { variant ->
variant.outputs.all {
outputFileName = "v${variant.versionName}-dav-android-${variant.versionCode}-${variant.buildType.name}.apk"
}
}
}
dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib:2.0.21"
implementation "androidx.appcompat:appcompat:1.7.0"
implementation "androidx.core:core:1.15.0"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0"
}

View File

@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<application
android:label="Yuzu GCA v0.1.0"
android:allowBackup="false"
android:supportsRtl="true"
android:theme="@style/Theme.AppCompat.DayNight">
<activity
android:name=".MainActivity"
android:label="Yuzu GCA v0.1.0"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>

View File

@@ -0,0 +1,255 @@
package dev.yuzu.gca
import android.app.PendingIntent
import android.content.Intent
import android.content.pm.PackageInstaller
import android.graphics.Color
import android.net.Uri
import android.os.Bundle
import android.provider.Settings
import android.widget.Button
import android.widget.LinearLayout
import android.widget.ScrollView
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity
import kotlinx.coroutines.*
import java.io.File
import java.io.FileOutputStream
import java.net.HttpURLConnection
import java.net.URL
import java.security.MessageDigest
import android.util.Log
import androidx.core.content.FileProvider
import org.json.JSONObject
class MainActivity : AppCompatActivity() {
private lateinit var logView: TextView
private var selectedChannel = "stable"
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val scroll = ScrollView(this)
val layout = LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
setPadding(24, 24, 24, 24)
}
val title = TextView(this).apply {
text = "Yuzu GCA"
textSize = 20f
setPadding(0, 0, 0, 4)
}
layout.addView(title)
val versionInfo = TextView(this).apply {
text = "v0.1.0-dav-android-8-debug"
textSize = 12f
setTextColor(Color.GRAY)
setPadding(0, 0, 0, 12)
}
layout.addView(versionInfo)
val channelLabel = TextView(this).apply {
text = "选择更新渠道:"
textSize = 13f
setPadding(0, 8, 0, 4)
}
layout.addView(channelLabel)
// 渠道按钮行
val channelRow = LinearLayout(this).apply {
orientation = LinearLayout.HORIZONTAL
setPadding(0, 0, 0, 12)
}
fun makeChannelBtn(label: String, channel: String): Button {
return Button(this).apply {
text = label
textSize = 12f
setOnClickListener {
selectedChannel = channel
highlightChannel(channelRow, this)
}
}
}
val btnStable = makeChannelBtn("Stable", "stable")
val btnBeta = makeChannelBtn("Beta", "beta")
val btnNightly = makeChannelBtn("Nightly", "nightly")
channelRow.addView(btnStable)
channelRow.addView(btnBeta)
channelRow.addView(btnNightly)
layout.addView(channelRow)
highlightChannel(channelRow, btnStable)
logView = TextView(this).apply {
textSize = 12f
setTextColor(Color.LTGRAY)
}
layout.addView(logView)
val btn = Button(this).apply {
text = "检查更新"
setOnClickListener {
val ch = selectedChannel
CoroutineScope(Dispatchers.IO).launch { doOtaTest(ch) }
}
}
layout.addView(btn)
scroll.addView(layout)
setContentView(scroll)
appendLog("渠道: stable | 点击[检查更新]开始")
}
private fun highlightChannel(row: LinearLayout, active: Button) {
for (i in 0 until row.childCount) {
val b = row.getChildAt(i) as Button
b.isSelected = (b == active)
}
}
private fun appendLog(msg: String) {
Log.i(TAG, msg)
runOnUiThread { logView.append("$msg\n") }
}
companion object { private const val TAG = "GCA-OTA" }
private fun manifestUrl(channel: String): String {
val base = "https://git.childish-ghost.com/LukeMackin/Yuzu-GCA/raw/branch/main/ota"
return when (channel) {
"beta" -> "$base/manifest-beta.json"
"nightly" -> "$base/manifest-nightly.json"
else -> "$base/manifest-stable.json"
}
}
private suspend fun doOtaTest(channel: String) = withContext(Dispatchers.IO) {
val url = manifestUrl(channel)
appendLog("=== OTA 渠道: $channel ===")
appendLog("manifest: $url")
// 1. 拉取 manifest
appendLog("[1/4] 拉取 manifest...")
val manifestJson: String
try {
manifestJson = httpGet(url)
appendLog(" ✅ 获取成功 (${manifestJson.length}B)")
} catch (e: Exception) {
appendLog(" ❌ 获取失败: ${e.message}")
appendLog("=== 测试中止 ===")
return@withContext
}
// 2. 解析
appendLog("[2/4] 解析 manifest...")
var manifestSha256 = ""
var manifestApkUrl = ""
var manifestTag = ""
try {
val json = JSONObject(manifestJson)
if (!json.getJSONObject("builds").has("android")) {
appendLog(" 该渠道暂无可用更新")
appendLog("=== $channel 渠道测试完成 ===")
return@withContext
}
val android = json.getJSONObject("builds").getJSONObject("android")
manifestSha256 = android.getString("sha256")
manifestApkUrl = android.getString("url")
manifestTag = android.getString("tag")
appendLog(" 渠道: $channel 版本: $manifestTag")
appendLog(" SHA256: ${manifestSha256.take(16)}...")
// 版本比较:一致则跳过
val localTag = "v0.1.0-dav-android-${packageManager.getPackageInfo(packageName, 0).versionCode}"
if (manifestTag == localTag) {
appendLog(" ✅ 已是最新版本,无需更新")
appendLog("=== $channel 渠道测试完成 ===")
return@withContext
}
appendLog(" 发现新版本,开始更新...")
} catch (e: Exception) {
appendLog(" ❌ 解析失败: ${e.message}")
return@withContext
}
// 3. 下载
appendLog("[3/4] 下载 APK...")
val apkFile = File(cacheDir, "update-$channel.apk")
try {
downloadFile(manifestApkUrl, apkFile)
appendLog(" ✅ 下载完成: ${apkFile.length()} bytes")
} catch (e: Exception) {
appendLog(" ❌ 下载失败: ${e.message}")
return@withContext
}
// 4. SHA256 校验
appendLog("[4/4] SHA256 校验...")
val actualSha256 = computeSha256(apkFile)
if (actualSha256 == manifestSha256) {
appendLog(" ✅ SHA256 校验通过!")
// 5. 安装 APK
appendLog("[5/5] 提交安装...")
try {
installApk(apkFile)
appendLog(" ✅ 安装已提交,稍后通知栏点击重启")
} catch (e: Exception) {
appendLog(" ❌ 安装失败: ${e.message}")
}
} else {
appendLog(" ❌ SHA256 校验失败!")
appendLog(" expected: ${manifestSha256.take(32)}")
appendLog(" actual: ${actualSha256.take(32)}")
}
appendLog("=== $channel 渠道测试完成 ===")
}
private fun httpGet(url: String): String {
val conn = URL(url).openConnection() as HttpURLConnection
conn.connectTimeout = 10000
conn.readTimeout = 10000
return conn.inputStream.bufferedReader().readText()
}
private val ALLOWED_HOSTS = setOf("git.childish-ghost.com", "github.com")
private fun downloadFile(url: String, dest: File) {
val host = URL(url).host
if (!ALLOWED_HOSTS.any { host == it || host.endsWith(".$it") }) {
throw SecurityException("不允许的下载域名: $host")
}
val conn = URL(url).openConnection() as HttpURLConnection
conn.connectTimeout = 30000
conn.readTimeout = 120000
conn.inputStream.use { input ->
FileOutputStream(dest).use { output -> input.copyTo(output) }
}
}
private fun installApk(apkFile: File) {
val uri = FileProvider.getUriForFile(this, "${packageName}.fileprovider", apkFile)
val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, "application/vnd.android.package-archive")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
}
startActivity(intent)
}
private fun computeSha256(file: File): String {
val digest = MessageDigest.getInstance("SHA-256")
file.inputStream().use { input ->
val buf = ByteArray(8192)
var read: Int
while (input.read(buf).also { read = it } != -1) {
digest.update(buf, 0, read)
}
}
return digest.digest().joinToString("") { "%02x".format(it) }
}
}

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="primary">#3b82f6</color>
<color name="background">#0f172a</color>
</resources>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Yuzu GCA v0.1.0</string>
</resources>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<cache-path name="apk" path="/" />
</paths>

View File

@@ -0,0 +1,26 @@
// Top-level build file
buildscript {
ext {
buildToolsVersion = "35.0.0"
minSdkVersion = 24
compileSdkVersion = 35
targetSdkVersion = 35
kotlinVersion = "2.0.21"
}
repositories {
google()
mavenCentral()
}
dependencies {
classpath("com.android.tools.build:gradle:8.7.3")
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:${kotlinVersion}")
}
}
allprojects {
repositories {
google()
mavenCentral()
maven { url 'https://www.jitpack.io' }
}
}

View File

@@ -0,0 +1,5 @@
org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m
android.useAndroidX=true
android.enableJetifier=true
newArchEnabled=true
hermesEnabled=true

Binary file not shown.

View File

@@ -0,0 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-all.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

View File

@@ -0,0 +1,26 @@
@echo off
setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
set APP_HOME=%DIRNAME%
set APP_BASE_NAME=%~n0
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
set ANDROID_HOME=C:\Users\Middl\AppData\Local\Android\Sdk
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if "%ERRORLEVEL%" == "0" goto execute
goto fail
:fail
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
exit /b 1
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
goto fail
:execute
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end

View File

@@ -0,0 +1,3 @@
rootProject.name = 'YuzuGCA'
include ':app'

View File

@@ -3,40 +3,26 @@
"name": "YuzuGCA", "name": "YuzuGCA",
"slug": "yuzu-gca", "slug": "yuzu-gca",
"version": "0.1.0", "version": "0.1.0",
"runtimeVersion": "0.1.0",
"orientation": "portrait", "orientation": "portrait",
"icon": "./assets/icon.png",
"userInterfaceStyle": "dark", "userInterfaceStyle": "dark",
"newArchEnabled": true, "newArchEnabled": true,
"splash": { "splash": { "backgroundColor": "#0f172a" },
"backgroundColor": "#0f172a" "ios": { "supportsTablet": false, "bundleIdentifier": "dev.yuzu.gca" },
},
"ios": {
"supportsTablet": false,
"bundleIdentifier": "dev.yuzu.gca"
},
"android": { "android": {
"adaptiveIcon": { "adaptiveIcon": { "backgroundColor": "#0f172a" },
"backgroundColor": "#0f172a"
},
"package": "dev.yuzu.gca", "package": "dev.yuzu.gca",
"allowBackup": false, "allowBackup": false,
"permissions": [ "versionCode": 8,
"REQUEST_INSTALL_PACKAGES" "permissions": ["REQUEST_INSTALL_PACKAGES"]
]
}, },
"plugins": [ "plugins": ["expo-updates"],
"expo-updates"
],
"updates": { "updates": {
"enabled": true, "enabled": true,
"checkAutomatically": "ON_LOAD", "checkAutomatically": "ON_LOAD",
"fallbackToCacheTimeout": 3000, "fallbackToCacheTimeout": 3000,
"url": "https://git.childish-ghost.com/LukeMackin/Yuzu-GCA/releases/download/manifest/expo-manifest.json" "url": "https://git.childish-ghost.com/LukeMackin/Yuzu-GCA/raw/branch/main/ota/expo-manifest.json"
}, },
"extra": { "extra": { "eas": { "projectId": "yuzu-gca-android" } }
"eas": {
"projectId": "yuzu-gca-android"
}
}
} }
} }

View File

@@ -0,0 +1,23 @@
{
"cli": {
"version": ">= 14.0.0"
},
"build": {
"preview": {
"android": {
"buildType": "apk",
"env": {
"APP_VERSION": "0.1.0"
}
}
},
"production": {
"android": {
"buildType": "apk"
}
}
},
"submit": {
"production": {}
}
}

View File

@@ -1,13 +0,0 @@
{
"version": "0.1.0",
"builds": {
"android": {
"type": "apk",
"tag": "v0.1.0-dav-android-1",
"url": "https://git.childish-ghost.com/LukeMackin/Yuzu-GCA/releases/download/v0.1.0-dav-android-1/gca-0.1.0.apk",
"sha256": "PLACEHOLDER",
"size": 0,
"minVersion": "0.1.0"
}
}
}

View File

@@ -1,69 +0,0 @@
package dev.yuzu.gca
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.PackageInstaller
import android.content.pm.PackageManager
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
import java.io.File
import java.io.FileInputStream
import java.security.DigestInputStream
import java.security.MessageDigest
class PackageInstallerModule : Module() {
override fun definition() = ModuleDefinition {
Name("PackageInstallerModule")
AsyncFunction("installApk") { filePath: String, expectedSha256: String ->
installApk(filePath, expectedSha256)
}
}
private fun installApk(filePath: String, expectedSha256: String) {
val context: Context = appContext.reactContext ?: return
val apkFile = File(filePath)
if (!apkFile.exists()) throw Exception("安装包不存在")
if (!context.packageManager.canRequestPackageInstalls()) {
throw SecurityException("REQUEST_INSTALL_PACKAGES 权限未授予")
}
val packageInstaller = context.packageManager.packageInstaller
val sessionParams = PackageInstaller.SessionParams(
PackageInstaller.SessionParams.MODE_FULL_INSTALL
)
val sessionId = packageInstaller.createSession(sessionParams)
val session = packageInstaller.openSession(sessionId)
try {
// 边写入边计算 SHA256原子化避免 TOCTOU
val sha256 = MessageDigest.getInstance("SHA-256")
FileInputStream(apkFile).use { input ->
val digestStream = DigestInputStream(input, sha256)
session.openWrite("package", 0, apkFile.length()).use { output ->
digestStream.copyTo(output)
session.fsync(output)
}
}
val actualSha256 = sha256.digest().joinToString("") { "%02x".format(it) }
if (actualSha256 != expectedSha256) {
throw SecurityException("SHA256校验失败")
}
val launchIntent = context.packageManager.getLaunchIntentForPackage(context.packageName)
?: throw Exception("无法获取启动Intent")
val pendingIntent = PendingIntent.getActivity(
context, 0, launchIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
session.commit(pendingIntent.intentSender)
} finally {
session.close()
}
}
}

View File

@@ -1,4 +0,0 @@
{
"platform": "android",
"modules": ["PackageInstallerModule"]
}

35
read_png.py Normal file
View File

@@ -0,0 +1,35 @@
"""
Read screen.png as UTF-16 LE text and save decoded text to file.
"""
import sys
path = r"D:\Yuzu-GCA\screen.png"
out_path = r"D:\Yuzu-GCA\screen_decoded.txt"
# Read raw bytes
with open(path, 'rb') as f:
raw = f.read()
print(f"File size: {len(raw)} bytes")
# Decode as UTF-16 LE (skip BOM)
text = raw.decode('utf-16-le')
print(f"Decoded text length: {len(text)} characters")
# Write full decoded text to file
with open(out_path, 'w', encoding='utf-8') as f:
f.write(text)
print(f"Full decoded text written to: {out_path}")
# Print first 3000 chars (replace non-printable)
safe_text = text[:3000].encode('ascii', errors='replace').decode('ascii')
print("\n=== First 3000 characters (non-ASCII replaced) ===")
print(safe_text)
# Search for specific keywords
keywords = ['manifest', 'download', 'SHA256', 'verify', 'install', 'OTA', 'version', 'error', 'fail', 'success', 'pass', 'update', '下载', '校验', '安装', '版本']
print("\n=== Keyword search ===")
for kw in keywords:
count = text.count(kw)
if count > 0:
print(f"'{kw}' found {count} times")

73
recover_png.py Normal file
View File

@@ -0,0 +1,73 @@
"""
Try to recover the original image from screen.png.
Strategy 1: Decode as UTF-16 LE, then encode back to bytes via UTF-16 LE.
This should give us the raw bytes without the BOM.
Strategy 2: Try to interpret as raw pixel data (e.g. RGBA raw dump).
Strategy 3: Check if the decoded text contains base64 encoded image data.
"""
import sys
import re
path = r"D:\Yuzu-GCA\screen.png"
with open(path, 'rb') as f:
raw = f.read()
# Decode as UTF-16 LE (with BOM automatically handled by Python)
text = raw.decode('utf-16-le')
# Strategy 1: Re-encode as UTF-16 LE to get bytes
reencoded = text.encode('utf-16-le')
print(f"Re-encoded size: {len(reencoded)} bytes")
# Check if it's a valid PNG after removing BOM
# Original raw starts with ff fe, so raw[2:] should equal reencoded
print(f"Original raw[2:] == reencoded: {raw[2:] == reencoded}")
# Now, what if the original binary PNG was processed wrong?
# Let's look at the first few bytes of reencoded
print(f"First 20 bytes of reencoded: {reencoded[:20].hex(' ')}")
print(f"First 20 bytes of raw (no BOM): {raw[2:22].hex(' ')}")
# The reencoded bytes should NOT be a valid PNG (since they start with RXNG etc.)
# But maybe the original PNG bytes were: each char's ord() is two PNG bytes?
# For ASCII chars like 'I', ord('I')=0x49, but 'I' in UTF-16LE is 49 00
# So each char gives us: low_byte = ord(c) & 0xFF, high_byte = (ord(c) >> 8) & 0xFF
# If the original PNG had 49 00 at some point, that would become 'I' in UTF-16LE text
# Let's try: take each char, extract its two bytes
# For chars in BMP: char -> code point -> 2 bytes
bytes_from_chars = bytearray()
for ch in text:
cp = ord(ch)
if cp <= 0xFFFF:
bytes_from_chars.append(cp & 0xFF)
bytes_from_chars.append((cp >> 8) & 0xFF)
else:
# Surrogate pair - skip for now
pass
print(f"\nBytes from chars (first 20): {bytes(bytes_from_chars[:20]).hex(' ')}")
# Check if this is a valid PNG
if bytes_from_chars[:8] == b'\x89PNG\r\n\x1a\n':
print("SUCCESS: This is a valid PNG!")
# Write the recovered PNG
with open(r"D:\Yuzu-GCA\screen_recovered.png", 'wb') as f:
f.write(bytes_from_chars)
print("Recovered PNG written to screen_recovered.png")
else:
print(f"Not a PNG. First 8 bytes: {bytes(bytes_from_chars[:8]).hex(' ')}")
# Check what it starts with
print(f"As text: {bytes(bytes_from_chars[:8])}")
# Strategy 2: Maybe the text contains base64?
# Search for base64 patterns
b64_pattern = r'[A-Za-z0-9+/=]{50,}'
b64_matches = re.findall(b64_pattern, text)
print(f"\nBase64-like strings found: {len(b64_matches)}")
for m in b64_matches[:3]:
print(f" {m[:80]}...")

BIN
screen2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

BIN
screen_decoded.txt Normal file

Binary file not shown.

BIN
screen_fixed.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

BIN
screen_fixed2.png Normal file

Binary file not shown.

BIN
screen_full_text.txt Normal file

Binary file not shown.

35
search_text.py Normal file
View File

@@ -0,0 +1,35 @@
"""
Search for readable ASCII strings in the decoded text from screen.png.
"""
import sys
import re
path = r"D:\Yuzu-GCA\screen.png"
with open(path, 'rb') as f:
raw = f.read()
# Decode as UTF-16 LE
text = raw.decode('utf-16-le')
# Find all printable ASCII sequences of length >= 4
matches = re.findall(r'[ -~]{4,}', text)
print(f"Found {len(matches)} ASCII strings of length >= 4")
print("\n=== First 60 matches ===")
for i, m in enumerate(matches[:60]):
print(f" [{i}] '{m}'")
# Also search for specific patterns
patterns = ['OTA', 'update', 'download', 'install', 'version', 'error', 'fail',
'success', 'manifest', 'SHA256', 'verify', 'check', '完成', '下载',
'安装', '校验', '版本', '测试', '日志']
print("\n=== Pattern search ===")
for p in patterns:
count = text.count(p)
if count > 0:
# Find context around first occurrence
idx = text.find(p)
start = max(0, idx - 30)
end = min(len(text), idx + len(p) + 50)
ctx = text[start:end].encode('ascii', errors='replace').decode('ascii')
print(f" '{p}' found {count} times. First context: ...{ctx}...")

1
tessdata_temp Submodule

Submodule tessdata_temp added at ced78752cc