diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..2b6eacf --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,343 @@ +name: CI + +on: + push: + branches: [master] + pull_request: + branches: [master] + +permissions: + contents: read + +jobs: + build-test: + name: Build & Test (C# compilation check) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET 8 + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '8.0.x' + + - name: Create Unity stub project for compilation + run: | + mkdir -p .ci-build + + # Create a .csproj that references all Runtime .cs files + # with Unity Engine stubs so pure C# logic compiles + cat > .ci-build/Allow2.SDK.CI.csproj << 'CSPROJ' + + + net8.0 + disable + false + + CS0649;CS0414;CS0169;CS0067 + + + + + + + CSPROJ + + # Create minimal Unity Engine stubs so the code compiles + cat > .ci-build/UnityStubs.cs << 'STUBS' + // Minimal Unity Engine stubs for CI compilation checks. + // These provide just enough type surface for the SDK's pure C# + // to compile outside of the Unity Editor. + + using System; + using System.Collections; + using System.Text; + + namespace UnityEngine + { + public class Object { } + + public class MonoBehaviour : Behaviour + { + public Coroutine StartCoroutine(IEnumerator routine) => new Coroutine(); + public void StopCoroutine(Coroutine routine) { } + } + + public class Behaviour : Component + { + public bool enabled { get; set; } + } + + public class Component : Object + { + public GameObject gameObject => null; + public Transform transform => null; + } + + public class GameObject : Object + { + public string name { get; set; } + public T AddComponent() where T : Component => default; + public T GetComponent() => default; + public static void DontDestroyOnLoad(Object target) { } + public GameObject(string name) { } + } + + public class Transform : Component { } + + public class ScriptableObject : Object { } + + public class Coroutine { } + + public class WaitForSeconds + { + public WaitForSeconds(float seconds) { } + } + + public class WaitForSecondsRealtime : CustomYieldInstruction + { + public WaitForSecondsRealtime(float seconds) { } + public override bool keepWaiting => false; + } + + public abstract class CustomYieldInstruction : IEnumerator + { + public abstract bool keepWaiting { get; } + public object Current => null; + public bool MoveNext() => keepWaiting; + public void Reset() { } + } + + public static class Debug + { + public static void Log(object message) { } + public static void LogWarning(object message) { } + public static void LogError(object message) { } + } + + public static class PlayerPrefs + { + public static string GetString(string key, string defaultValue = "") => defaultValue; + public static void SetString(string key, string value) { } + public static int GetInt(string key, int defaultValue = 0) => defaultValue; + public static void SetInt(string key, int value) { } + public static void DeleteKey(string key) { } + public static void Save() { } + public static bool HasKey(string key) => false; + } + + public static class JsonUtility + { + public static string ToJson(object obj) => "{}"; + public static T FromJson(string json) => default; + } + + public static class Application + { + public static RuntimePlatform platform => RuntimePlatform.LinuxPlayer; + public static string productName => "CI"; + public static string version => "0.0.0"; + public static string unityVersion => "2021.3.0f1"; + } + + public enum RuntimePlatform + { + LinuxPlayer, WindowsPlayer, OSXPlayer, Android, IPhonePlayer, WebGLPlayer + } + + [AttributeUsage(AttributeTargets.Field)] + public class SerializeFieldAttribute : Attribute { } + + [AttributeUsage(AttributeTargets.Field)] + public class HeaderAttribute : Attribute + { + public HeaderAttribute(string header) { } + } + + [AttributeUsage(AttributeTargets.Field)] + public class TooltipAttribute : Attribute + { + public TooltipAttribute(string tooltip) { } + } + + [AttributeUsage(AttributeTargets.Field)] + public class SpaceAttribute : Attribute + { + public SpaceAttribute() { } + public SpaceAttribute(float height) { } + } + + [AttributeUsage(AttributeTargets.Field)] + public class TextAreaAttribute : Attribute + { + public TextAreaAttribute() { } + public TextAreaAttribute(int minLines, int maxLines) { } + } + + [AttributeUsage(AttributeTargets.Field)] + public class RangeAttribute : Attribute + { + public RangeAttribute(float min, float max) { } + } + } + + namespace UnityEngine.Events + { + public class UnityEvent + { + public void Invoke() { } + public void AddListener(Action call) { } + public void RemoveListener(Action call) { } + } + + public class UnityEvent : UnityEvent + { + public void Invoke(T0 arg0) { } + public void AddListener(Action call) { } + public void RemoveListener(Action call) { } + } + + public class UnityEvent : UnityEvent + { + public void Invoke(T0 arg0, T1 arg1) { } + public void AddListener(Action call) { } + public void RemoveListener(Action call) { } + } + + public class UnityEvent : UnityEvent + { + public void Invoke(T0 arg0, T1 arg1, T2 arg2) { } + public void AddListener(Action call) { } + public void RemoveListener(Action call) { } + } + } + + namespace UnityEngine.Networking + { + public class UnityWebRequest : IDisposable + { + public string url { get; set; } + public string method { get; set; } + public long responseCode { get; } + public Result result { get; } + public DownloadHandler downloadHandler { get; set; } + public UploadHandler uploadHandler { get; set; } + + public UnityWebRequest(string url, string method) { } + + public static UnityWebRequest Get(string uri) => new UnityWebRequest(uri, "GET"); + public static string EscapeURL(string s) => Uri.EscapeDataString(s); + + public void SetRequestHeader(string name, string value) { } + public UnityWebRequestAsyncOperation SendWebRequest() => new UnityWebRequestAsyncOperation(); + public void Dispose() { } + + public enum Result { InProgress, Success, ConnectionError, ProtocolError, DataProcessingError } + } + + public class UnityWebRequestAsyncOperation : AsyncOperation { } + + public class AsyncOperation : YieldInstruction + { + public bool isDone { get; } + } + + public class YieldInstruction { } + + public class DownloadHandler : IDisposable + { + public virtual string text => ""; + public virtual byte[] data => Array.Empty(); + public void Dispose() { } + } + + public class DownloadHandlerBuffer : DownloadHandler + { + public DownloadHandlerBuffer() { } + } + + public class UploadHandler : IDisposable + { + public string contentType { get; set; } + public void Dispose() { } + } + + public class UploadHandlerRaw : UploadHandler + { + public UploadHandlerRaw(byte[] data) { } + } + } + STUBS + + - name: Restore .NET packages + run: dotnet restore .ci-build/Allow2.SDK.CI.csproj + + - name: Build SDK + run: dotnet build .ci-build/Allow2.SDK.CI.csproj --configuration Release --no-restore + + - name: Run tests (if present) + run: | + if compgen -G "com.allow2.sdk/Tests/**/*.cs" > /dev/null 2>&1; then + echo "Test files found — running tests" + dotnet test .ci-build/Allow2.SDK.CI.csproj --no-build --configuration Release + else + echo "No test files found in com.allow2.sdk/Tests/ — skipping" + fi + + validate-package: + name: Validate package.json + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Validate package.json structure + run: | + PKG="com.allow2.sdk/package.json" + + if [ ! -f "$PKG" ]; then + echo "::error::package.json not found at $PKG" + exit 1 + fi + + # Validate it's valid JSON + if ! python3 -c "import json; json.load(open('$PKG'))"; then + echo "::error::package.json is not valid JSON" + exit 1 + fi + + # Check required fields + for field in name version displayName description unity author license; do + if ! python3 -c " + import json, sys + pkg = json.load(open('$PKG')) + if '$field' not in pkg or not pkg['$field']: + print(f'::error::Missing required field: $field') + sys.exit(1) + "; then + exit 1 + fi + done + + # Verify package name matches expected + NAME=$(python3 -c "import json; print(json.load(open('$PKG'))['name'])") + if [ "$NAME" != "com.allow2.sdk" ]; then + echo "::error::Package name should be 'com.allow2.sdk', got '$NAME'" + exit 1 + fi + + VERSION=$(python3 -c "import json; print(json.load(open('$PKG'))['version'])") + echo "Package: $NAME v$VERSION" + echo "package-version=$VERSION" >> "$GITHUB_OUTPUT" + + - name: Check version matches tag (on tag push only) + if: startsWith(github.ref, 'refs/tags/v') + run: | + TAG_VERSION="${GITHUB_REF#refs/tags/v}" + PKG_VERSION=$(python3 -c "import json; print(json.load(open('com.allow2.sdk/package.json'))['version'])") + + if [ "$TAG_VERSION" != "$PKG_VERSION" ]; then + echo "::error::Tag version ($TAG_VERSION) does not match package.json version ($PKG_VERSION)" + exit 1 + fi + + echo "Version match confirmed: v$PKG_VERSION" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..b1ff815 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,299 @@ +name: Release + +on: + push: + tags: + - 'v*' + +permissions: + contents: write + +jobs: + validate: + name: Validate release + runs-on: ubuntu-latest + outputs: + version: ${{ steps.check.outputs.version }} + prerelease: ${{ steps.check.outputs.prerelease }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Validate tag matches package.json + id: check + run: | + TAG_VERSION="${GITHUB_REF#refs/tags/v}" + PKG_VERSION=$(python3 -c "import json; print(json.load(open('com.allow2.sdk/package.json'))['version'])") + + if [ "$TAG_VERSION" != "$PKG_VERSION" ]; then + echo "::error::Tag version ($TAG_VERSION) does not match package.json version ($PKG_VERSION)" + exit 1 + fi + + echo "version=$PKG_VERSION" >> "$GITHUB_OUTPUT" + + # Detect pre-release (alpha, beta, rc, preview) + if echo "$PKG_VERSION" | grep -qE '[-](alpha|beta|rc|preview)'; then + echo "prerelease=true" >> "$GITHUB_OUTPUT" + else + echo "prerelease=false" >> "$GITHUB_OUTPUT" + fi + + echo "Releasing v$PKG_VERSION (prerelease=${{ steps.check.outputs.prerelease || 'false' }})" + + build-check: + name: Compilation check + needs: validate + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET 8 + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '8.0.x' + + - name: Build SDK + run: | + mkdir -p .ci-build + + cat > .ci-build/Allow2.SDK.CI.csproj << 'CSPROJ' + + + net8.0 + disable + CS0649;CS0414;CS0169;CS0067 + + + + + + + CSPROJ + + # Reuse stubs from CI workflow (same file) + cat > .ci-build/UnityStubs.cs << 'STUBS' + using System; + using System.Collections; + + namespace UnityEngine + { + public class Object { } + public class MonoBehaviour : Behaviour + { + public Coroutine StartCoroutine(IEnumerator routine) => new Coroutine(); + public void StopCoroutine(Coroutine routine) { } + } + public class Behaviour : Component { public bool enabled { get; set; } } + public class Component : Object + { + public GameObject gameObject => null; + public Transform transform => null; + } + public class GameObject : Object + { + public string name { get; set; } + public T AddComponent() where T : Component => default; + public T GetComponent() => default; + public static void DontDestroyOnLoad(Object target) { } + public GameObject(string name) { } + } + public class Transform : Component { } + public class ScriptableObject : Object { } + public class Coroutine { } + public class WaitForSeconds { public WaitForSeconds(float s) { } } + public class WaitForSecondsRealtime : CustomYieldInstruction + { + public WaitForSecondsRealtime(float s) { } + public override bool keepWaiting => false; + } + public abstract class CustomYieldInstruction : IEnumerator + { + public abstract bool keepWaiting { get; } + public object Current => null; + public bool MoveNext() => keepWaiting; + public void Reset() { } + } + public static class Debug + { + public static void Log(object m) { } + public static void LogWarning(object m) { } + public static void LogError(object m) { } + } + public static class PlayerPrefs + { + public static string GetString(string k, string d = "") => d; + public static void SetString(string k, string v) { } + public static int GetInt(string k, int d = 0) => d; + public static void SetInt(string k, int v) { } + public static void DeleteKey(string k) { } + public static void Save() { } + public static bool HasKey(string k) => false; + } + public static class JsonUtility + { + public static string ToJson(object o) => "{}"; + public static T FromJson(string j) => default; + } + public static class Application + { + public static RuntimePlatform platform => RuntimePlatform.LinuxPlayer; + public static string productName => "CI"; + public static string version => "0.0.0"; + public static string unityVersion => "2021.3.0f1"; + } + public enum RuntimePlatform { LinuxPlayer, WindowsPlayer, OSXPlayer, Android, IPhonePlayer, WebGLPlayer } + [AttributeUsage(AttributeTargets.Field)] public class SerializeFieldAttribute : Attribute { } + [AttributeUsage(AttributeTargets.Field)] public class HeaderAttribute : Attribute { public HeaderAttribute(string h) { } } + [AttributeUsage(AttributeTargets.Field)] public class TooltipAttribute : Attribute { public TooltipAttribute(string t) { } } + [AttributeUsage(AttributeTargets.Field)] public class SpaceAttribute : Attribute { public SpaceAttribute() { } public SpaceAttribute(float h) { } } + [AttributeUsage(AttributeTargets.Field)] public class TextAreaAttribute : Attribute { public TextAreaAttribute() { } public TextAreaAttribute(int a, int b) { } } + [AttributeUsage(AttributeTargets.Field)] public class RangeAttribute : Attribute { public RangeAttribute(float a, float b) { } } + } + namespace UnityEngine.Events + { + public class UnityEvent { public void Invoke() { } public void AddListener(Action c) { } public void RemoveListener(Action c) { } } + public class UnityEvent : UnityEvent { public void Invoke(T0 a) { } public void AddListener(Action c) { } public void RemoveListener(Action c) { } } + public class UnityEvent : UnityEvent { public void Invoke(T0 a, T1 b) { } public void AddListener(Action c) { } public void RemoveListener(Action c) { } } + public class UnityEvent : UnityEvent { public void Invoke(T0 a, T1 b, T2 c) { } public void AddListener(Action c) { } public void RemoveListener(Action c) { } } + } + namespace UnityEngine.Networking + { + public class UnityWebRequest : IDisposable + { + public string url { get; set; } + public string method { get; set; } + public long responseCode { get; } + public Result result { get; } + public DownloadHandler downloadHandler { get; set; } + public UploadHandler uploadHandler { get; set; } + public UnityWebRequest(string u, string m) { } + public static UnityWebRequest Get(string u) => new UnityWebRequest(u, "GET"); + public static string EscapeURL(string s) => Uri.EscapeDataString(s); + public void SetRequestHeader(string n, string v) { } + public UnityWebRequestAsyncOperation SendWebRequest() => new UnityWebRequestAsyncOperation(); + public void Dispose() { } + public enum Result { InProgress, Success, ConnectionError, ProtocolError, DataProcessingError } + } + public class UnityWebRequestAsyncOperation : AsyncOperation { } + public class AsyncOperation : YieldInstruction { public bool isDone { get; } } + public class YieldInstruction { } + public class DownloadHandler : IDisposable { public virtual string text => ""; public virtual byte[] data => Array.Empty(); public void Dispose() { } } + public class DownloadHandlerBuffer : DownloadHandler { public DownloadHandlerBuffer() { } } + public class UploadHandler : IDisposable { public string contentType { get; set; } public void Dispose() { } } + public class UploadHandlerRaw : UploadHandler { public UploadHandlerRaw(byte[] d) { } } + } + STUBS + + dotnet build .ci-build/Allow2.SDK.CI.csproj --configuration Release + + create-release: + name: Create GitHub Release + needs: [validate, build-check] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Generate changelog + id: changelog + run: | + VERSION="${{ needs.validate.outputs.version }}" + + # Find the previous tag + PREV_TAG=$(git tag --sort=-v:refname | grep -E '^v[0-9]' | head -2 | tail -1) + + if [ -z "$PREV_TAG" ] || [ "$PREV_TAG" = "v$VERSION" ]; then + # First release or only one tag — use all commits + COMMITS=$(git log --pretty=format:"- %s (%h)" --no-merges HEAD) + else + COMMITS=$(git log --pretty=format:"- %s (%h)" --no-merges "$PREV_TAG"..HEAD) + fi + + if [ -z "$COMMITS" ]; then + COMMITS="- Initial release" + fi + + # Write changelog to file (handles multiline safely) + cat > /tmp/changelog.md << CHANGELOG_EOF + ## What's Changed + + $COMMITS + + ## Installation + + ### Unity Package Manager (Git URL) + + Add to your \`Packages/manifest.json\`: + + \`\`\`json + { + "dependencies": { + "com.allow2.sdk": "https://github.com/Allow2/allow2unity.git?path=com.allow2.sdk#v${VERSION}" + } + } + \`\`\` + + Or in the Unity Editor: + 1. Open **Window > Package Manager** + 2. Click **+** > **Add package from git URL** + 3. Enter: \`https://github.com/Allow2/allow2unity.git?path=com.allow2.sdk#v${VERSION}\` + + ### OpenUPM + + \`\`\`bash + openupm add com.allow2.sdk + \`\`\` + + ## Requirements + + - Unity 2021.3 or later + - No additional dependencies + CHANGELOG_EOF + + - name: Create release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + VERSION="${{ needs.validate.outputs.version }}" + PRERELEASE="${{ needs.validate.outputs.prerelease }}" + + PRERELEASE_FLAG="" + if [ "$PRERELEASE" = "true" ]; then + PRERELEASE_FLAG="--prerelease" + fi + + gh release create "v${VERSION}" \ + --title "v${VERSION}" \ + --notes-file /tmp/changelog.md \ + $PRERELEASE_FLAG + + notify-openupm: + name: Notify OpenUPM + needs: [validate, create-release] + runs-on: ubuntu-latest + # This job is optional — it will not fail the release if OpenUPM notification fails + continue-on-error: true + steps: + - name: Trigger OpenUPM update + run: | + VERSION="${{ needs.validate.outputs.version }}" + + # OpenUPM automatically detects new tags for registered packages. + # This step creates a lightweight notification; if the package is + # not yet registered on OpenUPM, this will simply be a no-op. + # + # To register: https://openupm.com/packages/add/ + # Package name: com.allow2.sdk + # Repository: https://github.com/Allow2/allow2unity + + echo "Release v${VERSION} published. OpenUPM will auto-detect the new tag." + echo "" + echo "If the package is not yet on OpenUPM, register it at:" + echo " https://openupm.com/packages/add/" + echo "" + echo "Package: com.allow2.sdk" + echo "Git URL: https://github.com/Allow2/allow2unity.git" + echo "Min Unity: 2021.3" diff --git a/.github/workflows/store-publish.yml b/.github/workflows/store-publish.yml new file mode 100644 index 0000000..9b8ca45 --- /dev/null +++ b/.github/workflows/store-publish.yml @@ -0,0 +1,197 @@ +name: Asset Store Publish + +on: + push: + tags: + - 'v*' + +permissions: + contents: write + +jobs: + build-unitypackage: + name: Build .unitypackage + runs-on: ubuntu-latest + outputs: + version: ${{ steps.version.outputs.version }} + filename: ${{ steps.version.outputs.filename }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Extract version + id: version + run: | + TAG_VERSION="${GITHUB_REF#refs/tags/v}" + PKG_VERSION=$(python3 -c "import json; print(json.load(open('com.allow2.sdk/package.json'))['version'])") + + if [ "$TAG_VERSION" != "$PKG_VERSION" ]; then + echo "::error::Tag version ($TAG_VERSION) does not match package.json version ($PKG_VERSION)" + exit 1 + fi + + echo "version=$PKG_VERSION" >> "$GITHUB_OUTPUT" + echo "filename=Allow2SDK-${PKG_VERSION}.unitypackage" >> "$GITHUB_OUTPUT" + echo "Building .unitypackage for v$PKG_VERSION" + + - name: Build .unitypackage + run: | + chmod +x packaging/asset-store/export-unitypackage.sh + ./packaging/asset-store/export-unitypackage.sh --version "${{ steps.version.outputs.version }}" + + - name: Verify package + run: | + FILENAME="${{ steps.version.outputs.filename }}" + + if [ ! -f "$FILENAME" ]; then + echo "::error::Expected output file $FILENAME not found" + exit 1 + fi + + # Verify it is a valid gzipped tar + if ! file "$FILENAME" | grep -q "gzip"; then + echo "::error::$FILENAME is not a valid gzip archive" + exit 1 + fi + + # List contents to verify structure + echo "Package contents (first 30 entries):" + tar -tzf "$FILENAME" | head -30 + + # Verify Assets/Allow2 paths exist + if ! tar -tzf "$FILENAME" | grep -q "pathname"; then + echo "::error::Package does not contain pathname entries — invalid .unitypackage structure" + exit 1 + fi + + echo "Package verified: $(du -h "$FILENAME" | cut -f1)" + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: unitypackage + path: ${{ steps.version.outputs.filename }} + retention-days: 90 + + attach-to-release: + name: Attach to GitHub Release + needs: build-unitypackage + runs-on: ubuntu-latest + steps: + - name: Download artifact + uses: actions/download-artifact@v4 + with: + name: unitypackage + + - name: Wait for release to exist + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + run: | + VERSION="${{ needs.build-unitypackage.outputs.version }}" + TAG="v${VERSION}" + + # The release.yml workflow may still be creating the release. + # Poll for up to 5 minutes (30 attempts, 10s apart). + for i in $(seq 1 30); do + if gh release view "$TAG" > /dev/null 2>&1; then + echo "Release $TAG found." + exit 0 + fi + echo "Waiting for release $TAG to be created... (attempt $i/30)" + sleep 10 + done + + echo "::error::Release $TAG was not found after 5 minutes" + exit 1 + + - name: Upload .unitypackage to release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + run: | + VERSION="${{ needs.build-unitypackage.outputs.version }}" + FILENAME="${{ needs.build-unitypackage.outputs.filename }}" + TAG="v${VERSION}" + + gh release upload "$TAG" "$FILENAME" --clobber + + echo "Attached $FILENAME to release $TAG" + + submit-asset-store: + name: Submit to Asset Store + needs: [build-unitypackage, attach-to-release] + runs-on: ubuntu-latest + if: vars.ENABLE_ASSET_STORE == 'true' + steps: + - uses: actions/checkout@v4 + + - name: Download artifact + uses: actions/download-artifact@v4 + with: + name: unitypackage + + - name: Submit to Unity Asset Store + env: + UNITY_ASSET_STORE_TOKEN: ${{ secrets.UNITY_ASSET_STORE_TOKEN }} + run: | + VERSION="${{ needs.build-unitypackage.outputs.version }}" + FILENAME="${{ needs.build-unitypackage.outputs.filename }}" + + echo "============================================================" + echo " Asset Store Submission — v${VERSION}" + echo "============================================================" + echo "" + + if [ -z "${UNITY_ASSET_STORE_TOKEN:-}" ]; then + echo "::warning::UNITY_ASSET_STORE_TOKEN secret is not set." + echo "" + echo "Automated Asset Store submission is not yet available." + echo "Unity does not currently provide a public API for automated" + echo "package submission. Follow the manual steps below." + echo "" + echo "MANUAL SUBMISSION STEPS:" + echo "========================" + echo "" + echo "1. Open Unity Editor (2021.3+)" + echo "" + echo "2. Install Asset Store Tools:" + echo " Window > Package Manager > + > Add package by name" + echo " Enter: com.unity.asset-store-tools" + echo "" + echo "3. Open Asset Store Tools:" + echo " Window > Asset Store Tools > Package Upload" + echo "" + echo "4. Log in with your Unity Publisher account" + echo "" + echo "5. Select your package draft or create a new one:" + echo " - Title: Allow2 Parental Freedom SDK" + echo " - Category: Tools/Integration" + echo " - Price: Free" + echo "" + echo "6. Upload method — choose ONE:" + echo "" + echo " a) Pre-built .unitypackage (recommended for CI):" + echo " - Download $FILENAME from the GitHub Release" + echo " - Use 'Upload from .unitypackage' option" + echo "" + echo " b) From project folder:" + echo " - Import the package into a Unity project" + echo " - Select Assets/Allow2 as the upload root" + echo "" + echo "7. Fill in metadata from:" + echo " packaging/asset-store/asset-store-metadata.json" + echo "" + echo "8. Upload key images (see metadata for required sizes)" + echo "" + echo "9. Submit for review" + echo "" + echo "Review typically takes 5-10 business days." + exit 0 + fi + + # If Unity provides an API in the future, automated submission + # would go here. For now, this is a placeholder. + echo "Asset Store token is set but automated submission API" + echo "is not yet implemented. Follow manual steps above." diff --git a/.github/workflows/version-check.yml b/.github/workflows/version-check.yml new file mode 100644 index 0000000..ce98d23 --- /dev/null +++ b/.github/workflows/version-check.yml @@ -0,0 +1,77 @@ +name: Version Check + +on: + pull_request: + branches: [master] + +permissions: + contents: read + pull-requests: read + +jobs: + check-version-bump: + name: Verify version bump if Runtime changed + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Check if Runtime files changed + id: changes + run: | + # Get list of changed files in this PR + CHANGED=$(git diff --name-only "origin/${{ github.base_ref }}"...HEAD 2>/dev/null || \ + git diff --name-only HEAD~1) + + echo "Changed files:" + echo "$CHANGED" + + # Check if any Runtime/ C# files changed + RUNTIME_CHANGED=$(echo "$CHANGED" | grep -c '^com\.allow2\.sdk/Runtime/.*\.cs$' || true) + echo "runtime-changed=$RUNTIME_CHANGED" >> "$GITHUB_OUTPUT" + + # Check if package.json changed + PKG_CHANGED=$(echo "$CHANGED" | grep -c '^com\.allow2\.sdk/package\.json$' || true) + echo "package-changed=$PKG_CHANGED" >> "$GITHUB_OUTPUT" + + echo "Runtime .cs files changed: $RUNTIME_CHANGED" + echo "package.json changed: $PKG_CHANGED" + + - name: Verify version was bumped + if: steps.changes.outputs.runtime-changed != '0' + run: | + PKG_CHANGED="${{ steps.changes.outputs.package-changed }}" + + if [ "$PKG_CHANGED" = "0" ]; then + echo "::warning::Runtime C# files were modified but package.json version was not updated." + echo "" + echo "If this PR includes user-facing changes, please bump the version in" + echo "com.allow2.sdk/package.json before merging." + echo "" + echo "Current version: $(python3 -c "import json; print(json.load(open('com.allow2.sdk/package.json'))['version'])")" + echo "" + echo "Semver guide:" + echo " - Bug fixes: patch (e.g., 2.0.1)" + echo " - New features: minor (e.g., 2.1.0)" + echo " - Breaking changes: major (e.g., 3.0.0)" + echo " - Pre-release: append (e.g., 2.0.0-alpha.2)" + exit 1 + fi + + # Verify the version actually changed (not just formatting) + BASE_VERSION=$(git show "origin/${{ github.base_ref }}:com.allow2.sdk/package.json" 2>/dev/null | \ + python3 -c "import json, sys; print(json.load(sys.stdin)['version'])" 2>/dev/null || echo "unknown") + HEAD_VERSION=$(python3 -c "import json; print(json.load(open('com.allow2.sdk/package.json'))['version'])") + + if [ "$BASE_VERSION" = "$HEAD_VERSION" ]; then + echo "::warning::package.json was modified but the version field is unchanged ($HEAD_VERSION)." + echo "Please bump the version number." + exit 1 + fi + + echo "Version bump confirmed: $BASE_VERSION -> $HEAD_VERSION" + + - name: Skip (no Runtime changes) + if: steps.changes.outputs.runtime-changed == '0' + run: echo "No Runtime C# files changed — version bump not required." diff --git a/.gitignore b/.gitignore index bf793ed..22ad11b 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ # build [Oo]bj/ [Bb]in/ +*.unitypackage packages/ TestResults/ @@ -39,3 +40,73 @@ Thumbs.db # dotCover *.dotCover + +# meta +**/*.meta + +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf + +# Generated files +.idea/**/contentModel.xml + +# Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + +# Gradle +.idea/**/gradle.xml +.idea/**/libraries + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +# .idea/modules.xml +# .idea/*.iml +# .idea/modules + +# CMake +cmake-build-*/ + +# Mongo Explorer plugin +.idea/**/mongoSettings.xml + +# File-based project format +*.iws + +# IntelliJ +out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# Editor-based Rest Client +.idea/httpRequests + +# Android studio 3.1+ serialized cache file +.idea/caches/build_file_checksums.ser diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..e69de29 diff --git a/.idea/Allow2.iml b/.idea/Allow2.iml new file mode 100644 index 0000000..24643cc --- /dev/null +++ b/.idea/Allow2.iml @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..28a804d --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..e01cbc3 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/Allow2/Allow2.cs b/Allow2/Allow2.cs index 7274ae0..64630bd 100644 --- a/Allow2/Allow2.cs +++ b/Allow2/Allow2.cs @@ -1,35 +1,755 @@ -using System; -using System.Net.Http; -using System.Threading.Tasks; +// +// Allow2Unity +// Allow2.cs +// +// Created by Andrew Longhorn in Jan 2019. +// Copyright © 2019 Allow2 Pty Ltd. All rights reserved. +// +// LICENSE: +// See LICENSE file in root directory +// + +using System; +using System.Collections; +using System.Collections.Generic; +using UnityEngine; +using UnityEngine.Networking; +using Allow2_SimpleJSON; +using System.Text; namespace Allow2 { - public class Connection + + /// + /// Access functionality of the Allow2 platform easily. + /// + public static class Allow2 { - private static readonly HttpClient client = new HttpClient(); + private static readonly string uuid; + private static string _deviceToken = "Not Set"; // ie: "iug893-kjg-fiug23" - not persisted: always set this on start + public static EnvType env = EnvType.Production; + + // + // relevant persistence items + // + static int userId; // ie: 27634 + static string pairToken; // ie: "98hbieg87-ilulieugil-dilufkucy" + static string _timezone; // ie: "Australia/Brisbane" + static Dictionary _children = new Dictionary(); + static int _childId = 0; + + //HashSet checkers = new HashSet(); // contains uuids for running autocheckers (abort if your uuid is missing) + static string checkerUuid = null; // uuid for the current checker + static IEnumerator checker = null; // the current autochecker + static IEnumerator qrCall = null; + static DateTime qrDebounce = new DateTime(1970, 1, 1, 0, 0, 0, 0, System.DateTimeKind.Utc); + static string pairingUuid = null; + + public static int childId; // ie: 34 + + public static readonly DateTime Epoch = new DateTime(1970, 1, 1, 0, 0, 0, 0, System.DateTimeKind.Utc); + + public static string ApiUrl + { + get + { + switch (env) + { + //case EnvType.Sandbox: + //return "https://sandbox-api.allow2.com" + case EnvType.Staging: + return "https://staging-api.allow2.com"; + default: + return "https://api.allow2.com"; + } + } + } + + public static Dictionary Children + { + get + { + return _children; + } + } + + public static string ServiceUrl + { + get + { + switch (env) + { + //case EnvType.Sandbox: + //return "https://sandbox-service.allow2.com" + case EnvType.Staging: + return "https://staging-service.allow2.com"; + default: + return "https://service.allow2.com"; + } + } + } + + public static resultClosure checkResultHandler; + + public static bool IsPaired + { + get + { + return (userId > 0) && (pairToken != null); + } + } + + public static string Timezone + { + get + { + return _timezone; + } + set + { + _timezone = value; + PlayerPrefs.SetString("timezone", _timezone); + } + } + + /// + /// Gets or sets the device token. + /// The device token is mandatory, this needs to be set before making any calls to the sdk/api. + /// Generate your device token for free at https://developer.allow2.com + /// Use it to manage your app/game/device, promote it on the Allow2 platform and track downloads and usage. + /// + /// The device token. + public static string DeviceToken + { + get + { + return _deviceToken; + } + set + { + _deviceToken = value; + PlayerPrefs.SetString("deviceToken", _deviceToken); + //if (!IsPaired) + //{ + // todo: start a regular timer to keep trying to call home on startup until we confirm we are NOT paired. + CheckForBrokenPairing(); + //} + } + } + + private static void Persist() + { + // todo: write to protected namespace storage? + PlayerPrefs.SetInt("userId", userId); + PlayerPrefs.SetString("pairToken", pairToken); + } + + // no persistence here + private static Dictionary resultCache = new Dictionary(); + + /// + /// A result closure provides the result from a call to the Allow2 platform. + /// + public delegate void resultClosure(string err, Allow2CheckResult result); + + /// + /// An image closure provides the image returned by the Allow2 platform. + /// + public delegate void imageClosure(string err, Texture2D image); + + static Allow2() + { + uuid = SystemInfo.deviceUniqueIdentifier; + if (uuid == SystemInfo.unsupportedIdentifier) + { + // cannot use on this platform, kludge is to generate and store one, use auditing on the server side to detect disconnection in any case + uuid = PlayerPrefs.GetString("uuid"); + if (uuid == null) + { + uuid = System.Guid.NewGuid().ToString(); + PlayerPrefs.SetString("uuid", uuid); + } + } + userId = PlayerPrefs.GetInt("userId"); + pairToken = PlayerPrefs.GetString("pairToken"); + _timezone = PlayerPrefs.GetString("timezone"); + } + + static IEnumerator CheckForBrokenPairing() + { + // here we just ask the question, was our unique ID paired and somehow it lost it's pairing? + WWWForm form = new WWWForm(); + form.AddField("uuid", uuid); + form.AddField("deviceToken", _deviceToken); + + using (UnityWebRequest www = UnityWebRequest.Post(ApiUrl + "/api/isDevicePaired", form)) + { + yield return www.SendWebRequest(); + + // we actually only need to check for a 200 response to know the server is informed and it's been checked. + if (www.isNetworkError || www.isHttpError) + { + Debug.Log(www.error); + } + else + { + Debug.Log(www.downloadHandler.text); + var response = Allow2_SimpleJSON.JSON.Parse(www.downloadHandler.text); + // extract + } + } + } + + /// + /// Pair your game/app/device to a parents Allow2 account. + /// + /// Provide a (any) MonoBehaviour for the sdk to use to call the platform. + /// The email address of the Allow2 account being paired. + /// The associated password for the Allow2 account being paired. + /// The name the user would like to use to identify this app/game/device. + /// Provides the image of the QR Code. + public static void Pair(MonoBehaviour behaviour, + string user, // ie: "fred@gmail.com", + string pass, // ie: "my super secret password", + string deviceName, // ie: "Fred's iPhone" + resultClosure callback) + { + behaviour.StartCoroutine(_Pair(user, pass, deviceName, callback)); + } + + static IEnumerator _Pair(string user, // ie: "fred@gmail.com", + string pass, // ie: "my super secret password", + string deviceName, // ie: "Fred's iPhone" + resultClosure callback + ) + { + if (IsPaired) + { + callback(Allow2Error.AlreadyPaired, null); + yield break; + } + WWWForm form = new WWWForm(); + form.AddField("user", user); + form.AddField("pass", pass); + form.AddField("deviceToken", _deviceToken); + form.AddField("name", deviceName); + form.AddField("uuid", uuid); + + Debug.Log(ApiUrl + "/api/pairDevice"); + + using (UnityWebRequest www = UnityWebRequest.Post(ApiUrl + "/api/pairDevice", form)) + { + yield return www.SendWebRequest(); + + var response = JSONNode.Parse(www.downloadHandler.text); + + if (response == null) + { + if (www.isNetworkError || www.isHttpError) + { + Debug.Log(www.error.ToString()); + callback(www.error, null); + } + yield break; + } + + //{ + // "status":"success", + // "pairId":21105, + // "token":"8314c722-36fe-4256-81f4-8cce6e4da32d" + // "name":"Unity Test" + // "userId":6 + // "children": [ + // {"id":68,"name":"Cody"}, + // {"id":69,"name":"Mikayla"}, + // {"id":21423,"name":"Mary"} + // ] + //} + + Debug.Log(www.downloadHandler.text); + var json = Allow2_SimpleJSON.JSON.Parse(www.downloadHandler.text); + + if (json["status"] != "success") + { + callback(Allow2Error.InvalidResponse, null); + yield break; + } + + userId = json["userId"]; + pairToken = json["token"]; + _children = ParseChildren(json["children"]); + + Persist(); + + // return + callback(null, null); + } + } + + const int QRDebounceDelay = 500; + + /// + /// Gets a new QR Code texture to show to the user to enable them to pair your game/app/device with Allow2. + /// Call this to get a new QR Code any time the user changes the name of the device. + /// Note this is debounced automatically, so just keep calling it immediately (even if the user is still typing). + /// + /// Provide a (any) MonoBehaviour for the sdk to use to call the platform. + /// The name the user would like to use to identify this app/game/device. + /// Callback. + public static void GetQR(MonoBehaviour behaviour, string deviceName, imageClosure callback) { + DateTime now = DateTime.Now; + Debug.Log(qrDebounce.CompareTo(now)); + if ((qrCall != null) && (qrDebounce.CompareTo(now) > 0)) { + Debug.Log("debounce " + qrDebounce.ToShortTimeString() + " < " + now.ToShortTimeString()); + IEnumerator oldCall = qrCall; + qrCall = null; + behaviour.StopCoroutine(oldCall); + } + qrDebounce = now.AddMilliseconds(QRDebounceDelay); + qrCall = _GetQR(deviceName, callback); + behaviour.StartCoroutine(qrCall); + } + + static IEnumerator _GetQR(string deviceName, imageClosure callback) + { + yield return new WaitForSeconds(QRDebounceDelay/1000); + string qrURL = ApiUrl + "/genqr/" + + UnityWebRequest.EscapeURL(_deviceToken) + "/" + + UnityWebRequest.EscapeURL(uuid) + "/" + + UnityWebRequest.EscapeURL(deviceName); + UnityWebRequest www = UnityWebRequestTexture.GetTexture(qrURL); + yield return www.SendWebRequest(); + + if (www.isNetworkError || www.isHttpError) + { + Debug.Log("QR LOAD ERROR: " + www.error); + Texture errorImage = Resources.Load("Allow2/QRError") as Texture2D; + callback(www.error, null); + yield break; + } + Texture2D qrCode = DownloadHandlerTexture.GetContent(www); + callback(null, qrCode); + } + + //public static IEnumerator Check(int[] activities, + // resultClosure callback, + // bool log = false + // ) + //{ + // return Check(_child, activities, + // resultClosure callback, + // bool log = false + // ) + //} + + private static Dictionary ParseChildren(JSONNode json) + { + Dictionary children = new Dictionary(); + foreach (JSONNode child in json) + { + children[child["id"]] = child["name"]; + } + return children; + } + + /// + /// Check if the specified child can use the current activities and optionally log usage. + /// Note that if you specify log as true, usage will be recorded even if the child is technically not allowed to use one of the + /// . This is to allow you the ability to be flexible in allowing usage, but should be used sparingly. + /// If you are, for instance, just checking if something CAN be done at this time, then make sure you supply false for the log parameter. + /// + /// Provide a (any) MonoBehaviour for the sdk to use to call the platform. + /// Id of the child for which you wish to check (and possibly log) activities. + /// The activity ids to check. + /// Provides the result of the check. + /// If set to true, then log the usage of these activities as well. + public static void Check(MonoBehaviour behaviour, + int childId, // childId == 0 ? Get Updated Child List and confirm Pairing + int[] activities, + resultClosure callback, + bool log = false + ) + { + behaviour.StartCoroutine(_Check(null, childId, activities, callback, log)); + } + + /// + /// Check if the specified child can use the current activities and optionally log usage. + /// You should ALWAYS log usage when the child is using the activities, otherwise their usage will not be debited from their quota. + /// Note that if you specify log as true, usage will be recorded even if the child is technically not allowed to use one of the + /// . This is to allow you the ability to be flexible in allowing usage, but should be used sparingly. + /// If you are, for instance, just checking if something CAN be done at this time, then make sure you supply false for the log parameter. + /// + /// Provide a (any) MonoBehaviour for the sdk to use to call the platform. + /// The activity ids to check. + /// Provides the result of the check. + /// If set to true, then log the usage of these activities as well. + public static void Check(MonoBehaviour behaviour, + int[] activities, + resultClosure callback, + bool log = false + ) + { + behaviour.StartCoroutine(_Check(null, childId, activities, callback, log)); + } + + static IEnumerator _Check(string myUuid, + int childId, // childId == 0 ? Get Updated Child List and confirm Pairing + int[] activities, + resultClosure callback, + bool log) // if set, then this is an autochecker and we drop it if we replace it. If null, it's adhoc, always return a value + { + if (!IsPaired) + { + callback(Allow2Error.NotPaired, null); + yield break; + } + + Debug.Log(userId); + Debug.Log(pairToken); + Debug.Log(_deviceToken); + + JSONNode body = new JSONObject(); + body.Add("userId", userId); + body.Add("pairToken", pairToken); + body.Add("deviceToken", _deviceToken); + body.Add("tz", _timezone); + JSONArray activityJson = new JSONArray(); + foreach (int activity in activities) + { + JSONNode activityParams = new JSONObject(); + activityParams.Add("id", activity); + activityParams.Add("log", log); + activityJson.Add(activityParams); + } + body.Add("activities", activityJson); + body.Add("log", log); + if (childId > 0) + { + body.Add("childId", childId); + } + string bodyStr = body.ToString(); + + // check the cache first + if (resultCache.ContainsKey(bodyStr)) + { + Allow2CheckResult checkResult = resultCache[bodyStr]; + + if (checkResult.Expires.CompareTo(new DateTime()) < 0) + { + // not expired yet, use cached value + callback(null, checkResult); + yield break; + } + + // clear cached value and ask the server again + resultCache.Remove(bodyStr); + } + + byte[] bytes = Encoding.UTF8.GetBytes(bodyStr); + using (UnityWebRequest www = new UnityWebRequest(ServiceUrl + "/serviceapi/check")) + { + www.method = UnityWebRequest.kHttpVerbPOST; + www.uploadHandler = new UploadHandlerRaw(bytes); + www.downloadHandler = new DownloadHandlerBuffer(); + www.uploadHandler.contentType = "application/json"; + www.chunkedTransfer = false; + yield return www.SendWebRequest(); + + if ((myUuid != null) && (checkerUuid != myUuid)) { + Debug.Log("drop response for check: " + myUuid); + yield break; // this check is aborted, just drop the response and don't return; + } + + Debug.Log(www.downloadHandler.text); + var json = Allow2_SimpleJSON.JSON.Parse(www.downloadHandler.text); + + if ((www.responseCode == 401) || + ((json["status"] == "error") && + ((json["message"] == "Invalid user.") || + (json["message"] == "invalid pairToken")))) + { + // special case, no longer controlled + Debug.Log("No Longer Paired"); + userId = 0; + pairToken = null; + Persist(); + //childId = 0; + //_children = [] + //_dayTypes = [] + var failOpen = new Allow2CheckResult(); + failOpen.Add("subscription", new Allow2_SimpleJSON.JSONArray()); + failOpen.Add("allowed", true); + failOpen.Add("activities", new Allow2_SimpleJSON.JSONArray()); + failOpen.Add("dayTypes", new Allow2_SimpleJSON.JSONArray()); + failOpen.Add("allDayTypes", new Allow2_SimpleJSON.JSONArray()); + failOpen.Add("children", new Allow2_SimpleJSON.JSONArray()); + callback(null, failOpen); + yield break; + } + + if (www.isNetworkError || www.isHttpError) + { + Debug.Log(www.error); + callback(www.error, null); + yield break; + } + + if (json["allowed"] == null) + { + callback(Allow2Error.InvalidResponse, null); + yield break; + } + + var response = new Allow2CheckResult(); + response.Add("activities", json["activities"]); + response.Add("subscription", json["subscription"]); + response.Add("dayTypes", json["dayTypes"]); + response.Add("children", json["children"]); + var _dayTypes = json["allDayTypes"]; + response.Add("allDayTypes", _dayTypes); + var oldChildIds = _children.Keys; + var children = json["children"]; + _children = ParseChildren(children); + response.Add("children", children); + + if (oldChildIds != _children.Keys) + { + Persist(); // only persist if the children change, this won't happen often. + } + + // cache the response + resultCache[bodyStr] = response; + callback(null, response); + } + } - public Connection() + private static IEnumerator CheckLoop(string myUuid, + int childId, + int[] activities, + resultClosure callback, + bool log = false) { + while (checkerUuid == myUuid) { + Debug.Log("check uuid: " + myUuid); + yield return _Check(myUuid, childId, activities, delegate (string err, Allow2CheckResult result) { + if (!IsPaired && (checkerUuid != myUuid)) { + checkerUuid = null; // stop the checker, we have been unpaired + } + callback(err, result); + }, log); + yield return new WaitForSeconds(3); + } } - public async Task test() + /// + /// Start checking (and optionally logging) the ability for the child to use the given activities. + /// This starts a process that regularly checks (and optionally logs) usage until stopped using Allow2.StopChecking. + /// You can call this repeatedly and change the child id at any time, but there will only ever be one process and + /// it will continue to use the last provided child id. + /// Note, that if the child is unable or disallowed to use any of the , they will still be continually checked/logged until Allow2.StopChecking() is called. + /// This is to allow you to selectively allow the child to finish an activity, but will put them in negative credit (which will come off future usage). + /// You should ALWAYS log usage when the child is using the activities, otherwise their usage will not be debited from their quota. + /// + /// Provide a (any) MonoBehaviour for the sdk to use to call the platform. + /// The child for which the activites are being checked (and optionally logged). + /// The activity ids to check. + /// Provides the result of the check. + /// If set to true, then log the usage of these activities as well. + public static void StartChecking(MonoBehaviour behaviour, + int childId, + int[] activities, + resultClosure callback, + bool log) { + // change the parameters + bool changed = (_childId != childId); + _childId = childId; + if (changed || (checkerUuid == null)) { + //switch checker + checkerUuid = System.Guid.NewGuid().ToString(); // this will abort the current checker and kill it. + /*checker = */ behaviour.StartCoroutine(CheckLoop(checkerUuid, childId, activities, callback, log)); + } + } + + /// + /// Stop checking (and logging) usage. + /// If there is no current checking/logging process started with Allow2.StartChecking(), this call has no effect. + /// + public static void StopChecking() + { + checkerUuid = null; // this will kill the running checker + } + + /// + /// Submit a request on behalf of the current child. + /// + /// Results of the request. + /// Provide a (any) MonoBehaviour for the sdk to use to call the platform. + /// The Id of the child making the request. + /// (optional)The Id of the day type they are requesting. + /// (optional) An Array of ids for Bans they are asking to be lifted. + /// (optional) Message to send with the request. + /// callback that will return the response success or error. + public static void Request(MonoBehaviour behaviour, + int childId, + int dayTypeId, + int[] lift, + string message, + resultClosure callback) + { + behaviour.StartCoroutine(_Request(childId, dayTypeId, lift, message, callback)); + } + + /// + /// Submit a request on behalf of the current child. + /// + /// Results of the request. + /// Provide a (any) MonoBehaviour for the sdk to use to call the platform. + /// (optional)The Id of the day type they are requesting. + /// (optional) An Array of ids for Bans they are asking to be lifted. + /// (optional) Message to send with the request. + /// callback that will return the response success or error. + public static void Request(MonoBehaviour behaviour, + int dayTypeId, + int[] lift, + string message, + resultClosure callback) + { + behaviour.StartCoroutine(_Request(childId, dayTypeId, lift, message, callback)); + } + + static IEnumerator _Request(int childId, + int dayTypeId, + int[] lift, + string message, + resultClosure callback) + { + if (!IsPaired) + { + callback(Allow2Error.NotPaired, null); + yield break; + } + if (childId < 1) + { + callback(Allow2Error.MissingChildId, null); + yield break; + } + + WWWForm body = new WWWForm(); + body.AddField("userId", userId); + body.AddField("pairToken", pairToken); + body.AddField("deviceToken", _deviceToken); + body.AddField("childId", childId); + //form.AddField("lift", lift.asJson); + //if (dayTypeId != nil) { + // body["dayType"] = JSON(dayTypeId!) + // body["changeDayType"] = true + //} + string bodyStr = body.ToString(); + + byte[] bytes = Encoding.UTF8.GetBytes(bodyStr); + using (UnityWebRequest www = new UnityWebRequest(ApiUrl + "/api/checkPairing")) + { + www.method = UnityWebRequest.kHttpVerbPOST; + www.uploadHandler = new UploadHandlerRaw(bytes); + www.downloadHandler = new DownloadHandlerBuffer(); + www.uploadHandler.contentType = "application/json"; + www.chunkedTransfer = false; + yield return www.SendWebRequest(); + + // anything other than a 200 response is a "try again" as far as we are concerned + if (www.responseCode != 200) + { + callback(Allow2Error.NoConnection, null); // let the caller know we are having problems + yield break; + } + + } + } + + /// + /// Use this routine to notify Allow2 you are starting a pairing session for a QR Code pairing. + /// Call this when you are about to display a QR code to the user to allow them to pair with ALlow2. + /// Get the appropriate QR Code using Allow2.GetQR(). + /// + /// + /// Provide a (any) MonoBehaviour for the sdk to use to call the platform. + /// Callback that will return response success or error + public static void StartPairing(MonoBehaviour behaviour, resultClosure callback) + { + //switch checker + pairingUuid = System.Guid.NewGuid().ToString(); // this will abort the current poll and kill it. + behaviour.StartCoroutine(PairingLoop(pairingUuid, callback)); + } + + /// + /// Tell Allow2 the QR Code for pairing is no longer being displayed. + /// Call this when you stop showing the QR code and therefore the user can no longer scan it. + /// + public static void StopPairing() + { + pairingUuid = null; // this will kill the running checker + } + + private static IEnumerator PairingLoop(string myUuid, resultClosure callback) + { + while (pairingUuid == myUuid) + { + Debug.Log("poll uuid: " + myUuid); + yield return _PollPairing(myUuid, delegate (string err, Allow2CheckResult result) { + if (IsPaired) + { + pairingUuid = null; // stop the checker, we have been paired + } + callback(err, result); + }); + yield return new WaitForSeconds(3); + } + } + + static IEnumerator _PollPairing(string myUuid, resultClosure callback) + { + JSONNode body = new JSONObject(); + body.Add("uuid", uuid); + body.Add("deviceToken", _deviceToken); + string bodyStr = body.ToString(); + + byte[] bytes = Encoding.UTF8.GetBytes(bodyStr); + using (UnityWebRequest www = new UnityWebRequest(ApiUrl + "/api/checkPairing")) + { + www.method = UnityWebRequest.kHttpVerbPOST; + www.uploadHandler = new UploadHandlerRaw(bytes); + www.downloadHandler = new DownloadHandlerBuffer(); + www.uploadHandler.contentType = "application/json"; + www.chunkedTransfer = false; + yield return www.SendWebRequest(); - //var values = new Dictionary - // { - // { "thing1", "hello" }, - // { "thing2", "world" } - // }; + // anything other than a 200 response is a "try again" as far as we are concerned + if (www.responseCode != 200) + { + callback(Allow2Error.NoConnection, null); // let the caller know we are having problems + yield break; + } - //var content = new FormUrlEncodedContent(values); + Debug.Log(www.downloadHandler.text); + var json = Allow2_SimpleJSON.JSON.Parse(www.downloadHandler.text); - //var response = await client.PostAsync("http://api.allow2.com/", content); + string status = json["status"]; - //var responseString = await response.Content.ReadAsStringAsync(); + if (status != "success") + { + callback(json["message"] ?? "Unknown Error", null); + yield break; + } - var responseString = await client.GetStringAsync("http://api.allow2.com/"); + pairToken = json["pairToken"]; + userId = json["userId"]; + childId = json["childId"]; + _children = childId > 0 ? new Dictionary() : ParseChildren(json["children"]); - return responseString; + callback(null, null); + } } } -} +} \ No newline at end of file diff --git a/Allow2/Allow2.csproj b/Allow2/Allow2.csproj deleted file mode 100644 index 7a6acc4..0000000 --- a/Allow2/Allow2.csproj +++ /dev/null @@ -1,44 +0,0 @@ - - - - - Debug - AnyCPU - {FFE961F4-4FDE-4736-A89D-721EB5C11A97} - {786C830F-07A1-408B-BD7F-6EE04809D6DB};{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC} - Library - Allow2 - Allow2 - v4.5 - Profile111 - Allow2 - 1.0.0 - andrew - Allow2 - - - true - full - false - bin\Debug - DEBUG; - prompt - 4 - - - true - bin\Release - prompt - 4 - - - - - - - - - - - - \ No newline at end of file diff --git a/Allow2/Allow2Ban.cs b/Allow2/Allow2Ban.cs new file mode 100644 index 0000000..2a5c7f4 --- /dev/null +++ b/Allow2/Allow2Ban.cs @@ -0,0 +1,34 @@ +// +// Allow2Unity +// Allow2Ban.cs +// +// Created by Andrew Longhorn in May 2019. +// Copyright © 2019 Allow2 Pty Ltd. All rights reserved. +// +// LICENSE: +// See LICENSE file in root directory +// + +using System; +using Allow2_SimpleJSON; + +namespace Allow2 +{ + public class Allow2Ban + { + public int Id { get; private set; } + public string Title { get; private set; } + public DateTime AppliedAt { get; private set; } + public int Duration { get; private set; } + public bool Selected { get; private set; } + + public Allow2Ban(string name, JSONNode val) + { + Id = val["id"].AsInt; + Title = name; + AppliedAt = Allow2.Epoch.AddSeconds(val["appliedAt"].AsInt).ToUniversalTime(); + Duration = val["durationMinutes"].AsInt; + Selected = false; + } + } +} \ No newline at end of file diff --git a/Allow2/Allow2CheckResult.cs b/Allow2/Allow2CheckResult.cs new file mode 100644 index 0000000..b1c3834 --- /dev/null +++ b/Allow2/Allow2CheckResult.cs @@ -0,0 +1,259 @@ +// +// Allow2Unity +// Allow2CheckResult.cs +// +// Created by Andrew Longhorn in Jan 2019. +// Copyright © 2019 Allow2 Pty Ltd. All rights reserved. +// +// LICENSE: +// See LICENSE file in root directory +// + +using System; +using System.Collections.Generic; +using Allow2_SimpleJSON; + +namespace Allow2 +{ + // Allow2Response JSON structure + //{ + // "allowed":false, + // "activities":{ + // "1":{ + // "id":1, + // "name":"Internet", + // "timed":true, + // "units":"minutes", + // "banned":false, + // "remaining":0, + // "cached":false, + // "expires":1557067560, + // "timeBlock":{ + // "allowed":true, + // "remaining":495 + // } + // }, + // "2":{ + // "id":2, + // "name":"Computer", + // "timed":true, + // "units":"minutes", + // "banned":false, + // "remaining":0, + // "cached":false, + // "expires":1557067560, + // "timeBlock":{ + // "allowed":true, + // "remaining":375 + // } + // } + // }, + // "dayTypes":{ + // "today":{"id":57,"name":"Weekend"}, + // "tomorrow":{"id":61,"name":"School Day"} + // }, + // "allDayTypes":[ + // {"id":57,"name":"Weekend"}, + // {"id":59,"name":"Weekday"}, + // {"id":61,"name":"School Day"}, + // {"id":64,"name":"Sick Day"}, + // {"id":66,"name":"Holiday"}, + // {"id":16997,"name":"No Limit"} + // ], + // "children":[ + // {"id":681,"name":"Bob","pin":"1234"}, + // {"id":639,"name":"Mary","pin":"4567"}, + // {"id":21423,"name":"Milly","pin":"5566"} + // ], + // "subscription":{ + // "active":false, + // "type":1, + // "maxChildren":6, + // "childCount":3, + // "deviceCount":12, + // "serviceCount":0, + // "financial":false + // } + //} + + public class Allow2CheckResult : JSONObject // shortcut implementation for now + { + //public Allow2CheckResult() + //{ + //} + + /// + /// Convenience method. + /// + /// The activities. + public JSONNode Activities { + get { + return this["activities"]; + } + } + + /// + /// Convenience method. + /// + /// The subscription. + public JSONNode Subscription + { + get + { + return this["subscription"]; + } + } + + /// + /// When does the validity of this result expire? (cache expiry). + /// + /// The expiry Date/Time + public DateTime Expires { + get { + int unixTimeStamp = ((Activities != null) && (Activities[0] != null)) ? + this["activities"]["0"]["expires"].AsInt : 0; + return Allow2.Epoch.AddSeconds(unixTimeStamp).ToUniversalTime(); // nineteen70.AddSeconds(unixTimeStamp).ToLocalTime(); + } + } + + /// + /// Returns if the user is financial or within free usage tier, otherwise returns a message indicating they need a subscription + /// + /// The need subscription. + public string NeedSubscription { + get { + if (!Subscription["financial"].AsBool) { + int childCount = Subscription["childCount"].AsInt; + int maxChildren = Subscription["maxChildren"].AsInt; + //int serviceCount = subscription["serviceCount"].AsInt; + //int deviceCount = subscription["deviceCount"].AsInt; + int type = Subscription["type"].AsInt; + + if ((maxChildren > 0 ) && (childCount > maxChildren) && (type == 1)) { + return "Subscription Upgrade Required."; + } + + return "Subscription Required."; + } + return null; + } + } + + /// + /// A simple top level result, the child is currently allowed or not based on the activity time and quotas. + /// + /// true if is allowed; otherwise, false. + public bool IsAllowed { + get { + return this["allowed"].AsBool; + } + } + + /// + /// A Summary explanation of the current reasons they may not be allowed at this time. + /// + /// The explanation. + public string Explanation { + get { + List reasons = new List(); + string subscriptionString = NeedSubscription; + if (subscriptionString != null) { + reasons.Add(subscriptionString); + } + foreach (JSONNode activity in Activities) { + if (activity["banned"].IsBoolean && activity["banned"].AsBool) + { + reasons.Add("You are currently banned from " + activity["name"].ToString()); + } + else + { + JSONNode timeblock = activity["timeblock"]; + if ((timeblock == null) || !timeblock["allowed"].IsBoolean || !timeblock["allowed"].AsBool) + { + reasons.Add("You cannot use " + activity["name"].ToString() + " at this time."); + } + else + { + // todo: reasons.append("You have \(activity["remaining"]) to use \(activity["name"]).") + } + } + } + return String.Join("/n", reasons.ToArray()); + } + } + + /// + /// A list of the current bans in place for this child. + /// + /// The current bans. + public Allow2Ban[] CurrentBans { + get { + List bans = new List(); + foreach (JSONNode activity in Activities) { + + if (activity["banned"].AsBool) { + //int id = activity.dictionary?["id"]?.uInt64Value, + string name = activity["name"].ToString(); + if ((activity["bans"] != null) && (activity["bans"]["bans"] != null) && (activity["bans"]["bans"].IsArray)) + { + JSONArray items = activity["bans"]["bans"].AsArray; + foreach (JSONNode item in items) + { + bans.Add(new Allow2Ban(name, item)); + } + } + else + { + // todo: reasons.append("You have \(activity["remaining"]) to use \(activity["name"]).") + } + } + } + return bans.ToArray(); + } + } + + /// + /// The type of day it is today. + /// + /// The day type. + public Allow2Day Today + { + get + { + if (this["dayTypes"] == null) { return null; } + return Allow2Day.DayOrNull(this["dayTypes"]["today"]); + } + } + + /// + /// The type of day it will be tomorrow. + /// + /// The day type. + public Allow2Day Tomorrow + { + get + { + if (this["dayTypes"] == null) { return null; } + return Allow2Day.DayOrNull(this["dayTypes"]["tomorrow"]); + } + } + + /// + /// All Day Types the parent has set on their account that the child should be aware of or can choose from for a request. + /// + /// All day types. + public Allow2Day[] AllDayTypes + { + get + { + List dayTypes = new List(); + foreach (JSONNode dayType in this["allDayTypes"]) + { + dayTypes.Add(new Allow2Day(dayType)); + } + return dayTypes.ToArray(); + } + } + } + +} diff --git a/Allow2/Allow2Child.cs b/Allow2/Allow2Child.cs new file mode 100644 index 0000000..015ca8f --- /dev/null +++ b/Allow2/Allow2Child.cs @@ -0,0 +1,26 @@ +// +// Allow2Unity +// Allow2Child.cs +// +// Created by Andrew Longhorn in May 2019. +// Copyright © 2019 Allow2 Pty Ltd. All rights reserved. +// +// LICENSE: +// See LICENSE file in root directory +// + +namespace Allow2 +{ + public class Allow2Child + { + public int Id { get; private set; } + public string Name { get; private set; } + public string Pin { get; private set; } + + Allow2Child(int id, string name, string pin) { + Id = id; + Name = name; + Pin = pin; + } + } +} \ No newline at end of file diff --git a/Allow2/Allow2Day.cs b/Allow2/Allow2Day.cs new file mode 100644 index 0000000..e562243 --- /dev/null +++ b/Allow2/Allow2Day.cs @@ -0,0 +1,42 @@ +// +// Allow2Unity +// Allow2Day.cs +// +// Created by Andrew Longhorn in May 2019. +// Copyright © 2019 Allow2 Pty Ltd. All rights reserved. +// +// LICENSE: +// See LICENSE file in root directory +// + +using Allow2_SimpleJSON; + +namespace Allow2 +{ + public class Allow2Day // shortcut implementation for now + { + public int Id { get; private set; } + public string Name { get; private set; } + + public Allow2Day(int id, string name) + { + Id = id; + Name = name; + } + + public Allow2Day(JSONNode json) + { + Id = json["id"].AsInt; + Name = json["name"]; + } + + public static Allow2Day DayOrNull(JSONNode json) + { + if (json == null) { + return null; + } + + return new Allow2Day(json["id"].AsInt, json["name"]); + } + } +} \ No newline at end of file diff --git a/Allow2/EnumeratedTypes.cs b/Allow2/EnumeratedTypes.cs new file mode 100644 index 0000000..97742a3 --- /dev/null +++ b/Allow2/EnumeratedTypes.cs @@ -0,0 +1,49 @@ +// +// Allow2Unity +// EnumeratedTypes.cs +// +// Created by Andrew Longhorn in Jan 2019. +// Copyright © 2019 Allow2 Pty Ltd. All rights reserved. +// +// LICENSE: +// See LICENSE file in root directory +// + +namespace Allow2 +{ + /// + /// Environment: Use Production ONLY (staging is for internal testing). + /// + public enum EnvType { + Production, + // Sandbox, + Staging + } + + /// + /// Activities: These are the current activities for Allow2. + /// + public enum Activity: int { + Internet = 1, + Computer = 2, + Gaming = 3, + Message = 4, + JunkFood = 5, + Lollies = 6, + Electricity = 7, + ScreenTime = 8, + Social = 9, + PhoneTime = 10 + } + + public static class Allow2Error + { + public const string NotPaired = "NotPaired"; + public const string AlreadyPaired = "AlreadyPaired"; + public const string MissingChildId = "MissingChildId"; + public const string NotAuthorised = "NotAuthorised"; + public const string InvalidResponse = "InvalidResponse"; + public const string NoConnection = "NoConnection"; + } + +} diff --git a/Allow2/Examples/CheckButton.cs b/Allow2/Examples/CheckButton.cs new file mode 100644 index 0000000..e1da1d7 --- /dev/null +++ b/Allow2/Examples/CheckButton.cs @@ -0,0 +1,91 @@ +// +// Copyright (C) 2019 Allow2 +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with +// the License. +// +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on +// an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and limitations under the License. +// +namespace Allow2.Allow2Examples +{ + + using UnityEngine; + + public class CheckButton : MonoBehaviour + { + + public int childId = 68; + public int[] activities = { + (int)Activity.Internet, + (int)Activity.Computer + }; + + /// + /// Check once and log activity for the given child and activities. + /// + public void Check() + { + Debug.Log("Check"); + Allow2.Check( + this, + childId, + activities, + delegate (string err, Allow2CheckResult result) + { + Debug.Log("Check Error" + err); + Debug.Log("Paired: " + Allow2.IsPaired); + if (result != null) + { + Debug.Log("Allowed: " + result.IsAllowed); + if (!result.IsAllowed) + { + Debug.Log(result.Explanation); + } + } + }, + true); + } + + /// + /// Start a continuous check (and logging usage) for the given child and activities. + /// + public void StartChecking() + { + Debug.Log("Start Checking"); + Allow2.StartChecking( + this, + childId, + activities, + delegate (string err, Allow2CheckResult result) + { + Debug.Log("Check Error" + err); + Debug.Log("Paired: " + Allow2.IsPaired); + if (result != null) + { + Debug.Log("Allowed: " + result.IsAllowed); + if (!result.IsAllowed) + { + Debug.Log(result.Explanation); + } + } + }, + true); + } + + /// + /// Stop checking and logging. + /// + public void StopChecking() + { + Debug.Log("Stop Checking"); + Allow2.StopChecking(); + } + } +} diff --git a/Allow2/Examples/DeviceNameInput.cs b/Allow2/Examples/DeviceNameInput.cs new file mode 100644 index 0000000..95500bc --- /dev/null +++ b/Allow2/Examples/DeviceNameInput.cs @@ -0,0 +1,50 @@ +// +// Copyright (C) 2019 Allow2 +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with +// the License. +// +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on +// an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and limitations under the License. +// +namespace Allow2.Allow2Examples +{ + + using UnityEngine; + using UnityEngine.UI; + + public class DeviceNameInput : MonoBehaviour + { + + public InputField inputField; + public RawImage qrImage; + + /// + /// On creating the field, we pre-populate it with the system device name. + /// This will auto-trigger the "InputValueChanged" and update the QR Code for pairing. + /// + void Awake() + { + inputField.text = SystemInfo.deviceName; + } + + /// + /// When the input value changes, generate a new QR Code, so the user can interactively edit the name and the pairing process uses that name. + /// + /// Input. + public void InputValueChanged(string input) + { + Allow2.GetQR(this, input, delegate (string err, Texture2D qrCode) + { + Debug.Log("Input Value qrcode error: " + (err ?? "No Error") + " : " + (qrCode != null ? qrCode.width.ToString() + "," + qrCode.height.ToString() : "no")); + qrImage.GetComponent().texture = qrCode; + }); + } + } +} diff --git a/Allow2/Examples/PairButton.cs b/Allow2/Examples/PairButton.cs new file mode 100644 index 0000000..49f8771 --- /dev/null +++ b/Allow2/Examples/PairButton.cs @@ -0,0 +1,52 @@ +// +// Copyright (C) 2019 Allow2 +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with +// the License. +// +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on +// an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and limitations under the License. +// +namespace Allow2.Allow2Examples +{ + + using UnityEngine; + using UnityEngine.UI; + + public class PairButton : MonoBehaviour + { + + public InputField UsernameField; + public InputField PasswordField; + public InputField DeviceNameField; + + /// + /// Manually pair with Allow2 by providing the username and password entered by the user in your pairing interface. + /// + public void Pair() + { + Debug.Log("Start Pairing"); + Allow2.Pair( + this, + UsernameField.text, + PasswordField.text, + DeviceNameField.text, + delegate (string err, Allow2CheckResult result) + { + Debug.Log("Stop Pairing"); + Debug.Log("Pairing Error" + err); + if (result) + { + Debug.Log("Pairing Result" + result.ToString()); + } + } + ); + } + } +} diff --git a/Allow2/Examples/RequestButton.cs b/Allow2/Examples/RequestButton.cs new file mode 100644 index 0000000..7f3bd7a --- /dev/null +++ b/Allow2/Examples/RequestButton.cs @@ -0,0 +1,49 @@ +// +// Copyright (C) 2019 Allow2 +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with +// the License. +// +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on +// an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and limitations under the License. +// +namespace Allow2.Allow2Examples +{ + + using UnityEngine; + + public class RequestButton : MonoBehaviour + { + + public int dayTypeId = 23; + public int[] bansToLift = {}; + + /// + /// Send a request on behalf of the child. + /// + public void Request() + { + Debug.Log("Request"); + Allow2.childId = 68; + Allow2.Request( + this, + dayTypeId, + bansToLift, + "test", + delegate (string err, Allow2CheckResult result) + { + Debug.Log("Request Error" + err); + if (result != null) + { + Debug.Log(result.Explanation); + } + }); + } + } +} diff --git a/Allow2/Examples/SceneClass.cs b/Allow2/Examples/SceneClass.cs new file mode 100644 index 0000000..a247859 --- /dev/null +++ b/Allow2/Examples/SceneClass.cs @@ -0,0 +1,68 @@ +// +// Copyright (C) 2019 Allow2 +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with +// the License. +// +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on +// an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and limitations under the License. +// +namespace Allow2.Allow2Examples +{ + + using UnityEngine; + using UnityEngine.UI; + + public class SceneClass : MonoBehaviour + { + + public RawImage qrImage; + + void Awake() + { + // staging is only really for Allow2 internal development, omit this line in your code + Allow2.env = EnvType.Staging; + + // set the deviceToken + // this needs top be done before ANY calls to the Allow2 platform + // create your own deviceToken at https://developer.allow2.com for free + // use it to manage your app/game/device and track metrics + // it's also important to use your own deviceToken and app definition in order to make use of + // the additional marketing channel opportunities that Allow2 provides for free + Allow2.DeviceToken = "B0hNax6VCFi9vphu"; + + // We can also now check if the device/app/game is already paired and what children are in the account + Debug.Log("isPaired: " + Allow2.IsPaired); + Debug.Log("Children: " + Allow2.Children); + + // Not Required if the textfield sets the name on display, it will also trigger an update of the QR Code + // and in the pairing interface, we need a QR code to make the process simple for our users + //Allow2.GetQR(this, SystemInfo.deviceName, delegate (string err, Texture2D qrCode) + //{ + // Debug.Log("qrcode error: " + (err ?? "No Error") + " : " + (qrCode ? "yes" : "no")); + // Debug.Log(qrImage.GetComponent()); + // qrImage.GetComponent().texture = qrCode; + //}); + + // usually start the pairing background process here, + // unless you have opted to not allow pairing with QR Code (but this is highly recommended for a better user experience) + Allow2.StartPairing(this, delegate (string err, Allow2CheckResult result) { + // this may be called several times with errors + if (err != null) { + Debug.Log(err); + return; + } + // once paired, the pairing process will automatically stop itself + // you should close off the pairing interface here and display success to the end user + Debug.Log("isPaired: " + Allow2.IsPaired); + Debug.Log("Children: " + Allow2.Children); + }); + } + } +} diff --git a/Allow2/Properties/AssemblyInfo.cs b/Allow2/Properties/AssemblyInfo.cs deleted file mode 100644 index b8e169d..0000000 --- a/Allow2/Properties/AssemblyInfo.cs +++ /dev/null @@ -1,26 +0,0 @@ -using System.Reflection; -using System.Runtime.CompilerServices; - -// Information about this assembly is defined by the following attributes. -// Change them to the values specific to your project. - -[assembly: AssemblyTitle("Allow2")] -[assembly: AssemblyDescription("")] -[assembly: AssemblyConfiguration("")] -[assembly: AssemblyCompany("")] -[assembly: AssemblyProduct("")] -[assembly: AssemblyCopyright("${AuthorCopyright}")] -[assembly: AssemblyTrademark("")] -[assembly: AssemblyCulture("")] - -// The assembly version has the format "{Major}.{Minor}.{Build}.{Revision}". -// The form "{Major}.{Minor}.*" will automatically update the build and revision, -// and "{Major}.{Minor}.{Build}.*" will update just the revision. - -[assembly: AssemblyVersion("1.0.0.0")] - -// The following attributes are used to specify the signing key for the assembly, -// if desired. See the Mono documentation for more information about signing. - -//[assembly: AssemblyDelaySign(false)] -//[assembly: AssemblyKeyFile("")] diff --git a/Allow2/SimpleJSON.cs b/Allow2/SimpleJSON.cs new file mode 100644 index 0000000..a3926b5 --- /dev/null +++ b/Allow2/SimpleJSON.cs @@ -0,0 +1,1367 @@ +/* * * * * + * A simple JSON Parser / builder + * ------------------------------ + * + * It mainly has been written as a simple JSON parser. It can build a JSON string + * from the node-tree, or generate a node tree from any valid JSON string. + * + * If you want to use compression when saving to file / stream / B64 you have to include + * SharpZipLib ( http://www.icsharpcode.net/opensource/sharpziplib/ ) in your project and + * define "USE_SharpZipLib" at the top of the file + * + * Written by Bunny83 + * 2012-06-09 + * + * [2012-06-09 First Version] + * - provides strongly typed node classes and lists / dictionaries + * - provides easy access to class members / array items / data values + * - the parser now properly identifies types. So generating JSON with this framework should work. + * - only double quotes (") are used for quoting strings. + * - provides "casting" properties to easily convert to / from those types: + * int / float / double / bool + * - provides a common interface for each node so no explicit casting is required. + * - the parser tries to avoid errors, but if malformed JSON is parsed the result is more or less undefined + * - It can serialize/deserialize a node tree into/from an experimental compact binary format. It might + * be handy if you want to store things in a file and don't want it to be easily modifiable + * + * + * [2012-12-17 Update] + * - Added internal JSONLazyCreator class which simplifies the construction of a JSON tree + * Now you can simple reference any item that doesn't exist yet and it will return a JSONLazyCreator + * The class determines the required type by it's further use, creates the type and removes itself. + * - Added binary serialization / deserialization. + * - Added support for BZip2 zipped binary format. Requires the SharpZipLib ( http://www.icsharpcode.net/opensource/sharpziplib/ ) + * The usage of the SharpZipLib library can be disabled by removing or commenting out the USE_SharpZipLib define at the top + * - The serializer uses different types when it comes to store the values. Since my data values + * are all of type string, the serializer will "try" which format fits best. The order is: int, float, double, bool, string. + * It's not the most efficient way but for a moderate amount of data it should work on all platforms. + * + * [2017-03-08 Update] + * - Optimised parsing by using a StringBuilder for token. This prevents performance issues when large + * string data fields are contained in the json data. + * - Finally refactored the badly named JSONClass into JSONObject. + * - Replaced the old JSONData class by distict typed classes ( JSONString, JSONNumber, JSONBool, JSONNull ) this + * allows to propertly convert the node tree back to json without type information loss. The actual value + * parsing now happens at parsing time and not when you actually access one of the casting properties. + * + * [2017-04-11 Update] + * - Fixed parsing bug where empty string values have been ignored. + * - Optimised "ToString" by using a StringBuilder internally. This should heavily improve performance for large files + * - Changed the overload of "ToString(string aIndent)" to "ToString(int aIndent)" + * + * [2017-11-29 Update] + * - Removed the IEnumerator implementations on JSONArray & JSONObject and replaced it with a common + * struct Enumerator in JSONNode that should avoid garbage generation. The enumerator always works + * on KeyValuePair, even for JSONArray. + * - Added two wrapper Enumerators that allows for easy key or value enumeration. A JSONNode now has + * a "Keys" and a "Values" enumerable property. Those are also struct enumerators / enumerables + * - A KeyValuePair can now be implicitly converted into a JSONNode. This allows + * a foreach loop over a JSONNode to directly access the values only. Since KeyValuePair as well as + * all the Enumerators are structs, no garbage is allocated. + * - To add Linq support another "LinqEnumerator" is available through the "Linq" property. This + * enumerator does implement the generic IEnumerable interface so most Linq extensions can be used + * on this enumerable object. This one does allocate memory as it's a wrapper class. + * - The Escape method now escapes all control characters (# < 32) in strings as uncode characters + * (\uXXXX) and if the static bool JSONNode.forceASCII is set to true it will also escape all + * characters # > 127. This might be useful if you require an ASCII output. Though keep in mind + * when your strings contain many non-ascii characters the strings become much longer (x6) and are + * no longer human readable. + * - The node types JSONObject and JSONArray now have an "Inline" boolean switch which will default to + * false. It can be used to serialize this element inline even you serialize with an indented format + * This is useful for arrays containing numbers so it doesn't place every number on a new line + * - Extracted the binary serialization code into a seperate extension file. All classes are now declared + * as "partial" so an extension file can even add a new virtual or abstract method / interface to + * JSONNode and override it in the concrete type classes. It's of course a hacky approach which is + * generally not recommended, but i wanted to keep everything tightly packed. + * - Added a static CreateOrGet method to the JSONNull class. Since this class is immutable it could + * be reused without major problems. If you have a lot null fields in your data it will help reduce + * the memory / garbage overhead. I also added a static setting (reuseSameInstance) to JSONNull + * (default is true) which will change the behaviour of "CreateOrGet". If you set this to false + * CreateOrGet will not reuse the cached instance but instead create a new JSONNull instance each time. + * I made the JSONNull constructor private so if you need to create an instance manually use + * JSONNull.CreateOrGet() + * + * + * The MIT License (MIT) + * + * Copyright (c) 2012-2017 Markus Göbel (Bunny83) + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + * + * * * * */ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace Allow2_SimpleJSON +{ + public enum JSONNodeType + { + Array = 1, + Object = 2, + String = 3, + Number = 4, + NullValue = 5, + Boolean = 6, + None = 7, + Custom = 0xFF, + } + public enum JSONTextMode + { + Compact, + Indent + } + + public abstract partial class JSONNode + { + #region Enumerators + public struct Enumerator + { + private enum Type { None, Array, Object } + private Type type; + private Dictionary.Enumerator m_Object; + private List.Enumerator m_Array; + public bool IsValid { get { return type != Type.None; } } + public Enumerator(List.Enumerator aArrayEnum) + { + type = Type.Array; + m_Object = default(Dictionary.Enumerator); + m_Array = aArrayEnum; + } + public Enumerator(Dictionary.Enumerator aDictEnum) + { + type = Type.Object; + m_Object = aDictEnum; + m_Array = default(List.Enumerator); + } + public KeyValuePair Current + { + get + { + if (type == Type.Array) + return new KeyValuePair(string.Empty, m_Array.Current); + else if (type == Type.Object) + return m_Object.Current; + return new KeyValuePair(string.Empty, null); + } + } + public bool MoveNext() + { + if (type == Type.Array) + return m_Array.MoveNext(); + else if (type == Type.Object) + return m_Object.MoveNext(); + return false; + } + } + public struct ValueEnumerator + { + private Enumerator m_Enumerator; + public ValueEnumerator(List.Enumerator aArrayEnum) : this(new Enumerator(aArrayEnum)) { } + public ValueEnumerator(Dictionary.Enumerator aDictEnum) : this(new Enumerator(aDictEnum)) { } + public ValueEnumerator(Enumerator aEnumerator) { m_Enumerator = aEnumerator; } + public JSONNode Current { get { return m_Enumerator.Current.Value; } } + public bool MoveNext() { return m_Enumerator.MoveNext(); } + public ValueEnumerator GetEnumerator() { return this; } + } + public struct KeyEnumerator + { + private Enumerator m_Enumerator; + public KeyEnumerator(List.Enumerator aArrayEnum) : this(new Enumerator(aArrayEnum)) { } + public KeyEnumerator(Dictionary.Enumerator aDictEnum) : this(new Enumerator(aDictEnum)) { } + public KeyEnumerator(Enumerator aEnumerator) { m_Enumerator = aEnumerator; } + public JSONNode Current { get { return m_Enumerator.Current.Key; } } + public bool MoveNext() { return m_Enumerator.MoveNext(); } + public KeyEnumerator GetEnumerator() { return this; } + } + + public class LinqEnumerator : IEnumerator>, IEnumerable> + { + private JSONNode m_Node; + private Enumerator m_Enumerator; + internal LinqEnumerator(JSONNode aNode) + { + m_Node = aNode; + if (m_Node != null) + m_Enumerator = m_Node.GetEnumerator(); + } + public KeyValuePair Current { get { return m_Enumerator.Current; } } + object IEnumerator.Current { get { return m_Enumerator.Current; } } + public bool MoveNext() { return m_Enumerator.MoveNext(); } + + public void Dispose() + { + m_Node = null; + m_Enumerator = new Enumerator(); + } + + public IEnumerator> GetEnumerator() + { + return new LinqEnumerator(m_Node); + } + + public void Reset() + { + if (m_Node != null) + m_Enumerator = m_Node.GetEnumerator(); + } + + IEnumerator IEnumerable.GetEnumerator() + { + return new LinqEnumerator(m_Node); + } + } + + #endregion Enumerators + + #region common interface + + public static bool forceASCII = false; // Use Unicode by default + + public abstract JSONNodeType Tag { get; } + + public virtual JSONNode this[int aIndex] { get { return null; } set { } } + + public virtual JSONNode this[string aKey] { get { return null; } set { } } + + public virtual string Value { get { return ""; } set { } } + + public virtual int Count { get { return 0; } } + + public virtual bool IsNumber { get { return false; } } + public virtual bool IsString { get { return false; } } + public virtual bool IsBoolean { get { return false; } } + public virtual bool IsNull { get { return false; } } + public virtual bool IsArray { get { return false; } } + public virtual bool IsObject { get { return false; } } + + public virtual bool Inline { get { return false; } set { } } + + public virtual void Add(string aKey, JSONNode aItem) + { + } + public virtual void Add(JSONNode aItem) + { + Add("", aItem); + } + + public virtual JSONNode Remove(string aKey) + { + return null; + } + + public virtual JSONNode Remove(int aIndex) + { + return null; + } + + public virtual JSONNode Remove(JSONNode aNode) + { + return aNode; + } + + public virtual IEnumerable Children + { + get + { + yield break; + } + } + + public IEnumerable DeepChildren + { + get + { + foreach (var C in Children) + foreach (var D in C.DeepChildren) + yield return D; + } + } + + public override string ToString() + { + StringBuilder sb = new StringBuilder(); + WriteToStringBuilder(sb, 0, 0, JSONTextMode.Compact); + return sb.ToString(); + } + + public virtual string ToString(int aIndent) + { + StringBuilder sb = new StringBuilder(); + WriteToStringBuilder(sb, 0, aIndent, JSONTextMode.Indent); + return sb.ToString(); + } + internal abstract void WriteToStringBuilder(StringBuilder aSB, int aIndent, int aIndentInc, JSONTextMode aMode); + + public abstract Enumerator GetEnumerator(); + public IEnumerable> Linq { get { return new LinqEnumerator(this); } } + public KeyEnumerator Keys { get { return new KeyEnumerator(GetEnumerator()); } } + public ValueEnumerator Values { get { return new ValueEnumerator(GetEnumerator()); } } + + #endregion common interface + + #region typecasting properties + + + public virtual double AsDouble + { + get + { + double v = 0.0; + if (double.TryParse(Value, out v)) + return v; + return 0.0; + } + set + { + Value = value.ToString(); + } + } + + public virtual int AsInt + { + get { return (int)AsDouble; } + set { AsDouble = value; } + } + + public virtual float AsFloat + { + get { return (float)AsDouble; } + set { AsDouble = value; } + } + + public virtual bool AsBool + { + get + { + bool v = false; + if (bool.TryParse(Value, out v)) + return v; + return !string.IsNullOrEmpty(Value); + } + set + { + Value = (value) ? "true" : "false"; + } + } + + public virtual JSONArray AsArray + { + get + { + return this as JSONArray; + } + } + + public virtual JSONObject AsObject + { + get + { + return this as JSONObject; + } + } + + + #endregion typecasting properties + + #region operators + + public static implicit operator JSONNode(string s) + { + return new JSONString(s); + } + public static implicit operator string(JSONNode d) + { + return (d == null) ? null : d.Value; + } + + public static implicit operator JSONNode(double n) + { + return new JSONNumber(n); + } + public static implicit operator double(JSONNode d) + { + return (d == null) ? 0 : d.AsDouble; + } + + public static implicit operator JSONNode(float n) + { + return new JSONNumber(n); + } + public static implicit operator float(JSONNode d) + { + return (d == null) ? 0 : d.AsFloat; + } + + public static implicit operator JSONNode(int n) + { + return new JSONNumber(n); + } + public static implicit operator int(JSONNode d) + { + return (d == null) ? 0 : d.AsInt; + } + + public static implicit operator JSONNode(bool b) + { + return new JSONBool(b); + } + public static implicit operator bool(JSONNode d) + { + return (d == null) ? false : d.AsBool; + } + + public static implicit operator JSONNode(KeyValuePair aKeyValue) + { + return aKeyValue.Value; + } + + public static bool operator ==(JSONNode a, object b) + { + if (ReferenceEquals(a, b)) + return true; + bool aIsNull = a is JSONNull || ReferenceEquals(a, null) || a is JSONLazyCreator; + bool bIsNull = b is JSONNull || ReferenceEquals(b, null) || b is JSONLazyCreator; + if (aIsNull && bIsNull) + return true; + return !aIsNull && a.Equals(b); + } + + public static bool operator !=(JSONNode a, object b) + { + return !(a == b); + } + + public override bool Equals(object obj) + { + return ReferenceEquals(this, obj); + } + + public override int GetHashCode() + { + return base.GetHashCode(); + } + + #endregion operators + + [ThreadStatic] + private static StringBuilder m_EscapeBuilder; + internal static StringBuilder EscapeBuilder + { + get + { + if (m_EscapeBuilder == null) + m_EscapeBuilder = new StringBuilder(); + return m_EscapeBuilder; + } + } + internal static string Escape(string aText) + { + var sb = EscapeBuilder; + sb.Length = 0; + if (sb.Capacity < aText.Length + aText.Length / 10) + sb.Capacity = aText.Length + aText.Length / 10; + foreach (char c in aText) + { + switch (c) + { + case '\\': + sb.Append("\\\\"); + break; + case '\"': + sb.Append("\\\""); + break; + case '\n': + sb.Append("\\n"); + break; + case '\r': + sb.Append("\\r"); + break; + case '\t': + sb.Append("\\t"); + break; + case '\b': + sb.Append("\\b"); + break; + case '\f': + sb.Append("\\f"); + break; + default: + if (c < ' ' || (forceASCII && c > 127)) + { + ushort val = c; + sb.Append("\\u").Append(val.ToString("X4")); + } + else + sb.Append(c); + break; + } + } + string result = sb.ToString(); + sb.Length = 0; + return result; + } + + static void ParseElement(JSONNode ctx, string token, string tokenName, bool quoted) + { + if (quoted) + { + ctx.Add(tokenName, token); + return; + } + string tmp = token.ToLower(); + if (tmp == "false" || tmp == "true") + ctx.Add(tokenName, tmp == "true"); + else if (tmp == "null") + ctx.Add(tokenName, null); + else + { + double val; + if (double.TryParse(token, out val)) + ctx.Add(tokenName, val); + else + ctx.Add(tokenName, token); + } + } + + public static JSONNode Parse(string aJSON) + { + Stack stack = new Stack(); + JSONNode ctx = null; + int i = 0; + StringBuilder Token = new StringBuilder(); + string TokenName = ""; + bool QuoteMode = false; + bool TokenIsQuoted = false; + while (i < aJSON.Length) + { + switch (aJSON[i]) + { + case '{': + if (QuoteMode) + { + Token.Append(aJSON[i]); + break; + } + stack.Push(new JSONObject()); + if (ctx != null) + { + ctx.Add(TokenName, stack.Peek()); + } + TokenName = ""; + Token.Length = 0; + ctx = stack.Peek(); + break; + + case '[': + if (QuoteMode) + { + Token.Append(aJSON[i]); + break; + } + + stack.Push(new JSONArray()); + if (ctx != null) + { + ctx.Add(TokenName, stack.Peek()); + } + TokenName = ""; + Token.Length = 0; + ctx = stack.Peek(); + break; + + case '}': + case ']': + if (QuoteMode) + { + + Token.Append(aJSON[i]); + break; + } + if (stack.Count == 0) + throw new Exception("JSON Parse: Too many closing brackets"); + + stack.Pop(); + if (Token.Length > 0 || TokenIsQuoted) + { + ParseElement(ctx, Token.ToString(), TokenName, TokenIsQuoted); + TokenIsQuoted = false; + } + TokenName = ""; + Token.Length = 0; + if (stack.Count > 0) + ctx = stack.Peek(); + break; + + case ':': + if (QuoteMode) + { + Token.Append(aJSON[i]); + break; + } + TokenName = Token.ToString(); + Token.Length = 0; + TokenIsQuoted = false; + break; + + case '"': + QuoteMode ^= true; + TokenIsQuoted |= QuoteMode; + break; + + case ',': + if (QuoteMode) + { + Token.Append(aJSON[i]); + break; + } + if (Token.Length > 0 || TokenIsQuoted) + { + ParseElement(ctx, Token.ToString(), TokenName, TokenIsQuoted); + TokenIsQuoted = false; + } + TokenName = ""; + Token.Length = 0; + TokenIsQuoted = false; + break; + + case '\r': + case '\n': + break; + + case ' ': + case '\t': + if (QuoteMode) + Token.Append(aJSON[i]); + break; + + case '\\': + ++i; + if (QuoteMode) + { + char C = aJSON[i]; + switch (C) + { + case 't': + Token.Append('\t'); + break; + case 'r': + Token.Append('\r'); + break; + case 'n': + Token.Append('\n'); + break; + case 'b': + Token.Append('\b'); + break; + case 'f': + Token.Append('\f'); + break; + case 'u': + { + string s = aJSON.Substring(i + 1, 4); + Token.Append((char)int.Parse( + s, + System.Globalization.NumberStyles.AllowHexSpecifier)); + i += 4; + break; + } + default: + Token.Append(C); + break; + } + } + break; + + default: + Token.Append(aJSON[i]); + break; + } + ++i; + } + if (QuoteMode) + { + throw new Exception("JSON Parse: Quotation marks seems to be messed up."); + } + return ctx; + } + + } + // End of JSONNode + + public partial class JSONArray : JSONNode + { + private List m_List = new List(); + private bool inline = false; + public override bool Inline + { + get { return inline; } + set { inline = value; } + } + + public override JSONNodeType Tag { get { return JSONNodeType.Array; } } + public override bool IsArray { get { return true; } } + public override Enumerator GetEnumerator() { return new Enumerator(m_List.GetEnumerator()); } + + public override JSONNode this[int aIndex] + { + get + { + if (aIndex < 0 || aIndex >= m_List.Count) + return new JSONLazyCreator(this); + return m_List[aIndex]; + } + set + { + if (value == null) + value = JSONNull.CreateOrGet(); + if (aIndex < 0 || aIndex >= m_List.Count) + m_List.Add(value); + else + m_List[aIndex] = value; + } + } + + public override JSONNode this[string aKey] + { + get { return new JSONLazyCreator(this); } + set + { + if (value == null) + value = JSONNull.CreateOrGet(); + m_List.Add(value); + } + } + + public override int Count + { + get { return m_List.Count; } + } + + public override void Add(string aKey, JSONNode aItem) + { + if (aItem == null) + aItem = JSONNull.CreateOrGet(); + m_List.Add(aItem); + } + + public override JSONNode Remove(int aIndex) + { + if (aIndex < 0 || aIndex >= m_List.Count) + return null; + JSONNode tmp = m_List[aIndex]; + m_List.RemoveAt(aIndex); + return tmp; + } + + public override JSONNode Remove(JSONNode aNode) + { + m_List.Remove(aNode); + return aNode; + } + + public override IEnumerable Children + { + get + { + foreach (JSONNode N in m_List) + yield return N; + } + } + + + internal override void WriteToStringBuilder(StringBuilder aSB, int aIndent, int aIndentInc, JSONTextMode aMode) + { + aSB.Append('['); + int count = m_List.Count; + if (inline) + aMode = JSONTextMode.Compact; + for (int i = 0; i < count; i++) + { + if (i > 0) + aSB.Append(','); + if (aMode == JSONTextMode.Indent) + aSB.AppendLine(); + + if (aMode == JSONTextMode.Indent) + aSB.Append(' ', aIndent + aIndentInc); + m_List[i].WriteToStringBuilder(aSB, aIndent + aIndentInc, aIndentInc, aMode); + } + if (aMode == JSONTextMode.Indent) + aSB.AppendLine().Append(' ', aIndent); + aSB.Append(']'); + } + } + // End of JSONArray + + public partial class JSONObject : JSONNode + { + private Dictionary m_Dict = new Dictionary(); + + private bool inline = false; + public override bool Inline + { + get { return inline; } + set { inline = value; } + } + + public override JSONNodeType Tag { get { return JSONNodeType.Object; } } + public override bool IsObject { get { return true; } } + + public override Enumerator GetEnumerator() { return new Enumerator(m_Dict.GetEnumerator()); } + + + public override JSONNode this[string aKey] + { + get + { + if (m_Dict.ContainsKey(aKey)) + return m_Dict[aKey]; + else + return new JSONLazyCreator(this, aKey); + } + set + { + if (value == null) + value = JSONNull.CreateOrGet(); + if (m_Dict.ContainsKey(aKey)) + m_Dict[aKey] = value; + else + m_Dict.Add(aKey, value); + } + } + + public override JSONNode this[int aIndex] + { + get + { + if (aIndex < 0 || aIndex >= m_Dict.Count) + return null; + return m_Dict.ElementAt(aIndex).Value; + } + set + { + if (value == null) + value = JSONNull.CreateOrGet(); + if (aIndex < 0 || aIndex >= m_Dict.Count) + return; + string key = m_Dict.ElementAt(aIndex).Key; + m_Dict[key] = value; + } + } + + public override int Count + { + get { return m_Dict.Count; } + } + + public override void Add(string aKey, JSONNode aItem) + { + if (aItem == null) + aItem = JSONNull.CreateOrGet(); + + if (!string.IsNullOrEmpty(aKey)) + { + if (m_Dict.ContainsKey(aKey)) + m_Dict[aKey] = aItem; + else + m_Dict.Add(aKey, aItem); + } + else + m_Dict.Add(Guid.NewGuid().ToString(), aItem); + } + + public override JSONNode Remove(string aKey) + { + if (!m_Dict.ContainsKey(aKey)) + return null; + JSONNode tmp = m_Dict[aKey]; + m_Dict.Remove(aKey); + return tmp; + } + + public override JSONNode Remove(int aIndex) + { + if (aIndex < 0 || aIndex >= m_Dict.Count) + return null; + var item = m_Dict.ElementAt(aIndex); + m_Dict.Remove(item.Key); + return item.Value; + } + + public override JSONNode Remove(JSONNode aNode) + { + try + { + var item = m_Dict.Where(k => k.Value == aNode).First(); + m_Dict.Remove(item.Key); + return aNode; + } + catch + { + return null; + } + } + + public override IEnumerable Children + { + get + { + foreach (KeyValuePair N in m_Dict) + yield return N.Value; + } + } + + internal override void WriteToStringBuilder(StringBuilder aSB, int aIndent, int aIndentInc, JSONTextMode aMode) + { + aSB.Append('{'); + bool first = true; + if (inline) + aMode = JSONTextMode.Compact; + foreach (var k in m_Dict) + { + if (!first) + aSB.Append(','); + first = false; + if (aMode == JSONTextMode.Indent) + aSB.AppendLine(); + if (aMode == JSONTextMode.Indent) + aSB.Append(' ', aIndent + aIndentInc); + aSB.Append('\"').Append(Escape(k.Key)).Append('\"'); + if (aMode == JSONTextMode.Compact) + aSB.Append(':'); + else + aSB.Append(" : "); + k.Value.WriteToStringBuilder(aSB, aIndent + aIndentInc, aIndentInc, aMode); + } + if (aMode == JSONTextMode.Indent) + aSB.AppendLine().Append(' ', aIndent); + aSB.Append('}'); + } + + } + // End of JSONObject + + public partial class JSONString : JSONNode + { + private string m_Data; + + public override JSONNodeType Tag { get { return JSONNodeType.String; } } + public override bool IsString { get { return true; } } + + public override Enumerator GetEnumerator() { return new Enumerator(); } + + + public override string Value + { + get { return m_Data; } + set + { + m_Data = value; + } + } + + public JSONString(string aData) + { + m_Data = aData; + } + + internal override void WriteToStringBuilder(StringBuilder aSB, int aIndent, int aIndentInc, JSONTextMode aMode) + { + aSB.Append('\"').Append(Escape(m_Data)).Append('\"'); + } + public override bool Equals(object obj) + { + if (base.Equals(obj)) + return true; + string s = obj as string; + if (s != null) + return m_Data == s; + JSONString s2 = obj as JSONString; + if (s2 != null) + return m_Data == s2.m_Data; + return false; + } + public override int GetHashCode() + { + return m_Data.GetHashCode(); + } + } + // End of JSONString + + public partial class JSONNumber : JSONNode + { + private double m_Data; + + public override JSONNodeType Tag { get { return JSONNodeType.Number; } } + public override bool IsNumber { get { return true; } } + public override Enumerator GetEnumerator() { return new Enumerator(); } + + public override string Value + { + get { return m_Data.ToString(); } + set + { + double v; + if (double.TryParse(value, out v)) + m_Data = v; + } + } + + public override double AsDouble + { + get { return m_Data; } + set { m_Data = value; } + } + + public JSONNumber(double aData) + { + m_Data = aData; + } + + public JSONNumber(string aData) + { + Value = aData; + } + + internal override void WriteToStringBuilder(StringBuilder aSB, int aIndent, int aIndentInc, JSONTextMode aMode) + { + aSB.Append(m_Data); + } + private static bool IsNumeric(object value) + { + return value is int || value is uint + || value is float || value is double + || value is decimal + || value is long || value is ulong + || value is short || value is ushort + || value is sbyte || value is byte; + } + public override bool Equals(object obj) + { + if (obj == null) + return false; + if (base.Equals(obj)) + return true; + JSONNumber s2 = obj as JSONNumber; + if (s2 != null) + return m_Data == s2.m_Data; + if (IsNumeric(obj)) + return Convert.ToDouble(obj) == m_Data; + return false; + } + public override int GetHashCode() + { + return m_Data.GetHashCode(); + } + } + // End of JSONNumber + + public partial class JSONBool : JSONNode + { + private bool m_Data; + + public override JSONNodeType Tag { get { return JSONNodeType.Boolean; } } + public override bool IsBoolean { get { return true; } } + public override Enumerator GetEnumerator() { return new Enumerator(); } + + public override string Value + { + get { return m_Data.ToString(); } + set + { + bool v; + if (bool.TryParse(value, out v)) + m_Data = v; + } + } + public override bool AsBool + { + get { return m_Data; } + set { m_Data = value; } + } + + public JSONBool(bool aData) + { + m_Data = aData; + } + + public JSONBool(string aData) + { + Value = aData; + } + + internal override void WriteToStringBuilder(StringBuilder aSB, int aIndent, int aIndentInc, JSONTextMode aMode) + { + aSB.Append((m_Data) ? "true" : "false"); + } + public override bool Equals(object obj) + { + if (obj == null) + return false; + if (obj is bool) + return m_Data == (bool)obj; + return false; + } + public override int GetHashCode() + { + return m_Data.GetHashCode(); + } + } + // End of JSONBool + + public partial class JSONNull : JSONNode + { + static JSONNull m_StaticInstance = new JSONNull(); + public static bool reuseSameInstance = true; + public static JSONNull CreateOrGet() + { + if (reuseSameInstance) + return m_StaticInstance; + return new JSONNull(); + } + private JSONNull() { } + + public override JSONNodeType Tag { get { return JSONNodeType.NullValue; } } + public override bool IsNull { get { return true; } } + public override Enumerator GetEnumerator() { return new Enumerator(); } + + public override string Value + { + get { return "null"; } + set { } + } + public override bool AsBool + { + get { return false; } + set { } + } + + public override bool Equals(object obj) + { + if (object.ReferenceEquals(this, obj)) + return true; + return (obj is JSONNull); + } + public override int GetHashCode() + { + return 0; + } + + internal override void WriteToStringBuilder(StringBuilder aSB, int aIndent, int aIndentInc, JSONTextMode aMode) + { + aSB.Append("null"); + } + } + // End of JSONNull + + internal partial class JSONLazyCreator : JSONNode + { + private JSONNode m_Node = null; + private string m_Key = null; + public override JSONNodeType Tag { get { return JSONNodeType.None; } } + public override Enumerator GetEnumerator() { return new Enumerator(); } + + public JSONLazyCreator(JSONNode aNode) + { + m_Node = aNode; + m_Key = null; + } + + public JSONLazyCreator(JSONNode aNode, string aKey) + { + m_Node = aNode; + m_Key = aKey; + } + + private void Set(JSONNode aVal) + { + if (m_Key == null) + { + m_Node.Add(aVal); + } + else + { + m_Node.Add(m_Key, aVal); + } + m_Node = null; // Be GC friendly. + } + + public override JSONNode this[int aIndex] + { + get + { + return new JSONLazyCreator(this); + } + set + { + var tmp = new JSONArray(); + tmp.Add(value); + Set(tmp); + } + } + + public override JSONNode this[string aKey] + { + get + { + return new JSONLazyCreator(this, aKey); + } + set + { + var tmp = new JSONObject(); + tmp.Add(aKey, value); + Set(tmp); + } + } + + public override void Add(JSONNode aItem) + { + var tmp = new JSONArray(); + tmp.Add(aItem); + Set(tmp); + } + + public override void Add(string aKey, JSONNode aItem) + { + var tmp = new JSONObject(); + tmp.Add(aKey, aItem); + Set(tmp); + } + + public static bool operator ==(JSONLazyCreator a, object b) + { + if (b == null) + return true; + return System.Object.ReferenceEquals(a, b); + } + + public static bool operator !=(JSONLazyCreator a, object b) + { + return !(a == b); + } + + public override bool Equals(object obj) + { + if (obj == null) + return true; + return System.Object.ReferenceEquals(this, obj); + } + + public override int GetHashCode() + { + return 0; + } + + public override int AsInt + { + get + { + JSONNumber tmp = new JSONNumber(0); + Set(tmp); + return 0; + } + set + { + JSONNumber tmp = new JSONNumber(value); + Set(tmp); + } + } + + public override float AsFloat + { + get + { + JSONNumber tmp = new JSONNumber(0.0f); + Set(tmp); + return 0.0f; + } + set + { + JSONNumber tmp = new JSONNumber(value); + Set(tmp); + } + } + + public override double AsDouble + { + get + { + JSONNumber tmp = new JSONNumber(0.0); + Set(tmp); + return 0.0; + } + set + { + JSONNumber tmp = new JSONNumber(value); + Set(tmp); + } + } + + public override bool AsBool + { + get + { + JSONBool tmp = new JSONBool(false); + Set(tmp); + return false; + } + set + { + JSONBool tmp = new JSONBool(value); + Set(tmp); + } + } + + public override JSONArray AsArray + { + get + { + JSONArray tmp = new JSONArray(); + Set(tmp); + return tmp; + } + } + + public override JSONObject AsObject + { + get + { + JSONObject tmp = new JSONObject(); + Set(tmp); + return tmp; + } + } + internal override void WriteToStringBuilder(StringBuilder aSB, int aIndent, int aIndentInc, JSONTextMode aMode) + { + aSB.Append("null"); + } + } + // End of JSONLazyCreator + + public static class JSON + { + public static JSONNode Parse(string aJSON) + { + return JSONNode.Parse(aJSON); + } + } +} \ No newline at end of file diff --git a/Allow2/packages.config b/Allow2/packages.config deleted file mode 100644 index e98b858..0000000 --- a/Allow2/packages.config +++ /dev/null @@ -1,4 +0,0 @@ - - - - \ No newline at end of file diff --git a/Documentation b/Documentation new file mode 160000 index 0000000..3066f7e --- /dev/null +++ b/Documentation @@ -0,0 +1 @@ +Subproject commit 3066f7efcd8c000b7c282d115d595ced731bb172 diff --git a/Images/Allow2Logo500.png b/Images/Allow2Logo500.png new file mode 100644 index 0000000..5e947d3 Binary files /dev/null and b/Images/Allow2Logo500.png differ diff --git a/Images/Allow2Logo_200x258.png b/Images/Allow2Logo_200x258.png new file mode 100644 index 0000000..e38967c Binary files /dev/null and b/Images/Allow2Logo_200x258.png differ diff --git a/Images/Allow2Logo_860x389.png b/Images/Allow2Logo_860x389.png new file mode 100644 index 0000000..d7c3647 Binary files /dev/null and b/Images/Allow2Logo_860x389.png differ diff --git a/Images/Allow2_200x124.png b/Images/Allow2_200x124.png new file mode 100644 index 0000000..07dbef1 Binary files /dev/null and b/Images/Allow2_200x124.png differ diff --git a/Images/Allow2_516x389.png b/Images/Allow2_516x389.png new file mode 100644 index 0000000..c7a5ddc Binary files /dev/null and b/Images/Allow2_516x389.png differ diff --git a/README.md b/README.md index a87fa01..9564674 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,17 @@ -# Allow2Unity +# Allow2 - Free and Powerful Parental Freedom for your apps and devices -Allow2 makes it easy to add parental controls to your apps. +*Who wants **more downloads**?* -This is an early release of the new SDK build in progress +Integrating **Allow2** in a few lines of code will remove fear and guilt so parents can purchase your apps! + +**Allow2** is a unique "Parental Freedom" platform. + +Simply integrate **Allow2** in about as many steps as integrating something like google analytics or Facebook, and you unlock a whole new demographic! + +It provides parents with a unified centralised mechanism to help teach responsibility and raise their children. + +Essentially, for parents and users that don't care, they will download your app or game anyway. But how can you address a brand new market you never had access to? Simply download and integrate **Allow2** for free! + +**[Visit the Wiki](https://github.com/Allow2/Allow2Unity/wiki)** for [Getting Started Guides](https://github.com/Allow2/Allow2Unity/wiki), [API Documentation](https://github.com/Allow2/Allow2Unity/wiki) and [Samples](https://github.com/Allow2/Allow2Unity/wiki). + +If you need help understanding it, integrating it or just love it, get in touch or discuss it online. diff --git a/com.allow2.sdk/Runtime/Allow2.Runtime.asmdef b/com.allow2.sdk/Runtime/Allow2.Runtime.asmdef new file mode 100644 index 0000000..efd5250 --- /dev/null +++ b/com.allow2.sdk/Runtime/Allow2.Runtime.asmdef @@ -0,0 +1,14 @@ +{ + "name": "Allow2.Runtime", + "rootNamespace": "Allow2", + "references": [], + "includePlatforms": [], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": true, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": false +} diff --git a/com.allow2.sdk/Runtime/Bridge/Allow2Coroutines.cs b/com.allow2.sdk/Runtime/Bridge/Allow2Coroutines.cs new file mode 100644 index 0000000..3b5cd8c --- /dev/null +++ b/com.allow2.sdk/Runtime/Bridge/Allow2Coroutines.cs @@ -0,0 +1,178 @@ +// Allow2 Unity SDK v2 +// Copyright (c) 2026 Allow2 Pty Ltd. All rights reserved. + +using System; +using System.Collections; +using System.Collections.Generic; +using UnityEngine; + +namespace Allow2 +{ + /// + /// Coroutine wrappers for async operations. + /// Provides WebGL-compatible alternatives to async/await. + /// + /// All coroutines run on the Allow2Manager MonoBehaviour. + /// + public class Allow2Coroutines + { + private readonly MonoBehaviour _runner; + private readonly Allow2Api _api; + + public Allow2Coroutines(MonoBehaviour runner, Allow2Api api) + { + _runner = runner; + _api = api; + } + + /// + /// Start a coroutine on the runner MonoBehaviour. + /// + public Coroutine Run(IEnumerator routine) + { + if (_runner == null || !_runner.gameObject.activeInHierarchy) + { + return null; + } + return _runner.StartCoroutine(routine); + } + + /// + /// Stop a coroutine. + /// + public void Cancel(Coroutine coroutine) + { + if (_runner != null && coroutine != null) + { + _runner.StopCoroutine(coroutine); + } + } + + // ---------------------------------------------------------------- + // API coroutine wrappers + // ---------------------------------------------------------------- + + /// + /// Run a permission check against the API. + /// + public Coroutine RunCheck(int userId, int pairId, string pairToken, + int childId, Dictionary activities, string tz, + Action callback) + { + return Run(_api.Check(userId, pairId, pairToken, childId, activities, tz, true, callback)); + } + + /// + /// Initialize PIN pairing. + /// + public Coroutine RunInitPairing(string uuid, string deviceName, string platform, + Action callback) + { + return Run(_api.InitPINPairing(uuid, deviceName, platform, callback)); + } + + /// + /// Poll pairing status. + /// + public Coroutine RunCheckPairingStatus(string sessionId, Action callback) + { + return Run(_api.CheckPairingStatus(sessionId, callback)); + } + + /// + /// Poll for updates. + /// + public Coroutine RunGetUpdates(int userId, int pairId, string pairToken, + long timestampMillis, Action callback) + { + return Run(_api.GetUpdates(userId, pairId, pairToken, timestampMillis, callback)); + } + + /// + /// Create a request (more time, etc.). + /// + public Coroutine RunCreateRequest(int userId, int pairId, string pairToken, + int childId, int duration, int activityId, string message, + Action callback) + { + return Run(_api.CreateRequest(userId, pairId, pairToken, childId, + duration, activityId, message, callback)); + } + + /// + /// Poll request status. + /// + public Coroutine RunGetRequestStatus(string requestId, string statusSecret, + Action callback) + { + return Run(_api.GetRequestStatus(requestId, statusSecret, callback)); + } + + /// + /// Submit feedback. + /// + public Coroutine RunSubmitFeedback(int userId, int pairId, string pairToken, + int childId, string category, string message, + Dictionary deviceContext, Action callback) + { + return Run(_api.SubmitFeedback(userId, pairId, pairToken, childId, + category, message, deviceContext, callback)); + } + + /// + /// Load feedback. + /// + public Coroutine RunLoadFeedback(int userId, int pairId, string pairToken, + Action callback) + { + return Run(_api.LoadFeedback(userId, pairId, pairToken, callback)); + } + + /// + /// Reply to feedback. + /// + public Coroutine RunFeedbackReply(int userId, int pairId, string pairToken, + string discussionId, string message, Action callback) + { + return Run(_api.FeedbackReply(userId, pairId, pairToken, + discussionId, message, callback)); + } + + // ---------------------------------------------------------------- + // Utility coroutines + // ---------------------------------------------------------------- + + /// + /// Wait for a number of seconds, then invoke the callback. + /// + public Coroutine Delay(float seconds, Action callback) + { + return Run(DelayCoroutine(seconds, callback)); + } + + private IEnumerator DelayCoroutine(float seconds, Action callback) + { + yield return new WaitForSecondsRealtime(seconds); + if (callback != null) callback(); + } + + /// + /// Repeat an action at a fixed interval. + /// Stops when the action returns true. + /// + public Coroutine RepeatUntil(float intervalSeconds, Func action) + { + return Run(RepeatCoroutine(intervalSeconds, action)); + } + + private IEnumerator RepeatCoroutine(float intervalSeconds, Func action) + { + while (true) + { + bool done = action(); + if (done) yield break; + yield return new WaitForSecondsRealtime(intervalSeconds); + } + } + } +} diff --git a/com.allow2.sdk/Runtime/Bridge/Allow2Manager.cs b/com.allow2.sdk/Runtime/Bridge/Allow2Manager.cs new file mode 100644 index 0000000..116dba3 --- /dev/null +++ b/com.allow2.sdk/Runtime/Bridge/Allow2Manager.cs @@ -0,0 +1,802 @@ +// Allow2 Unity SDK v2 +// Copyright (c) 2026 Allow2 Pty Ltd. All rights reserved. + +using System; +using System.Collections; +using System.Collections.Generic; +using UnityEngine; + +namespace Allow2 +{ + /// + /// MonoBehaviour singleton that bridges the pure C# Allow2Daemon + /// with Unity's coroutine system and lifecycle. + /// + /// This is the main Unity entry point. Add it to a GameObject or + /// call Allow2Manager.Instance to auto-create one. + /// + /// Responsibilities: + /// - DontDestroyOnLoad persistence + /// - Coroutine-based API calls (check loop, pairing polls, etc.) + /// - Unity lifecycle hooks (OnApplicationPause, OnApplicationQuit) + /// - Inspector-bindable UnityEvents + /// + public class Allow2Manager : MonoBehaviour + { + // ---------------------------------------------------------------- + // Singleton + // ---------------------------------------------------------------- + + private static Allow2Manager _instance; + private static bool _applicationQuitting; + + public static Allow2Manager Instance + { + get + { + if (_applicationQuitting) return null; + if (_instance == null) + { + _instance = FindObjectOfType(); + if (_instance == null) + { + GameObject go = new GameObject("Allow2Manager"); + _instance = go.AddComponent(); + } + } + return _instance; + } + } + + // ---------------------------------------------------------------- + // Inspector fields + // ---------------------------------------------------------------- + + [Header("Allow2 Configuration")] + [Tooltip("Version ID from developer.allow2.com")] + public int Vid; + + [Tooltip("Device token from developer.allow2.com")] + public string DeviceToken; + + [Tooltip("Activities this game monitors")] + public Allow2Activity[] Activities; + + [Tooltip("API base URL (leave empty for production)")] + public string ApiUrl; + + [Header("Behaviour")] + [Tooltip("Seconds between permission checks")] + public int CheckIntervalSeconds = 60; + + [Tooltip("Auto-pause game on soft-lock (Time.timeScale = 0)")] + public bool AutoPauseOnLock = true; + + [Header("Unity Events (Inspector Binding)")] + public Allow2PairingRequiredEvent OnPairingRequiredEvent; + public Allow2PairedEvent OnPairedEvent; + public Allow2ChildSelectRequiredEvent OnChildSelectRequiredEvent; + public Allow2ChildSelectedEvent OnChildSelectedEvent; + public Allow2SoftLockEvent OnSoftLockEvent; + public Allow2HardLockEvent OnHardLockEvent; + public Allow2UnlockEvent OnUnlockEvent; + public Allow2WarningEvent OnWarningEvent; + public Allow2CheckResultEvent OnCheckResultEvent; + public Allow2StateChangedEvent OnStateChangedEvent; + public Allow2UnpairedEvent OnUnpairedEvent; + public Allow2ParentModeEvent OnParentModeEvent; + public Allow2SessionTimeoutEvent OnSessionTimeoutEvent; + public Allow2ErrorEvent OnErrorEvent; + + // ---------------------------------------------------------------- + // Internal state + // ---------------------------------------------------------------- + + private Allow2Daemon _daemon; + private Allow2Coroutines _coroutines; + private ICredentialStore _credentialStore; + private bool _configured; + private bool _started; + + // Coroutine handles + private Coroutine _checkLoopCoroutine; + private Coroutine _pairingPollCoroutine; + private Coroutine _updatePollCoroutine; + private Coroutine _requestPollCoroutine; + + // ---------------------------------------------------------------- + // Public API + // ---------------------------------------------------------------- + + /// + /// The underlying daemon for advanced usage. + /// + public Allow2Daemon Daemon { get { return _daemon; } } + + /// + /// Current SDK state. + /// + public Allow2State State + { + get { return _daemon != null ? _daemon.State : Allow2State.Unpaired; } + } + + /// + /// Configure the SDK with the given config. + /// Call this before StartDaemon() if not using Inspector fields. + /// + public void Configure(Allow2Config config) + { + Configure(config, null); + } + + /// + /// Configure with a custom credential store. + /// + public void Configure(Allow2Config config, ICredentialStore credentialStore) + { + if (credentialStore == null) + { + credentialStore = new PlayerPrefsStore(); + } + _credentialStore = credentialStore; + + _daemon = new Allow2Daemon(config, credentialStore); + _coroutines = new Allow2Coroutines(this, _daemon.Api); + + WireDaemonEvents(); + _configured = true; + } + + /// + /// Start the daemon. Loads credentials and begins enforcement if paired. + /// + public void StartDaemon() + { + if (!_configured) + { + // Auto-configure from Inspector fields + AutoConfigureFromInspector(); + } + + if (_daemon == null) + { + Debug.LogError("[Allow2] Cannot start: not configured. Call Configure() first."); + return; + } + + _daemon.Start(); + _started = true; + + // Start the check loop if already enforcing + if (_daemon.State == Allow2State.Enforcing) + { + StartCheckLoop(); + } + + // Start the pairing flow if unpaired + if (_daemon.State == Allow2State.Pairing) + { + StartPairingFlow(); + } + } + + /// + /// Stop the daemon and all coroutines. + /// + public void StopDaemon() + { + _started = false; + StopAllLoops(); + if (_daemon != null) + { + _daemon.Stop(); + } + } + + /// + /// Open the Allow2 app (triggers pairing if unpaired). + /// + public void OpenApp() + { + if (_daemon != null) + { + _daemon.OpenApp(); + } + } + + /// + /// Select a child by ID with PIN verification. + /// + public bool SelectChild(int childId, string pin) + { + if (_daemon == null) return false; + bool success = _daemon.SelectChild(childId, pin); + if (success) + { + StartCheckLoop(); + } + return success; + } + + /// + /// Select a child without PIN (honour system). + /// + public bool SelectChild(int childId) + { + return SelectChild(childId, null); + } + + /// + /// Enter parent mode with PIN. + /// + public bool EnterParentMode(string pin) + { + if (_daemon == null) return false; + bool success = _daemon.EnterParentMode(pin); + if (success) + { + StopCheckLoop(); + } + return success; + } + + /// + /// End the current child/parent session. + /// + public void EndSession() + { + StopCheckLoop(); + if (_daemon != null) + { + _daemon.EndSession(); + } + } + + /// + /// Request more time for an activity. + /// + public void RequestMoreTime(int activityId, int durationMinutes, string message) + { + if (_daemon == null || _daemon.Credentials == null) return; + Allow2Credentials creds = _daemon.Credentials; + + _coroutines.RunCreateRequest( + creds.UserId, creds.PairId, creds.PairToken, + _daemon.ChildId, durationMinutes, activityId, message, + delegate(Allow2ApiResponse response) + { + _daemon.Request.HandleCreateResponse(response); + if (_daemon.Request.IsPolling) + { + StartRequestPolling(); + } + } + ); + } + + /// + /// Submit feedback. + /// + public void SubmitFeedback(string category, string message) + { + if (_daemon == null || _daemon.Credentials == null) return; + + string error = _daemon.Feedback.ValidateSubmission(category, message); + if (error != null) + { + Debug.LogWarning("[Allow2] Feedback validation: " + error); + return; + } + + Allow2Credentials creds = _daemon.Credentials; + Dictionary deviceContext = new Dictionary(); + deviceContext["deviceName"] = _daemon.Config.DeviceName; + deviceContext["platform"] = Application.platform.ToString(); + deviceContext["sdkVersion"] = "2.0.0-alpha.1"; + deviceContext["productName"] = Application.productName; + + _coroutines.RunSubmitFeedback( + creds.UserId, creds.PairId, creds.PairToken, + _daemon.ChildId, category, message, deviceContext, + delegate(Allow2ApiResponse response) + { + _daemon.Feedback.HandleSubmitResponse(response, category); + } + ); + } + + // ---------------------------------------------------------------- + // Unity Lifecycle + // ---------------------------------------------------------------- + + private void Awake() + { + if (_instance != null && _instance != this) + { + Destroy(gameObject); + return; + } + + _instance = this; + DontDestroyOnLoad(gameObject); + + if (OnPairingRequiredEvent == null) OnPairingRequiredEvent = new Allow2PairingRequiredEvent(); + if (OnPairedEvent == null) OnPairedEvent = new Allow2PairedEvent(); + if (OnChildSelectRequiredEvent == null) OnChildSelectRequiredEvent = new Allow2ChildSelectRequiredEvent(); + if (OnChildSelectedEvent == null) OnChildSelectedEvent = new Allow2ChildSelectedEvent(); + if (OnSoftLockEvent == null) OnSoftLockEvent = new Allow2SoftLockEvent(); + if (OnHardLockEvent == null) OnHardLockEvent = new Allow2HardLockEvent(); + if (OnUnlockEvent == null) OnUnlockEvent = new Allow2UnlockEvent(); + if (OnWarningEvent == null) OnWarningEvent = new Allow2WarningEvent(); + if (OnCheckResultEvent == null) OnCheckResultEvent = new Allow2CheckResultEvent(); + if (OnStateChangedEvent == null) OnStateChangedEvent = new Allow2StateChangedEvent(); + if (OnUnpairedEvent == null) OnUnpairedEvent = new Allow2UnpairedEvent(); + if (OnParentModeEvent == null) OnParentModeEvent = new Allow2ParentModeEvent(); + if (OnSessionTimeoutEvent == null) OnSessionTimeoutEvent = new Allow2SessionTimeoutEvent(); + if (OnErrorEvent == null) OnErrorEvent = new Allow2ErrorEvent(); + } + + private void Update() + { + if (_daemon == null) return; + + // Tick the soft-lock timer + if (_daemon.Checker != null && _daemon.Checker.IsRunning) + { + _daemon.Checker.UpdateSoftLockTimer(Time.unscaledDeltaTime); + } + + // Tick the session timer + if (_daemon.ChildShield != null) + { + _daemon.ChildShield.UpdateSessionTimer(Time.unscaledDeltaTime); + } + } + + private void OnApplicationPause(bool paused) + { + if (_daemon == null) return; + + if (paused) + { + // App backgrounded -- stop polling to save battery + StopCheckLoop(); + StopUpdateLoop(); + } + else + { + // App resumed -- restart loops if enforcing + if (_started && _daemon.State == Allow2State.Enforcing) + { + StartCheckLoop(); + StartUpdateLoop(); + } + } + } + + private void OnApplicationFocus(bool hasFocus) + { + // Record activity for session timer + if (hasFocus && _daemon != null && _daemon.ChildShield != null) + { + _daemon.ChildShield.RecordActivity(); + } + } + + private void OnApplicationQuit() + { + _applicationQuitting = true; + StopDaemon(); + } + + private void OnDestroy() + { + if (_instance == this) + { + _instance = null; + } + StopAllLoops(); + } + + // ---------------------------------------------------------------- + // Coroutine loops + // ---------------------------------------------------------------- + + private void StartCheckLoop() + { + StopCheckLoop(); + if (_daemon == null || _daemon.Credentials == null) return; + _daemon.Checker.Start(); + _checkLoopCoroutine = StartCoroutine(CheckLoopCoroutine()); + } + + private void StopCheckLoop() + { + if (_checkLoopCoroutine != null) + { + StopCoroutine(_checkLoopCoroutine); + _checkLoopCoroutine = null; + } + if (_daemon != null && _daemon.Checker != null) + { + _daemon.Checker.Stop(); + } + } + + private IEnumerator CheckLoopCoroutine() + { + while (_daemon != null && _daemon.Checker != null && _daemon.Checker.IsRunning) + { + Allow2Credentials creds = _daemon.Credentials; + if (creds == null || !creds.IsValid) + { + yield break; + } + + bool done = false; + _coroutines.RunCheck( + creds.UserId, creds.PairId, creds.PairToken, + _daemon.ChildId, _daemon.Checker.GetActivityMap(), + _daemon.Timezone, + delegate(Allow2ApiResponse response) + { + if (response.IsSuccess) + { + _daemon.Checker.ProcessResult(response); + // Cache for offline + if (response.Body != null) + { + _daemon.Offline.CacheResult(response.Body); + } + } + else + { + _daemon.Checker.HandleError(response); + } + done = true; + } + ); + + // Wait for the request to complete + while (!done) + { + yield return null; + } + + // Wait for check interval + yield return new WaitForSecondsRealtime(_daemon.Config.CheckIntervalSeconds); + } + } + + private void StartPairingFlow() + { + if (_daemon == null || _daemon.Pairing == null) return; + + string uuid = _daemon.Pairing.GetOrCreateUuid(); + string deviceName = _daemon.Config.DeviceName; + if (string.IsNullOrEmpty(deviceName)) + { + deviceName = SystemInfo.deviceName; + } + + _coroutines.RunInitPairing(uuid, deviceName, Application.platform.ToString(), + delegate(Allow2ApiResponse response) + { + _daemon.Pairing.HandleInitResponse(response); + + if (!string.IsNullOrEmpty(_daemon.Pairing.SessionId)) + { + StartPairingPoll(); + } + else + { + // Retry init after 5 seconds + StartCoroutine(RetryPairingInit()); + } + } + ); + } + + private IEnumerator RetryPairingInit() + { + while (_daemon != null && _daemon.State == Allow2State.Pairing + && string.IsNullOrEmpty(_daemon.Pairing.SessionId) + && !_daemon.Pairing.IsPaired) + { + yield return new WaitForSecondsRealtime(5f); + + if (_daemon == null || _daemon.State != Allow2State.Pairing) yield break; + + string uuid = _daemon.Pairing.GetOrCreateUuid(); + string deviceName = _daemon.Config.DeviceName; + if (string.IsNullOrEmpty(deviceName)) + { + deviceName = SystemInfo.deviceName; + } + + bool done = false; + _coroutines.RunInitPairing(uuid, deviceName, Application.platform.ToString(), + delegate(Allow2ApiResponse response) + { + _daemon.Pairing.HandleInitResponse(response); + done = true; + } + ); + + while (!done) yield return null; + + if (!string.IsNullOrEmpty(_daemon.Pairing.SessionId)) + { + StartPairingPoll(); + yield break; + } + } + } + + private void StartPairingPoll() + { + StopPairingPoll(); + _pairingPollCoroutine = StartCoroutine(PairingPollCoroutine()); + } + + private void StopPairingPoll() + { + if (_pairingPollCoroutine != null) + { + StopCoroutine(_pairingPollCoroutine); + _pairingPollCoroutine = null; + } + } + + private IEnumerator PairingPollCoroutine() + { + while (_daemon != null && _daemon.State == Allow2State.Pairing + && !_daemon.Pairing.IsPaired) + { + yield return new WaitForSecondsRealtime(5f); + + if (_daemon == null || _daemon.Pairing.IsPaired) yield break; + + string sessionId = _daemon.Pairing.SessionId; + if (string.IsNullOrEmpty(sessionId)) continue; + + bool done = false; + _coroutines.RunCheckPairingStatus(sessionId, + delegate(Allow2ApiResponse response) + { + _daemon.Pairing.HandlePollResponse(response); + done = true; + } + ); + + while (!done) yield return null; + + if (_daemon.Pairing.IsPaired) + { + // Pairing succeeded -- start enforcement + if (_daemon.State == Allow2State.Enforcing) + { + StartCheckLoop(); + StartUpdateLoop(); + } + yield break; + } + } + } + + private void StartUpdateLoop() + { + StopUpdateLoop(); + if (_daemon == null || _daemon.Credentials == null) return; + _daemon.Updates.Start(); + _updatePollCoroutine = StartCoroutine(UpdatePollCoroutine()); + } + + private void StopUpdateLoop() + { + if (_updatePollCoroutine != null) + { + StopCoroutine(_updatePollCoroutine); + _updatePollCoroutine = null; + } + if (_daemon != null && _daemon.Updates != null) + { + _daemon.Updates.Stop(); + } + } + + private IEnumerator UpdatePollCoroutine() + { + while (_daemon != null && _daemon.Updates != null && _daemon.Updates.IsRunning) + { + Allow2Credentials creds = _daemon.Credentials; + if (creds == null || !creds.IsValid) + { + yield break; + } + + bool done = false; + _coroutines.RunGetUpdates( + creds.UserId, creds.PairId, creds.PairToken, + _daemon.Updates.LastTimestamp, + delegate(Allow2ApiResponse response) + { + _daemon.Updates.HandleResponse(response); + done = true; + } + ); + + while (!done) yield return null; + + yield return new WaitForSecondsRealtime(30f); + } + } + + private void StartRequestPolling() + { + StopRequestPolling(); + _requestPollCoroutine = StartCoroutine(RequestPollCoroutine()); + } + + private void StopRequestPolling() + { + if (_requestPollCoroutine != null) + { + StopCoroutine(_requestPollCoroutine); + _requestPollCoroutine = null; + } + } + + private IEnumerator RequestPollCoroutine() + { + while (_daemon != null && _daemon.Request != null && _daemon.Request.IsPolling) + { + yield return new WaitForSecondsRealtime(5f); + + if (_daemon == null || !_daemon.Request.IsPolling) yield break; + + string requestId = _daemon.Request.RequestId; + string statusSecret = _daemon.Request.StatusSecret; + if (string.IsNullOrEmpty(requestId)) yield break; + + bool done = false; + _coroutines.RunGetRequestStatus(requestId, statusSecret, + delegate(Allow2ApiResponse response) + { + _daemon.Request.HandlePollResponse(response); + done = true; + } + ); + + while (!done) yield return null; + } + } + + private void StopAllLoops() + { + StopCheckLoop(); + StopPairingPoll(); + StopUpdateLoop(); + StopRequestPolling(); + } + + // ---------------------------------------------------------------- + // Auto-configure from Inspector + // ---------------------------------------------------------------- + + private void AutoConfigureFromInspector() + { + if (Vid <= 0 || string.IsNullOrEmpty(DeviceToken)) + { + Debug.LogWarning("[Allow2] Vid and DeviceToken must be set. Configure via Inspector or call Configure()."); + return; + } + + Allow2Config config = new Allow2Config(); + config.Vid = Vid; + config.DeviceToken = DeviceToken; + config.DeviceName = SystemInfo.deviceName; + config.Activities = Activities; + config.ApiUrl = ApiUrl; + config.CheckIntervalSeconds = CheckIntervalSeconds > 0 ? CheckIntervalSeconds : 60; + + Configure(config); + } + + // ---------------------------------------------------------------- + // Wire daemon events to UnityEvents + // ---------------------------------------------------------------- + + private void WireDaemonEvents() + { + _daemon.OnPairingRequired += delegate(string pin, string qrUrl) + { + if (OnPairingRequiredEvent != null) OnPairingRequiredEvent.Invoke(pin, qrUrl); + // Start the pairing poll loop + if (_daemon.State == Allow2State.Pairing) + { + StartPairingFlow(); + } + }; + + _daemon.OnPaired += delegate(Allow2Credentials creds) + { + if (OnPairedEvent != null) OnPairedEvent.Invoke(); + }; + + _daemon.OnChildSelectRequired += delegate(Allow2Child[] children) + { + if (OnChildSelectRequiredEvent != null) OnChildSelectRequiredEvent.Invoke(); + }; + + _daemon.OnChildSelected += delegate(int childId, string name) + { + if (OnChildSelectedEvent != null) OnChildSelectedEvent.Invoke(childId, name); + }; + + _daemon.OnSoftLock += delegate(string reason) + { + if (AutoPauseOnLock) + { + Time.timeScale = 0f; + } + if (OnSoftLockEvent != null) OnSoftLockEvent.Invoke(reason); + }; + + _daemon.OnHardLock += delegate(string reason) + { + if (OnHardLockEvent != null) OnHardLockEvent.Invoke(reason); + }; + + _daemon.OnUnlock += delegate(string reason) + { + if (AutoPauseOnLock) + { + Time.timeScale = 1f; + } + if (OnUnlockEvent != null) OnUnlockEvent.Invoke(reason); + }; + + _daemon.OnWarning += delegate(Allow2WarningEventArgs args) + { + if (OnWarningEvent != null) + { + OnWarningEvent.Invoke(args.Level.ToString(), args.ActivityId, args.RemainingSeconds); + } + }; + + _daemon.OnCheckResult += delegate(Allow2CheckResult result) + { + if (OnCheckResultEvent != null) OnCheckResultEvent.Invoke(); + }; + + _daemon.OnStateChanged += delegate(Allow2State newState) + { + if (OnStateChangedEvent != null) OnStateChangedEvent.Invoke((int)newState); + }; + + _daemon.OnUnpaired += delegate() + { + StopAllLoops(); + if (OnUnpairedEvent != null) OnUnpairedEvent.Invoke(); + }; + + _daemon.OnParentMode += delegate() + { + if (OnParentModeEvent != null) OnParentModeEvent.Invoke(); + }; + + _daemon.OnSessionTimeout += delegate() + { + StopCheckLoop(); + if (OnSessionTimeoutEvent != null) OnSessionTimeoutEvent.Invoke(); + }; + + _daemon.OnPairingError += delegate(string error) + { + if (OnErrorEvent != null) OnErrorEvent.Invoke(error); + }; + } + } +} diff --git a/com.allow2.sdk/Runtime/Core/Allow2Api.cs b/com.allow2.sdk/Runtime/Core/Allow2Api.cs new file mode 100644 index 0000000..7cca88f --- /dev/null +++ b/com.allow2.sdk/Runtime/Core/Allow2Api.cs @@ -0,0 +1,659 @@ +// Allow2 Unity SDK v2 +// Copyright (c) 2026 Allow2 Pty Ltd. All rights reserved. + +using System; +using System.Collections; +using System.Collections.Generic; +using System.Text; +using UnityEngine; +using UnityEngine.Networking; + +namespace Allow2 +{ + /// + /// UnityWebRequest-based API client for the Allow2 REST API. + /// All methods return data via callbacks (coroutine-compatible). + /// + public class Allow2Api + { + public string BaseUrl { get; private set; } + public int Vid { get; private set; } + public string Token { get; private set; } + public int TimeoutSeconds { get; set; } + + public Allow2Api(string baseUrl, int vid, string token) + { + BaseUrl = string.IsNullOrEmpty(baseUrl) ? "https://api.allow2.com" : baseUrl; + Vid = vid; + Token = token; + TimeoutSeconds = 15; + } + + // ---------------------------------------------------------------- + // Pairing + // ---------------------------------------------------------------- + + /// + /// Initiate PIN-code pairing. Returns session info via callback. + /// + public IEnumerator InitPINPairing(string uuid, string deviceName, string platform, + Action callback) + { + Dictionary body = new Dictionary(); + body["uuid"] = uuid; + body["name"] = deviceName; + body["deviceToken"] = Token; + body["vid"] = Vid; + body["platform"] = string.IsNullOrEmpty(platform) ? "unity" : platform; + + yield return PostJson("/api/pair/pin/init", body, callback); + } + + /// + /// Poll pairing status. + /// + public IEnumerator CheckPairingStatus(string sessionId, Action callback) + { + string url = BaseUrl + "/api/pair/status/" + sessionId; + yield return GetJson(url, null, callback); + } + + // ---------------------------------------------------------------- + // Check + // ---------------------------------------------------------------- + + /// + /// Check permissions for a child + activities. + /// + public IEnumerator Check(int userId, int pairId, string pairToken, + int childId, Dictionary activities, string tz, bool log, + Action callback) + { + Dictionary body = new Dictionary(); + body["userId"] = userId; + body["pairId"] = pairId; + body["pairToken"] = pairToken; + body["deviceToken"] = Token; + body["tz"] = tz; + body["childId"] = childId; + body["log"] = log; + + // Activities as object keyed by id + Dictionary actObj = new Dictionary(); + foreach (KeyValuePair kvp in activities) + { + actObj[kvp.Key.ToString()] = kvp.Value; + } + body["activities"] = actObj; + + yield return PostJson("/serviceapi/check", body, callback); + } + + // ---------------------------------------------------------------- + // Updates + // ---------------------------------------------------------------- + + /// + /// Poll for updates (extensions, day type changes, etc.). + /// + public IEnumerator GetUpdates(int userId, int pairId, string pairToken, + long timestampMillis, Action callback) + { + StringBuilder sb = new StringBuilder(BaseUrl); + sb.Append("/api/getUpdates?userId=").Append(userId); + sb.Append("&pairId=").Append(pairId); + sb.Append("&pairToken=").Append(UnityWebRequest.EscapeURL(pairToken)); + sb.Append("&deviceToken=").Append(UnityWebRequest.EscapeURL(Token)); + if (timestampMillis > 0) + { + sb.Append("×tampMillis=").Append(timestampMillis); + } + + yield return GetJson(sb.ToString(), null, callback); + } + + // ---------------------------------------------------------------- + // Requests + // ---------------------------------------------------------------- + + /// + /// Create a "Request More Time" request. + /// + public IEnumerator CreateRequest(int userId, int pairId, string pairToken, + int childId, int duration, int activityId, string message, + Action callback) + { + Dictionary body = new Dictionary(); + body["userId"] = userId; + body["pairId"] = pairId; + body["pairToken"] = pairToken; + body["childId"] = childId; + body["duration"] = duration; + body["activity"] = activityId; + if (!string.IsNullOrEmpty(message)) + { + body["message"] = message; + } + + yield return PostJson("/api/request/createRequest", body, callback); + } + + /// + /// Poll request approval status. + /// + public IEnumerator GetRequestStatus(string requestId, string statusSecret, + Action callback) + { + string url = BaseUrl + "/api/request/" + requestId + "/status"; + Dictionary headers = new Dictionary(); + headers["X-Status-Secret"] = statusSecret; + + yield return GetJson(url, headers, callback); + } + + // ---------------------------------------------------------------- + // Feedback + // ---------------------------------------------------------------- + + /// + /// Submit feedback to the Allow2 server. + /// + public IEnumerator SubmitFeedback(int userId, int pairId, string pairToken, + int childId, string category, string message, + Dictionary deviceContext, Action callback) + { + Dictionary body = new Dictionary(); + body["userId"] = userId; + body["pairId"] = pairId; + body["pairToken"] = pairToken; + body["childId"] = childId; + body["vid"] = Vid; + body["category"] = category; + body["message"] = message; + if (deviceContext != null) + { + body["deviceContext"] = deviceContext; + } + + yield return PostJson("/api/feedback/submit", body, callback); + } + + /// + /// Load feedback discussions for this device. + /// + public IEnumerator LoadFeedback(int userId, int pairId, string pairToken, + Action callback) + { + Dictionary body = new Dictionary(); + body["userId"] = userId; + body["pairId"] = pairId; + body["pairToken"] = pairToken; + + yield return PostJson("/api/feedback/load", body, callback); + } + + /// + /// Reply to an existing feedback discussion. + /// + public IEnumerator FeedbackReply(int userId, int pairId, string pairToken, + string discussionId, string message, Action callback) + { + Dictionary body = new Dictionary(); + body["userId"] = userId; + body["pairId"] = pairId; + body["pairToken"] = pairToken; + body["discussionId"] = discussionId; + body["message"] = message; + + yield return PostJson("/api/feedback/reply", body, callback); + } + + // ---------------------------------------------------------------- + // Internal HTTP helpers + // ---------------------------------------------------------------- + + private IEnumerator PostJson(string path, Dictionary body, + Action callback) + { + string url = path.StartsWith("http") ? path : BaseUrl + path; + string jsonBody = MiniJson.Serialize(body); + byte[] bodyBytes = Encoding.UTF8.GetBytes(jsonBody); + + using (UnityWebRequest request = new UnityWebRequest(url, "POST")) + { + request.uploadHandler = new UploadHandlerRaw(bodyBytes); + request.downloadHandler = new DownloadHandlerBuffer(); + request.SetRequestHeader("Content-Type", "application/json"); + request.timeout = TimeoutSeconds; + + yield return request.SendWebRequest(); + + Allow2ApiResponse response = ParseResponse(request); + if (callback != null) + { + callback(response); + } + } + } + + private IEnumerator GetJson(string url, Dictionary headers, + Action callback) + { + using (UnityWebRequest request = UnityWebRequest.Get(url)) + { + request.SetRequestHeader("Content-Type", "application/json"); + request.timeout = TimeoutSeconds; + + if (headers != null) + { + foreach (KeyValuePair kvp in headers) + { + request.SetRequestHeader(kvp.Key, kvp.Value); + } + } + + yield return request.SendWebRequest(); + + Allow2ApiResponse response = ParseResponse(request); + if (callback != null) + { + callback(response); + } + } + } + + private Allow2ApiResponse ParseResponse(UnityWebRequest request) + { + Allow2ApiResponse response = new Allow2ApiResponse(); + response.StatusCode = (int)request.responseCode; + +#if UNITY_2020_1_OR_NEWER + bool isError = request.result != UnityWebRequest.Result.Success; +#else + bool isError = request.isNetworkError || request.isHttpError; +#endif + + if (isError && request.responseCode == 0) + { + // Network error (no response at all) + response.IsNetworkError = true; + response.ErrorMessage = request.error; + return response; + } + + string text = request.downloadHandler != null ? request.downloadHandler.text : ""; + if (!string.IsNullOrEmpty(text)) + { + try + { + response.Body = MiniJson.Deserialize(text) as Dictionary; + } + catch (Exception) + { + response.Body = null; + } + } + + if (isError) + { + response.IsHttpError = true; + response.ErrorMessage = request.error; + if (response.Body != null && response.Body.ContainsKey("message")) + { + response.ErrorMessage = response.Body["message"].ToString(); + } + } + + return response; + } + } + + /// + /// API response wrapper. + /// + public class Allow2ApiResponse + { + public int StatusCode; + public bool IsNetworkError; + public bool IsHttpError; + public string ErrorMessage; + public Dictionary Body; + + public bool IsSuccess + { + get { return !IsNetworkError && !IsHttpError && StatusCode >= 200 && StatusCode < 300; } + } + + /// + /// Get a string value from the response body. + /// + public string GetString(string key) + { + if (Body != null && Body.ContainsKey(key)) + { + object val = Body[key]; + return val != null ? val.ToString() : null; + } + return null; + } + + /// + /// Get an int value from the response body. + /// + public int GetInt(string key, int defaultValue = 0) + { + if (Body != null && Body.ContainsKey(key)) + { + object val = Body[key]; + if (val is long) return (int)(long)val; + if (val is double) return (int)(double)val; + if (val is int) return (int)val; + int parsed; + if (val != null && int.TryParse(val.ToString(), out parsed)) + { + return parsed; + } + } + return defaultValue; + } + + /// + /// Get a long value from the response body. + /// + public long GetLong(string key, long defaultValue = 0) + { + if (Body != null && Body.ContainsKey(key)) + { + object val = Body[key]; + if (val is long) return (long)val; + if (val is double) return (long)(double)val; + long parsed; + if (val != null && long.TryParse(val.ToString(), out parsed)) + { + return parsed; + } + } + return defaultValue; + } + + /// + /// Get a bool value from the response body. + /// + public bool GetBool(string key, bool defaultValue = false) + { + if (Body != null && Body.ContainsKey(key)) + { + object val = Body[key]; + if (val is bool) return (bool)val; + } + return defaultValue; + } + } + + // ---------------------------------------------------------------- + // Minimal JSON serializer/deserializer + // Unity's JsonUtility doesn't handle Dictionary. + // This is a lightweight alternative. + // ---------------------------------------------------------------- + + internal static class MiniJson + { + public static string Serialize(object obj) + { + if (obj == null) return "null"; + + if (obj is string) + { + return "\"" + EscapeString((string)obj) + "\""; + } + + if (obj is bool) + { + return (bool)obj ? "true" : "false"; + } + + if (obj is int || obj is long || obj is float || obj is double) + { + return obj.ToString(); + } + + if (obj is Dictionary) + { + Dictionary dict = (Dictionary)obj; + StringBuilder sb = new StringBuilder("{"); + bool first = true; + foreach (KeyValuePair kvp in dict) + { + if (!first) sb.Append(","); + first = false; + sb.Append("\"").Append(EscapeString(kvp.Key)).Append("\":"); + sb.Append(Serialize(kvp.Value)); + } + sb.Append("}"); + return sb.ToString(); + } + + if (obj is Dictionary) + { + Dictionary dict = (Dictionary)obj; + StringBuilder sb = new StringBuilder("{"); + bool first = true; + foreach (KeyValuePair kvp in dict) + { + if (!first) sb.Append(","); + first = false; + sb.Append("\"").Append(EscapeString(kvp.Key)).Append("\":"); + sb.Append("\"").Append(EscapeString(kvp.Value)).Append("\""); + } + sb.Append("}"); + return sb.ToString(); + } + + if (obj is IList) + { + IList list = (IList)obj; + StringBuilder sb = new StringBuilder("["); + for (int i = 0; i < list.Count; i++) + { + if (i > 0) sb.Append(","); + sb.Append(Serialize(list[i])); + } + sb.Append("]"); + return sb.ToString(); + } + + return "\"" + EscapeString(obj.ToString()) + "\""; + } + + public static object Deserialize(string json) + { + if (string.IsNullOrEmpty(json)) return null; + int index = 0; + return ParseValue(json, ref index); + } + + private static string EscapeString(string s) + { + if (s == null) return ""; + StringBuilder sb = new StringBuilder(s.Length); + for (int i = 0; i < s.Length; i++) + { + char c = s[i]; + switch (c) + { + case '"': sb.Append("\\\""); break; + case '\\': sb.Append("\\\\"); break; + case '\n': sb.Append("\\n"); break; + case '\r': sb.Append("\\r"); break; + case '\t': sb.Append("\\t"); break; + default: sb.Append(c); break; + } + } + return sb.ToString(); + } + + private static void SkipWhitespace(string json, ref int index) + { + while (index < json.Length && char.IsWhiteSpace(json[index])) + { + index++; + } + } + + private static object ParseValue(string json, ref int index) + { + SkipWhitespace(json, ref index); + if (index >= json.Length) return null; + + char c = json[index]; + if (c == '{') return ParseObject(json, ref index); + if (c == '[') return ParseArray(json, ref index); + if (c == '"') return ParseString(json, ref index); + if (c == 't' || c == 'f') return ParseBool(json, ref index); + if (c == 'n') return ParseNull(json, ref index); + return ParseNumber(json, ref index); + } + + private static Dictionary ParseObject(string json, ref int index) + { + Dictionary dict = new Dictionary(); + index++; // skip { + SkipWhitespace(json, ref index); + + while (index < json.Length && json[index] != '}') + { + SkipWhitespace(json, ref index); + string key = ParseString(json, ref index); + SkipWhitespace(json, ref index); + if (index < json.Length && json[index] == ':') index++; + SkipWhitespace(json, ref index); + object value = ParseValue(json, ref index); + dict[key] = value; + SkipWhitespace(json, ref index); + if (index < json.Length && json[index] == ',') index++; + } + + if (index < json.Length) index++; // skip } + return dict; + } + + private static List ParseArray(string json, ref int index) + { + List list = new List(); + index++; // skip [ + SkipWhitespace(json, ref index); + + while (index < json.Length && json[index] != ']') + { + object value = ParseValue(json, ref index); + list.Add(value); + SkipWhitespace(json, ref index); + if (index < json.Length && json[index] == ',') index++; + } + + if (index < json.Length) index++; // skip ] + return list; + } + + private static string ParseString(string json, ref int index) + { + if (index >= json.Length || json[index] != '"') return ""; + index++; // skip opening " + StringBuilder sb = new StringBuilder(); + + while (index < json.Length && json[index] != '"') + { + if (json[index] == '\\' && index + 1 < json.Length) + { + index++; + char esc = json[index]; + switch (esc) + { + case '"': sb.Append('"'); break; + case '\\': sb.Append('\\'); break; + case 'n': sb.Append('\n'); break; + case 'r': sb.Append('\r'); break; + case 't': sb.Append('\t'); break; + default: sb.Append(esc); break; + } + } + else + { + sb.Append(json[index]); + } + index++; + } + + if (index < json.Length) index++; // skip closing " + return sb.ToString(); + } + + private static object ParseNumber(string json, ref int index) + { + int start = index; + bool isFloat = false; + + if (index < json.Length && json[index] == '-') index++; + while (index < json.Length && char.IsDigit(json[index])) index++; + if (index < json.Length && json[index] == '.') + { + isFloat = true; + index++; + while (index < json.Length && char.IsDigit(json[index])) index++; + } + if (index < json.Length && (json[index] == 'e' || json[index] == 'E')) + { + isFloat = true; + index++; + if (index < json.Length && (json[index] == '+' || json[index] == '-')) index++; + while (index < json.Length && char.IsDigit(json[index])) index++; + } + + string numStr = json.Substring(start, index - start); + + if (isFloat) + { + double d; + if (double.TryParse(numStr, System.Globalization.NumberStyles.Float, + System.Globalization.CultureInfo.InvariantCulture, out d)) + { + return d; + } + } + else + { + long l; + if (long.TryParse(numStr, out l)) + { + return l; + } + } + + return 0; + } + + private static bool ParseBool(string json, ref int index) + { + if (json.Length - index >= 4 && json.Substring(index, 4) == "true") + { + index += 4; + return true; + } + if (json.Length - index >= 5 && json.Substring(index, 5) == "false") + { + index += 5; + return false; + } + return false; + } + + private static object ParseNull(string json, ref int index) + { + if (json.Length - index >= 4 && json.Substring(index, 4) == "null") + { + index += 4; + } + return null; + } + } +} diff --git a/com.allow2.sdk/Runtime/Core/Allow2Checker.cs b/com.allow2.sdk/Runtime/Core/Allow2Checker.cs new file mode 100644 index 0000000..7eca919 --- /dev/null +++ b/com.allow2.sdk/Runtime/Core/Allow2Checker.cs @@ -0,0 +1,481 @@ +// Allow2 Unity SDK v2 +// Copyright (c) 2026 Allow2 Pty Ltd. All rights reserved. + +using System; +using System.Collections.Generic; + +namespace Allow2 +{ + /// + /// Check Loop + Per-Activity Enforcement. + /// Periodically calls the Allow2 check API and tracks per-activity + /// state transitions. Delegates warning scheduling to Allow2Warnings. + /// + /// This is pure C# -- the Unity MonoBehaviour bridge drives the + /// coroutine-based check loop and calls ProcessResult / HandleError. + /// + public class Allow2Checker + { + private readonly Allow2Activity[] _activities; + private readonly int _hardLockTimeoutMs; + private readonly int _gracePeriodMs; + private readonly Allow2Warnings _warnings; + + private int _childId; + private readonly Dictionary _state; + + private bool _softLocked; + private float _softLockElapsed; + private long _offlineSince; + private bool _offlineGraceEmitted; + private bool _running; + + // ScreenTime activity is the master switch + private const int SCREEN_TIME_ACTIVITY = 8; + + /// Fired when an activity transitions from allowed to blocked. + public event Action OnActivityBlocked; + + /// Fired when all activities are blocked (pause game). + public event Action OnSoftLock; + + /// Fired after soft-lock timeout expires (close game). + public event Action OnHardLock; + + /// Fired when at least one activity becomes allowed again. + public event Action OnUnlock; + + /// Fired on HTTP 401 -- device was unpaired. + public event Action OnUnpaired; + + /// Fired during offline grace period. + public event Action OnOfflineGrace; + + /// Fired when grace period expires. + public event Action OnOfflineDeny; + + /// Fired after each successful check with the full result. + public event Action OnCheckResult; + + /// Warning events (delegates to Allow2Warnings). + public event Action OnWarning + { + add { _warnings.OnWarning += value; } + remove { _warnings.OnWarning -= value; } + } + + private class ActivityState + { + public bool Allowed; + public int Remaining; + } + + public Allow2Checker(Allow2Activity[] activities, int hardLockTimeoutSeconds, + int gracePeriodSeconds, Allow2Warning[] warningThresholds) + { + _activities = activities; + _hardLockTimeoutMs = hardLockTimeoutSeconds * 1000; + _gracePeriodMs = gracePeriodSeconds * 1000; + _warnings = new Allow2Warnings(warningThresholds); + _state = new Dictionary(); + _softLocked = false; + _softLockElapsed = 0f; + _offlineSince = 0; + _offlineGraceEmitted = false; + _running = false; + } + + public bool IsRunning { get { return _running; } } + public bool IsSoftLocked { get { return _softLocked; } } + + public int ChildId + { + get { return _childId; } + set { _childId = value; } + } + + /// + /// Get the activity IDs to check as a dictionary (id -> 1). + /// + public Dictionary GetActivityMap() + { + Dictionary map = new Dictionary(); + for (int i = 0; i < _activities.Length; i++) + { + map[_activities[i].Id] = 1; + } + return map; + } + + public void Start() + { + _running = true; + } + + public void Stop() + { + _running = false; + } + + /// + /// Called by the bridge after a successful API check. + /// Parses the response and manages state transitions. + /// + public void ProcessResult(Allow2ApiResponse response) + { + if (!_running) return; + + // Clear offline state on successful API call + if (_offlineSince > 0) + { + _offlineSince = 0; + _offlineGraceEmitted = false; + } + + if (response == null || response.Body == null) return; + + Allow2CheckResult result = ParseCheckResult(response.Body); + if (result == null) return; + + if (OnCheckResult != null) + { + OnCheckResult(result); + } + + bool allBlocked = true; + Dictionary warningData = new Dictionary(); + + foreach (KeyValuePair kvp in result.Activities) + { + int id = kvp.Key; + Allow2ActivityResult current = kvp.Value; + bool allowed = current.IsAllowed; + int remaining = current.Remaining; + + ActivityState prev; + bool wasAllowed = true; + if (_state.TryGetValue(id, out prev)) + { + wasAllowed = prev.Allowed; + } + + // Detect allowed -> blocked transition + if (wasAllowed && !allowed) + { + if (OnActivityBlocked != null) + { + OnActivityBlocked(id, current.Name, 0); + } + + if (id == SCREEN_TIME_ACTIVITY) + { + TriggerSoftLock("screen-time-exhausted"); + } + } + + // Detect blocked -> allowed transition + if (!wasAllowed && allowed && prev != null) + { + _warnings.ResetActivity(id); + } + + // Update state + if (!_state.ContainsKey(id)) + { + _state[id] = new ActivityState(); + } + _state[id].Allowed = allowed; + _state[id].Remaining = remaining; + + if (allowed) + { + allBlocked = false; + warningData[id] = remaining; + } + } + + // All blocked -> soft-lock + if (result.Activities.Count > 0 && allBlocked && !_softLocked) + { + TriggerSoftLock("all-activities-blocked"); + } + + // Was soft-locked but something is now allowed -> unlock + if (_softLocked && !allBlocked) + { + _softLocked = false; + _softLockElapsed = 0f; + if (OnUnlock != null) + { + OnUnlock("activity-unblocked"); + } + } + + // Feed remaining times to warning scheduler + if (warningData.Count > 0) + { + _warnings.Update(warningData); + } + } + + /// + /// Called by the bridge when an API check fails. + /// + public void HandleError(Allow2ApiResponse response) + { + if (!_running) return; + + // HTTP 401 = device unpaired + if (response != null && response.StatusCode == 401) + { + if (OnUnpaired != null) + { + OnUnpaired(); + } + Stop(); + return; + } + + // Network / timeout errors -> offline handling + long now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + if (_offlineSince == 0) + { + _offlineSince = now; + } + + long offlineDuration = now - _offlineSince; + + if (offlineDuration < _gracePeriodMs) + { + if (!_offlineGraceEmitted) + { + _offlineGraceEmitted = true; + if (OnOfflineGrace != null) + { + OnOfflineGrace(_offlineSince, _gracePeriodMs - offlineDuration); + } + } + } + else + { + if (OnOfflineDeny != null) + { + OnOfflineDeny(_offlineSince, offlineDuration); + } + } + } + + /// + /// Notify the checker that time was extended for an activity. + /// Resets warning state for that activity. + /// + public void OnTimeExtended(int activityId) + { + _warnings.ResetActivity(activityId); + + if (_softLocked) + { + _softLocked = false; + _softLockElapsed = 0f; + if (OnUnlock != null) + { + OnUnlock("time-extended"); + } + } + } + + /// + /// Called each frame by the bridge to track soft-lock elapsed time. + /// + public void UpdateSoftLockTimer(float deltaTime) + { + if (!_softLocked || !_running) return; + + _softLockElapsed += deltaTime * 1000f; + if (_softLockElapsed >= _hardLockTimeoutMs) + { + if (OnHardLock != null) + { + OnHardLock("soft-lock-timeout"); + } + } + } + + /// + /// Get remaining time for all tracked activities. + /// Returns null if no state yet. + /// + public Dictionary GetRemaining() + { + if (_state.Count == 0) return null; + Dictionary result = new Dictionary(); + foreach (KeyValuePair kvp in _state) + { + result[kvp.Key] = kvp.Value.Remaining; + } + return result; + } + + /// + /// Reset all state (e.g., new child selected). + /// + public void Reset(int childId) + { + _childId = childId; + _state.Clear(); + _softLocked = false; + _softLockElapsed = 0f; + _offlineSince = 0; + _offlineGraceEmitted = false; + _warnings.ResetAll(); + } + + private void TriggerSoftLock(string reason) + { + if (_softLocked) return; + _softLocked = true; + _softLockElapsed = 0f; + + if (OnSoftLock != null) + { + OnSoftLock(reason); + } + } + + // ---------------------------------------------------------------- + // Response parsing + // ---------------------------------------------------------------- + + private Allow2CheckResult ParseCheckResult(Dictionary body) + { + Allow2CheckResult result = new Allow2CheckResult(); + result.Allowed = GetBool(body, "allowed"); + + object activitiesObj; + if (body.TryGetValue("activities", out activitiesObj)) + { + Dictionary activitiesDict = activitiesObj as Dictionary; + if (activitiesDict != null) + { + foreach (KeyValuePair kvp in activitiesDict) + { + int actId; + if (!int.TryParse(kvp.Key, out actId)) continue; + + Dictionary actDict = kvp.Value as Dictionary; + if (actDict == null) continue; + + Allow2ActivityResult ar = new Allow2ActivityResult(); + ar.Id = actId; + ar.Name = GetString(actDict, "name"); + ar.IsAllowed = GetBool(actDict, "allowed"); + ar.Banned = GetBool(actDict, "banned"); + ar.Timed = GetBool(actDict, "timed"); + ar.Remaining = GetInt(actDict, "remaining"); + ar.Units = GetString(actDict, "units"); + + object tbObj; + if (actDict.TryGetValue("timeBlock", out tbObj)) + { + Dictionary tbDict = tbObj as Dictionary; + if (tbDict != null) + { + ar.TimeBlock = new Allow2TimeBlock(); + ar.TimeBlock.Allowed = GetBool(tbDict, "allowed"); + ar.TimeBlock.Remaining = GetInt(tbDict, "remaining"); + } + } + + result.Activities[actId] = ar; + } + } + } + + // Parse day types + object dayTypesObj; + if (body.TryGetValue("dayTypes", out dayTypesObj)) + { + Dictionary dtDict = dayTypesObj as Dictionary; + if (dtDict != null) + { + result.Today = ParseDayType(dtDict, "today"); + result.Tomorrow = ParseDayType(dtDict, "tomorrow"); + } + } + + // Parse children + object childrenObj; + if (body.TryGetValue("children", out childrenObj)) + { + List childList = childrenObj as List; + if (childList != null) + { + result.Children = new Allow2Child[childList.Count]; + for (int i = 0; i < childList.Count; i++) + { + Dictionary cDict = childList[i] as Dictionary; + if (cDict != null) + { + Allow2Child child = new Allow2Child(); + child.Id = GetInt(cDict, "id"); + child.Name = GetString(cDict, "name"); + child.PinHash = GetString(cDict, "pinHash"); + child.PinSalt = GetString(cDict, "pinSalt"); + result.Children[i] = child; + } + } + } + } + + return result; + } + + private Allow2DayType ParseDayType(Dictionary parent, string key) + { + object obj; + if (parent.TryGetValue(key, out obj)) + { + Dictionary dtDict = obj as Dictionary; + if (dtDict != null) + { + return new Allow2DayType(GetInt(dtDict, "id"), GetString(dtDict, "name")); + } + } + return null; + } + + // -- Helpers for reading Dictionary safely -- + + private static string GetString(Dictionary dict, string key) + { + object val; + if (dict.TryGetValue(key, out val) && val != null) + { + return val.ToString(); + } + return null; + } + + private static bool GetBool(Dictionary dict, string key) + { + object val; + if (dict.TryGetValue(key, out val)) + { + if (val is bool) return (bool)val; + } + return false; + } + + private static int GetInt(Dictionary dict, string key) + { + object val; + if (dict.TryGetValue(key, out val)) + { + if (val is long) return (int)(long)val; + if (val is double) return (int)(double)val; + if (val is int) return (int)val; + } + return 0; + } + } +} diff --git a/com.allow2.sdk/Runtime/Core/Allow2ChildShield.cs b/com.allow2.sdk/Runtime/Core/Allow2ChildShield.cs new file mode 100644 index 0000000..2b68602 --- /dev/null +++ b/com.allow2.sdk/Runtime/Core/Allow2ChildShield.cs @@ -0,0 +1,443 @@ +// Allow2 Unity SDK v2 +// Copyright (c) 2026 Allow2 Pty Ltd. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Security.Cryptography; +using System.Text; + +namespace Allow2 +{ + /// + /// Child identification, PIN verification, and session management. + /// Port of the Brave browser's ChildShield/ChildManager pattern. + /// + /// Pure C# -- no Unity dependencies. + /// + public class Allow2ChildShield + { + public enum VerificationLevel + { + /// Trust the child's selection without PIN. + Honour, + /// Require PIN verification. + Pin, + /// Only parent can select (no child self-select). + ParentOnly + } + + private const int MAX_PIN_ATTEMPTS = 5; + private const long LOCKOUT_DURATION_MS = 300000; // 5 minutes + private const long DEFAULT_SESSION_TIMEOUT_MS = 300000; // 5 minutes + + private Allow2Child[] _children; + private readonly VerificationLevel _verificationLevel; + private readonly long _sessionTimeoutMs; + + private Allow2Child _currentChild; + private bool _parentMode; + private long _lastActivityTime; + + // Rate-limiting: keyed by childId (or -1 for parent) + private readonly Dictionary _attempts; + + // Session timer tracking + private float _sessionIdleElapsed; + private bool _sessionTimerActive; + + // Events + public event Action OnChildSelectRequired; + public event Action OnChildSelected; + public event Action OnParentModeEntered; + public event Action OnSessionTimeout; + public event Action OnChildPinFailed; // (failedCount, maxAttempts) + public event Action OnChildLockedOut; // (lockoutSeconds) + + private class AttemptRecord + { + public int Failed; + public long LockoutUntil; + } + + public Allow2ChildShield(VerificationLevel verificationLevel, long sessionTimeoutMs) + { + _verificationLevel = verificationLevel; + _sessionTimeoutMs = sessionTimeoutMs > 0 ? sessionTimeoutMs : DEFAULT_SESSION_TIMEOUT_MS; + _children = new Allow2Child[0]; + _attempts = new Dictionary(); + _sessionIdleElapsed = 0f; + _sessionTimerActive = false; + } + + public Allow2ChildShield() : this(VerificationLevel.Pin, DEFAULT_SESSION_TIMEOUT_MS) { } + + // --------------------------------------------------------------- + // Public API + // --------------------------------------------------------------- + + public Allow2Child CurrentChild { get { return _currentChild; } } + public bool IsParentMode { get { return _parentMode; } } + + /// + /// Set the children list (from pairing or getUpdates). + /// + public void SetChildren(Allow2Child[] children) + { + _children = children != null ? children : new Allow2Child[0]; + + // If current child was removed, force re-selection + if (_currentChild != null) + { + bool stillExists = false; + for (int i = 0; i < _children.Length; i++) + { + if (_children[i].Id == _currentChild.Id) + { + stillExists = true; + _currentChild = _children[i]; + break; + } + } + if (!stillExists) + { + ClearSelection(); + } + } + } + + /// + /// Select a child by ID, optionally verifying their PIN. + /// Returns true if the child was successfully selected. + /// + public bool SelectChild(int childId, string pin) + { + if (_verificationLevel == VerificationLevel.ParentOnly) + { + return false; + } + + Allow2Child child = FindChild(childId); + if (child == null) + { + return false; + } + + // Check lockout + if (IsLockedOut(childId)) + { + long remaining = LockoutRemaining(childId); + if (OnChildLockedOut != null) + { + OnChildLockedOut((int)(remaining / 1000)); + } + return false; + } + + // PIN verification + if (_verificationLevel == VerificationLevel.Pin) + { + if (string.IsNullOrEmpty(pin)) + { + return false; + } + if (!string.IsNullOrEmpty(child.PinHash) && !string.IsNullOrEmpty(child.PinSalt)) + { + string computed = HashPin(pin, child.PinSalt); + if (!SafeCompare(computed, child.PinHash)) + { + RecordFailedAttempt(childId); + return false; + } + } + // If child has no PIN set, treat as honour system + } + + // Success + ClearAttempts(childId); + ActivateChild(child); + return true; + } + + /// + /// Authenticate as parent. Returns true if parent mode was entered. + /// + public bool SelectParent(string pin) + { + if (string.IsNullOrEmpty(pin)) + { + return false; + } + + Allow2Child parentEntry = FindParentEntry(); + if (parentEntry == null) + { + return false; + } + + if (IsLockedOut(-1)) + { + long remaining = LockoutRemaining(-1); + if (OnChildLockedOut != null) + { + OnChildLockedOut((int)(remaining / 1000)); + } + return false; + } + + if (string.IsNullOrEmpty(parentEntry.PinHash) || string.IsNullOrEmpty(parentEntry.PinSalt)) + { + return false; + } + + string computed = HashPin(pin, parentEntry.PinSalt); + if (!SafeCompare(computed, parentEntry.PinHash)) + { + RecordFailedAttempt(-1); + return false; + } + + ClearAttempts(-1); + EnterParentMode(); + return true; + } + + /// + /// End the current session and request child re-selection. + /// + public void ClearSelection() + { + _currentChild = null; + _parentMode = false; + _sessionTimerActive = false; + _sessionIdleElapsed = 0f; + _lastActivityTime = 0; + if (OnChildSelectRequired != null) + { + OnChildSelectRequired(GetDisplayChildren()); + } + } + + /// + /// Record user activity to keep the session alive. + /// + public void RecordActivity() + { + _lastActivityTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + _sessionIdleElapsed = 0f; + } + + /// + /// Called each frame by the bridge to track session idle time. + /// + public void UpdateSessionTimer(float deltaTime) + { + if (!_sessionTimerActive) return; + if (_sessionTimeoutMs <= 0) return; + + _sessionIdleElapsed += deltaTime * 1000f; + if (_sessionIdleElapsed >= _sessionTimeoutMs) + { + _sessionTimerActive = false; + _currentChild = null; + _parentMode = false; + _lastActivityTime = 0; + _sessionIdleElapsed = 0f; + + if (OnSessionTimeout != null) + { + OnSessionTimeout(); + } + if (OnChildSelectRequired != null) + { + OnChildSelectRequired(GetDisplayChildren()); + } + } + } + + /// + /// Returns a safe copy of the children list (no PIN data). + /// + public Allow2Child[] GetDisplayChildren() + { + Allow2Child[] display = new Allow2Child[_children.Length]; + for (int i = 0; i < _children.Length; i++) + { + display[i] = _children[i].ToDisplayChild(); + } + return display; + } + + public void Destroy() + { + _sessionTimerActive = false; + OnChildSelectRequired = null; + OnChildSelected = null; + OnParentModeEntered = null; + OnSessionTimeout = null; + OnChildPinFailed = null; + OnChildLockedOut = null; + } + + // --------------------------------------------------------------- + // Private helpers + // --------------------------------------------------------------- + + private Allow2Child FindChild(int childId) + { + for (int i = 0; i < _children.Length; i++) + { + if (_children[i].Id == childId) + { + return _children[i]; + } + } + return null; + } + + private Allow2Child FindParentEntry() + { + for (int i = 0; i < _children.Length; i++) + { + if (_children[i].Id == 0 || _children[i].Name == "__parent__") + { + return _children[i]; + } + } + return null; + } + + private void ActivateChild(Allow2Child child) + { + _parentMode = false; + _currentChild = child; + _lastActivityTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + _sessionIdleElapsed = 0f; + _sessionTimerActive = true; + + if (OnChildSelected != null) + { + OnChildSelected(child.Id, child.Name); + } + } + + private void EnterParentMode() + { + _currentChild = null; + _parentMode = true; + _lastActivityTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + _sessionIdleElapsed = 0f; + _sessionTimerActive = true; + + if (OnParentModeEntered != null) + { + OnParentModeEntered(); + } + } + + // --------------------------------------------------------------- + // PIN hashing (SHA-256 + salt) + // --------------------------------------------------------------- + + /// + /// Hash a PIN with the given salt using SHA-256. + /// + public static string HashPin(string pin, string salt) + { + using (SHA256 sha256 = SHA256.Create()) + { + byte[] inputBytes = Encoding.UTF8.GetBytes(pin + salt); + byte[] hashBytes = sha256.ComputeHash(inputBytes); + StringBuilder sb = new StringBuilder(hashBytes.Length * 2); + for (int i = 0; i < hashBytes.Length; i++) + { + sb.Append(hashBytes[i].ToString("x2")); + } + return sb.ToString(); + } + } + + /// + /// Constant-time comparison of two hex hash strings. + /// + private static bool SafeCompare(string a, string b) + { + if (a == null || b == null) return false; + if (a.Length != b.Length) return false; + + int diff = 0; + for (int i = 0; i < a.Length; i++) + { + diff |= a[i] ^ b[i]; + } + return diff == 0; + } + + // --------------------------------------------------------------- + // Rate limiting + // --------------------------------------------------------------- + + private AttemptRecord GetAttemptRecord(int key) + { + AttemptRecord record; + if (!_attempts.TryGetValue(key, out record)) + { + record = new AttemptRecord(); + record.Failed = 0; + record.LockoutUntil = 0; + _attempts[key] = record; + } + return record; + } + + private bool IsLockedOut(int key) + { + AttemptRecord record = GetAttemptRecord(key); + long now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + if (record.LockoutUntil > 0 && now < record.LockoutUntil) + { + return true; + } + // Lockout expired + if (record.LockoutUntil > 0 && now >= record.LockoutUntil) + { + record.Failed = 0; + record.LockoutUntil = 0; + } + return false; + } + + private long LockoutRemaining(int key) + { + AttemptRecord record = GetAttemptRecord(key); + long remaining = record.LockoutUntil - DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + return remaining > 0 ? remaining : 0; + } + + private void RecordFailedAttempt(int key) + { + AttemptRecord record = GetAttemptRecord(key); + record.Failed += 1; + + if (record.Failed >= MAX_PIN_ATTEMPTS) + { + record.LockoutUntil = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() + LOCKOUT_DURATION_MS; + if (OnChildLockedOut != null) + { + OnChildLockedOut((int)(LOCKOUT_DURATION_MS / 1000)); + } + } + else + { + if (OnChildPinFailed != null) + { + OnChildPinFailed(record.Failed, MAX_PIN_ATTEMPTS); + } + } + } + + private void ClearAttempts(int key) + { + _attempts.Remove(key); + } + } +} diff --git a/com.allow2.sdk/Runtime/Core/Allow2Daemon.cs b/com.allow2.sdk/Runtime/Core/Allow2Daemon.cs new file mode 100644 index 0000000..f7af6c9 --- /dev/null +++ b/com.allow2.sdk/Runtime/Core/Allow2Daemon.cs @@ -0,0 +1,493 @@ +// Allow2 Unity SDK v2 +// Copyright (c) 2026 Allow2 Pty Ltd. All rights reserved. + +using System; +using System.Collections.Generic; + +namespace Allow2 +{ + /// + /// Main entry point for the Allow2 Device SDK. + /// + /// Manages the full device lifecycle: + /// 1. Unpaired - sits idle, waits for OpenApp() to start pairing + /// 2. Pairing - pairing wizard active + /// 3. Paired - paired but no child selected yet + /// 4. Enforcing - child selected, check loop running + /// 5. Parent - parent mode, no enforcement + /// + /// This is pure C# (no Unity dependencies). The MonoBehaviour bridge + /// (Allow2Manager) drives coroutines for API calls and timers. + /// + public class Allow2Daemon + { + private readonly Allow2Config _config; + private readonly ICredentialStore _credentialStore; + private readonly Allow2Api _api; + private readonly Allow2Checker _checker; + private readonly Allow2ChildShield _childShield; + private readonly Allow2Pairing _pairing; + private readonly Allow2Offline _offline; + private readonly Allow2Updates _updates; + private readonly Allow2Request _request; + private readonly Allow2Feedback _feedback; + + private Allow2Credentials _credentials; + private int _childId; + private bool _running; + private Allow2State _state; + private string _timezone; + + // ---------------------------------------------------------------- + // Events + // ---------------------------------------------------------------- + + /// Fired when the device needs pairing (show pairing UI). + public event Action OnPairingRequired; // (pin, qrUrl) + + /// Fired when pairing completes. + public event Action OnPaired; + + /// Fired on pairing error. + public event Action OnPairingError; + + /// Fired when a child must be selected (show child selector). + public event Action OnChildSelectRequired; + + /// Fired when a child is selected. + public event Action OnChildSelected; + + /// Fired when entering parent mode. + public event Action OnParentMode; + + /// Fired on session timeout (need child re-identification). + public event Action OnSessionTimeout; + + /// Fired with each check result. + public event Action OnCheckResult; + + /// Fired when an activity is blocked. + public event Action OnActivityBlocked; + + /// Fired when all activities are blocked (pause game). + public event Action OnSoftLock; + + /// Fired after soft-lock timeout (close game). + public event Action OnHardLock; + + /// Fired when at least one activity becomes allowed again. + public event Action OnUnlock; + + /// Warning events. + public event Action OnWarning; + + /// Fired on HTTP 401 (device released by parent). + public event Action OnUnpaired; + + /// Fired when state changes. + public event Action OnStateChanged; + + /// Fired when children list is updated. + public event Action OnChildrenUpdated; + + // ---------------------------------------------------------------- + // Properties + // ---------------------------------------------------------------- + + public Allow2State State { get { return _state; } } + public Allow2Api Api { get { return _api; } } + public Allow2Checker Checker { get { return _checker; } } + public Allow2ChildShield ChildShield { get { return _childShield; } } + public Allow2Pairing Pairing { get { return _pairing; } } + public Allow2Offline Offline { get { return _offline; } } + public Allow2Updates Updates { get { return _updates; } } + public Allow2Request Request { get { return _request; } } + public Allow2Feedback Feedback { get { return _feedback; } } + public Allow2Config Config { get { return _config; } } + public Allow2Credentials Credentials { get { return _credentials; } } + public int ChildId { get { return _childId; } } + public bool IsRunning { get { return _running; } } + public bool IsPaired + { + get { return _credentials != null && _credentials.IsValid; } + } + public bool IsParentMode { get { return _state == Allow2State.Parent; } } + + public string Timezone + { + get { return _timezone; } + set { _timezone = value; } + } + + // ---------------------------------------------------------------- + // Constructor + // ---------------------------------------------------------------- + + public Allow2Daemon(Allow2Config config, ICredentialStore credentialStore) + { + if (config == null) throw new ArgumentNullException("config"); + if (credentialStore == null) throw new ArgumentNullException("credentialStore"); + config.Validate(); + + _config = config; + _credentialStore = credentialStore; + _state = Allow2State.Unpaired; + _running = false; + + _api = new Allow2Api(config.ApiUrl, config.Vid, config.DeviceToken); + + _checker = new Allow2Checker( + config.Activities, + config.HardLockTimeoutSeconds, + config.GracePeriodSeconds, + config.WarningThresholds + ); + + _childShield = new Allow2ChildShield(); + _pairing = new Allow2Pairing(credentialStore); + _offline = new Allow2Offline(config.GracePeriodSeconds); + _updates = new Allow2Updates(); + _request = new Allow2Request(); + _feedback = new Allow2Feedback(); + + // Default timezone + _timezone = TimeZoneInfo.Local.Id; + + WireInternalEvents(); + } + + // ---------------------------------------------------------------- + // Lifecycle + // ---------------------------------------------------------------- + + /// + /// Start the daemon. Checks for stored credentials. + /// If unpaired, sits idle. If paired, proceeds to child identification. + /// + public void Start() + { + if (_running) return; + _running = true; + + // Load stored credentials + try + { + _credentials = _credentialStore.Load(); + } + catch (Exception) + { + _credentials = null; + } + + // If not paired, sit idle + if (_credentials == null || !_credentials.IsValid) + { + SetState(Allow2State.Unpaired); + return; + } + + // Already paired -- proceed to child identification + SetState(Allow2State.Paired); + BeginEnforcement(); + } + + /// + /// Stop the daemon. + /// + public void Stop() + { + _running = false; + _checker.Stop(); + _updates.Stop(); + _request.StopPolling(); + _childId = 0; + + if (_credentials != null && _credentials.IsValid) + { + SetState(Allow2State.Paired); + } + else + { + SetState(Allow2State.Unpaired); + } + } + + /// + /// Called when the user opens the Allow2 app/UI. + /// If unpaired, starts pairing. If paired, requests status. + /// + public void OpenApp() + { + if (_state == Allow2State.Unpaired || _credentials == null || !_credentials.IsValid) + { + StartPairing(); + } + } + + /// + /// Called when the user closes the Allow2 app/UI. + /// + public void CloseApp() + { + if (_credentials == null || !_credentials.IsValid) + { + SetState(Allow2State.Unpaired); + } + } + + // ---------------------------------------------------------------- + // Child Management + // ---------------------------------------------------------------- + + /// + /// Select a child (from the child selector UI). + /// + public bool SelectChild(int childId, string pin) + { + bool success = _childShield.SelectChild(childId, pin); + if (success) + { + _childId = childId; + _credentialStore.StoreLastUsedChildId(childId); + SetState(Allow2State.Enforcing); + _checker.Reset(childId); + _checker.Start(); + } + return success; + } + + /// + /// Enter parent mode (no enforcement). + /// + public bool EnterParentMode(string pin) + { + bool success = _childShield.SelectParent(pin); + if (success) + { + _checker.Stop(); + _childId = 0; + SetState(Allow2State.Parent); + if (OnParentMode != null) OnParentMode(); + } + return success; + } + + /// + /// End the current session. Stops checker and requests re-identification. + /// + public void EndSession() + { + _checker.Stop(); + _childId = 0; + _childShield.ClearSelection(); + SetState(Allow2State.Paired); + + if (_running && _credentials != null && _credentials.IsValid) + { + BeginEnforcement(); + } + } + + /// + /// Called when pairing completes externally (e.g., from a callback). + /// + public void OnPairingComplete(Allow2Credentials credentials) + { + HandlePaired(credentials); + } + + // ---------------------------------------------------------------- + // Internal + // ---------------------------------------------------------------- + + private void SetState(Allow2State newState) + { + if (_state == newState) return; + _state = newState; + if (OnStateChanged != null) OnStateChanged(newState); + } + + private void StartPairing() + { + if (_state == Allow2State.Pairing) return; + SetState(Allow2State.Pairing); + _pairing.Reset(); + _pairing.GetOrCreateUuid(); + // The bridge will call the API coroutine and feed the response back + } + + private void HandlePaired(Allow2Credentials credentials) + { + _credentials = credentials; + SetState(Allow2State.Paired); + + if (OnPaired != null) OnPaired(credentials); + + if (_running) + { + BeginEnforcement(); + } + } + + private void BeginEnforcement() + { + if (_credentials == null || _credentials.Children == null) + { + return; + } + + _childShield.SetChildren(_credentials.Children); + + // Try auto-resolve: check lastUsedChildId + int lastChildId = _credentialStore.LoadLastUsedChildId(); + if (lastChildId > 0) + { + // Check if that child still exists + bool exists = false; + for (int i = 0; i < _credentials.Children.Length; i++) + { + if (_credentials.Children[i].Id == lastChildId) + { + exists = true; + break; + } + } + if (exists) + { + // Auto-select without PIN (the child was already verified previously) + _childId = lastChildId; + _credentialStore.StoreLastUsedChildId(lastChildId); + SetState(Allow2State.Enforcing); + _checker.Reset(lastChildId); + _checker.Start(); + + string childName = null; + for (int i = 0; i < _credentials.Children.Length; i++) + { + if (_credentials.Children[i].Id == lastChildId) + { + childName = _credentials.Children[i].Name; + break; + } + } + if (OnChildSelected != null) + { + OnChildSelected(lastChildId, childName); + } + return; + } + } + + // No auto-resolve -- need interactive selection + if (OnChildSelectRequired != null) + { + OnChildSelectRequired(_childShield.GetDisplayChildren()); + } + } + + private void HandleUnpaired() + { + _checker.Stop(); + _updates.Stop(); + _childId = 0; + _credentials = null; + + try + { + _credentialStore.Clear(); + } + catch (Exception) + { + // Best effort + } + + SetState(Allow2State.Unpaired); + if (OnUnpaired != null) OnUnpaired(); + } + + private void WireInternalEvents() + { + // Pairing events + _pairing.OnPairingReady += delegate(string pin, string qrUrl) + { + if (OnPairingRequired != null) OnPairingRequired(pin, qrUrl); + }; + _pairing.OnPaired += delegate(Allow2Credentials creds) + { + HandlePaired(creds); + }; + _pairing.OnError += delegate(string error) + { + if (OnPairingError != null) OnPairingError(error); + }; + + // Checker events + _checker.OnCheckResult += delegate(Allow2CheckResult result) + { + if (OnCheckResult != null) OnCheckResult(result); + }; + _checker.OnActivityBlocked += delegate(int actId, string actName, int remaining) + { + if (OnActivityBlocked != null) OnActivityBlocked(actId, actName, remaining); + }; + _checker.OnSoftLock += delegate(string reason) + { + if (OnSoftLock != null) OnSoftLock(reason); + }; + _checker.OnHardLock += delegate(string reason) + { + if (OnHardLock != null) OnHardLock(reason); + }; + _checker.OnUnlock += delegate(string reason) + { + if (OnUnlock != null) OnUnlock(reason); + }; + _checker.OnWarning += delegate(Allow2WarningEventArgs args) + { + if (OnWarning != null) OnWarning(args); + }; + _checker.OnUnpaired += delegate() + { + HandleUnpaired(); + }; + + // ChildShield events + _childShield.OnChildSelected += delegate(int childId, string name) + { + if (OnChildSelected != null) OnChildSelected(childId, name); + }; + _childShield.OnChildSelectRequired += delegate(Allow2Child[] children) + { + if (OnChildSelectRequired != null) OnChildSelectRequired(children); + }; + _childShield.OnSessionTimeout += delegate() + { + _checker.Stop(); + _childId = 0; + SetState(Allow2State.Paired); + if (OnSessionTimeout != null) OnSessionTimeout(); + if (_running && _credentials != null && _credentials.IsValid) + { + BeginEnforcement(); + } + }; + + // Updates events + _updates.OnChildrenUpdated += delegate(Allow2Child[] children) + { + if (_credentials != null) + { + _credentials.Children = children; + _childShield.SetChildren(children); + try { _credentialStore.Store(_credentials); } + catch (Exception) { /* best effort */ } + } + if (OnChildrenUpdated != null) OnChildrenUpdated(children); + }; + _updates.OnUnpaired += delegate() + { + HandleUnpaired(); + }; + } + } +} diff --git a/com.allow2.sdk/Runtime/Core/Allow2Feedback.cs b/com.allow2.sdk/Runtime/Core/Allow2Feedback.cs new file mode 100644 index 0000000..df69fb0 --- /dev/null +++ b/com.allow2.sdk/Runtime/Core/Allow2Feedback.cs @@ -0,0 +1,143 @@ +// Allow2 Unity SDK v2 +// Copyright (c) 2026 Allow2 Pty Ltd. All rights reserved. + +using System; + +namespace Allow2 +{ + /// + /// Feedback submission manager. + /// Allows children and parents to report bugs, request features, + /// or report bypass attempts. + /// + /// API calls are driven by the bridge (coroutine). This class + /// validates inputs and interprets responses. + /// + public class Allow2Feedback + { + /// Valid feedback categories. + public static readonly string[] ValidCategories = new string[] + { + "bypass", + "missing_feature", + "not_working", + "question", + "other" + }; + + /// Fired when feedback is submitted successfully. + public event Action OnFeedbackSubmitted; // discussionId, category + + /// Fired on feedback error. + public event Action OnFeedbackError; + + /// Fired when feedback discussions are loaded. + public event Action OnFeedbackLoaded; + + /// Fired when a reply is sent. + public event Action OnFeedbackReplySent; // discussionId, messageId + + /// + /// Validate a feedback category string. + /// + public static bool IsValidCategory(string category) + { + if (string.IsNullOrEmpty(category)) return false; + for (int i = 0; i < ValidCategories.Length; i++) + { + if (ValidCategories[i] == category) return true; + } + return false; + } + + /// + /// Validate feedback parameters before submission. + /// Returns null if valid, or an error message string. + /// + public string ValidateSubmission(string category, string message) + { + if (string.IsNullOrEmpty(category)) + { + return "Category is required"; + } + if (!IsValidCategory(category)) + { + return "Invalid category. Must be one of: bypass, missing_feature, not_working, question, other"; + } + if (string.IsNullOrEmpty(message)) + { + return "Message is required"; + } + return null; + } + + /// + /// Handle the API response from submitFeedback. + /// + public void HandleSubmitResponse(Allow2ApiResponse response, string category) + { + if (response == null || !response.IsSuccess) + { + string error = response != null ? response.ErrorMessage : "Feedback submission failed"; + if (OnFeedbackError != null) OnFeedbackError(error); + return; + } + + string discussionId = response.GetString("discussionId"); + if (OnFeedbackSubmitted != null) + { + OnFeedbackSubmitted(discussionId, category); + } + } + + /// + /// Handle the API response from loadFeedback. + /// + public void HandleLoadResponse(Allow2ApiResponse response) + { + if (response == null || !response.IsSuccess) + { + string error = response != null ? response.ErrorMessage : "Failed to load feedback"; + if (OnFeedbackError != null) OnFeedbackError(error); + return; + } + + if (OnFeedbackLoaded != null) + { + OnFeedbackLoaded(response); + } + } + + /// + /// Handle the API response from feedbackReply. + /// + public void HandleReplyResponse(Allow2ApiResponse response, string discussionId) + { + if (response == null || !response.IsSuccess) + { + string error = response != null ? response.ErrorMessage : "Failed to send reply"; + if (OnFeedbackError != null) OnFeedbackError(error); + return; + } + + string messageId = response.GetString("messageId"); + if (OnFeedbackReplySent != null) + { + OnFeedbackReplySent(discussionId, messageId); + } + } + + /// + /// Convert a feedback category to a human-readable label. + /// + public static string CategoryToLabel(string category) + { + if (category == "bypass") return "Bypass / Circumvention report"; + if (category == "missing_feature") return "Missing Feature report"; + if (category == "not_working") return "Not Working report"; + if (category == "question") return "Question"; + if (category == "other") return "General feedback"; + return "Feedback"; + } + } +} diff --git a/com.allow2.sdk/Runtime/Core/Allow2Offline.cs b/com.allow2.sdk/Runtime/Core/Allow2Offline.cs new file mode 100644 index 0000000..84bd3ca --- /dev/null +++ b/com.allow2.sdk/Runtime/Core/Allow2Offline.cs @@ -0,0 +1,160 @@ +// Allow2 Unity SDK v2 +// Copyright (c) 2026 Allow2 Pty Ltd. All rights reserved. + +using System; +using System.Collections.Generic; +using UnityEngine; + +namespace Allow2 +{ + /// + /// Offline Handler. + /// Caches the last successful check result and enforces a grace period + /// when the device loses connectivity. After the grace period expires, + /// defaults to DENY (block all activities). + /// + /// Cache is stored in PlayerPrefs to survive app restarts. + /// + public class Allow2Offline + { + private const string CacheKey = "allow2_offline_cache"; + private const string TimestampKey = "allow2_offline_ts"; + + private readonly int _gracePeriodSeconds; + private Dictionary _cachedResult; + private long _cachedTimestamp; + private bool _loaded; + + /// Fired when entering grace period. + public event Action OnOfflineGrace; // elapsed seconds + + /// Fired when grace period expires. + public event Action OnOfflineDeny; + + public Allow2Offline(int gracePeriodSeconds) + { + _gracePeriodSeconds = gracePeriodSeconds > 0 ? gracePeriodSeconds : 300; + _cachedResult = null; + _cachedTimestamp = 0; + _loaded = false; + } + + public Allow2Offline() : this(300) { } + + /// + /// Store a successful check result. + /// + public void CacheResult(Dictionary checkResult) + { + _cachedResult = checkResult; + _cachedTimestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + + try + { + string json = MiniJson.Serialize(checkResult); + PlayerPrefs.SetString(CacheKey, json); + PlayerPrefs.SetString(TimestampKey, _cachedTimestamp.ToString()); + PlayerPrefs.Save(); + } + catch (Exception) + { + // Persist failure is non-fatal + } + } + + /// + /// Return the cached check result, loading from PlayerPrefs if needed. + /// Returns null if no cache exists. + /// + public Dictionary GetCachedResult() + { + EnsureLoaded(); + return _cachedResult; + } + + /// + /// Seconds elapsed since the last successful check. + /// Returns int.MaxValue if no cached result exists. + /// + public int GetGraceElapsed() + { + EnsureLoaded(); + if (_cachedTimestamp == 0) return int.MaxValue; + long now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + return (int)((now - _cachedTimestamp) / 1000); + } + + /// + /// True if we are still within the grace period. + /// + public bool IsInGracePeriod() + { + int elapsed = GetGraceElapsed(); + if (elapsed < _gracePeriodSeconds) + { + if (OnOfflineGrace != null) + { + OnOfflineGrace(elapsed); + } + return true; + } + return false; + } + + /// + /// True if the grace period has expired and we should deny by default. + /// + public bool ShouldDeny() + { + int elapsed = GetGraceElapsed(); + if (elapsed >= _gracePeriodSeconds) + { + if (OnOfflineDeny != null) + { + OnOfflineDeny(); + } + return true; + } + return false; + } + + /// + /// Clear all cached data. + /// + public void Clear() + { + _cachedResult = null; + _cachedTimestamp = 0; + PlayerPrefs.DeleteKey(CacheKey); + PlayerPrefs.DeleteKey(TimestampKey); + } + + private void EnsureLoaded() + { + if (_loaded) return; + _loaded = true; + + try + { + string json = PlayerPrefs.GetString(CacheKey, ""); + string tsStr = PlayerPrefs.GetString(TimestampKey, ""); + + if (!string.IsNullOrEmpty(json) && !string.IsNullOrEmpty(tsStr)) + { + _cachedResult = MiniJson.Deserialize(json) as Dictionary; + long ts; + if (long.TryParse(tsStr, out ts)) + { + _cachedTimestamp = ts; + } + } + } + catch (Exception) + { + // Corrupt cache -- start fresh + _cachedResult = null; + _cachedTimestamp = 0; + } + } + } +} diff --git a/com.allow2.sdk/Runtime/Core/Allow2Pairing.cs b/com.allow2.sdk/Runtime/Core/Allow2Pairing.cs new file mode 100644 index 0000000..035a002 --- /dev/null +++ b/com.allow2.sdk/Runtime/Core/Allow2Pairing.cs @@ -0,0 +1,325 @@ +// Allow2 Unity SDK v2 +// Copyright (c) 2026 Allow2 Pty Ltd. All rights reserved. + +using System; +using System.Collections.Generic; + +namespace Allow2 +{ + /// + /// Manages one-time device pairing with the Allow2 platform. + /// Parents NEVER enter credentials on the child's device. + /// + /// Flow: + /// 1. Call Start() to register a pairing session via the API + /// 2. API returns a server-assigned PIN and session ID + /// 3. Device displays the PIN (and QR code deep link) to the user + /// 4. Parent opens Allow2 app on their phone, enters the PIN + /// 5. Poll CheckPairingStatus until parent confirms + /// 6. On confirmation, receives credentials + /// 7. Stores credentials via ICredentialStore + /// + /// The coroutine-based polling is driven by the Allow2Manager bridge. + /// This class holds the state and provides callbacks. + /// + public class Allow2Pairing + { + private readonly ICredentialStore _credentialStore; + + private string _pin; + private string _sessionId; + private string _uuid; + private string _qrUrl; + private bool _paired; + private bool _started; + private int _pollCount; + private int _consecutiveErrors; + private bool _connected; + + private const int MAX_POLLS = 360; // 30 minutes at 5s intervals + + /// Fired when a PIN and session are ready for display. + public event Action OnPairingReady; // (pin, qrUrl) + + /// Fired when pairing completes successfully. + public event Action OnPaired; + + /// Fired on pairing error. + public event Action OnError; + + /// Fired when connection status changes. + public event Action OnConnectionStatus; // (connected, pin, qrUrl) + + /// Fired when pairing times out. + public event Action OnTimeout; + + public string Pin { get { return _pin; } } + public string QrUrl { get { return _qrUrl; } } + public string SessionId { get { return _sessionId; } } + public bool IsPaired { get { return _paired; } } + public bool IsStarted { get { return _started; } } + public bool IsConnected { get { return _connected; } } + + public Allow2Pairing(ICredentialStore credentialStore) + { + _credentialStore = credentialStore; + _paired = false; + _started = false; + _pollCount = 0; + _consecutiveErrors = 0; + _connected = false; + } + + /// + /// Get or create a device UUID. + /// + public string GetOrCreateUuid() + { + if (!string.IsNullOrEmpty(_uuid)) return _uuid; + + // Try loading from credential store + Allow2Credentials creds = _credentialStore.Load(); + if (creds != null && !string.IsNullOrEmpty(creds.Uuid)) + { + _uuid = creds.Uuid; + return _uuid; + } + + // Generate new UUID + _uuid = Guid.NewGuid().ToString(); + Allow2Credentials uuidCreds = new Allow2Credentials(); + uuidCreds.Uuid = _uuid; + try + { + _credentialStore.Store(uuidCreds); + } + catch (Exception) + { + // Best effort + } + return _uuid; + } + + /// + /// Handle the API response from initPINPairing. + /// Called by the bridge after the coroutine completes. + /// + public void HandleInitResponse(Allow2ApiResponse response) + { + _started = true; + + if (response == null || !response.IsSuccess) + { + // API unreachable -- no valid PIN yet + _pin = "------"; + _sessionId = null; + _connected = false; + _qrUrl = "https://app.allow2.com/pair?pin=" + _pin; + + if (OnPairingReady != null) + { + OnPairingReady(_pin, _qrUrl); + } + return; + } + + _pin = response.GetString("pin"); + if (string.IsNullOrEmpty(_pin)) + { + _pin = "------"; + } + + _sessionId = response.GetString("sessionId"); + if (string.IsNullOrEmpty(_sessionId)) + { + _sessionId = response.GetString("pairingSessionId"); + } + + _connected = !string.IsNullOrEmpty(_sessionId); + _consecutiveErrors = 0; + _qrUrl = "https://app.allow2.com/pair?pin=" + _pin; + + if (OnPairingReady != null) + { + OnPairingReady(_pin, _qrUrl); + } + } + + /// + /// Handle the API response from checkPairingStatus. + /// Returns true if pairing is complete. + /// + public bool HandlePollResponse(Allow2ApiResponse response) + { + if (_paired) return true; + + _pollCount++; + if (_pollCount > MAX_POLLS) + { + if (OnTimeout != null) OnTimeout(); + return false; + } + + if (response == null || !response.IsSuccess) + { + _consecutiveErrors++; + if (_consecutiveErrors >= 2 && _connected) + { + _connected = false; + if (OnConnectionStatus != null) + { + OnConnectionStatus(false, _pin, _qrUrl); + } + } + return false; + } + + // Successful poll -- mark as connected + if (!_connected) + { + _connected = true; + _consecutiveErrors = 0; + if (OnConnectionStatus != null) + { + OnConnectionStatus(true, _pin, _qrUrl); + } + } + + // Check if parent confirmed + bool paired = response.GetBool("paired"); + int userId = response.GetInt("userId"); + int pairId = response.GetInt("pairId"); + string pairToken = response.GetString("pairToken"); + + if (paired && userId > 0 && pairId > 0 && !string.IsNullOrEmpty(pairToken)) + { + CompletePairing(response); + return true; + } + + return false; + } + + /// + /// Complete pairing with the given API response data. + /// + public void CompletePairing(Allow2ApiResponse response) + { + if (response == null || response.Body == null) + { + if (OnError != null) OnError("Invalid pairing response"); + return; + } + + Allow2Credentials credentials = new Allow2Credentials(); + credentials.Uuid = _uuid; + credentials.UserId = response.GetInt("userId"); + credentials.PairId = response.GetInt("pairId"); + credentials.PairToken = response.GetString("pairToken"); + + // Parse children array + object childrenObj; + if (response.Body.TryGetValue("children", out childrenObj)) + { + List childList = childrenObj as List; + if (childList != null) + { + credentials.Children = new Allow2Child[childList.Count]; + for (int i = 0; i < childList.Count; i++) + { + Dictionary cDict = childList[i] as Dictionary; + if (cDict != null) + { + Allow2Child child = new Allow2Child(); + object val; + if (cDict.TryGetValue("id", out val)) + { + if (val is long) child.Id = (int)(long)val; + else if (val is double) child.Id = (int)(double)val; + } + if (cDict.TryGetValue("name", out val) && val != null) + { + child.Name = val.ToString(); + } + if (cDict.TryGetValue("pinHash", out val) && val != null) + { + child.PinHash = val.ToString(); + } + if (cDict.TryGetValue("pinSalt", out val) && val != null) + { + child.PinSalt = val.ToString(); + } + credentials.Children[i] = child; + } + } + } + } + + if (credentials.Children == null) + { + credentials.Children = new Allow2Child[0]; + } + + // Persist credentials + try + { + _credentialStore.Store(credentials); + } + catch (Exception ex) + { + if (OnError != null) OnError("Failed to store credentials: " + ex.Message); + return; + } + + _paired = true; + + if (OnPaired != null) + { + OnPaired(credentials); + } + } + + /// + /// Reset pairing state for a new attempt. + /// + public void Reset() + { + _pin = null; + _sessionId = null; + _qrUrl = null; + _paired = false; + _started = false; + _pollCount = 0; + _consecutiveErrors = 0; + _connected = false; + } + + /// + /// Handle an external pairing completion (e.g., from a callback). + /// + public void CompletePairingExternal(Allow2Credentials credentials) + { + if (credentials == null || !credentials.IsValid) + { + if (OnError != null) OnError("Invalid credentials"); + return; + } + + try + { + _credentialStore.Store(credentials); + } + catch (Exception ex) + { + if (OnError != null) OnError("Failed to store credentials: " + ex.Message); + return; + } + + _paired = true; + if (OnPaired != null) + { + OnPaired(credentials); + } + } + } +} diff --git a/com.allow2.sdk/Runtime/Core/Allow2Request.cs b/com.allow2.sdk/Runtime/Core/Allow2Request.cs new file mode 100644 index 0000000..3d36bc3 --- /dev/null +++ b/com.allow2.sdk/Runtime/Core/Allow2Request.cs @@ -0,0 +1,147 @@ +// Allow2 Unity SDK v2 +// Copyright (c) 2026 Allow2 Pty Ltd. All rights reserved. + +using System; + +namespace Allow2 +{ + /// + /// Request More Time manager. + /// Lets a child request additional time, a day type change, or a ban lift. + /// Creates the request via the Allow2 API, then polls for parent response. + /// + /// Polling is driven by the bridge (coroutine). This class holds state + /// and interprets responses. + /// + public class Allow2Request + { + private string _requestId; + private string _statusSecret; + private bool _polling; + private int _pollCount; + private readonly int _maxPollCount; + + /// Fired when the request is created. + public event Action OnRequestCreated; // requestId + + /// Fired when the parent approves. + public event Action OnRequestApproved; // requestId, extension + + /// Fired when the parent denies. + public event Action OnRequestDenied; // requestId + + /// Fired when polling times out. + public event Action OnRequestTimeout; + + /// Fired on error. + public event Action OnRequestError; + + public string RequestId { get { return _requestId; } } + public string StatusSecret { get { return _statusSecret; } } + public bool IsPolling { get { return _polling; } } + + /// Max wait time in seconds (default 300). + /// Seconds between polls (default 5). + public Allow2Request(int timeoutSeconds, int pollIntervalSeconds) + { + int timeout = timeoutSeconds > 0 ? timeoutSeconds : 300; + int interval = pollIntervalSeconds > 0 ? pollIntervalSeconds : 5; + _maxPollCount = timeout / interval; + _polling = false; + _pollCount = 0; + } + + public Allow2Request() : this(300, 5) { } + + /// + /// Handle the API response from createRequest. + /// + public void HandleCreateResponse(Allow2ApiResponse response) + { + if (response == null || !response.IsSuccess) + { + string error = response != null ? response.ErrorMessage : "Request failed"; + if (OnRequestError != null) OnRequestError(error); + return; + } + + _requestId = response.GetString("requestId"); + _statusSecret = response.GetString("statusSecret"); + _polling = true; + _pollCount = 0; + + if (OnRequestCreated != null) + { + OnRequestCreated(_requestId); + } + } + + /// + /// Handle the API response from getRequestStatus. + /// Returns true if the request has been resolved (approved/denied/timeout). + /// + public bool HandlePollResponse(Allow2ApiResponse response) + { + if (!_polling) return true; + + _pollCount++; + if (_pollCount >= _maxPollCount) + { + StopPolling(); + if (OnRequestTimeout != null) OnRequestTimeout(); + return true; + } + + if (response == null || !response.IsSuccess) + { + // Transient error -- keep polling + return false; + } + + string status = response.GetString("status"); + + if (status == "approved") + { + StopPolling(); + int extension = response.GetInt("extension"); + if (OnRequestApproved != null) + { + OnRequestApproved(_requestId, extension); + } + return true; + } + + if (status == "denied") + { + StopPolling(); + if (OnRequestDenied != null) + { + OnRequestDenied(_requestId); + } + return true; + } + + // Still pending + return false; + } + + /// + /// Stop polling. + /// + public void StopPolling() + { + _polling = false; + } + + /// + /// Reset for a new request. + /// + public void Reset() + { + _requestId = null; + _statusSecret = null; + _polling = false; + _pollCount = 0; + } + } +} diff --git a/com.allow2.sdk/Runtime/Core/Allow2Updates.cs b/com.allow2.sdk/Runtime/Core/Allow2Updates.cs new file mode 100644 index 0000000..3d64739 --- /dev/null +++ b/com.allow2.sdk/Runtime/Core/Allow2Updates.cs @@ -0,0 +1,224 @@ +// Allow2 Unity SDK v2 +// Copyright (c) 2026 Allow2 Pty Ltd. All rights reserved. + +using System; +using System.Collections.Generic; + +namespace Allow2 +{ + /// + /// Update Poller. + /// Polls GET /api/getUpdates for changes since the last check. + /// Emits events for extensions, day type changes, quota updates, + /// bans, and children list refreshes. + /// + /// Polling is driven by the bridge (coroutine). This class holds + /// state and interprets responses. + /// + public class Allow2Updates + { + private long _lastTimestamp; + private bool _running; + + /// Fired when a parent approves extra time. + public event Action OnExtension; // childId, activityId, additionalMinutes + + /// Fired when a day type changes. + public event Action OnDayTypeChanged; // childId, dayType + + /// Fired when a quota is updated. + public event Action OnQuotaUpdated; // childId, activityId, newQuota + + /// Fired when a ban is applied/removed. + public event Action OnBan; // childId, activityId, banned + + /// Fired when the children list changes. + public event Action OnChildrenUpdated; + + /// Fired on HTTP 401 (device unpaired). + public event Action OnUnpaired; + + public bool IsRunning { get { return _running; } } + public long LastTimestamp { get { return _lastTimestamp; } } + + public Allow2Updates() + { + _lastTimestamp = 0; + _running = false; + } + + public void Start() + { + _running = true; + } + + public void Stop() + { + _running = false; + } + + /// + /// Handle the API response from getUpdates. + /// + public void HandleResponse(Allow2ApiResponse response) + { + if (!_running) return; + + if (response == null) + { + return; + } + + // HTTP 401 = device unpaired + if (response.StatusCode == 401) + { + if (OnUnpaired != null) OnUnpaired(); + Stop(); + return; + } + + if (!response.IsSuccess || response.Body == null) + { + return; + } + + // Advance timestamp + long ts = response.GetLong("timestampMillis"); + if (ts > 0) + { + _lastTimestamp = ts; + } + + Dictionary body = response.Body; + + // Extensions + ProcessListEvent(body, "extensions", delegate(Dictionary item) + { + if (OnExtension != null) + { + OnExtension( + GetInt(item, "childId"), + GetInt(item, "activity"), + GetInt(item, "additionalMinutes") + ); + } + }); + + // Day type changes + ProcessListEvent(body, "dayTypeChanges", delegate(Dictionary item) + { + if (OnDayTypeChanged != null) + { + OnDayTypeChanged( + GetInt(item, "childId"), + GetString(item, "dayType") + ); + } + }); + + // Quota updates + ProcessListEvent(body, "quotaUpdates", delegate(Dictionary item) + { + if (OnQuotaUpdated != null) + { + OnQuotaUpdated( + GetInt(item, "childId"), + GetInt(item, "activity"), + GetInt(item, "newQuota") + ); + } + }); + + // Bans + ProcessListEvent(body, "bans", delegate(Dictionary item) + { + if (OnBan != null) + { + OnBan( + GetInt(item, "childId"), + GetInt(item, "activity"), + GetBool(item, "banned") + ); + } + }); + + // Children + object childrenObj; + if (body.TryGetValue("children", out childrenObj)) + { + List childList = childrenObj as List; + if (childList != null && childList.Count > 0) + { + Allow2Child[] children = new Allow2Child[childList.Count]; + for (int i = 0; i < childList.Count; i++) + { + Dictionary cDict = childList[i] as Dictionary; + if (cDict != null) + { + Allow2Child child = new Allow2Child(); + child.Id = GetInt(cDict, "id"); + child.Name = GetString(cDict, "name"); + child.PinHash = GetString(cDict, "pinHash"); + child.PinSalt = GetString(cDict, "pinSalt"); + children[i] = child; + } + } + if (OnChildrenUpdated != null) + { + OnChildrenUpdated(children); + } + } + } + } + + private void ProcessListEvent(Dictionary body, string key, + Action> handler) + { + object listObj; + if (!body.TryGetValue(key, out listObj)) return; + List list = listObj as List; + if (list == null || list.Count == 0) return; + + for (int i = 0; i < list.Count; i++) + { + Dictionary item = list[i] as Dictionary; + if (item != null) + { + handler(item); + } + } + } + + private static int GetInt(Dictionary dict, string key) + { + object val; + if (dict.TryGetValue(key, out val)) + { + if (val is long) return (int)(long)val; + if (val is double) return (int)(double)val; + if (val is int) return (int)val; + } + return 0; + } + + private static string GetString(Dictionary dict, string key) + { + object val; + if (dict.TryGetValue(key, out val) && val != null) + { + return val.ToString(); + } + return null; + } + + private static bool GetBool(Dictionary dict, string key) + { + object val; + if (dict.TryGetValue(key, out val)) + { + if (val is bool) return (bool)val; + } + return false; + } + } +} diff --git a/com.allow2.sdk/Runtime/Core/Allow2VoiceCode.cs b/com.allow2.sdk/Runtime/Core/Allow2VoiceCode.cs new file mode 100644 index 0000000..79f36cb --- /dev/null +++ b/com.allow2.sdk/Runtime/Core/Allow2VoiceCode.cs @@ -0,0 +1,154 @@ +// Allow2 Unity SDK v2 +// Copyright (c) 2026 Allow2 Pty Ltd. All rights reserved. + +using System; +using System.Security.Cryptography; +using System.Text; + +namespace Allow2 +{ + /// + /// Offline HMAC-SHA256 challenge-response for voice codes. + /// + /// Voice code format: T A MM NN + /// T = request type (1 = more time, 2 = day type, 3 = ban lift) + /// A = activity ID (single digit, mod 10 for >9) + /// MM = duration in 5-minute increments (00-99 -> 0-495 min) + /// NN = nonce (2 digits from HMAC) + /// + /// The parent reads the 6-digit challenge code displayed on the child's + /// device. The parent enters it into their Allow2 app which generates a + /// 4-digit response code using the shared secret. The child enters the + /// response code to get their time extended. + /// + /// This enables offline time extensions when there is no internet. + /// + public static class Allow2VoiceCode + { + /// + /// Generate a 6-digit challenge code. + /// + /// 1=more time, 2=day type, 3=ban lift + /// Activity ID + /// Requested duration in minutes + /// 6-character challenge string (e.g., "130412") + public static string GenerateChallenge(int requestType, int activityId, int durationMinutes) + { + int t = requestType; + if (t < 1 || t > 3) t = 1; + + int a = activityId % 10; + int mm = durationMinutes / 5; + if (mm > 99) mm = 99; + + // Generate a 2-digit nonce from current time + long now = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + int nn = (int)(now % 100); + + return string.Format("{0}{1}{2:D2}{3:D2}", t, a, mm, nn); + } + + /// + /// Verify a 4-digit response code against a challenge. + /// + /// The 6-digit challenge that was displayed + /// The 4-digit response entered by the child + /// The shared secret (from pairing credentials) + /// True if the response is valid + public static bool VerifyResponse(string challenge, string response, string sharedSecret) + { + if (string.IsNullOrEmpty(challenge) || challenge.Length != 6) + { + return false; + } + if (string.IsNullOrEmpty(response) || response.Length != 4) + { + return false; + } + if (string.IsNullOrEmpty(sharedSecret)) + { + return false; + } + + string expected = ComputeResponse(challenge, sharedSecret); + return SafeCompare(expected, response); + } + + /// + /// Compute the expected 4-digit response code for a challenge. + /// This is what the parent's app would compute. + /// + /// The 6-digit challenge + /// The shared secret + /// 4-digit response string + public static string ComputeResponse(string challenge, string sharedSecret) + { + byte[] keyBytes = Encoding.UTF8.GetBytes(sharedSecret); + byte[] messageBytes = Encoding.UTF8.GetBytes(challenge); + + using (HMACSHA256 hmac = new HMACSHA256(keyBytes)) + { + byte[] hash = hmac.ComputeHash(messageBytes); + + // Take first 4 bytes and reduce to a 4-digit number + int code = ((hash[0] & 0x7F) << 24 | + (hash[1] & 0xFF) << 16 | + (hash[2] & 0xFF) << 8 | + (hash[3] & 0xFF)) % 10000; + + return code.ToString("D4"); + } + } + + /// + /// Parse a challenge code into its components. + /// + public static bool ParseChallenge(string challenge, out int requestType, + out int activityId, out int durationMinutes, out int nonce) + { + requestType = 0; + activityId = 0; + durationMinutes = 0; + nonce = 0; + + if (string.IsNullOrEmpty(challenge) || challenge.Length != 6) + { + return false; + } + + int t; + if (!int.TryParse(challenge.Substring(0, 1), out t)) return false; + requestType = t; + + int a; + if (!int.TryParse(challenge.Substring(1, 1), out a)) return false; + activityId = a; + + int mm; + if (!int.TryParse(challenge.Substring(2, 2), out mm)) return false; + durationMinutes = mm * 5; + + int nn; + if (!int.TryParse(challenge.Substring(4, 2), out nn)) return false; + nonce = nn; + + return true; + } + + /// + /// Constant-time comparison. + /// + private static bool SafeCompare(string a, string b) + { + if (a == null || b == null) return false; + if (a.Length != b.Length) return false; + + int diff = 0; + for (int i = 0; i < a.Length; i++) + { + diff |= a[i] ^ b[i]; + } + return diff == 0; + } + } +} diff --git a/com.allow2.sdk/Runtime/Core/Allow2Warnings.cs b/com.allow2.sdk/Runtime/Core/Allow2Warnings.cs new file mode 100644 index 0000000..cbc1988 --- /dev/null +++ b/com.allow2.sdk/Runtime/Core/Allow2Warnings.cs @@ -0,0 +1,103 @@ +// Allow2 Unity SDK v2 +// Copyright (c) 2026 Allow2 Pty Ltd. All rights reserved. + +using System; +using System.Collections.Generic; + +namespace Allow2 +{ + /// + /// Warning Scheduler. + /// Tracks remaining time per activity and fires warnings + /// when configurable thresholds are crossed. Prevents duplicate + /// warnings for the same level+activity combination. + /// + public class Allow2Warnings + { + private readonly Allow2Warning[] _thresholds; + private readonly Dictionary> _fired; + + /// + /// Fired when a warning threshold is crossed for an activity. + /// + public event Action OnWarning; + + private static readonly Allow2Warning[] DefaultThresholds = new Allow2Warning[] + { + new Allow2Warning(15 * 60, Allow2WarningLevel.Info), + new Allow2Warning(5 * 60, Allow2WarningLevel.Urgent), + new Allow2Warning(60, Allow2WarningLevel.Final), + new Allow2Warning(30, Allow2WarningLevel.Countdown), + }; + + public Allow2Warnings(Allow2Warning[] customThresholds) + { + if (customThresholds != null && customThresholds.Length > 0) + { + _thresholds = (Allow2Warning[])customThresholds.Clone(); + } + else + { + _thresholds = DefaultThresholds; + } + + // Sort descending by remaining seconds + Array.Sort(_thresholds, (a, b) => b.RemainingSeconds.CompareTo(a.RemainingSeconds)); + + _fired = new Dictionary>(); + } + + /// + /// Called by the Checker after each check response. + /// Evaluates every activity's remaining time against thresholds. + /// + public void Update(Dictionary activityRemaining) + { + if (activityRemaining == null) return; + + foreach (KeyValuePair kvp in activityRemaining) + { + int activityId = kvp.Key; + int remaining = kvp.Value; + + if (remaining < 0) continue; + + HashSet firedSet; + if (!_fired.TryGetValue(activityId, out firedSet)) + { + firedSet = new HashSet(); + _fired[activityId] = firedSet; + } + + for (int t = 0; t < _thresholds.Length; t++) + { + Allow2Warning threshold = _thresholds[t]; + if (remaining <= threshold.RemainingSeconds && !firedSet.Contains(threshold.Level)) + { + firedSet.Add(threshold.Level); + if (OnWarning != null) + { + OnWarning(new Allow2WarningEventArgs(threshold.Level, activityId, remaining)); + } + } + } + } + } + + /// + /// Reset warnings for an activity (e.g., parent approved more time). + /// + public void ResetActivity(int activityId) + { + _fired.Remove(activityId); + } + + /// + /// Reset all warning state (e.g., new child session). + /// + public void ResetAll() + { + _fired.Clear(); + } + } +} diff --git a/com.allow2.sdk/Runtime/Credentials/ICredentialStore.cs b/com.allow2.sdk/Runtime/Credentials/ICredentialStore.cs new file mode 100644 index 0000000..38bbc58 --- /dev/null +++ b/com.allow2.sdk/Runtime/Credentials/ICredentialStore.cs @@ -0,0 +1,37 @@ +// Allow2 Unity SDK v2 +// Copyright (c) 2026 Allow2 Pty Ltd. All rights reserved. + +namespace Allow2 +{ + /// + /// Interface for storing and retrieving Allow2 pairing credentials. + /// Implement this to provide a custom credential storage backend. + /// + public interface ICredentialStore + { + /// + /// Load stored credentials. Returns null if none exist. + /// + Allow2Credentials Load(); + + /// + /// Persist credentials to storage. + /// + void Store(Allow2Credentials credentials); + + /// + /// Delete all stored credentials. + /// + void Clear(); + + /// + /// Load the last-used child ID. Returns 0 if none stored. + /// + int LoadLastUsedChildId(); + + /// + /// Store the last-used child ID for quicker re-selection. + /// + void StoreLastUsedChildId(int childId); + } +} diff --git a/com.allow2.sdk/Runtime/Credentials/PlayerPrefsStore.cs b/com.allow2.sdk/Runtime/Credentials/PlayerPrefsStore.cs new file mode 100644 index 0000000..8af07b4 --- /dev/null +++ b/com.allow2.sdk/Runtime/Credentials/PlayerPrefsStore.cs @@ -0,0 +1,126 @@ +// Allow2 Unity SDK v2 +// Copyright (c) 2026 Allow2 Pty Ltd. All rights reserved. + +using System; +using System.Text; +using UnityEngine; + +namespace Allow2 +{ + /// + /// Default credential store using Unity PlayerPrefs. + /// Data is obfuscated with a device-specific XOR key. + /// This is NOT secure encryption -- it prevents casual inspection only. + /// For production security, use platform-specific keychain/keystore backends. + /// + public class PlayerPrefsStore : ICredentialStore + { + private const string KeyPrefix = "allow2_"; + private const string CredKey = KeyPrefix + "cred"; + private const string LastUsedKey = KeyPrefix + "last_child"; + + private readonly string _deviceKey; + + public PlayerPrefsStore() + { + // Use a device-specific key for obfuscation. + // SystemInfo.deviceUniqueIdentifier is empty on some platforms; + // fall back to a stored GUID in that case. + string deviceId = SystemInfo.deviceUniqueIdentifier; + if (string.IsNullOrEmpty(deviceId) || deviceId == SystemInfo.unsupportedIdentifier) + { + deviceId = PlayerPrefs.GetString(KeyPrefix + "dk", ""); + if (string.IsNullOrEmpty(deviceId)) + { + deviceId = Guid.NewGuid().ToString(); + PlayerPrefs.SetString(KeyPrefix + "dk", deviceId); + PlayerPrefs.Save(); + } + } + _deviceKey = deviceId; + } + + public Allow2Credentials Load() + { + string stored = PlayerPrefs.GetString(CredKey, ""); + if (string.IsNullOrEmpty(stored)) + { + return null; + } + + try + { + string json = Deobfuscate(stored); + return JsonUtility.FromJson(json); + } + catch (Exception) + { + // Corrupt data -- clear and return null + Clear(); + return null; + } + } + + public void Store(Allow2Credentials credentials) + { + if (credentials == null) + { + Clear(); + return; + } + + string json = JsonUtility.ToJson(credentials); + string obfuscated = Obfuscate(json); + PlayerPrefs.SetString(CredKey, obfuscated); + PlayerPrefs.Save(); + } + + public void Clear() + { + PlayerPrefs.DeleteKey(CredKey); + PlayerPrefs.DeleteKey(LastUsedKey); + PlayerPrefs.Save(); + } + + public int LoadLastUsedChildId() + { + return PlayerPrefs.GetInt(LastUsedKey, 0); + } + + public void StoreLastUsedChildId(int childId) + { + PlayerPrefs.SetInt(LastUsedKey, childId); + PlayerPrefs.Save(); + } + + // -- Obfuscation (XOR with device key) -- + + private string Obfuscate(string plaintext) + { + byte[] data = Encoding.UTF8.GetBytes(plaintext); + byte[] key = Encoding.UTF8.GetBytes(_deviceKey); + byte[] result = new byte[data.Length]; + + for (int i = 0; i < data.Length; i++) + { + result[i] = (byte)(data[i] ^ key[i % key.Length]); + } + + return Convert.ToBase64String(result); + } + + private string Deobfuscate(string obfuscated) + { + byte[] data = Convert.FromBase64String(obfuscated); + byte[] key = Encoding.UTF8.GetBytes(_deviceKey); + byte[] result = new byte[data.Length]; + + for (int i = 0; i < data.Length; i++) + { + result[i] = (byte)(data[i] ^ key[i % key.Length]); + } + + return Encoding.UTF8.GetString(result); + } + } +} diff --git a/com.allow2.sdk/Runtime/Events/Allow2Events.cs b/com.allow2.sdk/Runtime/Events/Allow2Events.cs new file mode 100644 index 0000000..7ba00d6 --- /dev/null +++ b/com.allow2.sdk/Runtime/Events/Allow2Events.cs @@ -0,0 +1,131 @@ +// Allow2 Unity SDK v2 +// Copyright (c) 2026 Allow2 Pty Ltd. All rights reserved. + +using System; + +namespace Allow2 +{ + /// + /// C# event definitions for all Allow2 lifecycle transitions. + /// These are pure C# events (not Unity-specific). + /// For Inspector-bindable events, see Allow2UnityEvents. + /// + public static class Allow2Events + { + // ---------------------------------------------------------------- + // Event argument types + // ---------------------------------------------------------------- + + public class PairingRequiredArgs : EventArgs + { + public string Pin; + public string QrUrl; + + public PairingRequiredArgs(string pin, string qrUrl) + { + Pin = pin; + QrUrl = qrUrl; + } + } + + public class PairedArgs : EventArgs + { + public Allow2Credentials Credentials; + + public PairedArgs(Allow2Credentials credentials) + { + Credentials = credentials; + } + } + + public class ChildSelectRequiredArgs : EventArgs + { + public Allow2Child[] Children; + + public ChildSelectRequiredArgs(Allow2Child[] children) + { + Children = children; + } + } + + public class ChildSelectedArgs : EventArgs + { + public int ChildId; + public string ChildName; + + public ChildSelectedArgs(int childId, string childName) + { + ChildId = childId; + ChildName = childName; + } + } + + public class CheckResultArgs : EventArgs + { + public Allow2CheckResult Result; + + public CheckResultArgs(Allow2CheckResult result) + { + Result = result; + } + } + + public class ActivityBlockedArgs : EventArgs + { + public int ActivityId; + public string ActivityName; + public int Remaining; + + public ActivityBlockedArgs(int activityId, string activityName, int remaining) + { + ActivityId = activityId; + ActivityName = activityName; + Remaining = remaining; + } + } + + public class LockArgs : EventArgs + { + public string Reason; + + public LockArgs(string reason) + { + Reason = reason; + } + } + + public class StateChangedArgs : EventArgs + { + public Allow2State NewState; + + public StateChangedArgs(Allow2State newState) + { + NewState = newState; + } + } + + public class RequestStatusArgs : EventArgs + { + public string RequestId; + public Allow2RequestStatus Status; + public int Extension; + + public RequestStatusArgs(string requestId, Allow2RequestStatus status, int extension) + { + RequestId = requestId; + Status = status; + Extension = extension; + } + } + + public class ErrorArgs : EventArgs + { + public string Message; + + public ErrorArgs(string message) + { + Message = message; + } + } + } +} diff --git a/com.allow2.sdk/Runtime/Events/Allow2UnityEvents.cs b/com.allow2.sdk/Runtime/Events/Allow2UnityEvents.cs new file mode 100644 index 0000000..db37c77 --- /dev/null +++ b/com.allow2.sdk/Runtime/Events/Allow2UnityEvents.cs @@ -0,0 +1,56 @@ +// Allow2 Unity SDK v2 +// Copyright (c) 2026 Allow2 Pty Ltd. All rights reserved. + +using System; +using UnityEngine.Events; + +namespace Allow2 +{ + /// + /// UnityEvent wrappers for Inspector binding. + /// These can be configured in the Unity Editor's Inspector panel + /// on the Allow2Manager component. + /// + + [Serializable] + public class Allow2PairingRequiredEvent : UnityEvent { } + + [Serializable] + public class Allow2PairedEvent : UnityEvent { } + + [Serializable] + public class Allow2ChildSelectRequiredEvent : UnityEvent { } + + [Serializable] + public class Allow2ChildSelectedEvent : UnityEvent { } + + [Serializable] + public class Allow2SoftLockEvent : UnityEvent { } + + [Serializable] + public class Allow2HardLockEvent : UnityEvent { } + + [Serializable] + public class Allow2UnlockEvent : UnityEvent { } + + [Serializable] + public class Allow2WarningEvent : UnityEvent { } + + [Serializable] + public class Allow2CheckResultEvent : UnityEvent { } + + [Serializable] + public class Allow2StateChangedEvent : UnityEvent { } + + [Serializable] + public class Allow2UnpairedEvent : UnityEvent { } + + [Serializable] + public class Allow2ParentModeEvent : UnityEvent { } + + [Serializable] + public class Allow2SessionTimeoutEvent : UnityEvent { } + + [Serializable] + public class Allow2ErrorEvent : UnityEvent { } +} diff --git a/com.allow2.sdk/Runtime/Models/Allow2Activity.cs b/com.allow2.sdk/Runtime/Models/Allow2Activity.cs new file mode 100644 index 0000000..7670f9d --- /dev/null +++ b/com.allow2.sdk/Runtime/Models/Allow2Activity.cs @@ -0,0 +1,38 @@ +// Allow2 Unity SDK v2 +// Copyright (c) 2026 Allow2 Pty Ltd. All rights reserved. + +using System; + +namespace Allow2 +{ + /// + /// Represents an Allow2 activity (e.g., Gaming, Screen Time). + /// + [Serializable] + public class Allow2Activity + { + /// Activity ID from the Allow2 platform. + public int Id; + + /// Human-readable activity name. + public string Name; + + public Allow2Activity() { } + + public Allow2Activity(int id, string name) + { + Id = id; + Name = name; + } + + // Well-known activity IDs + public const int Internet = 1; + public const int Computer = 2; + public const int Gaming = 3; + public const int Messaging = 4; + public const int JunkFood = 5; + public const int Social = 6; + public const int Electricity = 7; + public const int ScreenTime = 8; + } +} diff --git a/com.allow2.sdk/Runtime/Models/Allow2CheckResult.cs b/com.allow2.sdk/Runtime/Models/Allow2CheckResult.cs new file mode 100644 index 0000000..2d77e9a --- /dev/null +++ b/com.allow2.sdk/Runtime/Models/Allow2CheckResult.cs @@ -0,0 +1,133 @@ +// Allow2 Unity SDK v2 +// Copyright (c) 2026 Allow2 Pty Ltd. All rights reserved. + +using System; +using System.Collections.Generic; + +namespace Allow2 +{ + /// + /// Result from a permission check API call. + /// Contains per-activity status, day types, children, and subscription info. + /// + [Serializable] + public class Allow2CheckResult + { + /// Top-level allowed flag. + public bool Allowed; + + /// Per-activity results keyed by activity ID. + public Dictionary Activities; + + /// Today's day type. + public Allow2DayType Today; + + /// Tomorrow's day type. + public Allow2DayType Tomorrow; + + /// All configured day types. + public Allow2DayType[] AllDayTypes; + + /// Children on this account. + public Allow2Child[] Children; + + /// Subscription status. + public Allow2Subscription Subscription; + + public Allow2CheckResult() + { + Activities = new Dictionary(); + } + + /// + /// Get result for a specific activity. + /// Returns null if the activity was not in the check response. + /// + public Allow2ActivityResult GetActivity(int activityId) + { + Allow2ActivityResult result; + if (Activities != null && Activities.TryGetValue(activityId, out result)) + { + return result; + } + return null; + } + + /// + /// Returns true if ALL checked activities are blocked. + /// + public bool AllBlocked + { + get + { + if (Activities == null || Activities.Count == 0) return false; + foreach (KeyValuePair kvp in Activities) + { + if (kvp.Value.IsAllowed) return false; + } + return true; + } + } + } + + /// + /// Per-activity result from a check call. + /// + [Serializable] + public class Allow2ActivityResult + { + public int Id; + public string Name; + public bool IsAllowed; + public bool Banned; + public bool Timed; + public int Remaining; + public string Units; + public Allow2TimeBlock TimeBlock; + + public Allow2ActivityResult() { } + } + + /// + /// Time block information within an activity result. + /// + [Serializable] + public class Allow2TimeBlock + { + public bool Allowed; + public int Remaining; + } + + /// + /// Day type (e.g., School Day, Weekend, Holiday). + /// + [Serializable] + public class Allow2DayType + { + public int Id; + public string Name; + + public Allow2DayType() { } + + public Allow2DayType(int id, string name) + { + Id = id; + Name = name; + } + } + + /// + /// Subscription status from the Allow2 platform. + /// + [Serializable] + public class Allow2Subscription + { + public bool Active; + public int Type; + public int MaxChildren; + public int ChildCount; + public int DeviceCount; + public int ServiceCount; + public bool Financial; + } +} diff --git a/com.allow2.sdk/Runtime/Models/Allow2Child.cs b/com.allow2.sdk/Runtime/Models/Allow2Child.cs new file mode 100644 index 0000000..7b1fde0 --- /dev/null +++ b/com.allow2.sdk/Runtime/Models/Allow2Child.cs @@ -0,0 +1,47 @@ +// Allow2 Unity SDK v2 +// Copyright (c) 2026 Allow2 Pty Ltd. All rights reserved. + +using System; + +namespace Allow2 +{ + /// + /// Represents a child entity in a controller's Allow2 account. + /// + [Serializable] + public class Allow2Child + { + public int Id; + public string Name; + public string PinHash; + public string PinSalt; + public string AvatarUrl; + public string Color; + public bool HasAccount; + public int LinkedUserId; + public string LastUsedAt; + + public Allow2Child() { } + + public Allow2Child(int id, string name) + { + Id = id; + Name = name; + } + + /// + /// Returns a copy safe for display (no PIN data). + /// + public Allow2Child ToDisplayChild() + { + Allow2Child copy = new Allow2Child(); + copy.Id = Id; + copy.Name = Name; + copy.AvatarUrl = AvatarUrl; + copy.Color = Color; + copy.HasAccount = HasAccount; + copy.LastUsedAt = LastUsedAt; + return copy; + } + } +} diff --git a/com.allow2.sdk/Runtime/Models/Allow2Config.cs b/com.allow2.sdk/Runtime/Models/Allow2Config.cs new file mode 100644 index 0000000..ea152c9 --- /dev/null +++ b/com.allow2.sdk/Runtime/Models/Allow2Config.cs @@ -0,0 +1,86 @@ +// Allow2 Unity SDK v2 +// Copyright (c) 2026 Allow2 Pty Ltd. All rights reserved. + +using System; + +namespace Allow2 +{ + /// + /// Configuration for the Allow2 SDK. + /// Pass to Allow2Manager.Configure() or Allow2Daemon constructor. + /// + [Serializable] + public class Allow2Config + { + /// + /// Version ID registered at developer.allow2.com. + /// + public int Vid; + + /// + /// Version token registered at developer.allow2.com. + /// + public string DeviceToken; + + /// + /// Human-readable device name shown to parents. + /// Falls back to SystemInfo.deviceName if empty. + /// + public string DeviceName; + + /// + /// Activities this game/app monitors. + /// Must not be empty. + /// + public Allow2Activity[] Activities; + + /// + /// API base URL. Defaults to https://api.allow2.com. + /// + public string ApiUrl; + + /// + /// Seconds between permission checks. Default 60. + /// + public int CheckIntervalSeconds; + + /// + /// Seconds after soft-lock before hard-lock. Default 300. + /// + public int HardLockTimeoutSeconds; + + /// + /// Offline grace period in seconds before deny-by-default. Default 300. + /// + public int GracePeriodSeconds; + + /// + /// Custom warning thresholds. Null uses defaults (15m, 5m, 1m, 30s). + /// + public Allow2Warning[] WarningThresholds; + + public Allow2Config() + { + ApiUrl = "https://api.allow2.com"; + CheckIntervalSeconds = 60; + HardLockTimeoutSeconds = 300; + GracePeriodSeconds = 300; + } + + public void Validate() + { + if (Vid <= 0) + { + throw new ArgumentException("Vid must be a positive integer"); + } + if (string.IsNullOrEmpty(DeviceToken)) + { + throw new ArgumentException("DeviceToken is required"); + } + if (Activities == null || Activities.Length == 0) + { + throw new ArgumentException("Activities array is required and must not be empty"); + } + } + } +} diff --git a/com.allow2.sdk/Runtime/Models/Allow2Credentials.cs b/com.allow2.sdk/Runtime/Models/Allow2Credentials.cs new file mode 100644 index 0000000..359c813 --- /dev/null +++ b/com.allow2.sdk/Runtime/Models/Allow2Credentials.cs @@ -0,0 +1,28 @@ +// Allow2 Unity SDK v2 +// Copyright (c) 2026 Allow2 Pty Ltd. All rights reserved. + +using System; + +namespace Allow2 +{ + /// + /// Stored pairing credentials for an Allow2 device. + /// + [Serializable] + public class Allow2Credentials + { + public string Uuid; + public int UserId; + public int PairId; + public string PairToken; + public Allow2Child[] Children; + + public bool IsValid + { + get + { + return PairId > 0 && !string.IsNullOrEmpty(PairToken); + } + } + } +} diff --git a/com.allow2.sdk/Runtime/Models/Allow2RequestResult.cs b/com.allow2.sdk/Runtime/Models/Allow2RequestResult.cs new file mode 100644 index 0000000..d6a4334 --- /dev/null +++ b/com.allow2.sdk/Runtime/Models/Allow2RequestResult.cs @@ -0,0 +1,32 @@ +// Allow2 Unity SDK v2 +// Copyright (c) 2026 Allow2 Pty Ltd. All rights reserved. + +using System; + +namespace Allow2 +{ + /// + /// Status of a request (more time, day type change, ban lift). + /// + public enum Allow2RequestStatus + { + Pending, + Approved, + Denied, + TimedOut + } + + /// + /// Result from creating or polling a request. + /// + [Serializable] + public class Allow2RequestResult + { + public string RequestId; + public string StatusSecret; + public Allow2RequestStatus Status; + public int ActivityId; + public int Duration; + public string Reason; + } +} diff --git a/com.allow2.sdk/Runtime/Models/Allow2State.cs b/com.allow2.sdk/Runtime/Models/Allow2State.cs new file mode 100644 index 0000000..7693513 --- /dev/null +++ b/com.allow2.sdk/Runtime/Models/Allow2State.cs @@ -0,0 +1,26 @@ +// Allow2 Unity SDK v2 +// Copyright (c) 2026 Allow2 Pty Ltd. All rights reserved. + +namespace Allow2 +{ + /// + /// Daemon lifecycle states. + /// + public enum Allow2State + { + /// Device has no stored pairing credentials. + Unpaired, + + /// Pairing wizard is active, waiting for parent to confirm. + Pairing, + + /// Device is paired but no child has been selected yet. + Paired, + + /// A child is selected and the check loop is running. + Enforcing, + + /// Parent mode: no enforcement applied. + Parent + } +} diff --git a/com.allow2.sdk/Runtime/Models/Allow2Warning.cs b/com.allow2.sdk/Runtime/Models/Allow2Warning.cs new file mode 100644 index 0000000..872c57e --- /dev/null +++ b/com.allow2.sdk/Runtime/Models/Allow2Warning.cs @@ -0,0 +1,56 @@ +// Allow2 Unity SDK v2 +// Copyright (c) 2026 Allow2 Pty Ltd. All rights reserved. + +using System; + +namespace Allow2 +{ + /// + /// Warning level emitted when remaining time crosses a threshold. + /// + public enum Allow2WarningLevel + { + Info, + Urgent, + Final, + Countdown + } + + /// + /// Warning threshold definition. + /// + [Serializable] + public class Allow2Warning + { + /// Remaining seconds at which to trigger this warning. + public int RemainingSeconds; + + /// Warning severity level. + public Allow2WarningLevel Level; + + public Allow2Warning() { } + + public Allow2Warning(int remainingSeconds, Allow2WarningLevel level) + { + RemainingSeconds = remainingSeconds; + Level = level; + } + } + + /// + /// Warning event data emitted to subscribers. + /// + public class Allow2WarningEventArgs + { + public Allow2WarningLevel Level; + public int ActivityId; + public int RemainingSeconds; + + public Allow2WarningEventArgs(Allow2WarningLevel level, int activityId, int remainingSeconds) + { + Level = level; + ActivityId = activityId; + RemainingSeconds = remainingSeconds; + } + } +} diff --git a/com.allow2.sdk/package.json b/com.allow2.sdk/package.json new file mode 100644 index 0000000..c0114ac --- /dev/null +++ b/com.allow2.sdk/package.json @@ -0,0 +1,27 @@ +{ + "name": "com.allow2.sdk", + "version": "2.0.0-alpha.1", + "displayName": "Allow2 Parental Freedom SDK", + "description": "Allow2 Parental Freedom enforcement for Unity games and apps. Provides pairing, child identification, permission checks, warnings, block screens, requests, and feedback.", + "unity": "2021.3", + "unityRelease": "0f1", + "keywords": [ + "allow2", + "parental", + "freedom", + "parental-controls", + "screen-time", + "child-safety" + ], + "author": { + "name": "Allow2 Pty Ltd", + "email": "support@allow2.com", + "url": "https://allow2.com" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/Allow2/allow2unity.git" + }, + "dependencies": {} +} diff --git a/docs/ALLOW2UNITY_DESIGN.md b/docs/ALLOW2UNITY_DESIGN.md new file mode 100644 index 0000000..d0b61e1 --- /dev/null +++ b/docs/ALLOW2UNITY_DESIGN.md @@ -0,0 +1,393 @@ +# Allow2 Unity SDK -- Design Document + +**Version:** 1.0 +**Date:** 11 March 2026 +**Status:** Initial Design + +--- + +## 1. Executive Summary + +The Allow2 Unity SDK is a Device SDK library that Unity game developers drop into their projects to provide Allow2 Parental Freedom enforcement. It follows the same Device Operational Lifecycle as all Allow2 integrations: pairing, child identification, continuous permission checks, warnings, block screens, requests, and feedback. + +**This is NOT an OS-level control** (like allow2linux/allow2mac/allow2windows). It runs inside a Unity game/app, controlling that specific title's gameplay time. + +--- + +## 2. Architecture Overview + +``` +┌─────────────────────────────────────────────────────────┐ +│ Unity Game/App │ +│ │ +│ ┌────────────────────────────────────────────────────┐ │ +│ │ Allow2 Unity SDK │ │ +│ │ │ │ +│ │ ┌──────────────┐ ┌───────────────────────────┐ │ │ +│ │ │ Allow2Manager│ │ Pure C# Core │ │ │ +│ │ │ (MonoBehaviour│ │ │ │ │ +│ │ │ Singleton) │ │ • Allow2Daemon │ │ │ +│ │ │ │ │ • Allow2Api (UnityWebReq) │ │ │ +│ │ │ DontDestroy │ │ • Allow2Checker │ │ │ +│ │ │ OnLoad │ │ • Allow2ChildShield │ │ │ +│ │ │ │ │ • Allow2Warnings │ │ │ +│ │ │ Coroutine │ │ • Allow2Offline │ │ │ +│ │ │ bridge for │ │ • Allow2Pairing │ │ │ +│ │ │ async ops │ │ • Allow2Request │ │ │ +│ │ │ │ │ • Allow2Feedback │ │ │ +│ │ └──────┬───────┘ │ • Allow2Updates │ │ │ +│ │ │ │ • Allow2Credentials │ │ │ +│ │ ▼ └───────────────────────────┘ │ │ +│ │ ┌──────────────────────────────────────────────┐ │ │ +│ │ │ UI Prefabs │ │ │ +│ │ │ • Lock Screen Canvas │ │ │ +│ │ │ • Child Selector Canvas │ │ │ +│ │ │ • Warning Banner (top bar) │ │ │ +│ │ │ • Request More Time Dialog │ │ │ +│ │ │ • Pairing Screen (QR + PIN) │ │ │ +│ │ │ • Feedback Dialog │ │ │ +│ │ └──────────────────────────────────────────────┘ │ │ +│ └────────────────────────────────────────────────────┘ │ +│ │ │ +│ │ HTTPS (UnityWebRequest) │ +│ ▼ │ +│ ┌─────────────────┐ │ +│ │ Allow2 Platform │ │ +│ │ api.allow2.com │ │ +│ └─────────────────┘ │ +└─────────────────────────────────────────────────────────┘ +``` + +--- + +## 3. Distribution + +### Unity Package Manager (UPM) + +Primary distribution via UPM git URL: + +```json +// manifest.json (Packages/) +{ + "dependencies": { + "com.allow2.sdk": "https://github.com/Allow2/allow2unity.git#2.0.0-alpha.1" + } +} +``` + +### Package Layout (UPM-compliant) + +``` +com.allow2.sdk/ +├── package.json # UPM manifest +├── Runtime/ +│ ├── Allow2.Runtime.asmdef # Assembly definition +│ ├── Core/ +│ │ ├── Allow2Daemon.cs # State machine orchestrator +│ │ ├── Allow2Api.cs # UnityWebRequest-based API client +│ │ ├── Allow2Checker.cs # Check loop with per-activity enforcement +│ │ ├── Allow2ChildShield.cs # PIN hashing (SHA-256+salt), rate limiting +│ │ ├── Allow2Warnings.cs # Progressive warning scheduler +│ │ ├── Allow2Offline.cs # Cache + grace period + deny-by-default +│ │ ├── Allow2Pairing.cs # PIN + QR code pairing flow +│ │ ├── Allow2Request.cs # Request More Time/Day Type/Ban Lift +│ │ ├── Allow2Updates.cs # getUpdates polling +│ │ ├── Allow2Feedback.cs # Bug reports + feature requests +│ │ └── Allow2VoiceCode.cs # Offline HMAC-SHA256 challenge-response +│ ├── Models/ +│ │ ├── Allow2Config.cs # Configuration (VID, token, activities) +│ │ ├── Allow2State.cs # State enum (Unpaired, Pairing, Paired, Enforcing, Parent) +│ │ ├── Allow2Child.cs # Child record +│ │ ├── Allow2CheckResult.cs # Per-activity check result +│ │ ├── Allow2Activity.cs # Activity definition +│ │ ├── Allow2Warning.cs # Warning level + remaining time +│ │ └── Allow2RequestResult.cs # Request status +│ ├── Credentials/ +│ │ ├── ICredentialStore.cs # Interface +│ │ ├── PlayerPrefsStore.cs # Default (all platforms) +│ │ ├── KeychainStore.cs # macOS/iOS (native plugin) +│ │ ├── DPAPIStore.cs # Windows (native plugin) +│ │ └── AndroidKeystoreStore.cs # Android (native plugin) +│ ├── Bridge/ +│ │ ├── Allow2Manager.cs # MonoBehaviour singleton, DontDestroyOnLoad +│ │ ├── Allow2Coroutines.cs # Coroutine wrappers for async operations +│ │ └── Allow2SceneLoader.cs # Optional: pause game, load lock scene +│ └── Events/ +│ ├── Allow2Events.cs # C# events for all lifecycle transitions +│ └── Allow2UnityEvents.cs # UnityEvent wrappers for Inspector binding +├── UI/ +│ ├── Allow2.UI.asmdef # Separate assembly for UI +│ ├── Prefabs/ +│ │ ├── Allow2LockScreen.prefab +│ │ ├── Allow2ChildSelector.prefab +│ │ ├── Allow2WarningBanner.prefab +│ │ ├── Allow2RequestDialog.prefab +│ │ ├── Allow2PairingScreen.prefab +│ │ └── Allow2FeedbackDialog.prefab +│ ├── Scripts/ +│ │ ├── Allow2LockScreenUI.cs +│ │ ├── Allow2ChildSelectorUI.cs +│ │ ├── Allow2WarningBannerUI.cs +│ │ ├── Allow2RequestDialogUI.cs +│ │ ├── Allow2PairingScreenUI.cs +│ │ └── Allow2FeedbackDialogUI.cs +│ └── Resources/ +│ ├── Allow2Theme.asset # Scriptable Object for theming +│ └── Sprites/ +├── Editor/ +│ ├── Allow2.Editor.asmdef +│ ├── Allow2ManagerEditor.cs # Custom inspector +│ └── Allow2SetupWizard.cs # Window: configure VID, activities +├── Samples~/ +│ ├── BasicIntegration/ # Minimal example +│ └── FullIntegration/ # All features demo +├── Documentation~/ +│ ├── index.md +│ └── quick-start.md +├── Tests/ +│ ├── Runtime/ +│ │ └── Allow2.Tests.asmdef +│ └── Editor/ +│ └── Allow2.EditorTests.asmdef +├── CHANGELOG.md +├── LICENSE +└── README.md +``` + +--- + +## 4. Core Architecture Decisions + +### 4.1 Pure C# Core + MonoBehaviour Bridge + +The SDK core (`Core/` directory) is pure C# with no Unity dependencies. This enables: +- Unit testing without Unity Test Runner +- Potential reuse in non-Unity .NET projects +- Clean separation of concerns + +The `Bridge/` layer provides Unity-specific integration: +- `Allow2Manager` (MonoBehaviour, singleton, DontDestroyOnLoad) — main entry point +- Coroutine wrappers for WebGL compatibility +- Unity lifecycle integration (OnApplicationPause, OnApplicationFocus, OnApplicationQuit) + +### 4.2 Async/Await with Coroutine Fallback + +```csharp +// Primary: async/await (Unity 2023.1+ / .NET Standard 2.1) +var result = await Allow2Manager.Instance.CheckAsync(activities); + +// Fallback: coroutine (WebGL, older Unity) +Allow2Manager.Instance.Check(activities, result => { + if (!result.Allowed) ShowLockScreen(); +}); +``` + +WebGL cannot use `System.Threading.Tasks` (single-threaded). The SDK detects the platform and uses `UnityWebRequest` + coroutines automatically. + +### 4.3 HTTP Client: UnityWebRequest + +All HTTP communication uses `UnityWebRequest` (not `HttpClient`), because: +- Works on ALL Unity platforms (including WebGL, consoles) +- Handles platform-specific TLS/certificate stores +- Integrates with Unity's coroutine system +- Respects Unity's threading model + +### 4.4 Credential Storage + +| Platform | Backend | Notes | +|----------|---------|-------| +| **All (default)** | `PlayerPrefs` | Encrypted with device-specific key | +| **macOS/iOS** | Keychain Services | Via native plugin | +| **Windows** | DPAPI | Via native plugin | +| **Android** | Android Keystore | Via native plugin | +| **WebGL** | `localStorage` | Browser sandbox | +| **Consoles** | Platform save system | Sony/MS/Nintendo APIs | + +The `ICredentialStore` interface allows developers to provide custom implementations. + +--- + +## 5. Integration Guide (Developer Perspective) + +### 5.1 Minimal Integration (5 Minutes) + +```csharp +using Allow2.Runtime; + +public class GameManager : MonoBehaviour +{ + void Start() + { + // Configure (VID + token from developer.allow2.com) + Allow2Manager.Instance.Configure(new Allow2Config { + Vid = 456, + DeviceToken = SystemInfo.deviceUniqueIdentifier, + DeviceName = SystemInfo.deviceName, + Activities = new[] { + new Allow2Activity(3, "Gaming"), // Gaming + new Allow2Activity(8, "Screen Time") // Screen Time + } + }); + + // Subscribe to events + Allow2Manager.Instance.OnSoftLock += reason => { + // Show lock screen (SDK has a prefab, or use your own) + Allow2Manager.Instance.ShowLockScreen(); + }; + + Allow2Manager.Instance.OnWarning += warning => { + Allow2Manager.Instance.ShowWarningBanner(warning); + }; + + // Start the daemon (loads credentials, starts check loop if paired) + Allow2Manager.Instance.StartDaemon(); + } +} +``` + +### 5.2 Full Integration + +```csharp +// Subscribe to all events +Allow2Manager.Instance.OnPairingRequired += info => { + // Show pairing screen (QR + PIN) + Allow2Manager.Instance.ShowPairingScreen(); +}; + +Allow2Manager.Instance.OnChildSelectRequired += children => { + Allow2Manager.Instance.ShowChildSelector(); +}; + +Allow2Manager.Instance.OnWarning += warning => { + Allow2Manager.Instance.ShowWarningBanner(warning); +}; + +Allow2Manager.Instance.OnSoftLock += reason => { + Time.timeScale = 0; // Pause game + Allow2Manager.Instance.ShowLockScreen(); +}; + +Allow2Manager.Instance.OnUnlock += () => { + Time.timeScale = 1; // Resume game +}; + +Allow2Manager.Instance.OnCheckResult += result => { + // Update HUD with remaining time + hudTimeRemaining.text = FormatTime(result.GetActivity(3).Remaining); +}; +``` + +### 5.3 Inspector Configuration + +The `Allow2Manager` component exposes fields in the Unity Inspector: +- VID (int) +- Activities (list) +- Auto-show pairing UI (bool) +- Auto-show child selector (bool) +- Auto-show warnings (bool) +- Auto-pause on lock (bool) +- Custom lock scene (SceneReference) +- Theme (Allow2Theme ScriptableObject) + +--- + +## 6. Module List (Gold Standard Compliance) + +| Module | File | Status | +|--------|------|--------| +| **Daemon/Core** | `Allow2Daemon.cs` | NEW — state machine (Unpaired→Pairing→Paired→Enforcing→Parent) | +| **API Client** | `Allow2Api.cs` | NEW — UnityWebRequest-based, handles both api.allow2.com and service.allow2.com | +| **Pairing** | `Allow2Pairing.cs` | NEW — PIN + QR code flows | +| **Child Shield** | `Allow2ChildShield.cs` | NEW — SHA-256+salt PIN hashing, rate limiting, lockout | +| **Checker** | `Allow2Checker.cs` | NEW — 30-60s check loop, per-activity enforcement, activity stacking | +| **Warnings** | `Allow2Warnings.cs` | NEW — progressive: 15min→5min→1min→30sec→10sec→BLOCKED | +| **Offline** | `Allow2Offline.cs` | NEW — response cache, grace period (5min default), deny-by-default | +| **Requests** | `Allow2Request.cs` | NEW — more time, day type change, ban lift with polling | +| **Voice Codes** | `Allow2VoiceCode.cs` | NEW — HMAC-SHA256 offline challenge-response | +| **Updates** | `Allow2Updates.cs` | NEW — getUpdates polling for children/quotas/bans | +| **Feedback** | `Allow2Feedback.cs` | NEW — submit/load/reply to feedback discussions | +| **Credentials** | `ICredentialStore.cs` + backends | NEW — pluggable, per-platform | + +--- + +## 7. UI Prefabs + +| Prefab | Canvas Type | Behaviour | +|--------|-------------|-----------| +| **Allow2PairingScreen** | Screen-space overlay | Full screen, QR code + 6-digit PIN, "Scan with Allow2 app" | +| **Allow2ChildSelector** | Screen-space overlay | List of children + "Parent" option, search/filter if 3+ children | +| **Allow2PinEntry** | Screen-space overlay | 4-digit PIN pad with rate limiting feedback | +| **Allow2LockScreen** | Screen-space overlay | "Time's up!" + Request More Time + Switch Child + Voice Code | +| **Allow2WarningBanner** | Screen-space overlay | Top bar, semi-transparent, remaining time + activity name | +| **Allow2RequestDialog** | Screen-space overlay | Duration picker + message field + status polling | +| **Allow2FeedbackDialog** | Screen-space overlay | Category picker + message + submission | + +All prefabs: +- Use Unity UI (Canvas + RectTransform) +- Respect `Allow2Theme` ScriptableObject for colours/fonts +- Can be replaced entirely with developer's own UI (just handle the events) +- Render on a high-sort-order canvas (above game UI) + +--- + +## 8. Platform Matrix + +| Platform | HTTP | Credentials | Overlay | Notes | +|----------|------|-------------|---------|-------| +| **Windows** | UnityWebRequest | PlayerPrefs / DPAPI | Canvas overlay | Full support | +| **macOS** | UnityWebRequest | PlayerPrefs / Keychain | Canvas overlay | Full support | +| **Linux** | UnityWebRequest | PlayerPrefs | Canvas overlay | Full support | +| **Android** | UnityWebRequest | Android Keystore | Canvas overlay | Full support | +| **iOS** | UnityWebRequest | Keychain | Canvas overlay | Full support | +| **WebGL** | UnityWebRequest | localStorage | Canvas overlay | No threading; coroutine-only | +| **Xbox** | UnityWebRequest | Platform save | Canvas overlay | Console cert required | +| **PlayStation** | UnityWebRequest | Platform save | Canvas overlay | Console cert required | +| **Switch** | UnityWebRequest | Platform save | Canvas overlay | Console cert required | + +--- + +## 9. Implementation Phases + +| Phase | Scope | Effort | +|-------|-------|--------| +| **Phase 1** | Core SDK: Daemon, Api, Checker, Credentials, Config | 2-3 weeks | +| **Phase 2** | Pairing, ChildShield, child selector flow | 1-2 weeks | +| **Phase 3** | Warnings, Offline handler, Request flow | 1-2 weeks | +| **Phase 4** | UI Prefabs (all 7 screens) | 2-3 weeks | +| **Phase 5** | Allow2Manager bridge, Inspector integration, Editor wizard | 1 week | +| **Phase 6** | Platform credential backends (Keychain, DPAPI, Android Keystore) | 1-2 weeks | +| **Phase 7** | Voice codes, Feedback, Updates polling | 1 week | +| **Phase 8** | Samples, documentation, WebGL testing | 1 week | +| **Total** | | **10-15 weeks** | + +--- + +## 10. Differences from Existing v1 Unity SDK + +The existing code at `examples/unity/Allow2/Allow2.cs` is a v1 implementation: +- Single monolithic file +- Direct API calls (no daemon, no state machine) +- No pairing flow (assumes pre-paired) +- No warnings, no offline, no requests +- No UI prefabs + +The v2 rewrite is a complete replacement — no backward compatibility with v1. + +--- + +## 11. Open Questions + +| # | Question | Notes | +|---|----------|-------| +| 1 | Should the SDK include a "parent mode" for testing? | Developer sets a flag to bypass checks during development | +| 2 | Console (Xbox/PS/Switch) credential storage APIs | Need platform-specific native plugins; may defer to Phase 2 | +| 3 | WebGL: can we open a popup for QR code pairing? | Browser popup blockers may interfere; may need in-game display only | +| 4 | Minimum Unity version? | 2021.3 LTS (for .NET Standard 2.1, async/await) | +| 5 | UPM scoped registry vs git URL? | Git URL is simpler; scoped registry needs hosting | + +--- + +## Document History + +- **Created**: 2026-03-11 +- **Related**: Allow2 C# SDK (sdk/csharp), allow2linux, Node.js SDK v2 (gold standard) diff --git a/package.json b/package.json index c07ba04..c866a7c 100644 --- a/package.json +++ b/package.json @@ -1,17 +1,30 @@ -{ - "category": "AssetStore/Unity", - "description": "Allow2 is cool.", - "gitHead": "0d2114387a99011be685886f8ea902242e323e70", +{ + "name": "com.allow2.sdk", + "version": "2.0.0-alpha.1", + "displayName": "Allow2 Parental Freedom SDK", + "description": "Allow2 Parental Freedom enforcement for Unity games and apps.", + "category": "Services", "keywords": [ "allow2", - "unity" + "parental", + "freedom", + "child", + "children", + "family", + "gaming", + "time-limit", + "screen-time", + "quota" ], - "name": "com.allow2.allow2", - "repoPackagePath": "build/install/com.allow2.ads", + "author": { + "name": "Allow2 Pty Ltd", + "email": "support@allow2.com", + "url": "https://allow2.com" + }, "repository": { "type": "git", - "url": "ssh://git@github.com/Unity-Technologies/com.unity.ads.git" + "url": "https://github.com/Allow2/allow2unity.git" }, - "unity": "2017.4", - "version": "1.0.0" + "unity": "2021.3", + "license": "MIT" } diff --git a/package.sh b/package.sh old mode 100644 new mode 100755 diff --git a/packaging/asset-store/asset-store-metadata.json b/packaging/asset-store/asset-store-metadata.json new file mode 100644 index 0000000..621a406 --- /dev/null +++ b/packaging/asset-store/asset-store-metadata.json @@ -0,0 +1,53 @@ +{ + "title": "Allow2 Parental Freedom SDK", + "category": "Tools/Integration", + "description": "Integrate Allow2 Parental Freedom into your Unity game or app. Provides a complete SDK for device pairing, child identification, real-time permission checks with automatic polling, progressive warnings and countdowns, block screens, time and activity requests, offline support with voice code challenge-response, and developer feedback.\n\nDesigned around the Allow2 Device API lifecycle: pair once, identify the child each session, check continuously, warn progressively, block when needed, and let the child request changes.\n\nNo additional dependencies required. Pure C# implementation using UnityWebRequest.", + "key_features": [ + "QR/PIN device pairing with Allow2 platform", + "Child identification with PIN verification", + "Real-time permission checking (configurable 30-60s polling)", + "Progressive warning system (15min, 5min, 1min, 30s, 10s, blocked)", + "Block screen enforcement", + "Request system (more time, day type change, ban lift)", + "Offline support with cached permissions", + "Voice code challenge-response for offline approvals", + "Bug report and feature request feedback", + "Event-driven architecture with C# events and UnityEvents" + ], + "key_images": { + "icon": "Images/icon_128x128.png (required: 128x128)", + "card": "Images/card_420x280.png (required: 420x280)", + "cover": "Images/cover_1950x1300.png (required: 1950x1300)", + "social": "Images/social_1200x630.png (recommended: 1200x630)", + "screenshots": [ + "Images/screenshot_01.png (recommended: 1920x1080, pairing flow)", + "Images/screenshot_02.png (recommended: 1920x1080, child selection)", + "Images/screenshot_03.png (recommended: 1920x1080, warning overlay)", + "Images/screenshot_04.png (recommended: 1920x1080, block screen)", + "Images/screenshot_05.png (recommended: 1920x1080, request dialog)" + ] + }, + "unity_versions": { + "minimum": "2021.3", + "tested": ["2021.3", "2022.3", "2023.2", "6000.0"], + "srp_compatibility": ["Built-in", "URP", "HDRP"] + }, + "platforms": [ + "Windows", + "macOS", + "Linux", + "Android", + "iOS", + "WebGL" + ], + "dependencies": [], + "publisher": { + "name": "Allow2 Pty Ltd", + "url": "https://developer.allow2.com", + "support_email": "support@allow2.com" + }, + "documentation_url": "https://github.com/Allow2/allow2unity", + "license": "MIT", + "price": "FREE", + "submission_notes": "This is the official Allow2 Parental Freedom SDK. It communicates with the Allow2 platform API (api.allow2.com) to enforce parental freedom settings in games and apps. No native plugins or platform-specific code — pure C# using UnityWebRequest." +} diff --git a/packaging/asset-store/export-unitypackage.sh b/packaging/asset-store/export-unitypackage.sh new file mode 100755 index 0000000..9569830 --- /dev/null +++ b/packaging/asset-store/export-unitypackage.sh @@ -0,0 +1,188 @@ +#!/usr/bin/env bash +# export-unitypackage.sh — Build a .unitypackage from the Allow2 SDK source tree. +# +# A .unitypackage is a gzipped tar archive where each file is represented by a +# directory named after a GUID. Inside that directory: +# pathname — text file containing the Assets/... path +# asset — the actual file content +# asset.meta — the .meta file content (optional but expected by Unity) +# +# This script generates deterministic GUIDs from file paths so the package is +# reproducible across builds. No Unity Editor installation is required. +# +# Usage: +# ./packaging/asset-store/export-unitypackage.sh [--version X.Y.Z] +# +# Output: Allow2SDK-.unitypackage in the repository root. +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +SDK_DIR="$REPO_ROOT/com.allow2.sdk" +PKG_JSON="$SDK_DIR/package.json" + +# --------------------------------------------------------------------------- +# Parse arguments +# --------------------------------------------------------------------------- +VERSION="" +while [[ $# -gt 0 ]]; do + case "$1" in + --version) VERSION="$2"; shift 2 ;; + *) echo "Unknown option: $1" >&2; exit 1 ;; + esac +done + +if [[ -z "$VERSION" ]]; then + VERSION=$(python3 -c "import json; print(json.load(open('$PKG_JSON'))['version'])") +fi + +OUTPUT="$REPO_ROOT/Allow2SDK-${VERSION}.unitypackage" +WORK_DIR=$(mktemp -d) +trap 'rm -rf "$WORK_DIR"' EXIT + +echo "Building Allow2SDK-${VERSION}.unitypackage ..." + +# --------------------------------------------------------------------------- +# Helper: deterministic GUID from a path string (MD5, lowercased hex). +# Unity GUIDs are 32-character lowercase hex strings. +# --------------------------------------------------------------------------- +generate_guid() { + echo -n "$1" | md5sum | cut -c1-32 +} + +# --------------------------------------------------------------------------- +# Helper: generate a .meta file for a folder +# --------------------------------------------------------------------------- +generate_folder_meta() { + local guid="$1" + cat < "$entry_dir/pathname" + generate_folder_meta "$guid" > "$entry_dir/asset.meta" +} + +# --------------------------------------------------------------------------- +# Add a file entry to the package +# --------------------------------------------------------------------------- +add_file() { + local src_file="$1" # absolute path on disk + local asset_path="$2" # e.g. Assets/Allow2/Runtime/Core/Allow2Api.cs + local guid + guid=$(generate_guid "$asset_path") + + local entry_dir="$WORK_DIR/$guid" + mkdir -p "$entry_dir" + echo -n "$asset_path" > "$entry_dir/pathname" + cp "$src_file" "$entry_dir/asset" + + local ext="${asset_path##*.}" + generate_file_meta "$guid" "$ext" > "$entry_dir/asset.meta" +} + +# --------------------------------------------------------------------------- +# Build the package contents +# --------------------------------------------------------------------------- + +# Root directories +add_directory "Assets" +add_directory "Assets/Allow2" + +# Copy top-level SDK files into Assets/Allow2/ +for f in "$SDK_DIR/package.json"; do + [[ -f "$f" ]] && add_file "$f" "Assets/Allow2/$(basename "$f")" +done + +# Copy repo-level files that should ship in the asset store package +for f in "$REPO_ROOT/README.md" "$REPO_ROOT/LICENSE"; do + [[ -f "$f" ]] && add_file "$f" "Assets/Allow2/$(basename "$f")" +done + +# Copy CHANGELOG if it exists +if [[ -f "$REPO_ROOT/CHANGELOG.md" ]]; then + add_file "$REPO_ROOT/CHANGELOG.md" "Assets/Allow2/CHANGELOG.md" +fi + +# Walk the Runtime directory tree +while IFS= read -r -d '' entry; do + # Relative path from SDK_DIR, e.g. Runtime/Core/Allow2Api.cs + rel="${entry#$SDK_DIR/}" + asset_path="Assets/Allow2/$rel" + + if [[ -d "$entry" ]]; then + add_directory "$asset_path" + elif [[ -f "$entry" ]]; then + add_file "$entry" "$asset_path" + fi +done < <(find "$SDK_DIR/Runtime" -print0 | sort -z) + +# --------------------------------------------------------------------------- +# Create the .unitypackage (gzipped tar of GUID directories) +# --------------------------------------------------------------------------- +tar -czf "$OUTPUT" -C "$WORK_DIR" . + +echo "Created: $OUTPUT" +echo "Size: $(du -h "$OUTPUT" | cut -f1)" +echo "Entries: $(find "$WORK_DIR" -maxdepth 1 -mindepth 1 -type d | wc -l) assets" diff --git a/scripts/bump-version.sh b/scripts/bump-version.sh new file mode 100755 index 0000000..c6d2fbc --- /dev/null +++ b/scripts/bump-version.sh @@ -0,0 +1,146 @@ +#!/usr/bin/env bash +# bump-version.sh — Version bumping for the Allow2 Unity SDK. +# +# This is the SINGLE entry point for releasing. Bumping a version here +# updates package.json, commits the change, and creates a git tag. +# Pushing the tag triggers all CI/CD pipelines (release.yml, store-publish.yml). +# +# Usage: +# ./scripts/bump-version.sh [--preid alpha|beta|rc] +# +# Examples: +# ./scripts/bump-version.sh prerelease --preid alpha # 2.0.0-alpha.1 -> 2.0.0-alpha.2 +# ./scripts/bump-version.sh prerelease --preid beta # 2.0.0-alpha.2 -> 2.0.0-beta.0 +# ./scripts/bump-version.sh patch # 2.0.0-beta.0 -> 2.0.1 +# ./scripts/bump-version.sh minor # 2.0.1 -> 2.1.0 +# ./scripts/bump-version.sh major # 2.1.0 -> 3.0.0 +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +PKG_JSON="$REPO_ROOT/com.allow2.sdk/package.json" + +if [[ ! -f "$PKG_JSON" ]]; then + echo "ERROR: package.json not found at $PKG_JSON" >&2 + exit 1 +fi + +# --------------------------------------------------------------------------- +# Parse arguments +# --------------------------------------------------------------------------- +BUMP="${1:-}" +PREID="" + +if [[ -z "$BUMP" ]]; then + echo "Usage: $0 [--preid alpha|beta|rc]" >&2 + exit 1 +fi + +shift +while [[ $# -gt 0 ]]; do + case "$1" in + --preid) PREID="${2:-alpha}"; shift 2 ;; + *) echo "Unknown option: $1" >&2; exit 1 ;; + esac +done + +# --------------------------------------------------------------------------- +# Read current version +# --------------------------------------------------------------------------- +OLD=$(python3 -c "import json; print(json.load(open('$PKG_JSON'))['version'])") +echo "Current version: $OLD" + +# --------------------------------------------------------------------------- +# Compute new version (pure bash/python, no npm required) +# --------------------------------------------------------------------------- +compute_new_version() { + python3 - "$OLD" "$BUMP" "$PREID" <<'PYEOF' +import sys, re + +old = sys.argv[1] +bump = sys.argv[2] +preid = sys.argv[3] if len(sys.argv) > 3 else "" + +# Parse semver: MAJOR.MINOR.PATCH[-PRERELEASE] +m = re.match(r'^(\d+)\.(\d+)\.(\d+)(?:-(.+))?$', old) +if not m: + print(f"ERROR: Cannot parse version: {old}", file=sys.stderr) + sys.exit(1) + +major, minor, patch = int(m.group(1)), int(m.group(2)), int(m.group(3)) +pre = m.group(4) or "" + +if bump == "major": + print(f"{major + 1}.0.0") +elif bump == "minor": + print(f"{major}.{minor + 1}.0") +elif bump == "patch": + # If currently pre-release, drop the pre-release tag (e.g. 2.0.0-alpha.1 -> 2.0.0) + if pre: + print(f"{major}.{minor}.{patch}") + else: + print(f"{major}.{minor}.{patch + 1}") +elif bump == "prerelease": + if not preid: + # No preid: increment existing pre-release number or start at 0 + if pre: + pm = re.match(r'^([a-zA-Z]+)\.(\d+)$', pre) + if pm: + print(f"{major}.{minor}.{patch}-{pm.group(1)}.{int(pm.group(2)) + 1}") + else: + print(f"{major}.{minor}.{patch}-{pre}.1") + else: + print(f"{major}.{minor}.{patch + 1}-0") + else: + # With preid: if same preid, increment; if different, start at 0 + if pre: + pm = re.match(r'^([a-zA-Z]+)\.(\d+)$', pre) + if pm and pm.group(1) == preid: + print(f"{major}.{minor}.{patch}-{preid}.{int(pm.group(2)) + 1}") + else: + print(f"{major}.{minor}.{patch}-{preid}.0") + else: + print(f"{major}.{minor}.{patch + 1}-{preid}.0") +else: + print(f"ERROR: Unknown bump type: {bump}", file=sys.stderr) + sys.exit(1) +PYEOF +} + +NEW=$(compute_new_version) + +if [[ -z "$NEW" || "$NEW" == "$OLD" ]]; then + echo "ERROR: Version computation failed or produced no change." >&2 + exit 1 +fi + +echo "New version: $NEW" + +# --------------------------------------------------------------------------- +# Update package.json +# --------------------------------------------------------------------------- +python3 -c " +import json +with open('$PKG_JSON', 'r') as f: + pkg = json.load(f) +pkg['version'] = '$NEW' +with open('$PKG_JSON', 'w') as f: + json.dump(pkg, f, indent=2) + f.write('\n') +" + +echo "Updated $PKG_JSON" + +# --------------------------------------------------------------------------- +# Git commit and tag +# --------------------------------------------------------------------------- +cd "$REPO_ROOT" +git add com.allow2.sdk/package.json +git commit -m "v$NEW" +git tag "v$NEW" + +echo "" +echo "$OLD -> $NEW" +echo "Tagged v$NEW" +echo "" +echo "To release, push the tag:" +echo " git push origin master --tags"