feat: verify first Android puzzle and preserve short touches
This commit is contained in:
@@ -8,6 +8,7 @@ UserSettings
|
|||||||
Build
|
Build
|
||||||
Builds
|
Builds
|
||||||
artifacts/
|
artifacts/
|
||||||
|
.utmp
|
||||||
.vs/
|
.vs/
|
||||||
*.csproj
|
*.csproj
|
||||||
*.sln
|
*.sln
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Xml;
|
||||||
|
using UnityEditor;
|
||||||
|
using UnityEditor.Android;
|
||||||
|
using UnityEditor.Build;
|
||||||
|
using UnityEditor.Build.Reporting;
|
||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
namespace MagnetismHeroes.Editor
|
||||||
|
{
|
||||||
|
public sealed class AndroidBuildSanitizer : IPreprocessBuildWithReport, IPostGenerateGradleAndroidProject
|
||||||
|
{
|
||||||
|
public int callbackOrder => int.MaxValue;
|
||||||
|
|
||||||
|
public void OnPreprocessBuild(BuildReport report)
|
||||||
|
{
|
||||||
|
if (report.summary.platform != BuildTarget.Android || (report.summary.options & BuildOptions.IncludeTestAssemblies) != 0) return;
|
||||||
|
foreach (string path in new[] { "Assets/Resources/PerformanceTestRunInfo.json", "Assets/Resources/PerformanceTestRunSettings.json" })
|
||||||
|
if (File.Exists(path)) AssetDatabase.DeleteAsset(path);
|
||||||
|
if (Directory.Exists("Assets/Resources") && !Directory.EnumerateFileSystemEntries("Assets/Resources").Any())
|
||||||
|
AssetDatabase.DeleteAsset("Assets/Resources");
|
||||||
|
Debug.Log("[AndroidBuild] Generated performance-test metadata excluded from release assets.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OnPostGenerateGradleAndroidProject(string path)
|
||||||
|
{
|
||||||
|
string manifestPath = Path.Combine(path, "src/main/AndroidManifest.xml");
|
||||||
|
var manifest = new XmlDocument(); manifest.Load(manifestPath);
|
||||||
|
var namespaces = new XmlNamespaceManager(manifest.NameTable);
|
||||||
|
namespaces.AddNamespace("android", "http://schemas.android.com/apk/res/android");
|
||||||
|
foreach (XmlNode permission in manifest.SelectNodes("/manifest/uses-permission[@android:name='android.permission.INTERNET']", namespaces))
|
||||||
|
permission.ParentNode.RemoveChild(permission);
|
||||||
|
manifest.Save(manifestPath);
|
||||||
|
Debug.Log("[AndroidBuild] Offline manifest: generated INTERNET permission removed.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 5c950257c624bf444a68a989a14c7118
|
||||||
@@ -67,6 +67,17 @@ namespace MagnetismHeroes.Editor
|
|||||||
Debug.Log("[Integration] Main connected to GameApp.");
|
Debug.Log("[Integration] Main connected to GameApp.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static void ConfigureAndroid()
|
||||||
|
{
|
||||||
|
PlayerSettings.SetScriptingBackend(UnityEditor.Build.NamedBuildTarget.Android, ScriptingImplementation.IL2CPP);
|
||||||
|
PlayerSettings.Android.targetArchitectures = AndroidArchitecture.ARM64;
|
||||||
|
PlayerSettings.Android.buildApkPerCpuArchitecture = false;
|
||||||
|
PlayerSettings.defaultInterfaceOrientation = UIOrientation.LandscapeLeft;
|
||||||
|
PlayerSettings.Android.forceInternetPermission = false;
|
||||||
|
AssetDatabase.SaveAssets();
|
||||||
|
Debug.Log("[AndroidConfiguration] ARM64 IL2CPP, landscape, no forced Internet permission.");
|
||||||
|
}
|
||||||
|
|
||||||
public static void BuildAndroid()
|
public static void BuildAndroid()
|
||||||
{
|
{
|
||||||
string apkPath = Environment.GetEnvironmentVariable("MAGNETISM_APK_PATH");
|
string apkPath = Environment.GetEnvironmentVariable("MAGNETISM_APK_PATH");
|
||||||
@@ -79,6 +90,11 @@ namespace MagnetismHeroes.Editor
|
|||||||
});
|
});
|
||||||
Debug.Log($"[AndroidBuild] {report.summary.result}: {report.summary.totalSize} bytes, {report.summary.totalErrors} errors.");
|
Debug.Log($"[AndroidBuild] {report.summary.result}: {report.summary.totalSize} bytes, {report.summary.totalErrors} errors.");
|
||||||
if (report.summary.result != BuildResult.Succeeded) throw new InvalidOperationException("Android build did not succeed.");
|
if (report.summary.result != BuildResult.Succeeded) throw new InvalidOperationException("Android build did not succeed.");
|
||||||
|
var packedPaths = report.packedAssets.SelectMany(asset => asset.contents)
|
||||||
|
.Select(asset => asset.sourceAssetPath).Distinct().OrderBy(path => path).ToArray();
|
||||||
|
File.WriteAllLines(apkPath + ".packed-assets.txt", packedPaths);
|
||||||
|
if (packedPaths.Any(path => path.Contains("PerformanceTestRun")))
|
||||||
|
throw new InvalidOperationException("Generated performance-test metadata entered the release APK.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,6 +60,79 @@ namespace MagnetismHeroes.Tests
|
|||||||
}
|
}
|
||||||
private void Drag(Vector2 start, Vector2 end) { _app.Input.Begin(start, false); _app.Input.Move(end); _app.Input.End(end); }
|
private void Drag(Vector2 start, Vector2 end) { _app.Input.Begin(start, false); _app.Input.Move(end); _app.Input.End(end); }
|
||||||
private void Tap(Vector2 point) { _app.Input.Begin(point, false); _app.Input.End(point); }
|
private void Tap(Vector2 point) { _app.Input.Begin(point, false); _app.Input.End(point); }
|
||||||
|
private void QueueTouch(int id, UnityEngine.InputSystem.TouchPhase phase, Vector2 position) =>
|
||||||
|
InputSystem.QueueStateEvent(_touchscreen, new TouchState { touchId = id, phase = phase, position = position });
|
||||||
|
|
||||||
|
[UnityTest] public IEnumerator SameUpdateShortTapRespondsBeforeCollectionAndCountsOnceAfterCollection()
|
||||||
|
{
|
||||||
|
_touchscreen = InputSystem.AddDevice<Touchscreen>(); _app.StartStage(1); yield return null; Physics.SyncTransforms();
|
||||||
|
var point = BallPoint(); QueueTouch(101, UnityEngine.InputSystem.TouchPhase.Began, point); QueueTouch(101, UnityEngine.InputSystem.TouchPhase.Ended, point);
|
||||||
|
yield return null; yield return null;
|
||||||
|
Assert.That(_app.Instruction, Does.Contain("먼저")); Assert.That(_app.Session.Board.CollectedBallIds, Is.Empty);
|
||||||
|
Drag(point, point + new Vector2(70, 20)); yield return null; Physics.SyncTransforms(); point = BallPoint();
|
||||||
|
QueueTouch(102, UnityEngine.InputSystem.TouchPhase.Began, point); QueueTouch(102, UnityEngine.InputSystem.TouchPhase.Ended, point);
|
||||||
|
yield return null; yield return null;
|
||||||
|
Assert.That(_app.Session.Screen, Is.EqualTo(SessionScreen.FreePlay)); Assert.That(_store.Saved.completedStageIds, Is.EqualTo(new[] { 1 }));
|
||||||
|
Assert.That(_app.Voice.PendingCount, Is.EqualTo(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
[UnityTest] public IEnumerator SameUpdateFastDragMovesWithoutCountingAnAlreadyCollectedFriend()
|
||||||
|
{
|
||||||
|
_touchscreen = InputSystem.AddDevice<Touchscreen>(); _app.StartStage(1); yield return null; Physics.SyncTransforms();
|
||||||
|
var point = BallPoint(); Drag(point, point + new Vector2(50, 15)); yield return null; Physics.SyncTransforms(); point = BallPoint();
|
||||||
|
var original = _app.Session.Board.Balls.Single().position;
|
||||||
|
QueueTouch(111, UnityEngine.InputSystem.TouchPhase.Began, point); QueueTouch(111, UnityEngine.InputSystem.TouchPhase.Moved, point + new Vector2(40, 15));
|
||||||
|
QueueTouch(111, UnityEngine.InputSystem.TouchPhase.Ended, point + new Vector2(40, 15)); yield return null; yield return null;
|
||||||
|
Assert.That(_app.Session.Screen, Is.EqualTo(SessionScreen.Puzzle)); Assert.That(_app.Session.Board.CountedCount, Is.Zero);
|
||||||
|
Assert.That(_app.Session.Board.Balls.Single().position, Is.Not.EqualTo(original));
|
||||||
|
}
|
||||||
|
|
||||||
|
[UnityTest] public IEnumerator SameUpdateCanceledAndOutsideDragsPreserveCommittedState()
|
||||||
|
{
|
||||||
|
_touchscreen = InputSystem.AddDevice<Touchscreen>(); _app.StartStage(1); yield return null; Physics.SyncTransforms(); _app.Session.SaveCurrent();
|
||||||
|
var point = BallPoint(); var original = _app.Session.Board.Balls.Single().position; var saved = JsonUtility.ToJson(_store.Saved);
|
||||||
|
foreach (var phase in new[] { UnityEngine.InputSystem.TouchPhase.Canceled, UnityEngine.InputSystem.TouchPhase.Ended })
|
||||||
|
{
|
||||||
|
QueueTouch(121, UnityEngine.InputSystem.TouchPhase.Began, point); QueueTouch(121, UnityEngine.InputSystem.TouchPhase.Moved, point + new Vector2(50, 10));
|
||||||
|
QueueTouch(121, phase, phase == UnityEngine.InputSystem.TouchPhase.Ended ? new Vector2(-1, point.y) : point + new Vector2(50, 10));
|
||||||
|
yield return null; yield return null;
|
||||||
|
Assert.That(_app.Session.Board.Balls.Single().position, Is.EqualTo(original)); Assert.That(_app.Session.Board.CollectedBallIds, Is.Empty);
|
||||||
|
Assert.That(JsonUtility.ToJson(_store.Saved), Is.EqualTo(saved));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[UnityTest] public IEnumerator SameUpdateTouchBeginningOnUiNeverCountsTheFriendAtRelease()
|
||||||
|
{
|
||||||
|
_touchscreen = InputSystem.AddDevice<Touchscreen>(); _app.StartStage(1); yield return null; Physics.SyncTransforms();
|
||||||
|
var point = BallPoint(); Drag(point, point + new Vector2(50, 15)); yield return null; Physics.SyncTransforms(); point = BallPoint();
|
||||||
|
var button = _app.transform.Find("Interface/SafeArea/Play/HomeButton").GetComponent<RectTransform>();
|
||||||
|
Vector2 uiPoint = button.TransformPoint(button.rect.center);
|
||||||
|
QueueTouch(131, UnityEngine.InputSystem.TouchPhase.Began, uiPoint); QueueTouch(131, UnityEngine.InputSystem.TouchPhase.Ended, point);
|
||||||
|
yield return null; yield return null;
|
||||||
|
Assert.That(_app.Session.Screen, Is.EqualTo(SessionScreen.Puzzle)); Assert.That(_app.Session.Board.CountedCount, Is.Zero);
|
||||||
|
}
|
||||||
|
|
||||||
|
[UnityTest] public IEnumerator SameUpdateSequentialShortTapsCompleteOnlyOnce()
|
||||||
|
{
|
||||||
|
_touchscreen = InputSystem.AddDevice<Touchscreen>(); _app.StartStage(1); yield return null; Physics.SyncTransforms();
|
||||||
|
var point = BallPoint(); Drag(point, point + new Vector2(50, 15)); yield return null; Physics.SyncTransforms(); point = BallPoint();
|
||||||
|
QueueTouch(141, UnityEngine.InputSystem.TouchPhase.Began, point); QueueTouch(141, UnityEngine.InputSystem.TouchPhase.Ended, point);
|
||||||
|
QueueTouch(142, UnityEngine.InputSystem.TouchPhase.Began, point); QueueTouch(142, UnityEngine.InputSystem.TouchPhase.Ended, point);
|
||||||
|
yield return null; yield return null;
|
||||||
|
Assert.That(_app.Session.Screen, Is.EqualTo(SessionScreen.FreePlay)); Assert.That(_store.Saved.completedStageIds, Is.EqualTo(new[] { 1 }));
|
||||||
|
Assert.That(_app.Voice.PendingCount, Is.EqualTo(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
[UnityTest] public IEnumerator SameUpdateConcurrentShortPinchNeverCountsTheFriend()
|
||||||
|
{
|
||||||
|
_touchscreen = InputSystem.AddDevice<Touchscreen>(); _app.StartStage(1); yield return null; Physics.SyncTransforms();
|
||||||
|
var point = BallPoint(); Drag(point, point + new Vector2(50, 15)); yield return null; Physics.SyncTransforms(); point = BallPoint();
|
||||||
|
QueueTouch(151, UnityEngine.InputSystem.TouchPhase.Began, point); QueueTouch(152, UnityEngine.InputSystem.TouchPhase.Began, point + new Vector2(80, 0));
|
||||||
|
QueueTouch(152, UnityEngine.InputSystem.TouchPhase.Moved, point + new Vector2(160, 0)); QueueTouch(151, UnityEngine.InputSystem.TouchPhase.Ended, point);
|
||||||
|
QueueTouch(152, UnityEngine.InputSystem.TouchPhase.Ended, point + new Vector2(160, 0)); yield return null; yield return null;
|
||||||
|
Assert.That(_app.Session.Screen, Is.EqualTo(SessionScreen.Puzzle)); Assert.That(_app.Session.Board.CountedCount, Is.Zero);
|
||||||
|
Assert.That(_app.Session.Camera.distance, Is.LessThan(8));
|
||||||
|
}
|
||||||
private void DragTo(Vector3 destination)
|
private void DragTo(Vector3 destination)
|
||||||
{
|
{
|
||||||
Physics.SyncTransforms(); var start = BallPoint();
|
Physics.SyncTransforms(); var start = BallPoint();
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ using MagnetismHeroes.UnityRuntime.Presentation;
|
|||||||
using UnityEngine;
|
using UnityEngine;
|
||||||
using UnityEngine.EventSystems;
|
using UnityEngine.EventSystems;
|
||||||
using UnityEngine.InputSystem;
|
using UnityEngine.InputSystem;
|
||||||
|
using UnityEngine.InputSystem.EnhancedTouch;
|
||||||
|
|
||||||
namespace MagnetismHeroes.UnityRuntime.Interaction
|
namespace MagnetismHeroes.UnityRuntime.Interaction
|
||||||
{
|
{
|
||||||
@@ -25,6 +26,17 @@ namespace MagnetismHeroes.UnityRuntime.Interaction
|
|||||||
private int _fingerId = -1;
|
private int _fingerId = -1;
|
||||||
private bool _waitForAllTouchesReleased;
|
private bool _waitForAllTouchesReleased;
|
||||||
private int _pinchFirstId = -1, _pinchSecondId = -1;
|
private int _pinchFirstId = -1, _pinchSecondId = -1;
|
||||||
|
private readonly struct TouchContact
|
||||||
|
{
|
||||||
|
public readonly int Id;
|
||||||
|
public readonly UnityEngine.InputSystem.TouchPhase Phase;
|
||||||
|
public readonly Vector2 Position;
|
||||||
|
public TouchContact(int id, UnityEngine.InputSystem.TouchPhase phase, Vector2 position)
|
||||||
|
{ Id = id; Phase = phase; Position = position; }
|
||||||
|
public bool InProgress => Phase == UnityEngine.InputSystem.TouchPhase.Began || Phase == UnityEngine.InputSystem.TouchPhase.Moved || Phase == UnityEngine.InputSystem.TouchPhase.Stationary;
|
||||||
|
}
|
||||||
|
private readonly Queue<TouchContact> _touchEvents = new Queue<TouchContact>();
|
||||||
|
private readonly Dictionary<int, TouchContact> _touches = new Dictionary<int, TouchContact>();
|
||||||
public bool AcceptInput => _app.Session != null && _app.Session.Board != null;
|
public bool AcceptInput => _app.Session != null && _app.Session.Board != null;
|
||||||
|
|
||||||
public void Begin(Vector2 point, bool uiBlocked)
|
public void Begin(Vector2 point, bool uiBlocked)
|
||||||
@@ -102,77 +114,99 @@ namespace MagnetismHeroes.UnityRuntime.Interaction
|
|||||||
|
|
||||||
private static bool IsInside(Vector2 point) => point.x >= 0 && point.x < Screen.width && point.y >= 0 && point.y < Screen.height;
|
private static bool IsInside(Vector2 point) => point.x >= 0 && point.x < Screen.width && point.y >= 0 && point.y < Screen.height;
|
||||||
|
|
||||||
private static UnityEngine.InputSystem.Controls.TouchControl FindTouch(Touchscreen screen, int id)
|
private TouchContact? FindTouch(int id) => _touches.TryGetValue(id, out var touch) ? touch : (TouchContact?)null;
|
||||||
|
|
||||||
|
private void FinishSingleTouch()
|
||||||
{
|
{
|
||||||
foreach (var touch in screen.touches) if (touch.touchId.ReadValue() == id) return touch;
|
var touch = FindTouch(_fingerId);
|
||||||
return null;
|
if (!touch.HasValue || touch.Value.Phase != UnityEngine.InputSystem.TouchPhase.Ended) { Cancel(); return; }
|
||||||
|
var point = touch.Value.Position; Move(point); End(point);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void FinishSingleTouch(Touchscreen screen)
|
private void RecordTouch(Finger finger)
|
||||||
{
|
{
|
||||||
var touch = FindTouch(screen, _fingerId);
|
if (!AcceptInput || finger.screen != Touchscreen.current) return;
|
||||||
if (touch == null || touch.phase.ReadValue() != UnityEngine.InputSystem.TouchPhase.Ended) { Cancel(); return; }
|
// Copy each official history callback before later events overwrite the native touch slot.
|
||||||
var point = touch.position.ReadValue(); Move(point); End(point);
|
var touch = finger.lastTouch;
|
||||||
|
_touchEvents.Enqueue(new TouchContact(touch.touchId, touch.phase, touch.screenPosition));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ProcessTouches()
|
||||||
|
{
|
||||||
|
var active = new List<TouchContact>();
|
||||||
|
foreach (var touch in _touches.Values) if (touch.InProgress) active.Add(touch);
|
||||||
|
if (_fingerId != -1 && !active.Exists(touch => touch.Id == _fingerId))
|
||||||
|
{
|
||||||
|
// Finish the original pointer before any new pointer can inherit its preview.
|
||||||
|
FinishSingleTouch(); _waitForAllTouchesReleased = active.Count > 0; return;
|
||||||
|
}
|
||||||
|
if (_gesture == Gesture.Pinch)
|
||||||
|
{
|
||||||
|
Vector2 release = _last; bool ended = false, canceled = false;
|
||||||
|
foreach (int id in new[] { _pinchFirstId, _pinchSecondId })
|
||||||
|
{
|
||||||
|
var touch = FindTouch(id);
|
||||||
|
if (!touch.HasValue) { canceled = true; continue; }
|
||||||
|
var phase = touch.Value.Phase;
|
||||||
|
if (phase == UnityEngine.InputSystem.TouchPhase.Canceled) canceled = true;
|
||||||
|
if (phase == UnityEngine.InputSystem.TouchPhase.Ended)
|
||||||
|
{ release = touch.Value.Position; ended = true; canceled |= !IsInside(release); }
|
||||||
|
}
|
||||||
|
if (canceled || ended)
|
||||||
|
{ End(release, canceled); _waitForAllTouchesReleased = active.Count > 0; return; }
|
||||||
|
}
|
||||||
|
// A canceled/finished pinch cannot restart when a third finger joins a remaining finger.
|
||||||
|
if (_waitForAllTouchesReleased && _gesture == Gesture.None && active.Count > 0) return;
|
||||||
|
if (active.Count >= 2)
|
||||||
|
{
|
||||||
|
var a = active[0].Position; var b = active[1].Position;
|
||||||
|
if (_gesture != Gesture.Pinch && _gesture != Gesture.Blocked)
|
||||||
|
{
|
||||||
|
BeginPinch(Vector2.Distance(a, b), IsUI(a) || IsUI(b));
|
||||||
|
_pinchFirstId = active[0].Id; _pinchSecondId = active[1].Id;
|
||||||
|
}
|
||||||
|
else Pinch(Vector2.Distance(a, b));
|
||||||
|
_waitForAllTouchesReleased = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (_waitForAllTouchesReleased)
|
||||||
|
{
|
||||||
|
if (active.Count == 0) { _waitForAllTouchesReleased = false; _gesture = Gesture.None; _fingerId = -1; }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (_gesture == Gesture.Pinch || (active.Count == 0 && _gesture == Gesture.Blocked))
|
||||||
|
{ End(_last); _fingerId = -1; return; }
|
||||||
|
if (active.Count == 1)
|
||||||
|
{
|
||||||
|
var touch = active[0]; var point = touch.Position;
|
||||||
|
if (_fingerId == -1)
|
||||||
|
{
|
||||||
|
if (touch.Phase != UnityEngine.InputSystem.TouchPhase.Began) { _waitForAllTouchesReleased = true; return; }
|
||||||
|
Begin(point, IsUI(point)); _fingerId = touch.Id;
|
||||||
|
}
|
||||||
|
else Move(point);
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void Update()
|
private void Update()
|
||||||
{
|
{
|
||||||
if (!AcceptInput) return;
|
if (!AcceptInput) { _touchEvents.Clear(); _touches.Clear(); return; }
|
||||||
var touchscreen = Touchscreen.current;
|
bool hadTouches = _touchEvents.Count > 0, suppressRestOfFrame = false;
|
||||||
if (touchscreen != null)
|
while (_touchEvents.Count > 0)
|
||||||
{
|
{
|
||||||
var active = new List<UnityEngine.InputSystem.Controls.TouchControl>();
|
var touch = _touchEvents.Dequeue(); _touches[touch.Id] = touch;
|
||||||
foreach (var touch in touchscreen.touches) if (touch.press.isPressed) active.Add(touch);
|
if (!suppressRestOfFrame)
|
||||||
if (_fingerId != -1 && !active.Exists(touch => touch.touchId.ReadValue() == _fingerId))
|
|
||||||
{
|
{
|
||||||
// Finish the original pointer before any new pointer can inherit its preview.
|
bool ownsGesture = touch.Id == _fingerId || (_gesture == Gesture.Pinch && (touch.Id == _pinchFirstId || touch.Id == _pinchSecondId));
|
||||||
FinishSingleTouch(touchscreen); _waitForAllTouchesReleased = active.Count > 0; return;
|
bool canceled = touch.Phase == UnityEngine.InputSystem.TouchPhase.Canceled ||
|
||||||
}
|
(touch.Phase == UnityEngine.InputSystem.TouchPhase.Ended && !IsInside(touch.Position));
|
||||||
if (_gesture == Gesture.Pinch)
|
ProcessTouches(); suppressRestOfFrame = ownsGesture && canceled;
|
||||||
{
|
|
||||||
Vector2 release = _last; bool ended = false, canceled = false;
|
|
||||||
foreach (int id in new[] { _pinchFirstId, _pinchSecondId })
|
|
||||||
{
|
|
||||||
var touch = FindTouch(touchscreen, id);
|
|
||||||
if (touch == null) { canceled = true; continue; }
|
|
||||||
var phase = touch.phase.ReadValue();
|
|
||||||
if (phase == UnityEngine.InputSystem.TouchPhase.Canceled) canceled = true;
|
|
||||||
if (phase == UnityEngine.InputSystem.TouchPhase.Ended)
|
|
||||||
{ release = touch.position.ReadValue(); ended = true; canceled |= !IsInside(release); }
|
|
||||||
}
|
|
||||||
if (canceled || ended)
|
|
||||||
{ End(release, canceled); _waitForAllTouchesReleased = active.Count > 0; return; }
|
|
||||||
}
|
|
||||||
// A canceled/finished pinch cannot restart when a third finger joins a remaining finger.
|
|
||||||
if (_waitForAllTouchesReleased && _gesture == Gesture.None && active.Count > 0) return;
|
|
||||||
if (active.Count >= 2)
|
|
||||||
{
|
|
||||||
var a = active[0].position.ReadValue(); var b = active[1].position.ReadValue();
|
|
||||||
if (_gesture != Gesture.Pinch && _gesture != Gesture.Blocked)
|
|
||||||
{
|
|
||||||
BeginPinch(Vector2.Distance(a, b), IsUI(a) || IsUI(b));
|
|
||||||
_pinchFirstId = active[0].touchId.ReadValue(); _pinchSecondId = active[1].touchId.ReadValue();
|
|
||||||
}
|
|
||||||
else Pinch(Vector2.Distance(a, b));
|
|
||||||
_waitForAllTouchesReleased = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (_waitForAllTouchesReleased)
|
|
||||||
{
|
|
||||||
if (active.Count == 0) { _waitForAllTouchesReleased = false; _gesture = Gesture.None; _fingerId = -1; }
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (_gesture == Gesture.Pinch || (active.Count == 0 && _gesture == Gesture.Blocked))
|
|
||||||
{ End(_last); _fingerId = -1; return; }
|
|
||||||
if (active.Count == 1)
|
|
||||||
{
|
|
||||||
var touch = active[0]; var point = touch.position.ReadValue();
|
|
||||||
if (_fingerId == -1)
|
|
||||||
{ Begin(point, IsUI(point)); _fingerId = touch.touchId.ReadValue(); }
|
|
||||||
else Move(point);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
if (!touch.InProgress) _touches.Remove(touch.Id);
|
||||||
}
|
}
|
||||||
|
if (suppressRestOfFrame) _waitForAllTouchesReleased = _touches.Count > 0;
|
||||||
|
if (hadTouches || _touches.Count > 0) return;
|
||||||
var mouse = Mouse.current;
|
var mouse = Mouse.current;
|
||||||
if (mouse == null) return;
|
if (mouse == null) return;
|
||||||
var position = mouse.position.ReadValue();
|
var position = mouse.position.ReadValue();
|
||||||
@@ -182,7 +216,20 @@ namespace MagnetismHeroes.UnityRuntime.Interaction
|
|||||||
if (mouse.scroll.ReadValue().y != 0 && !IsUI(position))
|
if (mouse.scroll.ReadValue().y != 0 && !IsUI(position))
|
||||||
{ _orbit.Zoom(Mathf.Exp(mouse.scroll.ReadValue().y * 0.001f)); _app.Session.CommitCamera(_orbit.Snapshot()); }
|
{ _orbit.Zoom(Mathf.Exp(mouse.scroll.ReadValue().y * 0.001f)); _app.Session.CommitCamera(_orbit.Snapshot()); }
|
||||||
}
|
}
|
||||||
private void OnApplicationPause(bool paused) { if (paused) Cancel(); }
|
private void OnEnable()
|
||||||
private void OnDisable() => Cancel();
|
{
|
||||||
|
EnhancedTouchSupport.Enable();
|
||||||
|
UnityEngine.InputSystem.EnhancedTouch.Touch.onFingerDown += RecordTouch;
|
||||||
|
UnityEngine.InputSystem.EnhancedTouch.Touch.onFingerMove += RecordTouch;
|
||||||
|
UnityEngine.InputSystem.EnhancedTouch.Touch.onFingerUp += RecordTouch;
|
||||||
|
}
|
||||||
|
private void OnApplicationPause(bool paused) { if (paused) { Cancel(); _touchEvents.Clear(); _touches.Clear(); } }
|
||||||
|
private void OnDisable()
|
||||||
|
{
|
||||||
|
UnityEngine.InputSystem.EnhancedTouch.Touch.onFingerDown -= RecordTouch;
|
||||||
|
UnityEngine.InputSystem.EnhancedTouch.Touch.onFingerMove -= RecordTouch;
|
||||||
|
UnityEngine.InputSystem.EnhancedTouch.Touch.onFingerUp -= RecordTouch;
|
||||||
|
EnhancedTouchSupport.Disable(); Cancel(); _touchEvents.Clear(); _touches.Clear();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -379,6 +379,9 @@ MonoBehaviour:
|
|||||||
highQualityFiltering:
|
highQualityFiltering:
|
||||||
m_OverrideState: 1
|
m_OverrideState: 1
|
||||||
m_Value: 0
|
m_Value: 0
|
||||||
|
filter:
|
||||||
|
m_OverrideState: 1
|
||||||
|
m_Value: 0
|
||||||
downscale:
|
downscale:
|
||||||
m_OverrideState: 1
|
m_OverrideState: 1
|
||||||
m_Value: 0
|
m_Value: 0
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ MonoBehaviour:
|
|||||||
m_SupportScreenSpaceLensFlare: 1
|
m_SupportScreenSpaceLensFlare: 1
|
||||||
m_GPUResidentDrawerMode: 0
|
m_GPUResidentDrawerMode: 0
|
||||||
m_SmallMeshScreenPercentage: 0
|
m_SmallMeshScreenPercentage: 0
|
||||||
|
m_ShadowSmallMeshScreenPercentages: {x: 0, y: 0, z: 0, w: 0}
|
||||||
m_GPUResidentDrawerEnableOcclusionCullingInCameras: 0
|
m_GPUResidentDrawerEnableOcclusionCullingInCameras: 0
|
||||||
m_ShadowType: 1
|
m_ShadowType: 1
|
||||||
m_LocalShadowsSupported: 0
|
m_LocalShadowsSupported: 0
|
||||||
@@ -100,26 +101,26 @@ MonoBehaviour:
|
|||||||
m_Keys: []
|
m_Keys: []
|
||||||
m_Values:
|
m_Values:
|
||||||
m_PrefilteringModeMainLightShadows: 3
|
m_PrefilteringModeMainLightShadows: 3
|
||||||
m_PrefilteringModeAdditionalLight: 4
|
m_PrefilteringModeAdditionalLight: 3
|
||||||
m_PrefilteringModeAdditionalLightShadows: 0
|
m_PrefilteringModeAdditionalLightShadows: 0
|
||||||
m_PrefilterXRKeywords: 1
|
m_PrefilterXRKeywords: 1
|
||||||
m_PrefilteringModeForwardPlus: 1
|
m_PrefilteringModeForwardPlus: 0
|
||||||
m_PrefilteringModeDeferredRendering: 0
|
m_PrefilteringModeDeferredRendering: 0
|
||||||
m_PrefilteringModeScreenSpaceOcclusion: 0
|
m_PrefilteringModeScreenSpaceOcclusion: 0
|
||||||
m_PrefilteringModeScreenSpaceReflection: 1
|
m_PrefilteringModeScreenSpaceReflection: 0
|
||||||
m_PrefilterWriteSmoothness: 1
|
m_PrefilterWriteSmoothness: 1
|
||||||
m_PrefilterDebugKeywords: 1
|
m_PrefilterDebugKeywords: 1
|
||||||
m_PrefilterWriteRenderingLayers: 1
|
m_PrefilterWriteRenderingLayers: 1
|
||||||
m_PrefilterHDROutput: 1
|
m_PrefilterHDROutput: 1
|
||||||
m_PrefilterAlphaOutput: 0
|
m_PrefilterAlphaOutput: 1
|
||||||
m_PrefilterSSAODepthNormals: 1
|
m_PrefilterSSAODepthNormals: 1
|
||||||
m_PrefilterSSAOSourceDepthLow: 1
|
m_PrefilterSSAOSourceDepthLow: 1
|
||||||
m_PrefilterSSAOSourceDepthMedium: 0
|
m_PrefilterSSAOSourceDepthMedium: 1
|
||||||
m_PrefilterSSAOSourceDepthHigh: 1
|
m_PrefilterSSAOSourceDepthHigh: 1
|
||||||
m_PrefilterSSAOInterleaved: 0
|
m_PrefilterSSAOInterleaved: 1
|
||||||
m_PrefilterSSAOBlueNoise: 1
|
m_PrefilterSSAOBlueNoise: 1
|
||||||
m_PrefilterSSAOSampleCountLow: 1
|
m_PrefilterSSAOSampleCountLow: 1
|
||||||
m_PrefilterSSAOSampleCountMedium: 0
|
m_PrefilterSSAOSampleCountMedium: 1
|
||||||
m_PrefilterSSAOSampleCountHigh: 1
|
m_PrefilterSSAOSampleCountHigh: 1
|
||||||
m_PrefilterDBufferMRT1: 1
|
m_PrefilterDBufferMRT1: 1
|
||||||
m_PrefilterDBufferMRT2: 1
|
m_PrefilterDBufferMRT2: 1
|
||||||
@@ -129,14 +130,17 @@ MonoBehaviour:
|
|||||||
m_PrefilterSoftShadowsQualityHigh: 1
|
m_PrefilterSoftShadowsQualityHigh: 1
|
||||||
m_PrefilterSoftShadows: 0
|
m_PrefilterSoftShadows: 0
|
||||||
m_PrefilterScreenCoord: 1
|
m_PrefilterScreenCoord: 1
|
||||||
m_PrefilterScreenSpaceIrradiance: 0
|
m_PrefilterScreenSpaceIrradiance: 1
|
||||||
m_PrefilterNativeRenderPass: 1
|
m_PrefilterNativeRenderPass: 1
|
||||||
m_PrefilterUseLegacyLightmaps: 0
|
m_PrefilterUseLegacyLightmaps: 0
|
||||||
m_PrefilterBicubicLightmapSampling: 0
|
m_PrefilterBicubicLightmapSampling: 1
|
||||||
m_PrefilterReflectionProbeRotation: 0
|
m_PrefilterReflectionProbeRotation: 0
|
||||||
m_PrefilterReflectionProbeBlending: 0
|
m_PrefilterReflectionProbeBlending: 0
|
||||||
m_PrefilterReflectionProbeBoxProjection: 0
|
m_PrefilterReflectionProbeBoxProjection: 0
|
||||||
m_PrefilterReflectionProbeAtlas: 0
|
m_PrefilterReflectionProbeAtlas: 1
|
||||||
|
m_PrefilterPointSamplingUpsampling: 1
|
||||||
|
m_Light2DPrefilteringMode: 0
|
||||||
|
m_Light2DKeptVariantCombos: []
|
||||||
m_ShaderVariantLogLevel: 0
|
m_ShaderVariantLogLevel: 0
|
||||||
m_ShadowCascades: 0
|
m_ShadowCascades: 0
|
||||||
m_Textures:
|
m_Textures:
|
||||||
|
|||||||
@@ -72,7 +72,21 @@ MonoBehaviour:
|
|||||||
- rid: 6588237625532874761
|
- rid: 6588237625532874761
|
||||||
- rid: 6588237625532874762
|
- rid: 6588237625532874762
|
||||||
m_RuntimeSettings:
|
m_RuntimeSettings:
|
||||||
m_List: []
|
m_List:
|
||||||
|
- rid: 6852985685364965378
|
||||||
|
- rid: 6852985685364965380
|
||||||
|
- rid: 6852985685364965381
|
||||||
|
- rid: 6852985685364965384
|
||||||
|
- rid: 6852985685364965392
|
||||||
|
- rid: 6852985685364965394
|
||||||
|
- rid: 8712630790384254976
|
||||||
|
- rid: 4284062438471237637
|
||||||
|
- rid: 5747374308340793348
|
||||||
|
- rid: 6588237625532874753
|
||||||
|
- rid: 6588237625532874756
|
||||||
|
- rid: 6588237625532874757
|
||||||
|
- rid: 6588237625532874759
|
||||||
|
- rid: 6588237625532874761
|
||||||
m_AssetVersion: 11
|
m_AssetVersion: 11
|
||||||
m_ObsoleteDefaultVolumeProfile: {fileID: 0}
|
m_ObsoleteDefaultVolumeProfile: {fileID: 0}
|
||||||
m_RenderingLayerNames:
|
m_RenderingLayerNames:
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# 첫 퍼즐 Android 검증
|
||||||
|
|
||||||
|
Unity 6000.6.3f1의 실제 Android 빌드는 ARM64 IL2CPP, 개발 모드 꺼짐, `BuildOptions.None`, Main 씬 하나로 구성합니다. Unity 6.5부터 Android x86_64 플레이어가 제거되었습니다([공식 변경 안내](https://docs.unity.com/ja-jp/engine/6000.5/manual/upgrade-guides/upgrade-guide-unity65)). 현재 API 37 Google Play AVD는 기본 시스템 ABI가 x86_64이며 지원 목록에 arm64-v8a, 번역기에 libndk_translation.so를 광고합니다. APK/기기 지원 ABI의 교집합과 실제 프로세스 실행을 검사하며 x86 네이티브 실행·실기기 성능 합격을 뜻하지 않습니다. ARM 번역의 원리는 [Google 공식 안내](https://android-developers.googleblog.com/2020/03/run-arm-apps-on-android-emulator.html)에 설명되어 있습니다.
|
||||||
|
|
||||||
|
Android Editor 콜백은 오프라인 앱에 자동 추가된 INTERNET 권한을 생성 manifest에서 제거합니다. Performance Testing 패키지의 order 0 콜백이 release에도 자동 생성하는 정확히 두 시험 JSON을 최후 순서 콜백에서 AssetDatabase API로 제거하며, 생성된 Resources 폴더가 비었을 때만 제거합니다. 다른 Resources 자산을 삭제하지 않습니다. URP가 실제 빌드에서 생성한 직렬화·shader prefilter 필드만 보존하며 YAML을 손으로 편집하지 않습니다. 전역 UPM/Gradle/Bee 캐시는 유지하고 Android 도구의 `.utmp` 캐시는 제외 규칙으로 처리합니다.
|
||||||
|
|
||||||
|
전용 프로젝트의 절대 경로에서 다음 두 명령을 실행합니다. 같은 프로젝트에 Editor와 batch를 동시에 열지 않습니다.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
pwsh -NoProfile -File tools/verify.ps1 -Phase AndroidBuild -Milestone FirstSlice
|
||||||
|
pwsh -NoProfile -File tools/verify.ps1 -Phase AndroidSmoke -Milestone FirstSlice
|
||||||
|
```
|
||||||
|
|
||||||
|
첫 API 37 headless 기동의 GPU auto는 SwiftShader GLES 3.0/Lavapipe Vulkan 1.3을 선택했으며 즉시 찍은 화면은 비어 있었습니다. 최종 검증 AVD는 `-gpu host -no-window -no-snapshot-save -grpc-use-token`으로 실행하며, 실제 선택된 렌더러는 최종 SurfaceFlinger와 `android-device-evidence.json`에 기록합니다. 이전 재기동의 SwiftShader 로그와 현재 호스트 렌더러를 구분하며 GPU 성능 합격은 주장하지 않습니다. 처음 시작 직후 프로세스만 생존하는 시점과 UI가 렌더링된 시점을 구분하며, 전체 화면 안내가 뜨면 Android의 안내를 닫고 실제 화면을 다시 캡처합니다.
|
||||||
|
|
||||||
|
`tools/android/exercise-first-slice.ps1`은 담당자가 설치한 시험용 앱 데이터만 초기화한 뒤 위 표의 실제 ADB 조작, 앱 pause/force-stop/재실행, 앱 전용 JSON 읽기, 오프라인 상태와 에뮬레이터 음성을 수집합니다. `android-state-checks.json`의 기계 검사는 실제 수집 필요·빈 계수·동일 보드 성공·자유 이동의 위치 변화·JSON 복원·과거 음성 없는 복원·오프라인 성공 전이와 네트워크 상태를 검사합니다. 그림의 뜻은 별도 직접 검수입니다. airplane=1, wifi=0, mobile_data=0 및 `Active default network: none`을 확인한 시간에 세 발화를 기록합니다.
|
||||||
|
|
||||||
|
`compare-audio.ps1`은 승인 원본과 실제 PCM을 mono PCM16 24kHz로 맞추고 가장 강한 구간의 위치를 찾은 후 전체 발화의 정규화 상관을 평가합니다. 기준은 상관 ≥0.95, RMS >32, 비무음 샘플 >100입니다. 세 발화의 시작/끝이 차례로 분리되어야 합니다. APK 생성 출처는 원본 `androidbuild-evidence.json`과 `apk-metadata.json`을 유지하고, Smoke의 재검사는 `apk-inspection.json`에만 씁니다. 시작/종료 dirty=false와 소스 SHA·APK hash를 대조하므로 이전 APK를 후속 커밋 산출물로 다시 이름 붙일 수 없습니다. 잘못된 SHA/hash 검사 복사본은 실제 기기 조작 전 실패해야 합니다.
|
||||||
|
|
||||||
|
그 거부 동작은 실제 Build 뒤 `pwsh -NoProfile -File tools/android/test-provenance.ps1`로 검사합니다. 원본 근거를 손상시키지 않고 별도 복사본 두 개의 소스 SHA/APK hash만 바꿔 Smoke의 실제 종료 1 및 기기 명령 0개를 확인합니다. 음성은 raw WAV뿐 아니라 수집 당시 소스 SHA/dirty·시각 메타데이터도 최종 Build와 맞아야 합니다. 입력 수정 전 ADB 짧은 `input tap` 두 번은 구 탭을 놓쳤고 같은 좌표 200ms 접촉은 정상 반응했습니다. 수정 전 원시 자료는 `pre-fix/`에 보존하고 수정 후 최종 절차는 짧은 ADB tap으로 다시 확인합니다. 물리 기기의 실제 손가락 동작이나 성능 합격으로 확대하지 않습니다.
|
||||||
|
|
||||||
|
APK는 `artifacts/android/FirstSlice/MagnetismHeroes.apk`, 원본 경로를 기록한 Unity 실제 packed-assets 목록은 APK 옆 `.packed-assets.txt`입니다. 근거는 `artifacts/verification/FirstSlice/`에 보존합니다. Build/Smoke의 명령·실제 종료 코드·소스 SHA/dirty·APK SHA-256, aapt 실제 badging/ZIP entries, 기기 ABI/번역기/graphics, ADB 명령별 종료 코드·화면·관찰 결과를 별도로 기록합니다. 설치·프로세스 생존만으로 게임 검사를 통과시키지 않습니다.
|
||||||
|
|
||||||
|
`tools/android/observe.ps1`은 실제 ADB 조작 후 screencap을 가져오고 각 명령을 `<step>-commands.json`에 기록합니다. Unity uGUI 문자는 Android native hierarchy에 노출되지 않으므로 PNG를 직접 열어 한국어와 화면 의미를 검사합니다. `android-observations.json`에는 아래 번호별 절차·기대와 실제 결과·근거 파일을 작성하며 APK hash·소스 SHA·기기 serial이 맞아야 합니다. 미실행·빈 파일·관찰 누락은 Smoke 실패입니다. 원본 상태는 앱 전용 저장 JSON을 ADB로 읽을 수 있을 때 추가 검증하며 release APK에 진단 서버·단계 우회·테스트 제어 기능을 넣지 않습니다.
|
||||||
|
|
||||||
|
| 번호 / 관찰 ID | 실제 절차와 기대 결과 |
|
||||||
|
| --- | --- |
|
||||||
|
| 1 landscape-korean-assets | 시작→친구 찾기→1. 가로 화면에 한글·숫자·자석 구 하나·얼굴 하나·번호 하나·기본 3D 소품과 재질을 확인합니다. |
|
||||||
|
| 2 precollect-tap | 자석을 수집 전에 탭합니다. 미완료 반응이 나오고 센 친구 0/1이며 계속 조작할 수 있어야 합니다. |
|
||||||
|
| 3 direct-collection | 구를 한 손가락으로 직접 끌어 놓습니다. 위치가 바뀌고 안내가 세기 단계로 바뀌며 수집됨·계수 0입니다. |
|
||||||
|
| 4 camera-orbit | 구·UI를 피한 빈 곳을 쓸어 카메라를 회전합니다. 같은 구가 유지되고 뷰가 달라집니다. |
|
||||||
|
| 5 incomplete-restore | 수집·이동·카메라 변경 후 Home/back으로 pause→force-stop→재실행→친구 찾기→1. 위치·카메라·수집 상태가 유지되고 0/1입니다. N=1에서는 첫 계수 즉시 성공하므로 부분 양수 계수 복원은 후속 N≥2 범위입니다. |
|
||||||
|
| 6 count-success-same-board | 복원된 구를 한 번 탭합니다. 같은 보드 배치로 자유 놀이터에 전환되고 친구 1이며 숫자 1 음성이 끝까지 재생됩니다. |
|
||||||
|
| 7 free-move | 성공한 같은 보드에서 구를 다시 끌어 위치를 바꿉니다. 새 화면으로 옮기지 않고 자유 조작이 됩니다. |
|
||||||
|
| 8 free-restore | pause→force-stop→재실행→자유 놀이 이어하기. 성공 후 위치·카메라·친구 1이 복원되고 과거 계수 음성을 다시 읽지 않습니다. |
|
||||||
|
| 9 offline-collection-count | Wi-Fi와 mobile data를 끈 실제 상태를 기록한 뒤 친구 찾기→1을 다시 플레이합니다. 직접 끌기와 탭 성공, 세 음성 출력이 네트워크 없이 동작합니다. |
|
||||||
|
| 10 offline-restore | 오프라인 자유 놀이 배치를 바꾼 뒤 pause→force-stop→재실행→이어하기. 배치·카메라가 유지됩니다. |
|
||||||
|
| 11 guide-collect-playback | 1 진입 시 실제 에뮬레이터 PCM을 받아 비무음 구간과 승인 guide_collect 원본 파형을 대조합니다. |
|
||||||
|
| 12 guide-count-playback | 직접 수집 시 실제 PCM을 받아 승인 guide_count 원본과 대조합니다. |
|
||||||
|
| 13 count01-playback | 한 번 계수 성공 시 실제 PCM을 받아 승인 count_01 원본과 대조하며 자유 놀이 전환 뒤에도 마지막 읽기가 유지되는지 확인합니다. |
|
||||||
|
|
||||||
|
`tools/android/record-audio.ps1`은 localhost EmulatorController.streamAudio의 24kHz mono S16 PCM을 WAV로 기록합니다. SDK에 설치된 공식 `emulator_controller.proto`의 wire format을 사용하고 외부 패키지 설치를 하지 않습니다([공식 서비스 정의](https://android.googlesource.com/platform/tools/base/+/refs/heads/mirror-goog-studio-main/emulator/proto/emulator_controller.proto)). 관찰을 위해 AVD를 `-grpc-use-token`으로 시작하고 기본 localhost 바인딩을 확인합니다. 도구는 로컬 discovery 파일의 토큰을 메모리에서만 읽고 공개 근거에 인증키·비밀을 쓰지 않습니다. 패킷 수만으로 음성 재생을 통과시키지 않고 실제 비무음 신호·승인 WAV 대비 파형/시간을 평가합니다. 사람의 Android 출력 청취 검수와는 구분합니다.
|
||||||
|
|
||||||
|
승인·최종 음성 주장은 count_01·guide_collect·guide_count 세 파일만 대상으로 합니다. 그 밖의 로컬 숫자 파일은 미검수 초안이며 count_05는 런타임에서 제외되어 있습니다. 모델/conda/SourceAssets는 APK 제작 입력에 포함되지 않습니다. 효과음·BGM은 사용자가 나중에 추가하기로 했으며 아직 없습니다. 이 검증은 첫 퍼즐이며 30개 게임 완성, iOS, 배포, 실기기 성능 합격을 뜻하지 않습니다.
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
param([Parameter(Mandatory)][string]$ReferencePath, [Parameter(Mandatory)][string]$CapturedPath, [Parameter(Mandatory)][string]$OutputPath)
|
||||||
|
$ErrorActionPreference='Stop'
|
||||||
|
Add-Type -TypeDefinition @'
|
||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
public static class AndroidPcmComparison {
|
||||||
|
static short[] Read(string path) {
|
||||||
|
using(var r=new BinaryReader(File.OpenRead(path))) {
|
||||||
|
if(new string(r.ReadChars(4))!="RIFF") throw new Exception("RIFF WAV required");
|
||||||
|
r.ReadInt32(); if(new string(r.ReadChars(4))!="WAVE") throw new Exception("WAVE required");
|
||||||
|
bool format=false; int rate=0;
|
||||||
|
while(r.BaseStream.Position+8<=r.BaseStream.Length) {
|
||||||
|
string id=new string(r.ReadChars(4)); int size=r.ReadInt32(); long end=r.BaseStream.Position+size;
|
||||||
|
if(id=="fmt ") { int encoding=r.ReadInt16(),channels=r.ReadInt16(); rate=r.ReadInt32(); r.ReadInt32(); r.ReadInt16(); format=encoding==1 && channels==1 && r.ReadInt16()==16 && rate>0; }
|
||||||
|
if(id=="data") {
|
||||||
|
if(!format) throw new Exception("Mono PCM16 required");
|
||||||
|
var data=new short[size/2]; for(int i=0;i<data.Length;i++) data[i]=r.ReadInt16();
|
||||||
|
if(rate==24000) return data;
|
||||||
|
var resampled=new short[(int)((long)data.Length*24000/rate)];
|
||||||
|
for(int i=0;i<resampled.Length;i++) { double index=(double)i*rate/24000; int left=(int)index; double fraction=index-left; resampled[i]=(short)(data[left]*(1-fraction)+data[Math.Min(left+1,data.Length-1)]*fraction); }
|
||||||
|
return resampled;
|
||||||
|
}
|
||||||
|
r.BaseStream.Position=end+(size%2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Exception("WAV data missing");
|
||||||
|
}
|
||||||
|
public static double[] Compare(string original,string captured) {
|
||||||
|
var source=Read(original); var output=Read(captured); int length=Math.Min(4096,source.Length);
|
||||||
|
double bestEnergy=-1; int anchor=0;
|
||||||
|
for(int offset=0;offset+length<=source.Length;offset+=512) {
|
||||||
|
double energy=0; for(int j=0;j<length;j++) energy+=(double)source[offset+j]*source[offset+j];
|
||||||
|
if(energy>bestEnergy) { bestEnergy=energy; anchor=offset; }
|
||||||
|
}
|
||||||
|
if(bestEnergy<=0 || output.Length<source.Length) throw new Exception("Non-silent complete reference/capture required");
|
||||||
|
double sourceAnchorEnergy=0; for(int j=0;j<length;j+=8) sourceAnchorEnergy+=(double)source[anchor+j]*source[anchor+j];
|
||||||
|
double best=-1; int bestLag=0;
|
||||||
|
for(int lag=0;lag+source.Length<=output.Length;lag++) {
|
||||||
|
double dot=0, energy=0;
|
||||||
|
for(int j=0;j<length;j+=8) { double value=output[lag+anchor+j]; dot+=source[anchor+j]*value; energy+=value*value; }
|
||||||
|
double correlation=energy>0 ? dot/Math.Sqrt(sourceAnchorEnergy*energy) : 0;
|
||||||
|
if(correlation>best) { best=correlation; bestLag=lag; }
|
||||||
|
}
|
||||||
|
double fullDot=0,sourceEnergy=0,outputEnergy=0; int nonzero=0;
|
||||||
|
for(int j=0;j<source.Length;j++) { double s=source[j],o=output[bestLag+j]; fullDot+=s*o; sourceEnergy+=s*s; outputEnergy+=o*o; if(Math.Abs(o)>32) nonzero++; }
|
||||||
|
double fullCorrelation=fullDot/Math.Sqrt(sourceEnergy*outputEnergy);
|
||||||
|
return new[]{fullCorrelation,bestLag/24000.0,source.Length/24000.0,output.Length/24000.0,Math.Sqrt(outputEnergy/source.Length),fullDot/sourceEnergy,(double)nonzero};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
'@
|
||||||
|
$values=[AndroidPcmComparison]::Compare((Resolve-Path -LiteralPath $ReferencePath).Path,(Resolve-Path -LiteralPath $CapturedPath).Path)
|
||||||
|
$passed=$values[0] -ge 0.95 -and $values[4] -gt 32 -and $values[6] -gt 100
|
||||||
|
$record=[ordered]@{ reference=$ReferencePath; referenceSha256=(Get-FileHash -LiteralPath $ReferencePath -Algorithm SHA256).Hash;
|
||||||
|
captured=$CapturedPath; capturedSha256=(Get-FileHash -LiteralPath $CapturedPath -Algorithm SHA256).Hash;
|
||||||
|
normalizedCorrelation=$values[0]; onsetSeconds=$values[1]; referenceDurationSeconds=$values[2]; capturedDurationSeconds=$values[3]; rmsPcm=$values[4]; gain=$values[5]; nonSilentSamples=$values[6]; thresholdCorrelation=0.95; passed=$passed; auditoryReview=$false }
|
||||||
|
$record | ConvertTo-Json | Set-Content -LiteralPath $OutputPath -Encoding utf8
|
||||||
|
Write-Host "실제 PCM 대조: 상관 $($values[0]), 비무음 샘플 $($values[6]), 통과 $passed"
|
||||||
|
if (!$passed) { exit 1 }
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
param([string]$EvidenceDir='artifacts/verification/FirstSlice', [string]$Serial='emulator-5554')
|
||||||
|
$ErrorActionPreference='Stop'
|
||||||
|
$EvidenceDir=(Resolve-Path -LiteralPath $EvidenceDir).Path
|
||||||
|
$apkPath=(Resolve-Path -LiteralPath 'artifacts/android/FirstSlice/MagnetismHeroes.apk').Path
|
||||||
|
$checks=[Collections.Generic.List[object]]::new()
|
||||||
|
$recordings=[Collections.Generic.List[object]]::new()
|
||||||
|
function Step([string]$Name,[string[]]$Arguments,[int]$Delay=800) {
|
||||||
|
& (Join-Path $PSScriptRoot 'observe.ps1') -Step $Name -AdbArguments $Arguments -Serial $Serial -EvidenceDir $EvidenceDir -DelayMilliseconds $Delay
|
||||||
|
}
|
||||||
|
function Save-State([string]$Name) {
|
||||||
|
Step $Name @('pull','/sdcard/Android/data/com.magnetismheroes.game/files/progress.json',(Join-Path $EvidenceDir "$Name.json"))
|
||||||
|
return (Get-Content -LiteralPath (Join-Path $EvidenceDir "$Name.json") -Raw | ConvertFrom-Json)
|
||||||
|
}
|
||||||
|
function Assert-State([string]$Id,[bool]$Passed,[string]$Actual) {
|
||||||
|
$checks.Add([ordered]@{ id=$Id; passed=$Passed; actual=$Actual })
|
||||||
|
if (!$Passed) { throw "저장 검증 실패: $Id ($Actual)" }
|
||||||
|
}
|
||||||
|
function Start-Audio([string]$Name,[int]$Seconds) {
|
||||||
|
$audioPath=Join-Path $EvidenceDir "$Name.wav"
|
||||||
|
$args=@('-NoProfile','-File',(Join-Path $PSScriptRoot 'record-audio.ps1'),'-OutputPath',$audioPath,'-Seconds',"$Seconds")
|
||||||
|
$process=Start-Process -FilePath 'pwsh' -ArgumentList ($args | ForEach-Object { '"'+$_+'"' }) -WindowStyle Hidden -RedirectStandardOutput (Join-Path $EvidenceDir "$Name.stdout") -RedirectStandardError (Join-Path $EvidenceDir "$Name.stderr") -PassThru
|
||||||
|
$recordings.Add(@{ name=$Name; process=$process; command=('pwsh '+($args -join ' ')) })
|
||||||
|
Start-Sleep -Seconds 2
|
||||||
|
}
|
||||||
|
function Restart([string]$Prefix) {
|
||||||
|
Step "$Prefix-pause" @('shell','input','keyevent','3')
|
||||||
|
Step "$Prefix-force-stop" @('shell','am','force-stop','com.magnetismheroes.game')
|
||||||
|
Step "$Prefix-home" @('shell','am','start','-W','-n','com.magnetismheroes.game/com.unity3d.player.UnityPlayerGameActivity') 10000
|
||||||
|
}
|
||||||
|
$result=[ordered]@{ sourceCommit=(& git rev-parse HEAD); sourceDirty=[bool](& git status --porcelain); apkSha256=(Get-FileHash -LiteralPath $apkPath -Algorithm SHA256).Hash; serial=$Serial; status='failed'; recordings=@(); checks=@() }
|
||||||
|
try {
|
||||||
|
Assert-State 'clean-source' (!$result.sourceDirty) $result.sourceCommit
|
||||||
|
Step 'final-00-airplane-off' @('shell','cmd','connectivity','airplane-mode','disable')
|
||||||
|
Step 'final-00-wifi-on' @('shell','svc','wifi','enable')
|
||||||
|
Step 'final-00-data-on' @('shell','svc','data','enable')
|
||||||
|
# 검증 담당자가 이 실행에서 설치한 앱 전용 데이터만 초기화합니다.
|
||||||
|
Step 'final-00-reset-own-test-app' @('shell','pm','clear','com.magnetismheroes.game')
|
||||||
|
Step 'final-01-home' @('shell','am','start','-W','-n','com.magnetismheroes.game/com.unity3d.player.UnityPlayerGameActivity') 10000
|
||||||
|
Step 'final-02-selection' @('shell','input','tap','1200','616')
|
||||||
|
Start-Audio 'audio-online-guidecollect' 8
|
||||||
|
Step 'final-03-stage' @('shell','input','tap','1200','560') 2500
|
||||||
|
Step 'final-04-precollect-tap' @('shell','input','tap','930','510')
|
||||||
|
$uncollected=Save-State 'final-04-uncollected'
|
||||||
|
Assert-State 'direct-collection-required' ($uncollected.puzzleStates[0].collectedBallIds.Count -eq 0 -and $uncollected.puzzleStates[0].countedBallIds.Count -eq 0 -and $uncollected.completedStageIds.Count -eq 0) ($uncollected | ConvertTo-Json -Depth 8 -Compress)
|
||||||
|
Start-Audio 'audio-online-guidecount' 8
|
||||||
|
Step 'final-05-direct-collection' @('shell','input','swipe','930','510','1200','510','800') 4000
|
||||||
|
Step 'final-06-camera-orbit' @('shell','input','swipe','1720','400','1860','470','600')
|
||||||
|
$before=Save-State 'final-06-incomplete-before'
|
||||||
|
Assert-State 'collected-with-empty-count' ($before.puzzleStates[0].collectedBallIds.Count -eq 1 -and $before.puzzleStates[0].countedBallIds.Count -eq 0) ($before | ConvertTo-Json -Depth 8 -Compress)
|
||||||
|
Restart 'final-07'
|
||||||
|
Step 'final-07-selection' @('shell','input','tap','1200','616')
|
||||||
|
Step 'final-07-restored-incomplete' @('shell','input','tap','1200','560')
|
||||||
|
$after=Save-State 'final-07-incomplete-after'
|
||||||
|
Assert-State 'incomplete-json-restored' (($before | ConvertTo-Json -Depth 8 -Compress) -eq ($after | ConvertTo-Json -Depth 8 -Compress)) '배치/카메라/그룹/수집/빈 계수 전체 JSON 동일'
|
||||||
|
Start-Audio 'audio-online-count01' 8
|
||||||
|
Step 'final-08-success-same-board' @('shell','input','tap','1182','514') 1500
|
||||||
|
$success=Save-State 'final-08-success'
|
||||||
|
Assert-State 'completion-atomic-state' ($success.completedStageIds.Count -eq 1 -and $success.completedStageIds[0] -eq 1 -and $success.puzzleStates.Count -eq 0 -and $success.latestFreePlay.sourceStageId -eq 1) ($success | ConvertTo-Json -Depth 8 -Compress)
|
||||||
|
Assert-State 'success-preserves-board-camera' (($before.puzzleStates[0].balls | ConvertTo-Json -Depth 6 -Compress) -eq ($success.latestFreePlay.balls | ConvertTo-Json -Depth 6 -Compress) -and ($before.puzzleStates[0].camera | ConvertTo-Json -Depth 6 -Compress) -eq ($success.latestFreePlay.camera | ConvertTo-Json -Depth 6 -Compress)) '성공 전후 balls/camera 동일'
|
||||||
|
Step 'final-09-free-move' @('shell','input','swipe','1182','514','1380','470','800')
|
||||||
|
$freeBefore=Save-State 'final-09-free-before'
|
||||||
|
Assert-State 'free-move-changes-position' (($freeBefore.latestFreePlay.balls | ConvertTo-Json -Depth 6 -Compress) -ne ($success.latestFreePlay.balls | ConvertTo-Json -Depth 6 -Compress)) '실제 자유 놀이 balls 위치 변경'
|
||||||
|
Restart 'final-10'
|
||||||
|
Start-Audio 'audio-free-restore' 8
|
||||||
|
Step 'final-10-continue-free' @('shell','input','tap','1200','810')
|
||||||
|
$freeAfter=Save-State 'final-10-free-after'
|
||||||
|
Assert-State 'free-json-restored' (($freeBefore | ConvertTo-Json -Depth 8 -Compress) -eq ($freeAfter | ConvertTo-Json -Depth 8 -Compress)) '자유 놀이 전체 JSON 동일'
|
||||||
|
$restoreRecorder=$recordings | Where-Object name -eq 'audio-free-restore' | Select-Object -First 1
|
||||||
|
if (!$restoreRecorder.process.WaitForExit(30000)) { throw '복원 음성 검사 수집 미종료' }
|
||||||
|
$silence=Get-Content -LiteralPath (Join-Path $EvidenceDir 'audio-free-restore.wav.json') -Raw | ConvertFrom-Json
|
||||||
|
Assert-State 'restore-no-historical-voice' ($silence.packets -gt 0 -and $silence.pcmBytes -gt 0 -and $silence.nonSilentSamples -eq 0) ($silence | ConvertTo-Json -Compress)
|
||||||
|
Step 'final-11-airplane-on' @('shell','cmd','connectivity','airplane-mode','enable')
|
||||||
|
Step 'final-11-wifi-off' @('shell','svc','wifi','disable')
|
||||||
|
Step 'final-11-data-off' @('shell','svc','data','disable')
|
||||||
|
Step 'final-11-wifi-state' @('shell','settings','get','global','wifi_on')
|
||||||
|
Step 'final-11-data-state' @('shell','settings','get','global','mobile_data')
|
||||||
|
Step 'final-11-connectivity' @('shell','dumpsys','connectivity')
|
||||||
|
Step 'final-11-airplane-state' @('shell','settings','get','global','airplane_mode_on')
|
||||||
|
$wifiState=Get-Content -LiteralPath (Join-Path $EvidenceDir 'final-11-wifi-state-commands.json') -Raw | ConvertFrom-Json
|
||||||
|
$dataState=Get-Content -LiteralPath (Join-Path $EvidenceDir 'final-11-data-state-commands.json') -Raw | ConvertFrom-Json
|
||||||
|
$connectivity=Get-Content -LiteralPath (Join-Path $EvidenceDir 'final-11-connectivity-commands.json') -Raw | ConvertFrom-Json
|
||||||
|
$airplaneState=Get-Content -LiteralPath (Join-Path $EvidenceDir 'final-11-airplane-state-commands.json') -Raw | ConvertFrom-Json
|
||||||
|
Assert-State 'offline-network-disabled' ($wifiState.commands[0].output.Trim() -eq '0' -and $dataState.commands[0].output.Trim() -eq '0' -and $airplaneState.commands[0].output.Trim() -eq '1' -and $connectivity.commands[0].output -match 'Active default network: none') 'airplane=1, wifi=0, mobile_data=0, Active default network: none'
|
||||||
|
Step 'final-12-home' @('shell','input','tap','310','108')
|
||||||
|
Step 'final-12-selection' @('shell','input','tap','1200','616')
|
||||||
|
Start-Audio 'audio-offline' 20
|
||||||
|
Step 'final-12-offline-stage' @('shell','input','tap','1200','560') 3000
|
||||||
|
Step 'final-12-offline-collect' @('shell','input','swipe','930','510','1200','510','800') 4500
|
||||||
|
$offlineCollected=Save-State 'final-12-offline-collected'
|
||||||
|
Assert-State 'offline-collected-unfinished' ($offlineCollected.puzzleStates.Count -eq 1 -and $offlineCollected.puzzleStates[0].collectedBallIds.Count -eq 1 -and $offlineCollected.puzzleStates[0].countedBallIds.Count -eq 0) '오프라인 실제 수집됨/계수 전 0'
|
||||||
|
Step 'final-12-offline-count-success' @('shell','input','tap','1182','510') 1500
|
||||||
|
$offlineSuccess=Save-State 'final-12-offline-success'
|
||||||
|
Assert-State 'offline-collection-count-actually-success' ($offlineSuccess.completedStageIds.Count -eq 1 -and $offlineSuccess.completedStageIds[0] -eq 1 -and $offlineSuccess.puzzleStates.Count -eq 0 -and $offlineSuccess.latestFreePlay.sourceStageId -eq 1 -and ($offlineCollected.puzzleStates[0].balls | ConvertTo-Json -Depth 6 -Compress) -eq ($offlineSuccess.latestFreePlay.balls | ConvertTo-Json -Depth 6 -Compress)) '완료1/미완료없음/수집 전후 같은 IDs와 배치 자유 전이'
|
||||||
|
Step 'final-13-offline-free-move' @('shell','input','swipe','1182','510','1350','450','800')
|
||||||
|
$offlineBefore=Save-State 'final-13-offline-before'
|
||||||
|
Assert-State 'offline-free-move-changes-position' (($offlineBefore.latestFreePlay.balls | ConvertTo-Json -Depth 6 -Compress) -ne ($offlineSuccess.latestFreePlay.balls | ConvertTo-Json -Depth 6 -Compress)) '오프라인 실제 자유 놀이 balls 위치 변경'
|
||||||
|
Restart 'final-14'
|
||||||
|
Step 'final-14-offline-continue' @('shell','input','tap','1200','810')
|
||||||
|
$offlineAfter=Save-State 'final-14-offline-after'
|
||||||
|
Assert-State 'offline-json-restored' (($offlineBefore | ConvertTo-Json -Depth 8 -Compress) -eq ($offlineAfter | ConvertTo-Json -Depth 8 -Compress)) '오프라인 자유 놀이 전체 JSON 동일'
|
||||||
|
foreach ($recording in $recordings) {
|
||||||
|
if (!$recording.process.WaitForExit(30000)) { throw "오디오 수집 미종료: $($recording.name)" }
|
||||||
|
$recording.process.Refresh()
|
||||||
|
$result.recordings += [ordered]@{ name=$recording.name; command=$recording.command; exitCode=$recording.process.ExitCode }
|
||||||
|
Assert-State "audio-recorder-$($recording.name)" ($recording.process.ExitCode -eq 0) "$($recording.process.ExitCode)"
|
||||||
|
}
|
||||||
|
$result.status='passed'
|
||||||
|
} catch { $result.error=$_.Exception.Message; Write-Host $result.error -ForegroundColor Red }
|
||||||
|
finally {
|
||||||
|
$result.checks=$checks.ToArray(); $result.finishedUtc=[DateTime]::UtcNow.ToString('o')
|
||||||
|
$result | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath (Join-Path $EvidenceDir 'android-state-checks.json') -Encoding utf8
|
||||||
|
}
|
||||||
|
if ($result.status -ne 'passed') { exit 1 }
|
||||||
|
Write-Host "실제 Android 저장/복원 검사 $($checks.Count)개 통과. PNG 의미 검수는 별도입니다."
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
param([Parameter(Mandatory)][string]$ApkPath, [Parameter(Mandatory)][string]$EvidenceDir,
|
||||||
|
[string]$SourceCommit=(& git rev-parse HEAD), [bool]$SourceDirty=[bool](& git status --porcelain), [string]$MetadataName='apk-inspection.json')
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$sdk = if ($env:ANDROID_SDK_ROOT) { $env:ANDROID_SDK_ROOT } else { Join-Path $env:LOCALAPPDATA 'Android/Sdk' }
|
||||||
|
$aapt = Get-ChildItem -LiteralPath (Join-Path $sdk 'build-tools') -Filter aapt.exe -Recurse | Sort-Object FullName -Descending | Select-Object -First 1
|
||||||
|
if (!$aapt) { throw 'APK manifest 검사에 필요한 aapt를 찾지 못했습니다.' }
|
||||||
|
$badging = & $aapt.FullName dump badging $ApkPath
|
||||||
|
$nativeExit = $LASTEXITCODE
|
||||||
|
$badging | Set-Content -LiteralPath (Join-Path $EvidenceDir 'apk-badging.txt') -Encoding utf8
|
||||||
|
if ($nativeExit -ne 0) { throw "aapt 종료 코드: $nativeExit" }
|
||||||
|
$zip = [IO.Compression.ZipFile]::OpenRead($ApkPath)
|
||||||
|
try {
|
||||||
|
$entries = @($zip.Entries | ForEach-Object FullName)
|
||||||
|
$abis = @($entries | Where-Object { $_ -match '^lib/([^/]+)/libil2cpp\.so$' } | ForEach-Object { ($_ -split '/')[1] })
|
||||||
|
if ($abis.Count -eq 0) { throw 'APK에 IL2CPP 라이브러리가 없습니다.' }
|
||||||
|
foreach ($abi in $abis) { if ($entries -notcontains "lib/$abi/libunity.so") { throw "$abi Unity 런타임 누락" } }
|
||||||
|
if ($entries | Where-Object { $_ -match '(?i)(^|/)(SourceAssets|conda|Qwen[^/]*)(/|$)|\.safetensors$' }) { throw '제작 환경 파일이 APK에 포함되었습니다.' }
|
||||||
|
$entries | Set-Content -LiteralPath (Join-Path $EvidenceDir 'apk-entries.txt') -Encoding utf8
|
||||||
|
} finally { $zip.Dispose() }
|
||||||
|
$package = [regex]::Match(($badging -join "`n"), "package: name='([^']+)'").Groups[1].Value
|
||||||
|
$activity = [regex]::Match(($badging -join "`n"), "launchable-activity: name='([^']+)'").Groups[1].Value
|
||||||
|
if ($package -ne 'com.magnetismheroes.game') { throw "예상하지 않은 패키지: $package" }
|
||||||
|
if (!$activity) { throw 'APK 시작 Activity를 찾지 못했습니다.' }
|
||||||
|
if (($badging -join "`n") -match "uses-permission: name='android.permission.INTERNET'") { throw '오프라인 앱에 INTERNET 권한이 있습니다.' }
|
||||||
|
[ordered]@{ apk=$ApkPath; apkSha256=(Get-FileHash -LiteralPath $ApkPath -Algorithm SHA256).Hash; package=$package;
|
||||||
|
abis=$abis; activity=$activity; aaptCommand=('"' + $aapt.FullName + '" dump badging "' + $ApkPath + '"'); aaptExit=$nativeExit;
|
||||||
|
sourceCommit=$SourceCommit; sourceDirty=$SourceDirty } |
|
||||||
|
ConvertTo-Json -Depth 4 | Set-Content -LiteralPath (Join-Path $EvidenceDir $MetadataName) -Encoding utf8
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
param(
|
||||||
|
[Parameter(Mandatory)][ValidatePattern('^[a-z0-9-]+$')][string]$Step,
|
||||||
|
[string[]]$AdbArguments = @(), [string]$Serial = 'emulator-5554',
|
||||||
|
[string]$EvidenceDir = 'artifacts/verification/FirstSlice', [int]$DelayMilliseconds = 800,
|
||||||
|
[switch]$Audio
|
||||||
|
)
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$sdk = if ($env:ANDROID_SDK_ROOT) { $env:ANDROID_SDK_ROOT } else { Join-Path $env:LOCALAPPDATA 'Android/Sdk' }
|
||||||
|
$adb = Join-Path $sdk 'platform-tools/adb.exe'
|
||||||
|
New-Item -ItemType Directory -Path $EvidenceDir -Force | Out-Null
|
||||||
|
$commands = [Collections.Generic.List[object]]::new()
|
||||||
|
function Invoke-Adb([string[]]$Arguments) {
|
||||||
|
$output = & $adb -s $Serial @Arguments 2>&1
|
||||||
|
$code = $LASTEXITCODE
|
||||||
|
$commands.Add([ordered]@{ command=('"' + $adb + '" -s ' + $Serial + ' ' + ($Arguments -join ' ')); exitCode=$code; output=($output -join "`n") })
|
||||||
|
if ($code -ne 0) { throw "ADB 실패: $($Arguments -join ' '), 종료 코드 $code" }
|
||||||
|
return $output
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if ($AdbArguments.Count) { Invoke-Adb $AdbArguments | Out-Null }
|
||||||
|
if ($Audio) { Invoke-Adb @('shell','dumpsys','media.audio_flinger') | Set-Content -LiteralPath (Join-Path $EvidenceDir "$Step-audio-flinger.txt") -Encoding utf8 }
|
||||||
|
Start-Sleep -Milliseconds $DelayMilliseconds
|
||||||
|
Invoke-Adb @('shell','screencap','-p',"/sdcard/magnetismheroes-$Step.png") | Out-Null
|
||||||
|
Invoke-Adb @('pull',"/sdcard/magnetismheroes-$Step.png",(Join-Path $EvidenceDir "$Step.png")) | Out-Null
|
||||||
|
Invoke-Adb @('shell','rm',"/sdcard/magnetismheroes-$Step.png") | Out-Null
|
||||||
|
} finally {
|
||||||
|
[ordered]@{ step=$Step; observedUtc=[DateTime]::UtcNow.ToString('o'); serial=$Serial; sourceCommit=(& git rev-parse HEAD);
|
||||||
|
commands=$commands.ToArray() } | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath (Join-Path $EvidenceDir "$Step-commands.json") -Encoding utf8
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
param([Parameter(Mandatory)][string]$OutputPath, [int]$Seconds=15, [int]$Port=8554)
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
Add-Type -TypeDefinition @'
|
||||||
|
using System;
|
||||||
|
public static class AndroidPcmLevels {
|
||||||
|
public static double[] Measure(byte[] bytes) {
|
||||||
|
double sum=0, peak=0; int active=0;
|
||||||
|
for(int i=0;i+1<bytes.Length;i+=2) { int sample=(short)(bytes[i] | (bytes[i+1]<<8)); sum+=(double)sample*sample; peak=Math.Max(peak,Math.Abs(sample)); if(Math.Abs(sample)>32) active++; }
|
||||||
|
return new[]{peak, bytes.Length>0 ? Math.Sqrt(sum/(bytes.Length/2)) : 0, (double)active};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
'@
|
||||||
|
# EmulatorController.streamAudio의 PCM을 직접 기록합니다. 제품 APK에는 포함되지 않습니다.
|
||||||
|
# wire schema: Android SDK emulator/lib/emulator_controller.proto.
|
||||||
|
[AppContext]::SetSwitch('System.Net.Http.SocketsHttpHandler.Http2UnencryptedSupport', $true)
|
||||||
|
$client = [Net.Http.HttpClient]::new()
|
||||||
|
$request = [Net.Http.HttpRequestMessage]::new([Net.Http.HttpMethod]::Post, "http://127.0.0.1:$Port/android.emulation.control.EmulatorController/streamAudio")
|
||||||
|
$request.Version = [Version]::new(2,0)
|
||||||
|
$request.VersionPolicy = [Net.Http.HttpVersionPolicy]::RequestVersionExact
|
||||||
|
$request.Headers.Add('TE','trailers')
|
||||||
|
$discovery = Get-ChildItem -LiteralPath (Join-Path $env:LOCALAPPDATA 'Temp/avd/running') -Filter 'pid_*.ini' | Sort-Object LastWriteTime -Descending | Select-Object -First 1
|
||||||
|
if (!$discovery) { throw 'AVD gRPC discovery 파일을 찾지 못했습니다.' }
|
||||||
|
$tokenLine = Get-Content -LiteralPath $discovery.FullName | Where-Object { $_ -match '^grpc.token=' } | Select-Object -First 1
|
||||||
|
if (!$tokenLine) { throw 'AVD를 -grpc-use-token으로 시작해야 합니다. 토큰을 로그에 기록하지 않습니다.' }
|
||||||
|
$request.Headers.Authorization = [Net.Http.Headers.AuthenticationHeaderValue]::new('Bearer', $tokenLine.Substring('grpc.token='.Length))
|
||||||
|
# AudioFormat: samplingRate=24000, channels=Mono(default), format=AUD_FMT_S16.
|
||||||
|
$request.Content = [Net.Http.ByteArrayContent]::new([byte[]]@(0,0,0,0,6,8,192,187,1,24,1))
|
||||||
|
$request.Content.Headers.ContentType = [Net.Http.Headers.MediaTypeHeaderValue]::new('application/grpc')
|
||||||
|
$cancel = [Threading.CancellationTokenSource]::new([TimeSpan]::FromSeconds($Seconds))
|
||||||
|
$pcm = [IO.MemoryStream]::new()
|
||||||
|
$packets = 0
|
||||||
|
$startedUtc=[DateTime]::UtcNow.ToString('o')
|
||||||
|
$firstPacketTimestamp=$null; $lastPacketTimestamp=$null
|
||||||
|
function Read-Exactly($Stream,[int]$Length) {
|
||||||
|
$bytes = [byte[]]::new($Length)
|
||||||
|
$offset=0
|
||||||
|
while ($offset -lt $Length) {
|
||||||
|
$n=$Stream.ReadAsync($bytes,$offset,$Length-$offset,$cancel.Token).GetAwaiter().GetResult()
|
||||||
|
if ($n -eq 0) { throw 'gRPC 오디오 스트림이 끝났습니다.' }
|
||||||
|
$offset += $n
|
||||||
|
}
|
||||||
|
return ,$bytes
|
||||||
|
}
|
||||||
|
function Read-Varint([byte[]]$Bytes,[ref]$Index) {
|
||||||
|
[long]$value=0; $shift=0
|
||||||
|
do { $b=$Bytes[$Index.Value]; $Index.Value++; $value=$value -bor (($b -band 127) -shl $shift); $shift+=7 } while ($b -band 128)
|
||||||
|
return $value
|
||||||
|
}
|
||||||
|
$status='failed'
|
||||||
|
try {
|
||||||
|
$response=$client.SendAsync($request,[Net.Http.HttpCompletionOption]::ResponseHeadersRead,$cancel.Token).GetAwaiter().GetResult()
|
||||||
|
$response.EnsureSuccessStatusCode() | Out-Null
|
||||||
|
$stream=$response.Content.ReadAsStream()
|
||||||
|
while (!$cancel.IsCancellationRequested) {
|
||||||
|
$header=Read-Exactly $stream 5
|
||||||
|
if ($header[0] -ne 0) { throw '압축 gRPC 프레임은 지원하지 않습니다.' }
|
||||||
|
$length=([int]$header[1] -shl 24) -bor ([int]$header[2] -shl 16) -bor ([int]$header[3] -shl 8) -bor [int]$header[4]
|
||||||
|
$message=Read-Exactly $stream $length
|
||||||
|
$i=0
|
||||||
|
while ($i -lt $message.Length) {
|
||||||
|
$tag=Read-Varint $message ([ref]$i)
|
||||||
|
switch ($tag -band 7) {
|
||||||
|
0 { $value=Read-Varint $message ([ref]$i); if (($tag -shr 3) -eq 2) { if ($null -eq $firstPacketTimestamp) { $firstPacketTimestamp=$value }; $lastPacketTimestamp=$value } }
|
||||||
|
2 {
|
||||||
|
$size=Read-Varint $message ([ref]$i)
|
||||||
|
if (($tag -shr 3) -eq 3) { $pcm.Write($message,$i,$size); $packets++ }
|
||||||
|
$i += $size
|
||||||
|
}
|
||||||
|
default { throw "예상하지 않은 protobuf wire type: $tag" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (!$cancel.IsCancellationRequested) { throw }
|
||||||
|
} finally {
|
||||||
|
$data=$pcm.ToArray()
|
||||||
|
$levels=[AndroidPcmLevels]::Measure($data)
|
||||||
|
$file=[IO.File]::Create($OutputPath)
|
||||||
|
$writer=[IO.BinaryWriter]::new($file)
|
||||||
|
try {
|
||||||
|
$writer.Write([Text.Encoding]::ASCII.GetBytes('RIFF')); $writer.Write([int](36+$data.Length)); $writer.Write([Text.Encoding]::ASCII.GetBytes('WAVEfmt '));
|
||||||
|
$writer.Write([int]16); $writer.Write([int16]1); $writer.Write([int16]1); $writer.Write([int]24000); $writer.Write([int]48000); $writer.Write([int16]2); $writer.Write([int16]16);
|
||||||
|
$writer.Write([Text.Encoding]::ASCII.GetBytes('data')); $writer.Write([int]$data.Length); $writer.Write($data)
|
||||||
|
} finally { $writer.Dispose(); $file.Dispose(); $client.Dispose(); $cancel.Dispose(); $pcm.Dispose() }
|
||||||
|
[ordered]@{ method='EmulatorController.streamAudio'; endpoint="127.0.0.1:$Port"; sourceCommit=(& git rev-parse HEAD); sourceDirty=[bool](& git status --porcelain); startedUtc=$startedUtc; finishedUtc=[DateTime]::UtcNow.ToString('o'); firstDevicePacketTimestampUs=$firstPacketTimestamp; lastDevicePacketTimestampUs=$lastPacketTimestamp; packets=$packets; pcmBytes=$data.Length; sampleRate=24000; channels=1; bitsPerSample=16; peakPcm=$levels[0]; rmsPcm=$levels[1]; nonSilentSamples=$levels[2]; auditoryReview=$false } |
|
||||||
|
ConvertTo-Json | Set-Content -LiteralPath ($OutputPath + '.json') -Encoding utf8
|
||||||
|
}
|
||||||
|
if ($packets -eq 0) { throw '실제 오디오 패킷을 받지 못했습니다.' }
|
||||||
|
Write-Host "에뮬레이터 실제 PCM $packets 패킷, $($data.Length)바이트: $OutputPath"
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
param([Parameter(Mandatory)][string]$ApkPath, [string]$Milestone='FirstSlice', [Parameter(Mandatory)][string]$EvidenceDir, [string]$Serial='emulator-5554')
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$sdk = if ($env:ANDROID_SDK_ROOT) { $env:ANDROID_SDK_ROOT } else { Join-Path $env:LOCALAPPDATA 'Android/Sdk' }
|
||||||
|
$adb = Join-Path $sdk 'platform-tools/adb.exe'
|
||||||
|
$commands = [Collections.Generic.List[object]]::new()
|
||||||
|
$checks = [Collections.Generic.List[object]]::new()
|
||||||
|
function Invoke-Adb([string[]]$Arguments) {
|
||||||
|
$output = & $adb -s $Serial @Arguments 2>&1
|
||||||
|
$code = $LASTEXITCODE
|
||||||
|
$commands.Add([ordered]@{ command=('"' + $adb + '" -s ' + $Serial + ' ' + ($Arguments -join ' ')); exitCode=$code; output=($output -join "`n") })
|
||||||
|
if ($code -ne 0) { throw "ADB 실패: $($Arguments -join ' '), 종료 코드 $code" }
|
||||||
|
return ($output -join "`n").Trim()
|
||||||
|
}
|
||||||
|
function Assert-Check([string]$Name,[bool]$Condition,[string]$Actual) {
|
||||||
|
$checks.Add([ordered]@{ criterion=$Name; passed=$Condition; actual=$Actual })
|
||||||
|
if (!$Condition) { throw "검사 실패: $Name ($Actual)" }
|
||||||
|
}
|
||||||
|
$result = [ordered]@{ milestone=$Milestone; serial=$Serial; sourceCommit=(& git rev-parse HEAD); sourceDirty=[bool](& git status --porcelain); status='failed'; commands=@(); checks=@() }
|
||||||
|
try {
|
||||||
|
Assert-Check 'clean-source' (!$result.sourceDirty) $result.sourceCommit
|
||||||
|
$buildRecord = Get-Content -LiteralPath (Join-Path $EvidenceDir 'androidbuild-evidence.json') -Raw | ConvertFrom-Json
|
||||||
|
$buildMetadata = Get-Content -LiteralPath (Join-Path $EvidenceDir 'apk-metadata.json') -Raw | ConvertFrom-Json
|
||||||
|
$actualApkHash = (Get-FileHash -LiteralPath $ApkPath -Algorithm SHA256).Hash
|
||||||
|
Assert-Check 'verified-build-provenance' ($buildRecord.status -eq 'passed' -and $buildRecord.exitCode -eq 0 -and $buildRecord.sourceDirty -eq $false -and $buildRecord.sourceDirtyEnd -eq $false -and $buildRecord.sourceCommit -eq $result.sourceCommit -and $buildRecord.sourceCommitEnd -eq $result.sourceCommit -and $buildRecord.apkSha256 -eq $actualApkHash -and $buildMetadata.sourceCommit -eq $buildRecord.sourceCommit -and $buildMetadata.apkSha256 -eq $actualApkHash -and $buildMetadata.sourceDirty -eq $false) $actualApkHash
|
||||||
|
& (Join-Path $PSScriptRoot 'inspect-apk.ps1') -ApkPath $ApkPath -EvidenceDir $EvidenceDir
|
||||||
|
$apk = Get-Content -LiteralPath (Join-Path $EvidenceDir 'apk-inspection.json') -Raw | ConvertFrom-Json
|
||||||
|
$result.apkSha256 = $apk.apkSha256
|
||||||
|
$supported = (Invoke-Adb @('shell','getprop','ro.product.cpu.abilist')) -split ','
|
||||||
|
$native = $supported[0]
|
||||||
|
$bridge = Invoke-Adb @('shell','getprop','ro.dalvik.vm.native.bridge')
|
||||||
|
$intersection = @($apk.abis | Where-Object { $supported -contains $_ })
|
||||||
|
$result.device = [ordered]@{ supportedAbis=$supported; nativeArchitecture=$native; bridge=$bridge; apkSupportedIntersection=$intersection;
|
||||||
|
model=(Invoke-Adb @('shell','getprop','ro.product.model')); api=(Invoke-Adb @('shell','getprop','ro.build.version.sdk'));
|
||||||
|
features=(Invoke-Adb @('shell','pm','list','features')); graphics=(Invoke-Adb @('shell','dumpsys','SurfaceFlinger')) }
|
||||||
|
Assert-Check 'supported-abi-intersection' ($intersection.Count -gt 0) ($intersection -join ',')
|
||||||
|
if ($supported[0] -ne $intersection[0]) { Assert-Check 'arm-native-bridge' ($bridge -match '^lib.+\.so$') $bridge }
|
||||||
|
Invoke-Adb @('install','-r',$ApkPath) | Out-Null
|
||||||
|
Invoke-Adb @('shell','am','start','-W','-n',($apk.package + '/' + $apk.activity)) | Out-Null
|
||||||
|
Start-Sleep -Seconds 3
|
||||||
|
$pidValue = Invoke-Adb @('shell','pidof',$apk.package)
|
||||||
|
Assert-Check 'running-process' ($pidValue -match '^\d+') $pidValue
|
||||||
|
Invoke-Adb @('logcat','-d','-s','Unity','AndroidRuntime') | Set-Content -LiteralPath (Join-Path $EvidenceDir 'android-runtime.log') -Encoding utf8
|
||||||
|
& (Join-Path $PSScriptRoot 'observe.ps1') -Step 'smoke-home' -Serial $Serial -EvidenceDir $EvidenceDir
|
||||||
|
$manualPath = Join-Path $EvidenceDir 'android-observations.json'
|
||||||
|
Assert-Check 'observations-present' (Test-Path -LiteralPath $manualPath) $manualPath
|
||||||
|
$manual = Get-Content -LiteralPath $manualPath -Raw | ConvertFrom-Json
|
||||||
|
Assert-Check 'observations-match-apk' ($manual.apkSha256 -eq $apk.apkSha256 -and $manual.sourceCommit -eq $result.sourceCommit -and $manual.serial -eq $Serial) $manual.apkSha256
|
||||||
|
$required = @('landscape-korean-assets','precollect-tap','direct-collection','camera-orbit','incomplete-restore','count-success-same-board','free-move','free-restore','offline-collection-count','offline-restore','guide-collect-playback','guide-count-playback','count01-playback')
|
||||||
|
foreach ($id in $required) {
|
||||||
|
$observations = @($manual.cases | Where-Object id -eq $id)
|
||||||
|
Assert-Check "observation-$id" ($observations.Count -eq 1 -and $observations[0].passed -eq $true -and $observations[0].actual -and $observations[0].procedure -and $observations[0].expected) (($observations | ForEach-Object actual) -join ' ')
|
||||||
|
foreach ($file in $observations[0].evidence) {
|
||||||
|
$path = Join-Path $EvidenceDir $file
|
||||||
|
Assert-Check "evidence-$id-$file" ((Test-Path -LiteralPath $path) -and (Get-Item -LiteralPath $path).Length -gt 0) $file
|
||||||
|
}
|
||||||
|
Assert-Check "has-evidence-$id" (@($observations[0].evidence).Count -gt 0) (@($observations[0].evidence) -join ',')
|
||||||
|
}
|
||||||
|
$voiceHashes = [ordered]@{
|
||||||
|
guide_collect='62E15C9E38C36F4094A6C7E9C5148510141290B5F41DB5AAD9DC1D8F1A158594';
|
||||||
|
guide_count='DEBE29B89F3E2856F2BF4C5E01707A5F0A0657E5768C03BE385EC88FA2328B40';
|
||||||
|
count_01='13A882CE1620140D447635E14F2D4758045D5E348960C17386DF1C720050C100'
|
||||||
|
}
|
||||||
|
$audioMatches = @()
|
||||||
|
$audioMetadata = Get-Content -LiteralPath (Join-Path $EvidenceDir 'audio-offline.wav.json') -Raw | ConvertFrom-Json
|
||||||
|
Assert-Check 'actual-pcm-provenance' ($audioMetadata.sourceCommit -eq $result.sourceCommit -and $audioMetadata.sourceDirty -eq $false -and $audioMetadata.packets -gt 0 -and $audioMetadata.pcmBytes -gt 0 -and [DateTime]$audioMetadata.startedUtc -ge [DateTime]$buildRecord.finishedUtc) $audioMetadata.sourceCommit
|
||||||
|
foreach ($voiceId in $voiceHashes.Keys) {
|
||||||
|
$reference = Join-Path (Split-Path (Split-Path $PSScriptRoot -Parent) -Parent) "Assets/MagnetismHeroes/UnityRuntime/Presentation/Audio/Voice/$voiceId.wav"
|
||||||
|
Assert-Check "approved-wave-$voiceId" ((Get-FileHash -LiteralPath $reference -Algorithm SHA256).Hash -eq $voiceHashes[$voiceId]) $voiceHashes[$voiceId]
|
||||||
|
$comparisonPath = Join-Path $EvidenceDir "audio-offline-$voiceId-match.json"
|
||||||
|
$capturedPath = Join-Path $EvidenceDir 'audio-offline.wav'
|
||||||
|
& pwsh -NoProfile -File (Join-Path $PSScriptRoot 'compare-audio.ps1') -ReferencePath $reference -CapturedPath $capturedPath -OutputPath $comparisonPath
|
||||||
|
$comparisonExit = $LASTEXITCODE
|
||||||
|
$comparison = Get-Content -LiteralPath $comparisonPath -Raw | ConvertFrom-Json
|
||||||
|
Assert-Check "actual-pcm-$voiceId" ($comparisonExit -eq 0 -and $comparison.passed -eq $true -and $comparison.normalizedCorrelation -ge 0.95 -and $comparison.rmsPcm -gt 32 -and $comparison.referenceSha256 -eq $voiceHashes[$voiceId]) ($comparison | ConvertTo-Json -Compress)
|
||||||
|
$audioMatches += $comparison
|
||||||
|
}
|
||||||
|
Assert-Check 'offline-voice-order' ($audioMatches[0].onsetSeconds + $audioMatches[0].referenceDurationSeconds -le $audioMatches[1].onsetSeconds -and $audioMatches[1].onsetSeconds + $audioMatches[1].referenceDurationSeconds -le $audioMatches[2].onsetSeconds) (($audioMatches | ForEach-Object onsetSeconds) -join ',')
|
||||||
|
$result.audioComparisons=$audioMatches
|
||||||
|
$result.manualObservations = $manual
|
||||||
|
$state = Get-Content -LiteralPath (Join-Path $EvidenceDir 'android-state-checks.json') -Raw | ConvertFrom-Json
|
||||||
|
Assert-Check 'state-checks-provenance' ($state.sourceCommit -eq $result.sourceCommit -and $state.sourceDirty -eq $false -and $state.apkSha256 -eq $actualApkHash -and $state.serial -eq $Serial) $state.sourceCommit
|
||||||
|
Assert-Check 'state-checks-passed' ($state.status -eq 'passed' -and @($state.checks).Count -ge 8 -and @($state.checks | Where-Object passed -ne $true).Count -eq 0) "$(@($state.checks).Count)"
|
||||||
|
foreach ($id in @('direct-collection-required','collected-with-empty-count','incomplete-json-restored','completion-atomic-state','success-preserves-board-camera','free-move-changes-position','free-json-restored','restore-no-historical-voice','offline-network-disabled','offline-collected-unfinished','offline-collection-count-actually-success','offline-free-move-changes-position','offline-json-restored')) {
|
||||||
|
Assert-Check "required-state-$id" (@($state.checks | Where-Object { $_.id -eq $id -and $_.passed -eq $true }).Count -eq 1) $id
|
||||||
|
}
|
||||||
|
$result.stateChecks=$state
|
||||||
|
$result.status='passed'
|
||||||
|
} catch { $result.error=$_.Exception.Message; Write-Host $result.error -ForegroundColor Red }
|
||||||
|
finally {
|
||||||
|
$result.commands=$commands.ToArray(); $result.checks=$checks.ToArray(); $result.finishedUtc=[DateTime]::UtcNow.ToString('o')
|
||||||
|
$result | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath (Join-Path $EvidenceDir 'android-device-evidence.json') -Encoding utf8
|
||||||
|
}
|
||||||
|
if ($result.status -ne 'passed') { exit 1 }
|
||||||
|
Write-Host "Android 실제 기기 검사와 $($required.Count)개 관찰 검사 통과. 근거: $EvidenceDir"
|
||||||
|
exit 0
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
param([string]$EvidenceDir='artifacts/verification/FirstSlice')
|
||||||
|
$ErrorActionPreference='Stop'
|
||||||
|
$EvidenceDir=(Resolve-Path -LiteralPath $EvidenceDir).Path
|
||||||
|
$apkPath=(Resolve-Path -LiteralPath 'artifacts/android/FirstSlice/MagnetismHeroes.apk').Path
|
||||||
|
$source=& git rev-parse HEAD
|
||||||
|
$build=Get-Content -LiteralPath (Join-Path $EvidenceDir 'androidbuild-evidence.json') -Raw | ConvertFrom-Json
|
||||||
|
if ($build.sourceCommit -ne $source -or $build.sourceDirty -ne $false -or $build.sourceDirtyEnd -ne $false -or $build.apkSha256 -ne (Get-FileHash -LiteralPath $apkPath).Hash) { throw '현재 clean 소스의 실제 Build 근거가 있어야 부정 검사를 실행할 수 있습니다.' }
|
||||||
|
$results=@()
|
||||||
|
foreach ($field in @('sourceCommit','apkSha256')) {
|
||||||
|
$caseDir=Join-Path $EvidenceDir "red/provenance-$field"
|
||||||
|
New-Item -ItemType Directory -Path $caseDir -Force | Out-Null
|
||||||
|
$copy=Get-Content -LiteralPath (Join-Path $EvidenceDir 'androidbuild-evidence.json') -Raw | ConvertFrom-Json
|
||||||
|
$copy.$field='deliberately-invalid-verification-copy'
|
||||||
|
$copy | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath (Join-Path $caseDir 'androidbuild-evidence.json') -Encoding utf8
|
||||||
|
Copy-Item -LiteralPath (Join-Path $EvidenceDir 'apk-metadata.json') -Destination (Join-Path $caseDir 'apk-metadata.json')
|
||||||
|
$log=Join-Path $caseDir 'negative-smoke.log'
|
||||||
|
$smoke=Join-Path $PSScriptRoot 'smoke.ps1'
|
||||||
|
& pwsh -NoProfile -File $smoke -ApkPath $apkPath -Milestone FirstSlice -EvidenceDir $caseDir *> $log
|
||||||
|
$code=$LASTEXITCODE
|
||||||
|
$outcome=Get-Content -LiteralPath (Join-Path $caseDir 'android-device-evidence.json') -Raw | ConvertFrom-Json
|
||||||
|
$passed=$code -eq 1 -and $outcome.status -eq 'failed' -and @($outcome.commands).Count -eq 0 -and $outcome.checks[-1].criterion -eq 'verified-build-provenance' -and $outcome.checks[-1].passed -eq $false
|
||||||
|
$results += [ordered]@{ field=$field; command="pwsh -NoProfile -File $smoke -ApkPath $apkPath -Milestone FirstSlice -EvidenceDir $caseDir"; actualExitCode=$code; deviceCommandCount=@($outcome.commands).Count; passed=$passed; log=$log }
|
||||||
|
}
|
||||||
|
[ordered]@{ sourceCommit=$source; apkSha256=$build.apkSha256; cases=$results } | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath (Join-Path $EvidenceDir 'negative-provenance-results.json') -Encoding utf8
|
||||||
|
if (@($results | Where-Object passed -ne $true).Count) { throw '출처가 틀린 검사 복사본을 기기 조작 전에 거부하지 못했습니다.' }
|
||||||
|
Write-Host '소스 SHA/APK hash 손상 복사본 2개를 실제 종료 코드 1로 기기 조작 전 거부했습니다.'
|
||||||
+26
-1
@@ -45,6 +45,30 @@ try {
|
|||||||
if ($LASTEXITCODE -ne 0 -or !(Test-Path -LiteralPath $apkPath)) { throw 'Android build failed or produced no APK.' }
|
if ($LASTEXITCODE -ne 0 -or !(Test-Path -LiteralPath $apkPath)) { throw 'Android build failed or produced no APK.' }
|
||||||
$record.apk = $apkPath
|
$record.apk = $apkPath
|
||||||
$record.apkSha256 = (Get-FileHash -LiteralPath $apkPath -Algorithm SHA256).Hash
|
$record.apkSha256 = (Get-FileHash -LiteralPath $apkPath -Algorithm SHA256).Hash
|
||||||
|
& (Join-Path $PSScriptRoot 'android/inspect-apk.ps1') -ApkPath $apkPath -EvidenceDir $evidenceDir -SourceCommit $record.sourceCommit -SourceDirty $record.sourceDirty -MetadataName 'apk-metadata.json'
|
||||||
|
$record.apkMetadata = Join-Path $evidenceDir 'apk-metadata.json'
|
||||||
|
# Unity rewrites these files with LF; restore only when Git proves their content is unchanged.
|
||||||
|
$record.normalizedUnchangedSettings = @()
|
||||||
|
$record.settingsContentChecks = @()
|
||||||
|
if (!$record.sourceDirty) {
|
||||||
|
foreach ($settingsPath in @('ProjectSettings/GraphicsSettings.asset','ProjectSettings/ProjectAuditorSettings.asset','ProjectSettings/ProjectSettings.asset')) {
|
||||||
|
$workingBlob = & git hash-object --path=$settingsPath -- $settingsPath
|
||||||
|
$workingExit = $LASTEXITCODE
|
||||||
|
$sourceBlob = & git rev-parse "HEAD:$settingsPath"
|
||||||
|
$sourceExit = $LASTEXITCODE
|
||||||
|
$record.settingsContentChecks += @{ path=$settingsPath; workingBlob=$workingBlob; sourceBlob=$sourceBlob; hashObjectExit=$workingExit; revParseExit=$sourceExit }
|
||||||
|
if ($workingExit -eq 0 -and $sourceExit -eq 0 -and $workingBlob -eq $sourceBlob -and [bool](& git status --porcelain -- $settingsPath)) {
|
||||||
|
& git restore --source=HEAD --worktree -- $settingsPath
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "Cannot restore unchanged generated setting: $settingsPath" }
|
||||||
|
$record.normalizedUnchangedSettings += $settingsPath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$record.sourceCommitEnd = (& git rev-parse HEAD)
|
||||||
|
& git update-index --refresh | Out-Null
|
||||||
|
$record.sourceDirtyEnd = [bool](& git status --porcelain)
|
||||||
|
$record.trackedDiffEnd = @(& git diff --name-only HEAD)
|
||||||
|
if ($record.sourceDirty -or $record.sourceDirtyEnd -or $record.sourceCommitEnd -ne $record.sourceCommit) { throw 'Source changed during the Android build or is dirty.' }
|
||||||
if ($Milestone -eq 'Final') {
|
if ($Milestone -eq 'Final') {
|
||||||
Copy-Item -LiteralPath $apkPath -Destination (Join-Path $projectRoot 'artifacts/android/MagnetismHeroes.apk')
|
Copy-Item -LiteralPath $apkPath -Destination (Join-Path $projectRoot 'artifacts/android/MagnetismHeroes.apk')
|
||||||
}
|
}
|
||||||
@@ -52,8 +76,9 @@ try {
|
|||||||
$smokeScript = Join-Path $PSScriptRoot 'android/smoke.ps1'
|
$smokeScript = Join-Path $PSScriptRoot 'android/smoke.ps1'
|
||||||
if (!(Test-Path -LiteralPath $smokeScript)) { throw 'Android smoke verification has not been implemented yet.' }
|
if (!(Test-Path -LiteralPath $smokeScript)) { throw 'Android smoke verification has not been implemented yet.' }
|
||||||
$record.command = "pwsh -NoProfile -File tools/android/smoke.ps1 -ApkPath $apkPath -Milestone $Milestone -EvidenceDir $evidenceDir"
|
$record.command = "pwsh -NoProfile -File tools/android/smoke.ps1 -ApkPath $apkPath -Milestone $Milestone -EvidenceDir $evidenceDir"
|
||||||
& pwsh -NoProfile -File $smokeScript -ApkPath $apkPath -Milestone $Milestone -EvidenceDir $evidenceDir
|
& pwsh -NoProfile -File $smokeScript -ApkPath $apkPath -Milestone $Milestone -EvidenceDir $evidenceDir *> $logPath
|
||||||
$record.exitCode = $LASTEXITCODE
|
$record.exitCode = $LASTEXITCODE
|
||||||
|
Get-Content -LiteralPath $logPath | Select-Object -Last 5 | Write-Host
|
||||||
if ($LASTEXITCODE -ne 0) { throw "Android smoke verification exited $LASTEXITCODE." }
|
if ($LASTEXITCODE -ne 0) { throw "Android smoke verification exited $LASTEXITCODE." }
|
||||||
}
|
}
|
||||||
$record.status = 'passed'
|
$record.status = 'passed'
|
||||||
|
|||||||
Reference in New Issue
Block a user