diff --git a/tools/android/exercise-final.ps1 b/tools/android/exercise-final.ps1 index 95702b1..703e056 100644 --- a/tools/android/exercise-final.ps1 +++ b/tools/android/exercise-final.ps1 @@ -13,12 +13,16 @@ try { Check 'final-and-deliverable-apk-identical' ((Get-FileHash -LiteralPath $standard).Hash-eq$record.apkSha256) $standard $adoption=Get-Content -LiteralPath (Join-Path $projectRoot 'SourceAssets/Audio/Voice/active-adoption.json') -Raw|ConvertFrom-Json Check 'approved44-active-voices' ($adoption.approvedCount-eq44-and$adoption.draftCount-eq0-and$adoption.clips.Count-eq44-and@($adoption.clips.id|Sort-Object -Unique).Count-eq44) 'All active voices approved, no draft' - $packed=@(Get-Content -LiteralPath ($ApkPath+'.packed-assets.txt')) + & pwsh -NoProfile -File (Join-Path $PSScriptRoot 'inspect-voice-payload.ps1') -ApkPath $ApkPath -EvidenceDir (Join-Path $EvidenceDir 'VoicePayload') + $voiceExit=$LASTEXITCODE;$voicePayload=Get-Content -LiteralPath (Join-Path $EvidenceDir 'VoicePayload/apk-voice-payload.json') -Raw|ConvertFrom-Json + Check 'actual-apk44-voice-payloads' ($voiceExit-eq0-and$voicePayload.status-eq'passed'-and$voicePayload.apkSha256-eq$record.apkSha256-and$voicePayload.actualClipCount-eq44-and$voicePayload.checks.Count-eq44-and@($voicePayload.checks|Where-Object passed -ne $true).Count-eq0) 'Exact actual APK PCM bytes for all44, independent of incremental BuildReport' + & (Join-Path $projectRoot 'artifacts/tools/apk-inspector/Scripts/python.exe') -I (Join-Path $PSScriptRoot 'test-voice-payload-negative.py') --apk $ApkPath --project $projectRoot --evidence (Join-Path $EvidenceDir 'VoicePayloadNegative') + Check 'actual-apk-payload-negative-controls' ($LASTEXITCODE-eq0) 'Same inspector accepts actual44; rejects missing serialized clips and altered actual PCM copies' foreach($clip in $adoption.clips){ foreach($entry in @(@{kind='source';path=(Join-Path $projectRoot ('SourceAssets/Audio/Voice/'+$clip.sourceFile));expected=$clip.sourceSha256},@{kind='edited';path=(Join-Path $projectRoot ('SourceAssets/Audio/Voice/'+$clip.editedFile));expected=$clip.editedSha256},@{kind='runtime';path=(Join-Path $projectRoot $clip.runtimeFile);expected=$clip.runtimeSha256})){ Check "voice-$($clip.id)-$($entry.kind)-exact-sha" ((Get-FileHash -LiteralPath $entry.path).Hash-eq$entry.expected) $entry.expected } - Check "voice-$($clip.id)-packed-in-built-apk" ($packed-contains$clip.runtimeFile) 'BuildReport packedAssets contains adopted runtime clip; Unity imported representation, not original WAV byte identity' + Check "voice-$($clip.id)-packed-in-built-apk" (@($voicePayload.checks|Where-Object {$_.id-eq$clip.id-and$_.passed-and$_.runtimeSha256-eq$clip.runtimeSha256}).Count-eq1) 'Deserialized actual APK AudioClip PCM and format exactly match approved runtime WAV' } $zip=[IO.Compression.ZipFile]::OpenRead($ApkPath) try { diff --git a/tools/android/inspect-voice-payload.ps1 b/tools/android/inspect-voice-payload.ps1 new file mode 100644 index 0000000..1772c24 --- /dev/null +++ b/tools/android/inspect-voice-payload.ps1 @@ -0,0 +1,23 @@ +param([Parameter(Mandatory)][string]$ApkPath,[Parameter(Mandatory)][string]$EvidenceDir) +$ErrorActionPreference='Stop' +$projectRoot=Split-Path (Split-Path $PSScriptRoot -Parent) -Parent +$toolRoot=Join-Path $projectRoot 'artifacts/tools/apk-inspector' +$python=Join-Path $toolRoot 'Scripts/python.exe' +New-Item -ItemType Directory -Path $EvidenceDir -Force|Out-Null +$commands=[Collections.Generic.List[object]]::new() +try { + if(!(Test-Path -LiteralPath $python)){ + & python -m venv $toolRoot *> (Join-Path $EvidenceDir 'venv-create.log');$code=$LASTEXITCODE + $commands.Add(@{command='python -m venv '+$toolRoot;exitCode=$code});if($code-ne0){throw 'QA inspector environment creation failed'} + } + $version=& $python -c 'import importlib.metadata; print(importlib.metadata.version("UnityPy"))' 2>$null + if($LASTEXITCODE-ne0-or$version-ne'1.25.4'){ + & $python -m pip install UnityPy==1.25.4 *> (Join-Path $EvidenceDir 'tool-install.log');$code=$LASTEXITCODE + $commands.Add(@{command='isolated QA venv pip install UnityPy==1.25.4';exitCode=$code});if($code-ne0){throw 'Pinned APK voice inspector installation failed'} + } + & $python -m pip freeze | Set-Content -LiteralPath (Join-Path $EvidenceDir 'tool-packages.txt') + & $python -I (Join-Path $PSScriptRoot 'inspect-voice-payload.py') --apk $ApkPath --project $projectRoot --evidence $EvidenceDir *> (Join-Path $EvidenceDir 'inspection.log') + $code=$LASTEXITCODE;$commands.Add(@{command=('inspect-voice-payload.py --apk '+$ApkPath+' --project '+$projectRoot+' --evidence '+$EvidenceDir);exitCode=$code}) + if($code-ne0){throw 'Actual APK voice payload verification failed'} +}finally{@{sourceCommit=(& git rev-parse HEAD);sourceDirty=[bool](& git status --porcelain);commands=$commands.ToArray()}|ConvertTo-Json -Depth 5|Set-Content -LiteralPath (Join-Path $EvidenceDir 'inspection-commands.json')} +exit 0 diff --git a/tools/android/inspect-voice-payload.py b/tools/android/inspect-voice-payload.py new file mode 100644 index 0000000..ff54e9e --- /dev/null +++ b/tools/android/inspect-voice-payload.py @@ -0,0 +1,95 @@ +"""Read actual APK AudioClip payloads; never modify the APK or project assets.""" +import argparse +import hashlib +import importlib.metadata +import io +import json +from pathlib import Path, PurePosixPath +import re +import wave +import uuid +import zipfile + +import UnityPy + + +def sha(data): + return hashlib.sha256(data).hexdigest() + + +def require(condition, message): + if not condition: + raise ValueError(message) + + +def pcm(wav_bytes): + with wave.open(io.BytesIO(wav_bytes)) as wav: + return ((wav.getnchannels(), wav.getsampwidth(), wav.getframerate(), wav.getnframes()), + wav.readframes(wav.getnframes())) + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument('--apk', required=True) + parser.add_argument('--project', required=True) + parser.add_argument('--evidence', required=True) + args = parser.parse_args() + project, apk, evidence = Path(args.project), Path(args.apk), Path(args.evidence) + evidence.mkdir(parents=True, exist_ok=True) + version = importlib.metadata.version('UnityPy') + require(version == '1.25.4', f'Expected pinned QA inspector1.25.4, got {version}') + adoption = json.loads((project / 'SourceAssets/Audio/Voice/active-adoption.json').read_text(encoding='utf-8-sig')) + require(len(adoption['clips']) == 44 and len({c['id'] for c in adoption['clips']}) == 44 + and adoption['approvedCount'] == 44 and adoption['draftCount'] == 0, '44 unique approved adoption IDs required') + adopted = {c['id']: c for c in adoption['clips']} + extracted = evidence / ('apk-serialized-data-' + uuid.uuid4().hex) + extracted.mkdir() + record = {'apkSha256': sha(apk.read_bytes()), 'tool': 'UnityPy', 'toolVersion': version, + 'method': 'Join actual APK split assets; deserialize AudioClips; decode PCM16; exact PCM SHA and format comparison to adopted WAV', + 'status': 'failed', 'checks': [], 'extractedEntries': []} + try: + with zipfile.ZipFile(apk) as archive: + entries = [n for n in archive.namelist() if n.startswith('assets/bin/Data/') + and not n.endswith('/') and '/Managed/' not in n] + names = sorted({re.sub(r'\.split\d+$', '', n) for n in entries}) + for name in names: + parts = sorted([n for n in entries if re.sub(r'\.split\d+$', '', n) == name], + key=lambda n: int(n.rsplit('.split', 1)[1]) if '.split' in n else -1) + if '.split' in parts[0]: + require([int(n.rsplit('.split', 1)[1]) for n in parts] == list(range(len(parts))), 'Contiguous split asset parts required') + data = b''.join(archive.read(n) for n in parts) + path = extracted / PurePosixPath(name).name + path.write_bytes(data) + record['extractedEntries'].append({'parts': parts, 'joinedFile': str(path), 'sha256': sha(data)}) + clips = [obj.read() for obj in UnityPy.load(str(extracted)).objects if obj.type.name == 'AudioClip'] + record['actualClipCount'] = len(clips) + require(len(clips) == 44 and len({clip.m_Name for clip in clips}) == 44, 'Actual APK must contain44 unique AudioClips') + require({clip.m_Name for clip in clips} == set(adopted), 'Actual APK clip IDs must exactly equal adoption IDs') + for clip in sorted(clips, key=lambda c: c.m_Name): + adoption_clip = adopted[clip.m_Name] + runtime = (project / adoption_clip['runtimeFile']).read_bytes() + require(sha(runtime) == adoption_clip['runtimeSha256'].lower(), 'Adopted runtime SHA mismatch: ' + clip.m_Name) + samples = clip.samples + require(len(samples) == 1, 'Exactly one PCM payload required: ' + clip.m_Name) + packed_wave = next(iter(samples.values())) + packed_format, packed_pcm = pcm(packed_wave) + runtime_format, runtime_pcm = pcm(runtime) + passed = (packed_format == runtime_format and packed_format[:3] == (1, 2, 44100) + and sha(packed_pcm) == sha(runtime_pcm) and clip.m_CompressionFormat == 0) + record['checks'].append({'id': clip.m_Name, 'passed': passed, + 'runtimeSha256': sha(runtime), 'apkPcmSha256': sha(packed_pcm), + 'runtimePcmSha256': sha(runtime_pcm), 'format': packed_format, + 'resource': clip.m_Resource.m_Source, 'offset': clip.m_Resource.m_Offset, + 'resourceSize': clip.m_Resource.m_Size}) + require(passed, f'Actual packed voice PCM mismatch: {clip.m_Name}') + record['status'] = 'passed' + except Exception as exc: + record['error'] = str(exc) + raise + finally: + (evidence / 'apk-voice-payload.json').write_text(json.dumps(record, ensure_ascii=False, indent=2), encoding='utf-8') + print(f'Actual APK voice PCM exact matches: {len(record["checks"])}/44') + + +if __name__ == '__main__': + main() diff --git a/tools/android/test-voice-payload-negative.py b/tools/android/test-voice-payload-negative.py new file mode 100644 index 0000000..58a9256 --- /dev/null +++ b/tools/android/test-voice-payload-negative.py @@ -0,0 +1,57 @@ +"""Actual inspector rejects missing serialized clips and changed PCM in QA-only APK copies.""" +import argparse +import hashlib +import json +from pathlib import Path +import subprocess +import sys +import zipfile + +parser = argparse.ArgumentParser() +parser.add_argument('--apk', required=True) +parser.add_argument('--project', required=True) +parser.add_argument('--evidence', required=True) +args = parser.parse_args() +apk, evidence = Path(args.apk), Path(args.evidence) +evidence.mkdir(parents=True, exist_ok=True) +inspector = Path(__file__).with_name('inspect-voice-payload.py') +checks = [] +for kind in ('positive', 'missing-clips', 'altered-pcm'): + case = evidence / kind + case.mkdir(exist_ok=True) + target = apk if kind == 'positive' else case / 'qa-mutated.apk' + mutation = None + if kind != 'positive': + with zipfile.ZipFile(apk) as source, zipfile.ZipFile(target, 'w') as output: + for entry in source.infolist(): + if not entry.filename.startswith('assets/bin/Data/'): + continue + if kind == 'missing-clips' and entry.filename.startswith('assets/bin/Data/sharedassets0.assets'): + continue + data = source.read(entry) + if kind == 'altered-pcm' and entry.filename in ('assets/bin/Data/sharedassets0.resource.split0', 'assets/bin/Data/sharedassets0.resource'): + data = bytearray(data) + offset = 194000 # Inside first known PCM16 AudioClip resource, outside its FSB header. + data[offset] ^= 32 + data = bytes(data) + mutation = {'entry': entry.filename, 'offset': offset, 'xor': 32} + output.writestr(entry, data) + if kind == 'altered-pcm' and mutation is None: + raise RuntimeError('Expected actual PCM split resource is absent') + command = [sys.executable, '-I', str(inspector), '--apk', str(target), '--project', args.project, '--evidence', str(case)] + process = subprocess.run(command, capture_output=True, text=True) + (case / 'stdout.log').write_text(process.stdout, encoding='utf-8') + (case / 'stderr.log').write_text(process.stderr, encoding='utf-8') + record = json.loads((case / 'apk-voice-payload.json').read_text(encoding='utf-8')) + expected = kind == 'positive' + passed = process.returncode == (0 if expected else 1) and (record['status'] == 'passed') == expected + if kind == 'missing-clips': + passed = passed and record.get('actualClipCount') == 0 + if kind == 'altered-pcm': + passed = passed and any(not check['passed'] for check in record['checks']) and 'PCM mismatch' in record.get('error', '') + checks.append({'case': kind, 'command': command, 'exitCode': process.returncode, 'passed': passed, + 'mutation': mutation, 'inspectorRecord': record}) +result = {'originalApkSha256': hashlib.sha256(apk.read_bytes()).hexdigest(), 'scope': 'QA synthetic negative copies only; never installed', + 'checks': checks, 'passed': all(check['passed'] for check in checks)} +(evidence / 'regression.json').write_text(json.dumps(result, indent=2), encoding='utf-8') +sys.exit(0 if result['passed'] else 1)