From 2e0c0791710f3e85dba37dfad18842933ee80673 Mon Sep 17 00:00:00 2001 From: Andrew Date: Fri, 26 Apr 2019 08:23:52 +1000 Subject: [PATCH 01/21] Basic Unity REST implementation --- Allow2.cs | 117 ++++++++++++++++++++++++++++++ Allow2.cs.meta | 11 +++ Allow2/Allow2.cs | 35 --------- Allow2/Allow2.csproj | 44 ----------- Allow2/Properties/AssemblyInfo.cs | 26 ------- Allow2/packages.config | 4 - EnumeratedTypes.cs | 18 +++++ EnumeratedTypes.cs.meta | 11 +++ 8 files changed, 157 insertions(+), 109 deletions(-) create mode 100644 Allow2.cs create mode 100644 Allow2.cs.meta delete mode 100644 Allow2/Allow2.cs delete mode 100644 Allow2/Allow2.csproj delete mode 100644 Allow2/Properties/AssemblyInfo.cs delete mode 100644 Allow2/packages.config create mode 100644 EnumeratedTypes.cs create mode 100644 EnumeratedTypes.cs.meta diff --git a/Allow2.cs b/Allow2.cs new file mode 100644 index 0000000..ab06510 --- /dev/null +++ b/Allow2.cs @@ -0,0 +1,117 @@ +// +// Allow2Unity +// +// Created by Andrew Longhorn in early 2019. +// Copyright © 2019 Allow2 Pty Ltd. All rights reserved. +// +// LICENSE: +// See LICENSE file in root directory +// + +using Application; +using UnityEngine; +using UnityEngine.Networking; + +namespace Allow2 +{ + + /// + /// Access functionality of the Allow2 platform easily. + /// + public static class Allow2 + { + + public static string deviceToken = "Not Set"; + public static EnvType env = EnvType.Production; + + // + // cannot instantiate this class + // + //protected Allow2() { + //} + + 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 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 void Pair(string user, // ie: "fred@gmail.com", + // string pass, // ie: "my super secret password", + // string deviceToken, // ie: "346-34269hcubi-187gigi8g-14i3ugkug", + // string deviceName // ie: "Fred's iPhone" + // ) { + // WWWForm form = new WWWForm(); + // form.AddField("user", user); + // form.AddField("pass", pass); + // form.AddField("deviceToken", deviceToken); + // form.AddField("name", deviceName); + + // using (UnityWebRequest www = UnityWebRequest.Post(apiUrl + "/api/pairDevice", form)) + // { + // yield return www.SendWebRequest(); + + // if (www.isNetworkError || www.isHttpError) + // { + // Debug.Log(www.error); + // } + // else + // { + // Debug.Log(www.downloadHandler.text); + // } + // } + //} + + //public static void Check(int userId, + // string pairToken, // ie: "98hbieg87-ilulieugil-dilufkucy" + // string deviceToken, // ie: "iug893-kjg-fiug23" + // string timezone, // ie: "Australia/Brisbane" + // int childId, + // int[] activities, + // bool log = false, + // bool staging = false // INTERNAL USE ONLY! + // ) { + // UnityWebRequest www = UnityWebRequest.Get("http://www.my-server.com"); + // yield return www.SendWebRequest(); + + // if (www.isNetworkError || www.isHttpError) + // { + // Debug.Log(www.error); + // } + // else + // { + // // Show results as text + // Debug.Log(www.downloadHandler.text); + + // // Or retrieve results as binary data + // byte[] results = www.downloadHandler.data; + // } + //} + } +} diff --git a/Allow2.cs.meta b/Allow2.cs.meta new file mode 100644 index 0000000..9141519 --- /dev/null +++ b/Allow2.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: ae78c48eb0201447cae54fb3a8c92d93 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Allow2/Allow2.cs b/Allow2/Allow2.cs deleted file mode 100644 index 7274ae0..0000000 --- a/Allow2/Allow2.cs +++ /dev/null @@ -1,35 +0,0 @@ -using System; -using System.Net.Http; -using System.Threading.Tasks; - -namespace Allow2 -{ - public class Connection - { - private static readonly HttpClient client = new HttpClient(); - - public Connection() - { - } - - public async Task test() - { - - //var values = new Dictionary - // { - // { "thing1", "hello" }, - // { "thing2", "world" } - // }; - - //var content = new FormUrlEncodedContent(values); - - //var response = await client.PostAsync("http://api.allow2.com/", content); - - //var responseString = await response.Content.ReadAsStringAsync(); - - var responseString = await client.GetStringAsync("http://api.allow2.com/"); - - return responseString; - } - } -} 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/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/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/EnumeratedTypes.cs b/EnumeratedTypes.cs new file mode 100644 index 0000000..be85755 --- /dev/null +++ b/EnumeratedTypes.cs @@ -0,0 +1,18 @@ +using System; +namespace Application +{ + public enum EnvType { Production, /*Sandbox,*/ Staging } + + public enum Activity { + Internet = 1, + Computer = 2, + Gaming = 3, + Message = 4, + JunkFood = 5, + Lollies = 6, + Electricity = 7, + ScreenTime = 8, + Social = 9, + PhoneTime = 10 + } +} diff --git a/EnumeratedTypes.cs.meta b/EnumeratedTypes.cs.meta new file mode 100644 index 0000000..13968fa --- /dev/null +++ b/EnumeratedTypes.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 68a0eb7dddae34242ad01c8b5270264c +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: From 0773390ae624ebf61f16439a05d85b850602ac34 Mon Sep 17 00:00:00 2001 From: Andrew Date: Fri, 26 Apr 2019 08:29:31 +1000 Subject: [PATCH 02/21] restructure --- .gitignore | 3 +++ Allow2.cs.meta | 11 ----------- Allow2.cs => Allow2/Allow2.cs | 0 EnumeratedTypes.cs => Allow2/EnumeratedTypes.cs | 6 ++++++ EnumeratedTypes.cs.meta | 11 ----------- 5 files changed, 9 insertions(+), 22 deletions(-) delete mode 100644 Allow2.cs.meta rename Allow2.cs => Allow2/Allow2.cs (100%) rename EnumeratedTypes.cs => Allow2/EnumeratedTypes.cs (62%) delete mode 100644 EnumeratedTypes.cs.meta diff --git a/.gitignore b/.gitignore index bf793ed..dd4dc33 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,6 @@ Thumbs.db # dotCover *.dotCover + +# meta +**/*.meta diff --git a/Allow2.cs.meta b/Allow2.cs.meta deleted file mode 100644 index 9141519..0000000 --- a/Allow2.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: ae78c48eb0201447cae54fb3a8c92d93 -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Allow2.cs b/Allow2/Allow2.cs similarity index 100% rename from Allow2.cs rename to Allow2/Allow2.cs diff --git a/EnumeratedTypes.cs b/Allow2/EnumeratedTypes.cs similarity index 62% rename from EnumeratedTypes.cs rename to Allow2/EnumeratedTypes.cs index be85755..d48140b 100644 --- a/EnumeratedTypes.cs +++ b/Allow2/EnumeratedTypes.cs @@ -1,8 +1,14 @@ using System; namespace Application { + /// + /// 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 { Internet = 1, Computer = 2, diff --git a/EnumeratedTypes.cs.meta b/EnumeratedTypes.cs.meta deleted file mode 100644 index 13968fa..0000000 --- a/EnumeratedTypes.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: 68a0eb7dddae34242ad01c8b5270264c -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: From 581fa8351cf8b18c9d57f086a0217842074731fa Mon Sep 17 00:00:00 2001 From: Andrew Date: Fri, 26 Apr 2019 08:55:02 +1000 Subject: [PATCH 03/21] basic calls working --- Allow2/Allow2.cs | 97 +++++++++++++++++++++++++----------------------- 1 file changed, 50 insertions(+), 47 deletions(-) diff --git a/Allow2/Allow2.cs b/Allow2/Allow2.cs index ab06510..be9790c 100644 --- a/Allow2/Allow2.cs +++ b/Allow2/Allow2.cs @@ -9,6 +9,7 @@ // using Application; +using System.Collections; using UnityEngine; using UnityEngine.Networking; @@ -21,7 +22,7 @@ namespace Allow2 public static class Allow2 { - public static string deviceToken = "Not Set"; + public static string deviceToken = "Not Set"; // ie: "346-34269hcubi-187gigi8g-14i3ugkug", public static EnvType env = EnvType.Production; // @@ -62,56 +63,58 @@ public static string serviceUrl } } - //public static void Pair(string user, // ie: "fred@gmail.com", - // string pass, // ie: "my super secret password", - // string deviceToken, // ie: "346-34269hcubi-187gigi8g-14i3ugkug", - // string deviceName // ie: "Fred's iPhone" - // ) { - // WWWForm form = new WWWForm(); - // form.AddField("user", user); - // form.AddField("pass", pass); - // form.AddField("deviceToken", deviceToken); - // form.AddField("name", deviceName); + public static IEnumerator Pair(string user, // ie: "fred@gmail.com", + string pass, // ie: "my super secret password", + string deviceName // ie: "Fred's iPhone" + ) { + WWWForm form = new WWWForm(); + form.AddField("user", user); + form.AddField("pass", pass); + form.AddField("deviceToken", deviceToken); + form.AddField("name", deviceName); + + Debug.Log(apiUrl + "/api/pairDevice"); + Debug.Log(form); - // using (UnityWebRequest www = UnityWebRequest.Post(apiUrl + "/api/pairDevice", form)) - // { - // yield return www.SendWebRequest(); + using (UnityWebRequest www = UnityWebRequest.Post(apiUrl + "/api/pairDevice", form)) + { + yield return www.SendWebRequest(); - // if (www.isNetworkError || www.isHttpError) - // { - // Debug.Log(www.error); - // } - // else - // { - // Debug.Log(www.downloadHandler.text); - // } - // } - //} + if (www.isNetworkError || www.isHttpError) + { + Debug.Log(www.error); + } + else + { + Debug.Log(www.downloadHandler.text); + } + } + } - //public static void Check(int userId, - // string pairToken, // ie: "98hbieg87-ilulieugil-dilufkucy" - // string deviceToken, // ie: "iug893-kjg-fiug23" - // string timezone, // ie: "Australia/Brisbane" - // int childId, - // int[] activities, - // bool log = false, - // bool staging = false // INTERNAL USE ONLY! - // ) { - // UnityWebRequest www = UnityWebRequest.Get("http://www.my-server.com"); - // yield return www.SendWebRequest(); + public static IEnumerator Check(int userId, + string pairToken, // ie: "98hbieg87-ilulieugil-dilufkucy" + string deviceToken, // ie: "iug893-kjg-fiug23" + string timezone, // ie: "Australia/Brisbane" + int childId, + int[] activities, + bool log = false, + bool staging = false // INTERNAL USE ONLY! + ) { + UnityWebRequest www = UnityWebRequest.Get("http://www.my-server.com"); + yield return www.SendWebRequest(); - // if (www.isNetworkError || www.isHttpError) - // { - // Debug.Log(www.error); - // } - // else - // { - // // Show results as text - // Debug.Log(www.downloadHandler.text); + if (www.isNetworkError || www.isHttpError) + { + Debug.Log(www.error); + } + else + { + // Show results as text + Debug.Log(www.downloadHandler.text); - // // Or retrieve results as binary data - // byte[] results = www.downloadHandler.data; - // } - //} + // Or retrieve results as binary data + byte[] results = www.downloadHandler.data; + } + } } } From dfcf5fffc26b2b177d4c584c8bd9b6856a07512f Mon Sep 17 00:00:00 2001 From: Andrew Date: Wed, 1 May 2019 08:22:36 +1000 Subject: [PATCH 04/21] imp[lementing return values --- Allow2/Allow2.cs | 144 ++-- Allow2/Allow2CheckResult.cs | 10 + Allow2/EnumeratedTypes.cs | 11 + Allow2/SimpleJSON.cs | 1367 +++++++++++++++++++++++++++++++++++ 4 files changed, 1490 insertions(+), 42 deletions(-) create mode 100644 Allow2/Allow2CheckResult.cs create mode 100644 Allow2/SimpleJSON.cs diff --git a/Allow2/Allow2.cs b/Allow2/Allow2.cs index be9790c..04acae1 100644 --- a/Allow2/Allow2.cs +++ b/Allow2/Allow2.cs @@ -22,11 +22,18 @@ namespace Allow2 public static class Allow2 { - public static string deviceToken = "Not Set"; // ie: "346-34269hcubi-187gigi8g-14i3ugkug", + public static string deviceToken = "Not Set"; // ie: "iug893-kjg-fiug23" - not persisted: always set this on start public static EnvType env = EnvType.Production; - - // - // cannot instantiate this class + + // + // relevant persistence items + // + static int userId; // ie: 27634 + static string pairToken; // ie: "98hbieg87-ilulieugil-dilufkucy" + static string timezone; // ie: "Australia/Brisbane" + + // + // cannot instantiate this class // //protected Allow2() { //} @@ -62,58 +69,111 @@ public static string serviceUrl } } } - - public static IEnumerator Pair(string user, // ie: "fred@gmail.com", - string pass, // ie: "my super secret password", - string deviceName // ie: "Fred's iPhone" - ) { - WWWForm form = new WWWForm(); - form.AddField("user", user); - form.AddField("pass", pass); - form.AddField("deviceToken", deviceToken); - form.AddField("name", deviceName); + + private static void persist() { + // todo: write to protected namespace storage? + } + + public delegate void resultClosure(string err, Allow2CheckResult result); + + //void Awake() + //{ + // DontDestroyOnLoad(this); + //} + + public static IEnumerator Pair(string user, // ie: "fred@gmail.com", + string pass, // ie: "my super secret password", + string deviceName, // ie: "Fred's iPhone" + resultClosure callback + ) + { + WWWForm form = new WWWForm(); + form.AddField("user", user); + form.AddField("pass", pass); + form.AddField("deviceToken", deviceToken); + form.AddField("name", deviceName); Debug.Log(apiUrl + "/api/pairDevice"); - Debug.Log(form); - - using (UnityWebRequest www = UnityWebRequest.Post(apiUrl + "/api/pairDevice", form)) - { - yield return www.SendWebRequest(); - - if (www.isNetworkError || www.isHttpError) - { - Debug.Log(www.error); - } - else - { - Debug.Log(www.downloadHandler.text); - } - } - } - - public static IEnumerator Check(int userId, - string pairToken, // ie: "98hbieg87-ilulieugil-dilufkucy" - string deviceToken, // ie: "iug893-kjg-fiug23" - string timezone, // ie: "Australia/Brisbane" - int childId, + + using (UnityWebRequest www = UnityWebRequest.Post(apiUrl + "/api/pairDevice", form)) + { + yield return www.SendWebRequest(); + + if (www.isNetworkError || www.isHttpError) + { + Debug.Log(www.error); + callback(www.error, null); + } + else + { + Debug.Log(www.downloadHandler.text); + var response = Allow2_SimpleJSON.JSON.Parse(www.downloadHandler.text); + // extract + + // persist + + // return + callback(null, null); + } + } + } + + public static IEnumerator Check(int childId, int[] activities, - bool log = false, - bool staging = false // INTERNAL USE ONLY! - ) { + resultClosure callback, + bool log = false + ) + { UnityWebRequest www = UnityWebRequest.Get("http://www.my-server.com"); yield return www.SendWebRequest(); if (www.isNetworkError || www.isHttpError) { Debug.Log(www.error); + callback(www.error, null); } else { - // Show results as text Debug.Log(www.downloadHandler.text); + var json = Allow2_SimpleJSON.JSON.Parse(www.downloadHandler.text); + + if ((json["error"] == "invalid pairId") || + (json["error"] == "invalid pairToken")) + { + // todo: || (response?.statusCode == 401) { + // special case, no longer controlled + 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 (json["allowed"] == null) { + callback(Allow2Error.InvalidResponse, null); + yield break; + } - // Or retrieve results as binary data - byte[] results = www.downloadHandler.data; + 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 _children = json["allDayTypes"]; + response.Add("children", _children); + callback(null, response); } } } diff --git a/Allow2/Allow2CheckResult.cs b/Allow2/Allow2CheckResult.cs new file mode 100644 index 0000000..e421066 --- /dev/null +++ b/Allow2/Allow2CheckResult.cs @@ -0,0 +1,10 @@ +using System; +namespace Allow2 +{ + public class Allow2CheckResult : Allow2_SimpleJSON.JSONObject // shortcut implementation for now + { + //public Allow2CheckResult() + //{ + //} + } +} diff --git a/Allow2/EnumeratedTypes.cs b/Allow2/EnumeratedTypes.cs index d48140b..aeef194 100644 --- a/Allow2/EnumeratedTypes.cs +++ b/Allow2/EnumeratedTypes.cs @@ -21,4 +21,15 @@ public enum Activity { 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"; + } +} + } 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 From 20bb6eb4a357d2883383746536f3e2d99d479c85 Mon Sep 17 00:00:00 2001 From: Andrew Date: Wed, 1 May 2019 17:36:42 +1000 Subject: [PATCH 05/21] adding qrcode functions and persistence --- Allow2/Allow2.cs | 103 ++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 97 insertions(+), 6 deletions(-) diff --git a/Allow2/Allow2.cs b/Allow2/Allow2.cs index 04acae1..207ca61 100644 --- a/Allow2/Allow2.cs +++ b/Allow2/Allow2.cs @@ -21,8 +21,8 @@ namespace Allow2 /// public static class Allow2 { - - public static string deviceToken = "Not Set"; // ie: "iug893-kjg-fiug23" - not persisted: always set this on start + static string uuid; + static string _deviceToken = "Not Set"; // ie: "iug893-kjg-fiug23" - not persisted: always set this on start public static EnvType env = EnvType.Production; // @@ -30,7 +30,7 @@ public static class Allow2 // static int userId; // ie: 27634 static string pairToken; // ie: "98hbieg87-ilulieugil-dilufkucy" - static string timezone; // ie: "Australia/Brisbane" + static string _timezone; // ie: "Australia/Brisbane" // // cannot instantiate this class @@ -70,12 +70,84 @@ public static string serviceUrl } } + public static string timezone + { + get + { + return _timezone; + } + set + { + _timezone = value; + PlayerPrefs.SetString("timezone", _timezone); + } + } + + public static string deviceToken + { + get + { + return _deviceToken; + } + set + { + _deviceToken = value; + PlayerPrefs.SetString("deviceToken", _deviceToken); + if ((userId < 1) || (pairToken == null)) { + // 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); } public delegate void resultClosure(string err, Allow2CheckResult result); + 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 + uuid = PlayerPrefs.GetString("uuid"); + if (uuid == null) { + + 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 this 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 + } + } + } + //void Awake() //{ // DontDestroyOnLoad(this); @@ -90,7 +162,7 @@ resultClosure callback WWWForm form = new WWWForm(); form.AddField("user", user); form.AddField("pass", pass); - form.AddField("deviceToken", deviceToken); + form.AddField("deviceToken", _deviceToken); form.AddField("name", deviceName); Debug.Log(apiUrl + "/api/pairDevice"); @@ -110,7 +182,7 @@ resultClosure callback var response = Allow2_SimpleJSON.JSON.Parse(www.downloadHandler.text); // extract - // persist + persist(); // return callback(null, null); @@ -118,13 +190,32 @@ resultClosure callback } } + public static string getQR() + { + return "https://api.allow2.com/genqr/" + + WWW.EscapeURL(_deviceToken) + "/" + + WWW.EscapeURL(uuid) + "/" + + WWW.EscapeURL(name); + } + public static IEnumerator Check(int childId, int[] activities, resultClosure callback, bool log = false ) { - UnityWebRequest www = UnityWebRequest.Get("http://www.my-server.com"); + if ((userId < 1) || (pairToken == null)) { + callback(Allow2Error.NotPaired, null); + yield break; + } + + WWWForm form = new WWWForm(); + form.AddField("user", user); + form.AddField("pass", pass); + form.AddField("deviceToken", _deviceToken); + form.AddField("name", deviceName); + + UnityWebRequest www = UnityWebRequest.Get(apiUrl + "/api/pairDevice"); yield return www.SendWebRequest(); if (www.isNetworkError || www.isHttpError) From bf5f11924bbc2f314af5d63de6a932da22661e1d Mon Sep 17 00:00:00 2001 From: Andrew Date: Thu, 2 May 2019 08:31:25 +1000 Subject: [PATCH 06/21] Add DayType and Child classes implement result cache fill more stuff in --- Allow2/Allow2.cs | 159 ++++++++++++++++++++++++++---------- Allow2/Allow2CheckResult.cs | 19 ++++- Allow2/Allow2Child.cs | 26 ++++++ Allow2/Allow2Day.cs | 25 ++++++ Allow2/EnumeratedTypes.cs | 21 ++++- 5 files changed, 201 insertions(+), 49 deletions(-) create mode 100644 Allow2/Allow2Child.cs create mode 100644 Allow2/Allow2Day.cs diff --git a/Allow2/Allow2.cs b/Allow2/Allow2.cs index 207ca61..6e4a7d2 100644 --- a/Allow2/Allow2.cs +++ b/Allow2/Allow2.cs @@ -1,15 +1,17 @@ // // Allow2Unity +// Allow2.cs // -// Created by Andrew Longhorn in early 2019. +// Created by Andrew Longhorn in Jan 2019. // Copyright © 2019 Allow2 Pty Ltd. All rights reserved. // // LICENSE: // See LICENSE file in root directory // -using Application; +using System; using System.Collections; +using System.Collections.Generic; using UnityEngine; using UnityEngine.Networking; @@ -21,24 +23,19 @@ namespace Allow2 /// public static class Allow2 { - static string uuid; - static string _deviceToken = "Not Set"; // ie: "iug893-kjg-fiug23" - not persisted: always set this on start + 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 int _childId; // ie: 34 static string pairToken; // ie: "98hbieg87-ilulieugil-dilufkucy" - static string _timezone; // ie: "Australia/Brisbane" + static string _timezone; // ie: "Australia/Brisbane" - // - // cannot instantiate this class - // - //protected Allow2() { - //} - - public static string apiUrl + public static string ApiUrl { get { @@ -54,7 +51,7 @@ public static string apiUrl } } - public static string serviceUrl + public static string ServiceUrl { get { @@ -70,7 +67,17 @@ public static string serviceUrl } } - public static string timezone + public static resultClosure checkResultHandler; + + public static bool IsPaired + { + get + { + return (userId > 0) && (pairToken != null); + } + } + + public static string Timezone { get { @@ -83,7 +90,7 @@ public static string timezone } } - public static string deviceToken + public static string DeviceToken { get { @@ -93,44 +100,51 @@ public static string deviceToken { _deviceToken = value; PlayerPrefs.SetString("deviceToken", _deviceToken); - if ((userId < 1) || (pairToken == null)) { + 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() { + private static void Persist() + { // todo: write to protected namespace storage? PlayerPrefs.SetInt("userId", userId); } + // no persistence here + private static Dictionary resultCache = new Dictionary(); + public delegate void resultClosure(string err, Allow2CheckResult result); 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 + 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) { - + if (uuid == null) + { + uuid = System.Guid.NewGuid().ToString(); PlayerPrefs.SetString("uuid", uuid); } } userId = PlayerPrefs.GetInt("userId"); pairToken = PlayerPrefs.GetString("pairToken"); - timezone = PlayerPrefs.GetString("timezone"); + _timezone = PlayerPrefs.GetString("timezone"); } static IEnumerator CheckForBrokenPairing() { - // here we just ask the question, was this unique ID paired and somehow it lost it's pairing? + // 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)) + using (UnityWebRequest www = UnityWebRequest.Post(ApiUrl + "/api/isDevicePaired", form)) { yield return www.SendWebRequest(); @@ -148,11 +162,10 @@ static IEnumerator CheckForBrokenPairing() } } - //void Awake() - //{ - // DontDestroyOnLoad(this); - //} - + /* + * + * + */ public static IEnumerator Pair(string user, // ie: "fred@gmail.com", string pass, // ie: "my super secret password", string deviceName, // ie: "Fred's iPhone" @@ -164,16 +177,17 @@ resultClosure callback form.AddField("pass", pass); form.AddField("deviceToken", _deviceToken); form.AddField("name", deviceName); + form.AddField("uuid", uuid); - Debug.Log(apiUrl + "/api/pairDevice"); + Debug.Log(ApiUrl + "/api/pairDevice"); - using (UnityWebRequest www = UnityWebRequest.Post(apiUrl + "/api/pairDevice", form)) + using (UnityWebRequest www = UnityWebRequest.Post(ApiUrl + "/api/pairDevice", form)) { yield return www.SendWebRequest(); if (www.isNetworkError || www.isHttpError) { - Debug.Log(www.error); + Debug.Log(www.error.ToString()); callback(www.error, null); } else @@ -182,7 +196,7 @@ resultClosure callback var response = Allow2_SimpleJSON.JSON.Parse(www.downloadHandler.text); // extract - persist(); + Persist(); // return callback(null, null); @@ -190,32 +204,54 @@ resultClosure callback } } - public static string getQR() + public static string GetQR(string name) { - return "https://api.allow2.com/genqr/" + + return ApiUrl + "/genqr/" + WWW.EscapeURL(_deviceToken) + "/" + WWW.EscapeURL(uuid) + "/" + WWW.EscapeURL(name); } - public static IEnumerator Check(int childId, + public static IEnumerator Check(int childId, // childId == 0 ? Get Updated Child List and confirm Pairing int[] activities, resultClosure callback, bool log = false ) { - if ((userId < 1) || (pairToken == null)) { + if (!IsPaired) + { callback(Allow2Error.NotPaired, null); yield break; } WWWForm form = new WWWForm(); - form.AddField("user", user); - form.AddField("pass", pass); + form.AddField("userId", userId); + form.AddField("pairToken", pairToken); form.AddField("deviceToken", _deviceToken); - form.AddField("name", deviceName); + form.AddField("tz", _timezone); + //form.AddField("activities", activities); + form.AddField("log", log ? 1 : 0); + if (childId > 0) + { + form.AddField("childId", childId); + } + + // check the cache first + string key = form.ToString(); + if (resultCache.ContainsKey(key)) { + Allow2CheckResult checkResult = resultCache[key]; + + 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(key); + } - UnityWebRequest www = UnityWebRequest.Get(apiUrl + "/api/pairDevice"); + UnityWebRequest www = UnityWebRequest.Get(ApiUrl + "/api/pairDevice"); yield return www.SendWebRequest(); if (www.isNetworkError || www.isHttpError) @@ -235,7 +271,7 @@ public static IEnumerator Check(int childId, // special case, no longer controlled userId = 0; pairToken = null; - persist(); + Persist(); //childId = 0; //_children = [] //_dayTypes = [] @@ -250,7 +286,8 @@ public static IEnumerator Check(int childId, yield break; } - if (json["allowed"] == null) { + if (json["allowed"] == null) + { callback(Allow2Error.InvalidResponse, null); yield break; } @@ -264,8 +301,42 @@ public static IEnumerator Check(int childId, response.Add("allDayTypes", _dayTypes); var _children = json["allDayTypes"]; response.Add("children", _children); + + // cache the response + resultCache[key] = response; callback(null, response); } } + + public static IEnumerator Request( + 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 form = new WWWForm(); + form.AddField("userId", userId); + form.AddField("pairToken", pairToken); + form.AddField("deviceToken", _deviceToken); + form.AddField("childId", _childId); + //form.AddField("lift", lift.asJson); + //if (dayTypeId != nil) { + // body["dayType"] = JSON(dayTypeId!) + // body["changeDayType"] = true + //} + + + } } -} +} \ No newline at end of file diff --git a/Allow2/Allow2CheckResult.cs b/Allow2/Allow2CheckResult.cs index e421066..5302b2c 100644 --- a/Allow2/Allow2CheckResult.cs +++ b/Allow2/Allow2CheckResult.cs @@ -1,4 +1,15 @@ -using System; +// +// 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; namespace Allow2 { public class Allow2CheckResult : Allow2_SimpleJSON.JSONObject // shortcut implementation for now @@ -6,5 +17,11 @@ namespace Allow2 //public Allow2CheckResult() //{ //} + + public DateTime Expires { + get { + return new DateTime(); + } + } } } diff --git a/Allow2/Allow2Child.cs b/Allow2/Allow2Child.cs new file mode 100644 index 0000000..a70ea45 --- /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 // shortcut implementation for now + { + 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..3cdd8a9 --- /dev/null +++ b/Allow2/Allow2Day.cs @@ -0,0 +1,25 @@ +// +// 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 +// + +namespace Allow2 +{ + public class Allow2Day // shortcut implementation for now + { + public int Id { get; private set; } + public string Name { get; private set; } + + Allow2Day(int id, string name) + { + Id = id; + Name = name; + } + } +} \ No newline at end of file diff --git a/Allow2/EnumeratedTypes.cs b/Allow2/EnumeratedTypes.cs index aeef194..e932324 100644 --- a/Allow2/EnumeratedTypes.cs +++ b/Allow2/EnumeratedTypes.cs @@ -1,10 +1,24 @@ -using System; -namespace Application +// +// 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 } + public enum EnvType { + Production, + // Sandbox, + Staging + } /// /// Activities: These are the current activities for Allow2. @@ -30,6 +44,5 @@ public static class Allow2Error public const string NotAuthorised = "NotAuthorised"; public const string InvalidResponse = "InvalidResponse"; } -} } From 05e21461bd03f692a2fdcb321a126abbff987c44 Mon Sep 17 00:00:00 2001 From: Andrew Date: Sun, 5 May 2019 13:54:17 +1000 Subject: [PATCH 07/21] Update README.md --- README.md | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) 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. From 397208a13a5d6e7be855bc7029af79cd93d18677 Mon Sep 17 00:00:00 2001 From: Andrew Date: Sun, 5 May 2019 14:13:19 +1000 Subject: [PATCH 08/21] filling out functionality --- .gitignore | 67 +++++++++++ .idea/Allow2.iml | 12 ++ .idea/misc.xml | 6 + .idea/modules.xml | 8 ++ .idea/vcs.xml | 6 + Allow2/Allow2.cs | 226 ++++++++++++++++++++++++++---------- Allow2/Allow2CheckResult.cs | 4 +- 7 files changed, 265 insertions(+), 64 deletions(-) create mode 100644 .idea/Allow2.iml create mode 100644 .idea/misc.xml create mode 100644 .idea/modules.xml create mode 100644 .idea/vcs.xml diff --git a/.gitignore b/.gitignore index dd4dc33..11596a5 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,70 @@ Thumbs.db # 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/.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 6e4a7d2..e1d7b38 100644 --- a/Allow2/Allow2.cs +++ b/Allow2/Allow2.cs @@ -14,6 +14,7 @@ using System.Collections.Generic; using UnityEngine; using UnityEngine.Networking; +using Allow2_SimpleJSON; namespace Allow2 { @@ -31,9 +32,11 @@ public static class Allow2 // relevant persistence items // static int userId; // ie: 27634 - static int _childId; // ie: 34 static string pairToken; // ie: "98hbieg87-ilulieugil-dilufkucy" static string _timezone; // ie: "Australia/Brisbane" + static Dictionary _children = new Dictionary(); + + public static int childId; // ie: 34 public static string ApiUrl { @@ -51,6 +54,14 @@ public static string ApiUrl } } + public static Dictionary Children + { + get + { + return _children; + } + } + public static string ServiceUrl { get @@ -100,11 +111,11 @@ public static string DeviceToken { _deviceToken = value; PlayerPrefs.SetString("deviceToken", _deviceToken); - if (!IsPaired) - { + //if (!IsPaired) + //{ // todo: start a regular timer to keep trying to call home on startup until we confirm we are NOT paired. CheckForBrokenPairing(); - } + //} } } @@ -118,6 +129,7 @@ private static void Persist() private static Dictionary resultCache = new Dictionary(); public delegate void resultClosure(string err, Allow2CheckResult result); + public delegate void imageClosure(string err, Texture qrCode); static Allow2() { @@ -172,6 +184,10 @@ public static IEnumerator Pair(string user, // ie: "fred@gmail.com", resultClosure callback ) { + if (IsPaired) { + callback(Allow2Error.AlreadyPaired, null); + yield break; + } WWWForm form = new WWWForm(); form.AddField("user", user); form.AddField("pass", pass); @@ -185,31 +201,88 @@ resultClosure callback { yield return www.SendWebRequest(); - if (www.isNetworkError || www.isHttpError) + var response = JSONNode.Parse(www.downloadHandler.text); + + if (response == null) { - Debug.Log(www.error.ToString()); - callback(www.error, null); + if (www.isNetworkError || www.isHttpError) + { + Debug.Log(www.error.ToString()); + callback(www.error, null); + } + yield break; } - else - { - Debug.Log(www.downloadHandler.text); - var response = Allow2_SimpleJSON.JSON.Parse(www.downloadHandler.text); - // extract - Persist(); + //{ + // "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"} + // ] + //} - // return - callback(null, null); + 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); } } - public static string GetQR(string name) + public static IEnumerator GetQR(string name, + imageClosure callback) { - return ApiUrl + "/genqr/" + + string qrURL = ApiUrl + "/genqr/" + WWW.EscapeURL(_deviceToken) + "/" + WWW.EscapeURL(uuid) + "/" + WWW.EscapeURL(name); + UnityWebRequest www = UnityWebRequestTexture.GetTexture(qrURL); + yield return www.SendWebRequest(); + + if (www.isNetworkError || www.isHttpError) + { + Debug.Log(www.error); + callback(www.error, null); + yield break; + } + Texture qrCode = ((DownloadHandlerTexture)www.downloadHandler).texture; + 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; } public static IEnumerator Check(int childId, // childId == 0 ? Get Updated Child List and confirm Pairing @@ -224,6 +297,10 @@ public static IEnumerator Check(int childId, // childId == 0 ? Get Updated Ch yield break; } + Debug.Log(userId); + Debug.Log(pairToken); + Debug.Log(_deviceToken); + WWWForm form = new WWWForm(); form.AddField("userId", userId); form.AddField("pairToken", pairToken); @@ -251,61 +328,84 @@ public static IEnumerator Check(int childId, // childId == 0 ? Get Updated Ch resultCache.Remove(key); } - UnityWebRequest www = UnityWebRequest.Get(ApiUrl + "/api/pairDevice"); + UnityWebRequest www = UnityWebRequest.Get(ServiceUrl + "/serviceapi/check"); yield return www.SendWebRequest(); + Debug.Log(www.downloadHandler.text); + var json = Allow2_SimpleJSON.JSON.Parse(www.downloadHandler.text); + + if ((www.responseCode == 401) || + (www.responseCode == 404) || + ((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; } - else + + if (json["allowed"] == null) { - Debug.Log(www.downloadHandler.text); - var json = Allow2_SimpleJSON.JSON.Parse(www.downloadHandler.text); + callback(Allow2Error.InvalidResponse, null); + yield break; + } - if ((json["error"] == "invalid pairId") || - (json["error"] == "invalid pairToken")) - { - // todo: || (response?.statusCode == 401) { - // special case, no longer controlled - 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; - } + 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["allDayTypes"]; + _children = Children; + response.Add("children", children); + + if (oldChildIds != _children.Keys) + { + Persist(); // only persist if the children change, this won't happen often. + } - if (json["allowed"] == null) - { - callback(Allow2Error.InvalidResponse, null); - yield break; - } + // cache the response + resultCache[key] = response; + callback(null, response); + } - 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 _children = json["allDayTypes"]; - response.Add("children", _children); - - // cache the response - resultCache[key] = response; - callback(null, response); - } + public static IEnumerator StartChecking(int childId, + int[] activities, + resultClosure callback, + bool log = false + ) + { + return Check(childId, activities, callback, log); + } + + public static IEnumerator StopChecking() + { + return null; } public static IEnumerator Request( @@ -319,7 +419,7 @@ public static IEnumerator Request( callback(Allow2Error.NotPaired, null); yield break; } - if (_childId < 1) + if (childId < 1) { callback(Allow2Error.MissingChildId, null); yield break; @@ -329,7 +429,7 @@ public static IEnumerator Request( form.AddField("userId", userId); form.AddField("pairToken", pairToken); form.AddField("deviceToken", _deviceToken); - form.AddField("childId", _childId); + form.AddField("childId", childId); //form.AddField("lift", lift.asJson); //if (dayTypeId != nil) { // body["dayType"] = JSON(dayTypeId!) diff --git a/Allow2/Allow2CheckResult.cs b/Allow2/Allow2CheckResult.cs index 5302b2c..07d75d4 100644 --- a/Allow2/Allow2CheckResult.cs +++ b/Allow2/Allow2CheckResult.cs @@ -17,10 +17,12 @@ namespace Allow2 //public Allow2CheckResult() //{ //} + private static readonly DateTime Epoch = new DateTime(1970, 1, 1, 0, 0, 0, 0, System.DateTimeKind.Utc); public DateTime Expires { get { - return new DateTime(); + int unixTimeStamp = this["activities"]["0"]["expires"]; + return Epoch.AddSeconds(unixTimeStamp).ToUniversalTime(); // nineteen70.AddSeconds(unixTimeStamp).ToLocalTime(); } } } From 68ebd52c49ccee8ffbd8934b4414694241e9181a Mon Sep 17 00:00:00 2001 From: Andrew Date: Sun, 5 May 2019 14:28:16 +1000 Subject: [PATCH 09/21] Updated metadata --- package.json | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index c07ba04..7198731 100644 --- a/package.json +++ b/package.json @@ -1,9 +1,24 @@ { "category": "AssetStore/Unity", - "description": "Allow2 is cool.", + "description": "Allow2 Parental Freedom.", "gitHead": "0d2114387a99011be685886f8ea902242e323e70", "keywords": [ "allow2", + "child", + "children", + "control", + "family", + "freedom", + "gaming", + "limit", + "limits", + "parental", + "parental-control", + "quota", + "social", + "social-network", + "time", + "time-limit", "unity" ], "name": "com.allow2.allow2", @@ -13,5 +28,5 @@ "url": "ssh://git@github.com/Unity-Technologies/com.unity.ads.git" }, "unity": "2017.4", - "version": "1.0.0" + "version": "0.1.0" } From dd7af38ce90e008b3166e3d8b6d1a4fb5585a1c4 Mon Sep 17 00:00:00 2001 From: Andrew Date: Mon, 6 May 2019 00:47:33 +1000 Subject: [PATCH 10/21] check working --- Allow2/Allow2.cs | 176 ++++++++++++++++++++++++++--------------------- 1 file changed, 99 insertions(+), 77 deletions(-) diff --git a/Allow2/Allow2.cs b/Allow2/Allow2.cs index e1d7b38..a18b41d 100644 --- a/Allow2/Allow2.cs +++ b/Allow2/Allow2.cs @@ -15,6 +15,7 @@ using UnityEngine; using UnityEngine.Networking; using Allow2_SimpleJSON; +using System.Text; namespace Allow2 { @@ -112,9 +113,9 @@ public static string DeviceToken _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(); + //{ + // todo: start a regular timer to keep trying to call home on startup until we confirm we are NOT paired. + CheckForBrokenPairing(); //} } } @@ -123,6 +124,7 @@ private static void Persist() { // todo: write to protected namespace storage? PlayerPrefs.SetInt("userId", userId); + PlayerPrefs.SetString("pairToken", pairToken); } // no persistence here @@ -184,7 +186,8 @@ public static IEnumerator Pair(string user, // ie: "fred@gmail.com", resultClosure callback ) { - if (IsPaired) { + if (IsPaired) + { callback(Allow2Error.AlreadyPaired, null); yield break; } @@ -277,9 +280,11 @@ public static IEnumerator GetQR(string name, // ) //} - private static Dictionary ParseChildren(JSONNode json) { + private static Dictionary ParseChildren(JSONNode json) + { Dictionary children = new Dictionary(); - foreach(JSONNode child in json) { + foreach (JSONNode child in json) + { children[child["id"]] = child["name"]; } return children; @@ -301,97 +306,114 @@ public static IEnumerator Check(int childId, // childId == 0 ? Get Updated Ch Debug.Log(pairToken); Debug.Log(_deviceToken); - WWWForm form = new WWWForm(); - form.AddField("userId", userId); - form.AddField("pairToken", pairToken); - form.AddField("deviceToken", _deviceToken); - form.AddField("tz", _timezone); - //form.AddField("activities", activities); - form.AddField("log", log ? 1 : 0); + 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) { - form.AddField("childId", childId); + body.Add("childId", childId); } + string bodyStr = body.ToString(); // check the cache first - string key = form.ToString(); - if (resultCache.ContainsKey(key)) { - Allow2CheckResult checkResult = resultCache[key]; + if (resultCache.ContainsKey(bodyStr)) + { + Allow2CheckResult checkResult = resultCache[bodyStr]; - if (checkResult.Expires.CompareTo(new DateTime()) < 0 ) { + 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(key); + resultCache.Remove(bodyStr); } - UnityWebRequest www = UnityWebRequest.Get(ServiceUrl + "/serviceapi/check"); - yield return www.SendWebRequest(); + 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(); - Debug.Log(www.downloadHandler.text); - var json = Allow2_SimpleJSON.JSON.Parse(www.downloadHandler.text); + Debug.Log(www.downloadHandler.text); + var json = Allow2_SimpleJSON.JSON.Parse(www.downloadHandler.text); - if ((www.responseCode == 401) || - (www.responseCode == 404) || - ((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.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 (www.isNetworkError || www.isHttpError) + { + Debug.Log(www.error); + callback(www.error, null); + yield break; + } - if (json["allowed"] == null) - { - callback(Allow2Error.InvalidResponse, 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["allDayTypes"]; - _children = Children; - response.Add("children", children); - - if (oldChildIds != _children.Keys) - { - Persist(); // only persist if the children change, this won't happen often. - } + 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["allDayTypes"]; + _children = 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[key] = response; - callback(null, response); + // cache the response + resultCache[bodyStr] = response; + callback(null, response); + } } public static IEnumerator StartChecking(int childId, From 247345ba5b88940d349f5c881f8c96a382476cc9 Mon Sep 17 00:00:00 2001 From: Andrew Date: Wed, 8 May 2019 09:00:04 +1000 Subject: [PATCH 11/21] implement Allow2Result helpers --- Allow2/Allow2.cs | 2 + Allow2/Allow2Ban.cs | 34 ++++++++ Allow2/Allow2CheckResult.cs | 167 +++++++++++++++++++++++++++++++++++- Allow2/Allow2Child.cs | 2 +- 4 files changed, 200 insertions(+), 5 deletions(-) create mode 100644 Allow2/Allow2Ban.cs diff --git a/Allow2/Allow2.cs b/Allow2/Allow2.cs index a18b41d..70682f2 100644 --- a/Allow2/Allow2.cs +++ b/Allow2/Allow2.cs @@ -39,6 +39,8 @@ public static class Allow2 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 diff --git a/Allow2/Allow2Ban.cs b/Allow2/Allow2Ban.cs new file mode 100644 index 0000000..e91936e --- /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; } + + 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 index 07d75d4..e1bd6df 100644 --- a/Allow2/Allow2CheckResult.cs +++ b/Allow2/Allow2CheckResult.cs @@ -10,20 +10,179 @@ // using System; +using System.Collections.Generic; +using Allow2_SimpleJSON; + namespace Allow2 { - public class Allow2CheckResult : Allow2_SimpleJSON.JSONObject // shortcut implementation for now + // 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() //{ //} - private static readonly DateTime Epoch = new DateTime(1970, 1, 1, 0, 0, 0, 0, System.DateTimeKind.Utc); + + public JSONNode Activities { + get { + return this["activities"]; + } + } + + public JSONNode Subscription + { + get + { + return this["subscription"]; + } + } public DateTime Expires { get { - int unixTimeStamp = this["activities"]["0"]["expires"]; - return Epoch.AddSeconds(unixTimeStamp).ToUniversalTime(); // nineteen70.AddSeconds(unixTimeStamp).ToLocalTime(); + int unixTimeStamp = ((Activities != null) && (Activities[0] != null)) ? + this["activities"]["0"]["expires"].AsInt : 0; + return Allow2.Epoch.AddSeconds(unixTimeStamp).ToUniversalTime(); // nineteen70.AddSeconds(unixTimeStamp).ToLocalTime(); + } + } + + 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; + } + } + + public bool IsAllowed { + get { + return this["allowed"].AsBool; + } + } + + 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()); + } + } + + 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(); } } } + } diff --git a/Allow2/Allow2Child.cs b/Allow2/Allow2Child.cs index a70ea45..015ca8f 100644 --- a/Allow2/Allow2Child.cs +++ b/Allow2/Allow2Child.cs @@ -11,7 +11,7 @@ namespace Allow2 { - public class Allow2Child // shortcut implementation for now + public class Allow2Child { public int Id { get; private set; } public string Name { get; private set; } From c96d637e6aeb60ab69ff98e9fdd31a9c3c4d5ce9 Mon Sep 17 00:00:00 2001 From: Andrew Date: Thu, 9 May 2019 08:10:23 +1000 Subject: [PATCH 12/21] add qrcode pair polling --- Allow2/Allow2.cs | 170 ++++++++++++++++++++++++++++++++---- Allow2/Allow2Ban.cs | 2 +- Allow2/Allow2CheckResult.cs | 33 ++++++- Allow2/Allow2Day.cs | 19 +++- Allow2/EnumeratedTypes.cs | 1 + 5 files changed, 203 insertions(+), 22 deletions(-) diff --git a/Allow2/Allow2.cs b/Allow2/Allow2.cs index 70682f2..632eb86 100644 --- a/Allow2/Allow2.cs +++ b/Allow2/Allow2.cs @@ -36,6 +36,12 @@ public static class Allow2 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 string pollUuid = null; public static int childId; // ie: 34 @@ -179,10 +185,19 @@ static IEnumerator CheckForBrokenPairing() } /* - * + * Pairing * */ - public static IEnumerator Pair(string user, // ie: "fred@gmail.com", + public static void Pair(MonoBehaviour behavior, + string user, // ie: "fred@gmail.com", + string pass, // ie: "my super secret password", + string deviceName, // ie: "Fred's iPhone" + resultClosure callback ) + { + behavior.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 @@ -251,8 +266,11 @@ resultClosure callback } } - public static IEnumerator GetQR(string name, - imageClosure callback) + public static void GetQR(MonoBehaviour behavior, string name, imageClosure callback) { + behavior.StartCoroutine(_GetQR(name, callback)); + } + + static IEnumerator _GetQR(string name, imageClosure callback) { string qrURL = ApiUrl + "/genqr/" + WWW.EscapeURL(_deviceToken) + "/" + @@ -292,11 +310,21 @@ private static Dictionary ParseChildren(JSONNode json) return children; } - public static IEnumerator Check(int childId, // childId == 0 ? Get Updated Child List and confirm Pairing - int[] activities, - resultClosure callback, - bool log = false - ) + 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(childId, activities, callback, log, null)); + } + + 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) { @@ -355,6 +383,11 @@ public static IEnumerator Check(int childId, // childId == 0 ? Get Updated Ch 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); @@ -403,8 +436,8 @@ public static IEnumerator Check(int childId, // childId == 0 ? Get Updated Ch var _dayTypes = json["allDayTypes"]; response.Add("allDayTypes", _dayTypes); var oldChildIds = _children.Keys; - var children = json["allDayTypes"]; - _children = Children; + var children = json["children"]; + _children = ParseChildren(children); response.Add("children", children); if (oldChildIds != _children.Keys) @@ -418,18 +451,43 @@ public static IEnumerator Check(int childId, // childId == 0 ? Get Updated Ch } } - public static IEnumerator StartChecking(int childId, - int[] activities, - resultClosure callback, - bool log = false - ) + private static IEnumerator CheckLoop(string myUuid, + int childId, + int[] activities, + resultClosure callback, + bool log = false) { - return Check(childId, activities, callback, log); + 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 static IEnumerator StopChecking() + public static void StartChecking(MonoBehaviour behavior, + int childId, + int[] activities, + resultClosure callback, + bool log) { - return null; + // 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 = */ behavior.StartCoroutine(CheckLoop(checkerUuid, childId, activities, callback, log)); + } + } + + public static void StopChecking() + { + checkerUuid = null; // this will kill the running checker } public static IEnumerator Request( @@ -460,7 +518,81 @@ public static IEnumerator Request( // body["changeDayType"] = true //} + } + + public static void StartPairing(MonoBehaviour behavior, + string deviceName, // ie: "Fred's iPhone" + resultClosure callback) + { + //switch checker + pollUuid = System.Guid.NewGuid().ToString(); // this will abort the current poll and kill it. + behavior.StartCoroutine(PollLoop(pollUuid, deviceName, callback)); + } + + public static void StopPolling() + { + pollUuid = null; // this will kill the running checker + } + + private static IEnumerator PollLoop(string myUuid, string deviceName, resultClosure callback) + { + while (pollUuid == myUuid) + { + Debug.Log("poll uuid: " + myUuid); + yield return _Poll(myUuid, deviceName, delegate (string err, Allow2CheckResult result) { + if (IsPaired) + { + pollUuid = null; // stop the checker, we have been paired + } + callback(err, result); + }); + yield return new WaitForSeconds(3); + } + } + + static IEnumerator _Poll(string myUuid, string deviceName, resultClosure callback) + { + JSONNode body = new JSONObject(); + body.Add("uuid", uuid); + body.Add("deviceToken", _deviceToken); + body.Add("deviceName", deviceName); + 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; + } + + Debug.Log(www.downloadHandler.text); + var json = Allow2_SimpleJSON.JSON.Parse(www.downloadHandler.text); + + string status = json["status"]; + + if (status != "success") + { + callback(json["message"] ?? "Unknown Error", null); + yield break; + } + + pairToken = json["pairToken"]; + userId = json["userId"]; + childId = json["childId"]; + _children = childId > 0 ? new Dictionary() : ParseChildren(json["children"]); + + callback(null, null); + } } } } \ No newline at end of file diff --git a/Allow2/Allow2Ban.cs b/Allow2/Allow2Ban.cs index e91936e..2a5c7f4 100644 --- a/Allow2/Allow2Ban.cs +++ b/Allow2/Allow2Ban.cs @@ -22,7 +22,7 @@ public class Allow2Ban public int Duration { get; private set; } public bool Selected { get; private set; } - Allow2Ban(string name, JSONNode val) + public Allow2Ban(string name, JSONNode val) { Id = val["id"].AsInt; Title = name; diff --git a/Allow2/Allow2CheckResult.cs b/Allow2/Allow2CheckResult.cs index e1bd6df..66ab28b 100644 --- a/Allow2/Allow2CheckResult.cs +++ b/Allow2/Allow2CheckResult.cs @@ -171,7 +171,7 @@ public Allow2Ban[] CurrentBans { JSONArray items = activity["bans"]["bans"].AsArray; foreach (JSONNode item in items) { - //bans.Add(new Allow2Ban(name, item)); + bans.Add(new Allow2Ban(name, item)); } } else @@ -183,6 +183,37 @@ public Allow2Ban[] CurrentBans { return bans.ToArray(); } } + + public Allow2Day Today + { + get + { + if (this["dayTypes"] == null) { return null; } + return Allow2Day.DayOrNull(this["dayTypes"]["today"]); + } + } + + public Allow2Day Tomorrow + { + get + { + if (this["dayTypes"] == null) { return null; } + return Allow2Day.DayOrNull(this["dayTypes"]["tomorrow"]); + } + } + + 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/Allow2Day.cs b/Allow2/Allow2Day.cs index 3cdd8a9..e562243 100644 --- a/Allow2/Allow2Day.cs +++ b/Allow2/Allow2Day.cs @@ -9,6 +9,8 @@ // See LICENSE file in root directory // +using Allow2_SimpleJSON; + namespace Allow2 { public class Allow2Day // shortcut implementation for now @@ -16,10 +18,25 @@ namespace Allow2 public int Id { get; private set; } public string Name { get; private set; } - Allow2Day(int id, string name) + 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 index e932324..2f7c1e4 100644 --- a/Allow2/EnumeratedTypes.cs +++ b/Allow2/EnumeratedTypes.cs @@ -43,6 +43,7 @@ public static class Allow2Error public const string MissingChildId = "MissingChildId"; public const string NotAuthorised = "NotAuthorised"; public const string InvalidResponse = "InvalidResponse"; + public const string NoConnection = "NoConnection"; } } From c91b2966ead6443b02c53e1954dc9f15f89cfb66 Mon Sep 17 00:00:00 2001 From: Andrew Date: Tue, 14 May 2019 08:26:11 +1000 Subject: [PATCH 13/21] add examples add qrcode debounce upgrade project --- Allow2/Allow2.cs | 78 +++++++++++++++++++++--------- Allow2/EnumeratedTypes.cs | 2 +- Allow2/Examples/CheckButton.cs | 54 +++++++++++++++++++++ Allow2/Examples/DeviceNameInput.cs | 43 ++++++++++++++++ Allow2/Examples/PairButton.cs | 44 +++++++++++++++++ Allow2/Examples/SceneClass.cs | 51 +++++++++++++++++++ 6 files changed, 247 insertions(+), 25 deletions(-) create mode 100644 Allow2/Examples/CheckButton.cs create mode 100644 Allow2/Examples/DeviceNameInput.cs create mode 100644 Allow2/Examples/PairButton.cs create mode 100644 Allow2/Examples/SceneClass.cs diff --git a/Allow2/Allow2.cs b/Allow2/Allow2.cs index 632eb86..c7d5390 100644 --- a/Allow2/Allow2.cs +++ b/Allow2/Allow2.cs @@ -41,7 +41,9 @@ public static class Allow2 //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 string pollUuid = null; + static Coroutine 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 @@ -266,22 +268,34 @@ resultClosure callback } } + const int QRDebounceDelay = 500; + public static void GetQR(MonoBehaviour behavior, string name, imageClosure callback) { - behavior.StartCoroutine(_GetQR(name, callback)); + DateTime now = new DateTime(); + if ((qrCall != null) && (qrDebounce.CompareTo(now) > 0)) { + Debug.Log("debounce"); + Coroutine oldCall = qrCall; + qrCall = null; + behavior.StopCoroutine(oldCall); + } + qrDebounce = now.AddMilliseconds(QRDebounceDelay); + qrCall = behavior.StartCoroutine(_GetQR(name, callback)); } static IEnumerator _GetQR(string name, imageClosure callback) { + yield return new WaitForSeconds(QRDebounceDelay/1000); string qrURL = ApiUrl + "/genqr/" + - WWW.EscapeURL(_deviceToken) + "/" + - WWW.EscapeURL(uuid) + "/" + - WWW.EscapeURL(name); + UnityWebRequest.EscapeURL(_deviceToken) + "/" + + UnityWebRequest.EscapeURL(uuid) + "/" + + UnityWebRequest.EscapeURL(name); UnityWebRequest www = UnityWebRequestTexture.GetTexture(qrURL); yield return www.SendWebRequest(); if (www.isNetworkError || www.isHttpError) { Debug.Log(www.error); + Texture errorImage = Resources.Load("Allow2/QRError") as Texture2D; callback(www.error, null); yield break; } @@ -317,7 +331,7 @@ public static void Check(MonoBehaviour behaviour, bool log = false ) { - behaviour.StartCoroutine(_Check(childId, activities, callback, log, null)); + behaviour.StartCoroutine(_Check(null, childId, activities, callback, log)); } static IEnumerator _Check(string myUuid, @@ -507,42 +521,59 @@ public static IEnumerator Request( yield break; } - WWWForm form = new WWWForm(); - form.AddField("userId", userId); - form.AddField("pairToken", pairToken); - form.AddField("deviceToken", _deviceToken); - form.AddField("childId", childId); + 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; + } + + } } - public static void StartPairing(MonoBehaviour behavior, - string deviceName, // ie: "Fred's iPhone" - resultClosure callback) + public static void StartPairing(MonoBehaviour behavior, resultClosure callback) { //switch checker - pollUuid = System.Guid.NewGuid().ToString(); // this will abort the current poll and kill it. - behavior.StartCoroutine(PollLoop(pollUuid, deviceName, callback)); + pairingUuid = System.Guid.NewGuid().ToString(); // this will abort the current poll and kill it. + behavior.StartCoroutine(PairingLoop(pairingUuid, callback)); } - public static void StopPolling() + public static void StopPairing() { - pollUuid = null; // this will kill the running checker + pairingUuid = null; // this will kill the running checker } - private static IEnumerator PollLoop(string myUuid, string deviceName, resultClosure callback) + private static IEnumerator PairingLoop(string myUuid, resultClosure callback) { - while (pollUuid == myUuid) + while (pairingUuid == myUuid) { Debug.Log("poll uuid: " + myUuid); - yield return _Poll(myUuid, deviceName, delegate (string err, Allow2CheckResult result) { + yield return _PollPairing(myUuid, delegate (string err, Allow2CheckResult result) { if (IsPaired) { - pollUuid = null; // stop the checker, we have been paired + pairingUuid = null; // stop the checker, we have been paired } callback(err, result); }); @@ -550,12 +581,11 @@ private static IEnumerator PollLoop(string myUuid, string deviceName, resultClos } } - static IEnumerator _Poll(string myUuid, string deviceName, resultClosure callback) + static IEnumerator _PollPairing(string myUuid, resultClosure callback) { JSONNode body = new JSONObject(); body.Add("uuid", uuid); body.Add("deviceToken", _deviceToken); - body.Add("deviceName", deviceName); string bodyStr = body.ToString(); byte[] bytes = Encoding.UTF8.GetBytes(bodyStr); diff --git a/Allow2/EnumeratedTypes.cs b/Allow2/EnumeratedTypes.cs index 2f7c1e4..97742a3 100644 --- a/Allow2/EnumeratedTypes.cs +++ b/Allow2/EnumeratedTypes.cs @@ -23,7 +23,7 @@ public enum EnvType { /// /// Activities: These are the current activities for Allow2. /// - public enum Activity { + public enum Activity: int { Internet = 1, Computer = 2, Gaming = 3, diff --git a/Allow2/Examples/CheckButton.cs b/Allow2/Examples/CheckButton.cs new file mode 100644 index 0000000..857405e --- /dev/null +++ b/Allow2/Examples/CheckButton.cs @@ -0,0 +1,54 @@ +// +// 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 + }; + + 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); + } + + } +} diff --git a/Allow2/Examples/DeviceNameInput.cs b/Allow2/Examples/DeviceNameInput.cs new file mode 100644 index 0000000..8f65b1a --- /dev/null +++ b/Allow2/Examples/DeviceNameInput.cs @@ -0,0 +1,43 @@ +// +// 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; + + void Awake() + { + inputField.text = SystemInfo.deviceName; + } + + public void InputValueChanged(string input) + { + Allow2.GetQR(this, input, delegate (string err, Texture qrCode) + { + Debug.Log("qrcode error: " + (err ?? "No Error") + " : " + (qrCode ? "yes" : "no")); + GameObject qrImage = GameObject.Find("qrcode"); + Debug.Log("qrImage error: " + (qrImage ? "yes" : "no")); + qrImage.GetComponent().texture = qrCode; + }); + } + } +} diff --git a/Allow2/Examples/PairButton.cs b/Allow2/Examples/PairButton.cs new file mode 100644 index 0000000..239c55c --- /dev/null +++ b/Allow2/Examples/PairButton.cs @@ -0,0 +1,44 @@ +// +// 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 void Pair() + { + Debug.Log("Start Pairing"); + Allow2.Pair( + this, + "andrew@imagineit.com.au", + "2}LJA84$3y88H]o", + "Unity Test", + 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/SceneClass.cs b/Allow2/Examples/SceneClass.cs new file mode 100644 index 0000000..cf18918 --- /dev/null +++ b/Allow2/Examples/SceneClass.cs @@ -0,0 +1,51 @@ +// +// 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 + { + 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); + + // 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, Texture qrCode) + { + Debug.Log("qrcode error: " + (err ?? "No Error") + " : " + (qrCode ? "yes" : "no")); + GameObject qrImage = GameObject.Find("qrcode"); + Debug.Log("qrImage error: " + (qrImage ? "yes" : "no")); + qrImage.GetComponent().texture = qrCode; + }); + } + } +} From e84ca41b0fbaf3123fee2a2712a666fde6d1ac0b Mon Sep 17 00:00:00 2001 From: Andrew Date: Tue, 14 May 2019 17:28:06 +1000 Subject: [PATCH 14/21] clean up examples --- Allow2/Allow2.cs | 1 + Allow2/Examples/CheckButton.cs | 28 ++++++++++++++++++++++++++++ Allow2/Examples/DeviceNameInput.cs | 3 +-- Allow2/Examples/SceneClass.cs | 5 +++-- 4 files changed, 33 insertions(+), 4 deletions(-) diff --git a/Allow2/Allow2.cs b/Allow2/Allow2.cs index c7d5390..47bb83b 100644 --- a/Allow2/Allow2.cs +++ b/Allow2/Allow2.cs @@ -272,6 +272,7 @@ resultClosure callback public static void GetQR(MonoBehaviour behavior, string name, imageClosure callback) { DateTime now = new DateTime(); + Debug.Log(qrDebounce.CompareTo(now)); if ((qrCall != null) && (qrDebounce.CompareTo(now) > 0)) { Debug.Log("debounce"); Coroutine oldCall = qrCall; diff --git a/Allow2/Examples/CheckButton.cs b/Allow2/Examples/CheckButton.cs index 857405e..5f6de64 100644 --- a/Allow2/Examples/CheckButton.cs +++ b/Allow2/Examples/CheckButton.cs @@ -50,5 +50,33 @@ public void Check() true); } + 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); + } + + public void StopChecking() + { + Debug.Log("Stop Checking"); + Allow2.StopChecking(); + } } } diff --git a/Allow2/Examples/DeviceNameInput.cs b/Allow2/Examples/DeviceNameInput.cs index 8f65b1a..fe6bee4 100644 --- a/Allow2/Examples/DeviceNameInput.cs +++ b/Allow2/Examples/DeviceNameInput.cs @@ -23,6 +23,7 @@ public class DeviceNameInput : MonoBehaviour { public InputField inputField; + public Image qrImage; void Awake() { @@ -34,8 +35,6 @@ public void InputValueChanged(string input) Allow2.GetQR(this, input, delegate (string err, Texture qrCode) { Debug.Log("qrcode error: " + (err ?? "No Error") + " : " + (qrCode ? "yes" : "no")); - GameObject qrImage = GameObject.Find("qrcode"); - Debug.Log("qrImage error: " + (qrImage ? "yes" : "no")); qrImage.GetComponent().texture = qrCode; }); } diff --git a/Allow2/Examples/SceneClass.cs b/Allow2/Examples/SceneClass.cs index cf18918..5bfd03a 100644 --- a/Allow2/Examples/SceneClass.cs +++ b/Allow2/Examples/SceneClass.cs @@ -21,6 +21,9 @@ namespace Allow2.Allow2Examples public class SceneClass : MonoBehaviour { + + public Image qrImage; + void Awake() { // staging is only really for Allow2 internal development, omit this line in your code @@ -42,8 +45,6 @@ void Awake() Allow2.GetQR(this, SystemInfo.deviceName, delegate (string err, Texture qrCode) { Debug.Log("qrcode error: " + (err ?? "No Error") + " : " + (qrCode ? "yes" : "no")); - GameObject qrImage = GameObject.Find("qrcode"); - Debug.Log("qrImage error: " + (qrImage ? "yes" : "no")); qrImage.GetComponent().texture = qrCode; }); } From e6e9e3e940fd810a5a1113b13845503cd5e73c6f Mon Sep 17 00:00:00 2001 From: Andrew Date: Wed, 15 May 2019 08:40:03 +1000 Subject: [PATCH 15/21] Add RequestButton example Fix debounce timer Adjust QR Code textures --- Allow2/Allow2.cs | 182 ++++++++++++++++++++++++----- Allow2/Examples/DeviceNameInput.cs | 4 +- Allow2/Examples/RequestButton.cs | 46 ++++++++ Allow2/Examples/SceneClass.cs | 3 +- 4 files changed, 204 insertions(+), 31 deletions(-) create mode 100644 Allow2/Examples/RequestButton.cs diff --git a/Allow2/Allow2.cs b/Allow2/Allow2.cs index 47bb83b..64630bd 100644 --- a/Allow2/Allow2.cs +++ b/Allow2/Allow2.cs @@ -41,7 +41,7 @@ public static class Allow2 //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 Coroutine qrCall = null; + static IEnumerator qrCall = null; static DateTime qrDebounce = new DateTime(1970, 1, 1, 0, 0, 0, 0, System.DateTimeKind.Utc); static string pairingUuid = null; @@ -112,6 +112,13 @@ public static string 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 @@ -140,8 +147,15 @@ private static void Persist() // 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); - public delegate void imageClosure(string err, Texture qrCode); + + /// + /// An image closure provides the image returned by the Allow2 platform. + /// + public delegate void imageClosure(string err, Texture2D image); static Allow2() { @@ -186,17 +200,21 @@ static IEnumerator CheckForBrokenPairing() } } - /* - * Pairing - * - */ - public static void Pair(MonoBehaviour behavior, + /// + /// 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 ) + resultClosure callback) { - behavior.StartCoroutine(_Pair(user, pass, deviceName, callback)); + behaviour.StartCoroutine(_Pair(user, pass, deviceName, callback)); } static IEnumerator _Pair(string user, // ie: "fred@gmail.com", @@ -270,37 +288,46 @@ resultClosure callback const int QRDebounceDelay = 500; - public static void GetQR(MonoBehaviour behavior, string name, imageClosure callback) { - DateTime now = new DateTime(); + /// + /// 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"); - Coroutine oldCall = qrCall; + Debug.Log("debounce " + qrDebounce.ToShortTimeString() + " < " + now.ToShortTimeString()); + IEnumerator oldCall = qrCall; qrCall = null; - behavior.StopCoroutine(oldCall); + behaviour.StopCoroutine(oldCall); } qrDebounce = now.AddMilliseconds(QRDebounceDelay); - qrCall = behavior.StartCoroutine(_GetQR(name, callback)); + qrCall = _GetQR(deviceName, callback); + behaviour.StartCoroutine(qrCall); } - static IEnumerator _GetQR(string name, imageClosure callback) + 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(name); + UnityWebRequest.EscapeURL(deviceName); UnityWebRequest www = UnityWebRequestTexture.GetTexture(qrURL); yield return www.SendWebRequest(); if (www.isNetworkError || www.isHttpError) { - Debug.Log(www.error); + Debug.Log("QR LOAD ERROR: " + www.error); Texture errorImage = Resources.Load("Allow2/QRError") as Texture2D; callback(www.error, null); yield break; } - Texture qrCode = ((DownloadHandlerTexture)www.downloadHandler).texture; + Texture2D qrCode = DownloadHandlerTexture.GetContent(www); callback(null, qrCode); } @@ -325,6 +352,17 @@ private static Dictionary ParseChildren(JSONNode json) 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, @@ -335,6 +373,26 @@ public static void Check(MonoBehaviour behaviour, 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, @@ -484,7 +542,21 @@ private static IEnumerator CheckLoop(string myUuid, } } - public static void StartChecking(MonoBehaviour behavior, + /// + /// 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, @@ -496,20 +568,62 @@ public static void StartChecking(MonoBehaviour behavior, if (changed || (checkerUuid == null)) { //switch checker checkerUuid = System.Guid.NewGuid().ToString(); // this will abort the current checker and kill it. - /*checker = */ behavior.StartCoroutine(CheckLoop(checkerUuid, childId, activities, callback, log)); + /*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 } - public static IEnumerator Request( - int dayTypeId, - int[] lift, - string message, - resultClosure 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. + /// 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) { @@ -554,13 +668,25 @@ public static IEnumerator Request( } } - public static void StartPairing(MonoBehaviour behavior, resultClosure callback) + /// + /// 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. - behavior.StartCoroutine(PairingLoop(pairingUuid, callback)); + 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 diff --git a/Allow2/Examples/DeviceNameInput.cs b/Allow2/Examples/DeviceNameInput.cs index fe6bee4..b0f2480 100644 --- a/Allow2/Examples/DeviceNameInput.cs +++ b/Allow2/Examples/DeviceNameInput.cs @@ -32,9 +32,9 @@ void Awake() public void InputValueChanged(string input) { - Allow2.GetQR(this, input, delegate (string err, Texture qrCode) + Allow2.GetQR(this, input, delegate (string err, Texture2D qrCode) { - Debug.Log("qrcode error: " + (err ?? "No Error") + " : " + (qrCode ? "yes" : "no")); + Debug.Log("qrcode error: " + (err ?? "No Error") + " : " + (qrCode != null ? qrCode.dimension.ToString() : "no")); qrImage.GetComponent().texture = qrCode; }); } diff --git a/Allow2/Examples/RequestButton.cs b/Allow2/Examples/RequestButton.cs new file mode 100644 index 0000000..1ddb655 --- /dev/null +++ b/Allow2/Examples/RequestButton.cs @@ -0,0 +1,46 @@ +// +// 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 = {}; + + 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 index 5bfd03a..96e9e5c 100644 --- a/Allow2/Examples/SceneClass.cs +++ b/Allow2/Examples/SceneClass.cs @@ -42,9 +42,10 @@ void Awake() Debug.Log("Children: " + Allow2.Children); // 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, Texture qrCode) + 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; }); } From 1f720cdd4158f3f797b40578063f80aa0ee9edf0 Mon Sep 17 00:00:00 2001 From: Andrew Date: Wed, 15 May 2019 17:24:54 +1000 Subject: [PATCH 16/21] prepare for publication --- Allow2/Allow2CheckResult.cs | 40 +++++++++++++++++++++++++++++ Allow2/Examples/CheckButton.cs | 9 +++++++ Allow2/Examples/DeviceNameInput.cs | 12 +++++++-- Allow2/Examples/PairButton.cs | 14 +++++++--- Allow2/Examples/RequestButton.cs | 3 +++ Allow2/Examples/SceneClass.cs | 27 ++++++++++++++----- Images/Allow2Logo500.png | Bin 0 -> 64326 bytes Images/Allow2Logo_200x258.png | Bin 0 -> 13961 bytes Images/Allow2Logo_860x389.png | Bin 0 -> 49016 bytes Images/Allow2_200x124.png | Bin 0 -> 7363 bytes Images/Allow2_516x389.png | Bin 0 -> 45938 bytes package.sh | 0 12 files changed, 94 insertions(+), 11 deletions(-) create mode 100644 Images/Allow2Logo500.png create mode 100644 Images/Allow2Logo_200x258.png create mode 100644 Images/Allow2Logo_860x389.png create mode 100644 Images/Allow2_200x124.png create mode 100644 Images/Allow2_516x389.png mode change 100644 => 100755 package.sh diff --git a/Allow2/Allow2CheckResult.cs b/Allow2/Allow2CheckResult.cs index 66ab28b..b1c3834 100644 --- a/Allow2/Allow2CheckResult.cs +++ b/Allow2/Allow2CheckResult.cs @@ -82,12 +82,20 @@ namespace Allow2 //{ //} + /// + /// Convenience method. + /// + /// The activities. public JSONNode Activities { get { return this["activities"]; } } + /// + /// Convenience method. + /// + /// The subscription. public JSONNode Subscription { get @@ -96,6 +104,10 @@ public JSONNode 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)) ? @@ -104,6 +116,10 @@ public DateTime Expires { } } + /// + /// 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) { @@ -123,12 +139,20 @@ public string NeedSubscription { } } + /// + /// 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(); @@ -158,6 +182,10 @@ public string Explanation { } } + /// + /// A list of the current bans in place for this child. + /// + /// The current bans. public Allow2Ban[] CurrentBans { get { List bans = new List(); @@ -184,6 +212,10 @@ public Allow2Ban[] CurrentBans { } } + /// + /// The type of day it is today. + /// + /// The day type. public Allow2Day Today { get @@ -193,6 +225,10 @@ public Allow2Day Today } } + /// + /// The type of day it will be tomorrow. + /// + /// The day type. public Allow2Day Tomorrow { get @@ -202,6 +238,10 @@ public Allow2Day 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 diff --git a/Allow2/Examples/CheckButton.cs b/Allow2/Examples/CheckButton.cs index 5f6de64..e1da1d7 100644 --- a/Allow2/Examples/CheckButton.cs +++ b/Allow2/Examples/CheckButton.cs @@ -27,6 +27,9 @@ public class CheckButton : MonoBehaviour (int)Activity.Computer }; + /// + /// Check once and log activity for the given child and activities. + /// public void Check() { Debug.Log("Check"); @@ -50,6 +53,9 @@ public void Check() true); } + /// + /// Start a continuous check (and logging usage) for the given child and activities. + /// public void StartChecking() { Debug.Log("Start Checking"); @@ -73,6 +79,9 @@ public void StartChecking() true); } + /// + /// Stop checking and logging. + /// public void StopChecking() { Debug.Log("Stop Checking"); diff --git a/Allow2/Examples/DeviceNameInput.cs b/Allow2/Examples/DeviceNameInput.cs index b0f2480..95500bc 100644 --- a/Allow2/Examples/DeviceNameInput.cs +++ b/Allow2/Examples/DeviceNameInput.cs @@ -23,18 +23,26 @@ public class DeviceNameInput : MonoBehaviour { public InputField inputField; - public Image qrImage; + 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("qrcode error: " + (err ?? "No Error") + " : " + (qrCode != null ? qrCode.dimension.ToString() : "no")); + 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 index 239c55c..49f8771 100644 --- a/Allow2/Examples/PairButton.cs +++ b/Allow2/Examples/PairButton.cs @@ -21,14 +21,22 @@ namespace Allow2.Allow2Examples 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, - "andrew@imagineit.com.au", - "2}LJA84$3y88H]o", - "Unity Test", + UsernameField.text, + PasswordField.text, + DeviceNameField.text, delegate (string err, Allow2CheckResult result) { Debug.Log("Stop Pairing"); diff --git a/Allow2/Examples/RequestButton.cs b/Allow2/Examples/RequestButton.cs index 1ddb655..7f3bd7a 100644 --- a/Allow2/Examples/RequestButton.cs +++ b/Allow2/Examples/RequestButton.cs @@ -24,6 +24,9 @@ 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"); diff --git a/Allow2/Examples/SceneClass.cs b/Allow2/Examples/SceneClass.cs index 96e9e5c..a247859 100644 --- a/Allow2/Examples/SceneClass.cs +++ b/Allow2/Examples/SceneClass.cs @@ -22,7 +22,7 @@ namespace Allow2.Allow2Examples public class SceneClass : MonoBehaviour { - public Image qrImage; + public RawImage qrImage; void Awake() { @@ -41,12 +41,27 @@ void Awake() 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; + //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/Images/Allow2Logo500.png b/Images/Allow2Logo500.png new file mode 100644 index 0000000000000000000000000000000000000000..5e947d318e8665a4fe8623ee7caf3511bbacfb3a GIT binary patch literal 64326 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 HYaa4H7w$DY literal 0 HcmV?d00001 diff --git a/Images/Allow2Logo_200x258.png b/Images/Allow2Logo_200x258.png new file mode 100644 index 0000000000000000000000000000000000000000..e38967cfc71d5fbf32cf0c598d9addc66e24312e GIT binary patch literal 13961 zcmeHu({m*buykzO-q_q=W81c^jlCzfHcn1#J2^2owr$(a_p9$8xOLy}Q_s`XR6k60 z_4Gt4D@q|F5Fmhofg#ICi>v;JBVb_Q5O6U6RUP{2FaH7fZ&fK#u$n2NQ!p@YF&S|Y zbuaKse^@Vr#gr?)L+(dYnbaSvu1@KuF=FsT`B2huP=?YFRLN-6@<|y73J4iI$r;l~ zei=O8$?VCa=1KA+A>^SLaNJBV`mEe7^|7&tZMziXXNR(_+iF`qac;4UmFI@1Bf5lf z*Rx&67hTgm&w)?6cC}sHiBuxOXlPJS;Qt5zTMB$A^B%rLzkptF*?f-8hIw?Q^mnK_Rp8jc3A#rtICodF&~2c`3X{L(jt|*+$!ugYMhR06p=lnVnr`c>rvmN$JEutX3dg-1KHU zg?_oyzppcN&taW?GrxwSqXFde0-Ws!{1Gotj)bTN!f`BNk?0wgWae+d#Acb*3w}x_ ztHn}hP$!yHDE%-!I%a-h45dMXGBaER-{Lnj#HDivgW^N+Gn1{d7*LCx=|e&cYhN4cpjcT4#p+G6WH%7k#vr(ZByw$!z1_? ziL{%FWxXhgqH{S+HU@|q?yKJ9Wq}_rfPi@sP0Ehpyua6tqlJaRqGZiX zb4g)2v7@a+CHc(Uroib_U@CIBQJI(5)MT^W^awL@;L7L@bY^BzkPze?>tHh&H7;a; zs+D5lz49hKk<^^>-^fgI|6y(tDpAJLVUo3Q{N=8H5Of2c(fIS}^wF=fuYiAs)lrP&)rPFSaYr+G-=R4^FwuZ|@E&d)XK1@2eOFgf& ziU1!FqFG6=0UU-DlSkpKRbuj}r!1nm^MMTCL7i=Ke}Ww~i>SN>R%XS-S+`u0MlnNH zv-hjC)-TE9hi zp6l^ZKNP8b?%Kd#q3~O!9$7A|V#rEh0?PTOS$AfR43@iRJss>{Jd_m$<-N+ zi6>&5DA(kvOl6}&ctsL*zKSH%uTnYDb4~e{-g! z#Ud_f|7rRdUhWzK6n(1Xvl1 zRA}8;^V@5!9a?MsuEghU5?G%4R-x75z1g1pYR$$rE@Xf z@3V?_nx055W?7bzL+fexk4&`A%7umx%xlJ?{FA9r3a;I2%s1KbgaLx|Hs+nbQLEbA z3H_se^;hugsS5g>1g*SIF{HqO7;DjQuo5}^p&|$|Ccy8KE0EM~>*+7PU>jaeuH|1( zo$tHd{{DLgfy}$UBy`<`Jc2*6i{JMwbiP}<0;=%m!FTB)*_C`(~ ztUqvN%2Y%pae}p%k3ZhS{ddn8NM$`Pe3ZEe-MHPAm}Xf^0rDA!bWx9ju=w6XW~*4S z6=NokY6?HG|9xL=mM&bcGHl8uO+3UI27zvsDAD!udMo=OJ)@gw+>n5jyRF9?^cA3I z%7^mAzqdQVxpgK`XG=z^U5-?|kN&9fWb&P1nh0VWrbFJ+(WO^iZ(%Hw!SCOa^}(Ew zM-C)R;gsoYf4sbJkYcFqxkAG3Vhw(uRQfNw`t{@#LDgfA$5{NXL%6L;SfwP<+#6PY_O*HF<#g`9^9jA*2<*zMTA*OyG8y-WCRvPncU0y+_N! z!hd*kc|JF`h*O$sa>GnVOWJb}9cnOdiEN@HBr;Eg2wSH~HsndjnC86nFP3OgKO8TG zYY_<)>QNtJQIqtK+;CS}gj1s`bDWHra43sjQB8X6 zsB%?jELy#};dz)O@lh6oO%G#oC%-=p_(-;uQ~_YN5}Qt$>G*QVdM3HC;lrM8=p!c_ zB`S-Xr1{g&-kV@X^qHlr{pL+6;1rFKxEf8!gD%3@+OQ@BPgxTuqk8HL=~`foUVK(h zytNP$#D~4Xmz_F;CEH^G3~a+f^11*j2mdq+JBMAkH*pwPbjNAB8HtsLjt>FQIe@px z>!(9D=U*ShS?nECq3rcAH>PtgvhdkC8JP(v2^X~am$!>p&lJ4KsnE`fh+6({m7>rMIbsFZpQ$CC#V$=KU5 zWs}JDzrvan0^ccAw%yDtXUx_$tYXhk#aZB}Ch^&cDjq&%8(oA+iS95CV|@kNuhLOLBHw+xx-~K3__wwFD3ZqEQjyE z$ql5r1eg1Q$(gROW9MKeKj!@XOq&#{Qq&M4E9AaEBZ+P?Bnzpsg<>|0X2%2SaLasC zsHpr+)62_n;*WC^;$SywZeAbt_{vEA1G%dv8Ayh- z$Ps>`<^2BTlzN|~$3bhGUo~Gi0s+F*tS%O_p-=9-uKU3-nzvDVV2UOg_yyl_SkJIFgpP2hLgwwfOg3%@1EWk6I3Vi0bE+{JG~2Yp@2Gtu^aka zAmTY0M{DLg%TzG5NUK*zipGJJYx4r%bh)L_<<=H~=fAG2uvN$)gFDObcZCYC{Ttxt z;qYKyG$u`r;KfX%R*uP1Y@Ll!dzI%SJaDeM#-Xv~e1{Eq5I!&PwZ;^$4)+dgCwY_Z zVV8>c4f7u>g!4;xWnI-&k(J2p_P9|o7a6_N)tHZGwV5w3^k+#F&hHp8fO)Ev)E ziQpOle$X1Jix-kb(U76PaHI)?zU`47M7oHl98`bV%RR6FI-9SYqWnyTADg8R!7xmm zmdd5G`C)a(2|w*&MG;DYR*Ug>qwcfGOC#D;T|^ zQ+FE$pW>JwuCOdX-|w?{w*)S(Ih#EaSCv32RSvSm=3raFrmBWtt;dD*rN8DEEnOR^GjxyCf}A=lZAJ+;r-s4@%PZ0J z!FwAyCm$IhChwe?+Yo9y*#p!l`s@n4okT^SRD4x%{2qEsukhhL)NY*+Pp#;mNVz(e3b5abr5Ro83_shwn*;DI>^Kjd{PD>~h6|)C zNBo0rl#jL){qaM*x&)@J;Ks4u<_CPkKG*H*Dgl5*d8(QrTcv0lj<)4UZ~UHIeno}m z?<60zUW04HNIb5Hqx<8kPXfsrKC3+C_9_?OB-Acf&V>x}kU*2aP&4H_rWZ3#tunu7 zQR5cgRoRlVtw|+U);H*6uw>nm)WCB9kcqrJ{j25k>CXLp3AyY7<;O8_neh|pQc}42 z+W0VMvWC{i6Hi==cI@~Q0=va}v(1#wV7~DKcns=s=7$0ja1#|3l2ipdx4*6)NWB#L z8J*apE7{pjvMVs;o+nxckQwJ(R-IJY6>dRZlcS_2Qo>;2^e(^Y5G-KZaS3M`6$+&8ZZ=%D_#go~HKgD23J^Ty77s>PxWW5|*8}A`?)NSNl>b{rKJjl@#cp7bA-5LgT z3hOz>_59eb<pMXe)o_}hvV(-8A|Nl~JF$qxj(<0gc~!;2&&6FR+Gx824Q?)Bvu zl^#TvOSGe3jOWj|L6KI9i$B>E^NH&F-0+YR6qxT^o<+V*ioZKZ7VRDmR3dy|_MUlT z7xtY88|A5`^M4RLBwGKf()~D+vodATi4)Y;mHyJJEn%T_YN5|b5%4ko_0(Z2zpQG4 z@^mDn>|5n=04*EZg(!~~hQQ;NIyjf9Eq*(|@7Ug=G0Vksff1B(b8NtWbVDS%NCE0Lo{{Ch@{(?+g4l2srqJWkvFsrkjs@~C83()Oh;!oTU#HFrNFuF`3?uAFDAm& znvqsn+mDKlT7->`Xc$@{o)UJysL;2vPAn+@g`AsM*z$7~^P)oLx|(3NblIt$9lfS&sE{yjgsr00yh3r)N%zW+h?93b9_>T(g?kaD>urh@@mkh^dtqAQ zTDPZ;@zDh+VDA76IsYR#4;WfTc}{^?aXRMfSQP)HL8CD~OVs}FwX~KY?1gyjH${yw zYWpn$$mUw@$9I7Bqy#B{_tlv1p6}O3yVtQV>2&|dcwCB&4Xk>!1jAB3fE=M&_&ey& z9#*$U=(D5Kg1$5wVb&1Ica>?Y@6R3GH9o_$(OGiszT=zs4K^%(O&c0MQvMhDsX!wC z5)hgDs&Vd^Y^N6GQtQl$lrF$h*Uat~iX<6|t>7W6_NOma`MTdT`*@7u*c+Z+k7Sk? zlu5L4%k*5A?D%m^9OU${?)Dog$K6!;9R&J6*>vWw-v-_b`&>M++J?Rr=$Ij4W_!>M z8)^Dd32N)tEQ-{+HM8oFPOlGL%~OExcZEq%oCOvi30q*59R-5T6+>xW^5 z4Bi^g>=Hc9juppRTEJ6X9!gAP8n?<2s&aJ0dsl;JTE2BX)c`dQ6HX5dB{uueW zkQ-K6q61ZaKQCY+oRa4g?X=tF4BN})sIUHkIptFg2poZeZH0 z)PQ>6u#<%5+C0}@y+tGs)DX3Rtc>FeOTEHl>Jvy~T}+`?$U%H73so-7Y)Ub0W#ILO zHZc}0uhT({JO;Y+j?LjUR}!g3pZgX-25U3+$3Y-biELgbx24w5&p&qz?na87vV+oB7M6$SLr8J1@usJ)qjX4##yz8Ur0RPfbo>}#A zPt{Z48z)pStf7?dkI*1j|GgZrU+CSiyA`e2$Fa&7?aMhXrg_nqz2_fKV><!cy6A4Kufg+MD9 z!OHLv>M|ZH&{6SXvU#YQC-o*-^rHP43Yl7RvNAj<=+Wl^C~x?piOeYu%{9?lt7{jM z%jLJ-`ypL0H*#@3OuETX_iO9PRIHf3=Gtu5z5od(^nox@g7dPSM_O@qx z23!m%LB14@W%`OI8V_s4hKBR7|BQ-@e?#k}b``%2G?L?c9tktoF(-h>(O0a7loGxn zG-&geINOcc@Sa_1%Ct2xg?21c-SSCqr`7LEI)27EJs_25UX-B@N$CZIg z&vz@{o(q&KdL*Vg?z|`M6^H*S+3D!(N>H?&+jPO)#nR4O%bCBBKF>DI9+d161y+On z=jz&H?_mh8!SNNw9qBraa zCw-4@Tq11%(r=v4eZ;KO+$`NoYle-ncpzsPvdZ$mjsX{=+%SvY3m3whIU|R+AD_I- ztyX+*OYwK3+^n7eRz&A|PmuUDBK59!ttV7G3Efm12J$i{R2lyE7n}d_^;dBB>lpXi zGD$iC0BNi&A(*Y3z#hl4AuLF6WES`v~QX9qh9wGSNr$v)OCfP&w6wR{Sl74 ziyVeie<;b0sq@clcHgDwnG7aIZ7-U4EGap zlYGcXegC{ep+mQ#1N3VwI&7#%8>c@Pu^_M2+4pR7#e1$LdtzVS1Dq%znQFVJ3rxLOPaOEf7K=>R)HB zIj~2Gf<}JI2>})iso-YnSlJz_YqTosyTewG+=D2h82q;zGY+Bo8%VI$@aFDQTLMfX zlM!5UIW~Rg`~eWj+P=VF<+cy9GFFL#WR}Om_YIO79Z?b>T>uAgHmj{?@>m;r09s5r z`o$#?rpJ@NOm9l7>ma z2lK?*RI=)Dita-dnreS2>Zvd+znLjm10HN0smFkyQm3(Lt#dB+Iy;rL`Q?W3m&gSo zUhuV)9*-cIFCt&4mBu{m-2lxl$#+VZH2}d)TuY7oF@}gLn5VJWh=JboT&e+V8NRHa zjt*N&!-@q}ha@Ijdr2O_yAsYXok4_9x~CTcAKbouFQ`+z_e`Yf!QY8wxtr52wYP96ODl_LrucAt|;p`$1sF#}3PAT0bMv50p2Gs+Bi*FX9>I+vU+slVI~%rU#*jhe&is|RP~ z_?O|6L@DAN!aRTVL~>J3se5SPPlk_!P^_e(nk3VwM**S;v9}*kA{cc#p#?1nY%jEK z!^CA%k+)TI!WK#`HM4aVEY(9mtVxcslTH8;e{L(OcXb;o(ecn1Zg?M8dMtbR2aPQR(sSh23sh+OWsVStO{S7_$dIz?_YKxIFp7d#EKcV>h&qB4`r2;_F%JDh zx@;5Hy_l&0b&5pImII1>8~RG?bxk)hm!+_yrV{};05`GNuDh*YFiN|t8ckI{bH{k~ zQKk5-HpNvl(ZPyV0%G@E^oD>pG+TDgj5;-(j|d_@O~35#ny<6-(yUVFDJC)|juNS0 zqjQGWcI%~5rY6;n+K+C!ZsDv9>PH?oQ$;kV1!4hJ~afF5m&2;o)KAEcT(a7_q$56u|(m zU%G$XGL*_ch^xQWOGX&4<6#)nyt6J5Whm)^y1{i5ghnp?jZ73?JCiw{s!X?`#(Wg( z;)P2;EAs|xuiaBe(yu2%WACuf%oj)Ix{fs99|b})-_hg({x_G=Z#M2c`n+QM%!$ce zVr$rLnE+x42r{^7ev=-zY5sp@x7Cnw1ydcHAbNNa7$=ndSjdMlF5EeJab}(VB_=~n z1}c1_w!Ru;q(U~iK0j%%NzJwB^Jip_Y3*-jD13-LVq$ZzUk`T|2)aqwdEP4@L*$W+ zmBbKUkopDvAyZL&;tYq24sKhCoBfG@q^~q?Y~0 zwjVYJa5sq5r}pp;zMdDQYpqZG;6(b(Ub1lPF;D(xED~em*2EjCH7e@96|s+30vWV6 zA#h-Pk}SZwrZ>$7o}Np_o?mSJ8|dxwN+yG?)Rv@}k3 zs1^E?7mLtx^Gj~Nxaw8I|MW;kyPSCl`i{@H&^#~<=gn`f$k@Meh)^}o2B`*JeYLr@ zu3+li552F|T-yodd zrSlL6b>`;n=10urBec$4cL8tS8_GEbMY?N!Xo*c>@zj0|A8-YKif7#V1jfWxx){Q9 zWsa`U05KTrZ5S_cCgWM^{!+j(gIU+2A&r)jYqc z`wZJ@mYNcS&Fr=o`Vx zk!xs1B6)h8fyDZ6&P~NfII(LQ@s`She)+12>%JYnth_U7#FZFf7$NIQv|%>yJ5xIY zTUcuLYC5LtYyV7Lh|yQNF0q9CUPkUY4Aq*m)BL-2)=R6kHwEmoHT)AxGDvVRsbayv zoA9qM`$5tq0wqjxwPCd^!K{D4jd-6$(^)sFASEqLZD*nU7S;Ar7qH;i`8uC? zblJnX@BT;if>tj9*3@~=b4+=gp{r(pHjGdt+*evtGmUeQ5Hz^Q9ZJCl9g<-|y>6A^ zGM!uQBX+sFc*sKUb5(1#>1EtlK+D+oDBGe*_&U=XqkS3P;Ag-V7_Q%H$gA)q)b0RU zlj7o1V7WRO8I}APa*2Ptk_BY1xV>x4wlIC$P@h4h917Tibs6UPs}om9845Ht3Tb$x zUY#(n6eCO~4KhpLB5=%l8?i5at%^-6dSBi|HwI=L8a>GJ`geU6T{G3W|07~}`%~+| zN&Eo_=lff}VyiHBf@XaC)22M^Y_`#D6Go)W#*H`b%W=8cc3qDFaqTSNxmL4N&`DPY zW@HxEax=svGG>$HBOo>kkrQpG;3+Ph2N+?H2Ip>QCB7c#S;ntOU%?YyHDb@ce*d8h zpdWjvIc)nCv8=^*XYdWD9kJKD=fqx)58fj+j^Ooid3pJ{%_`Z`7Dtx9Q)$$+$Ax>e z``IU_KhNii1g(B`@H3sUofYP$k3yT(e5cU?<{=%QC@C0wO#ebGJ?$Jnchf!mjuG>O zfaI6<2J*j$BGe_6-08uMw-*{M^dif9Bitcf;Meb=C}kJ&qV(GUO3$=h0*#fD2`Apv z6s20)1H0i4;Qn^Jq!#2(&e;%g5m2;v8w#dXvoZW=^c{W50QI&j0 z$^Ii5_&K8@hN>H6A`mt1g#@Y6Qmj#k%@OZPUY*g#ig5uEwtfCjTr#`Rnrvtd%wa*-1B-j0OU zLA&#r2_{%XfHyCF(o+682(5_b$;Bvnxdlmxp*M*d6~eyj3@NMW+RO3A%S@4TXOT1h z-@Nm@0#E2h5Ww#5gR`1IIXyR0MG`eKgcrVt>GZ=B%A${ll#W@dQmcD>zu1T`gI3MO zz;Ba_M3>kI?2253VtM$xIgPjvrqc~6u<;ZTl;jptdfUz4O?8;Q4bBbuft{DBrJl$l zshi*TR1vo*$&`tMLKL>`#qs7PB-Yy{+{w0Cosr+G%I@^jm3K4w!q3*NjN@FsIG`}H z4TQ`y1G`KDb5*UFnv7Gtq{{m{J7iHdC#|Y~-0Hd3@JECGf*P}&lZZ(dTbn7E$U0%ErGUr0bm`QHW$++jb4uez%?!> zX)Sc~TdBJ3gaW}R_r?VRC^7K~zE-@sV4~@69q+Tz$;HPZaB0o|B6}yhef2E-{Jz0i zBx@Q_CdTq&ubu;{D6O8_7*Kr*zbrW2!P&H1?d{phYDQafSM&|;*B={gqy6MjQbzm2 zW6=*17b~^m-w6_c@n?Jh#%>OMC*7rG6Vmua?)1*t!WgPwdiE4KjZi_so{iOZ@^kfS zz`^Nn&hHUY?#VKikRb#!m}m|8I54+(+%y)WuZne&c+$QUn|sQKK!k?V4nEV?)U=AJ ztwTkLK3o}0Fz^S)ILu+ojTW+5Jt@R>5(A-gbFtqZj68_^#RC=vz6ZubA7tW{EWch4 zlJ~oXD|i}ee!$UJd>)y+^%dB@+2Zs^SeR|!(Lo#Z5%>s&?6)r7%cvfICYIN3zv_N< z_xMiHt9PGZf*X(2oac~3F<&*Ec=?mPT|8BQkl#yM-s-P5V1F}XLKYbVyz>7pn_#`3 zRcKVAJY;PD49qyA-`9@~h<^6JRC_?>F;X-FswhHV6dpd8Phsf9+3wk!#L?`GO0@W7 zpt*T>NiLgH0w8ubPOr-q%_ZV%JsI5rsBn>D!xKTvba^n(S?KivR5>!g3(&x=Bn(44lruB{HjtCY_SO zHR(m9DUR5O@|?LH4z^+ozuyhkLI;Wvk;l%%eNZ3(@a%y?iwDs?4V6{5hnp6YeRV<^ zhzuz;{K-La($J#9AXWdLEkRVMg6|iNH5vEPkHx@T%s*u1Z_IQoC3-m11<`3t09{pS z-q-e%Rsy=q9IW_3IuZmmtSv;#x@07Kr3Cxa5S0nCm8Fi6KQUOS{U#=*w8-ziUU~ao z>UheojkG~Kqrf}5zp=&Le=fjMi{Dwby~)tMZu-qsZ>59D$06r#5C*_d;%LqpFJu>; z+0ny%QGh%lObctlFkH}FW}oXn6e?TPIXOLFk|XLXy*N7^w{T0n4sJxM5O(j`s-C@| z_@p?YbM%C0FhuagP;4j2?^t?OE>+6Wh&KqY*9-_D1%AxNV|wM2ClxdvbAJOAy0ar` z{4S{iEcK71)i0_}`%pW-R>Kwey1>yOFR!GswS_#A#+b)Sv+pfc1V@sMbj4rWSN|f< zd3CiVXF&Gap4JHt z4iR9QIA?o3Em48~6)Zvjlxu7MNEP@w)|AY0MQcMRPS#~;sfR>>$EW`2zB2p$xu|8d znTs#|k4B4J`pCtq0qYOry|G}o*D3_)rDWV@6qY4VXJ|&ttAHKR5Mt=H!c5Skf@4cu zq;`jkSHP913O*#T4fM8Bzm_d6!fcKFiiY4RTV&ekfH`tIZ_fTY@%DJWYU-uq91idpoGUb-(8msB+ zC7wf3JXv|L(F;&3R|G+>y?nE?&xLmMUfX#$=4*zyzmk&LP#t3sncP9e&Hr2c$TKte z;fT=JdYZxMzbz%p&1l*VUYmp6pT!SeooisLsMkFV!GRXM>84#|Yuk9K;q-Uud;JWb zEAyTxjEG&k9g_Xu#^pR7wEib}K-z$)*c2crr;?`xwXE<)NZRq_5@ z+oKu#6h(V(!iqLR&O!J2J#M(x<;dSWj9H{OY9+CSa#KM01pWI-^U2lN&;egdI+RKGH!Lzo;3FGU=eY*qZ1cIlr|IbK_SgpA5heC{S+jPq?~ zTc4xYhLX4hmW*6znF_l>XL0%nHAClMv7j$Fip71a6fXGg6Ubqin+42s^wr~w{tEWT zuq+m>nl6uaE>Fc21LcI^Y8;bGfEk%LYtA$S%c=_dV%|h@jXQhGzV?XTIPdzn$@4`f zOrZIEHy}056Suh;1`>YaUvD2;rbH_#cC^b2(TkGvK9AmecsYq(~TFH&PQCyN* z30e?laiF(hoGW2xG>cL93cU`fcOt{k(&G4dg`{VBX@wW#J|0ERD^?OiRFAz}J4a=* z%h4 zlWURswZgn*_Fsk})K_q#MLul4hC;x!?$`n2+0oS0cwyzzm3FwuM>K)$4=-iti>xey zpfSUi9+R9wTt<{%0!$LNN2CUq8l>7v4zwfr4ZWsusOw+}kP=$yss0Yxu}p;MBI;`) z44IHRTh2#v=5k6ME=k7iAMJIyTB&Ab#jz^wC8@AfF0872pQx4)Nbva!ShVw-5SGQz zc0k$%>e#85ui52ttyyZg41`&MM?Ml_Thqs&XJB9eHm(K@ryMsmEd@U1|AO-V#XD|5 zm^dy26;ZR<;-Cj9oz&oq3~bjYhl7Iv1d);bAN)@gV50x|Ms39geiab7MgR9(1|}n+ KC|)CK4ETQ>bC!w# literal 0 HcmV?d00001 diff --git a/Images/Allow2Logo_860x389.png b/Images/Allow2Logo_860x389.png new file mode 100644 index 0000000000000000000000000000000000000000..d7c36478a8dadae2c2a2d5dc86a482509ab48b84 GIT binary patch literal 49016 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%0Du=%`Kgkzqg88j0GFq>_(YWpM!Psl; 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℘vk+Yqcns#f+Bp!Ny_Ci0I2TghH#N?RyZK43VG`${}p(y6Sv&dNwI5@^q`c zC;MX*?KHUV53d$}?iIZ8sxIboM-SdgF-S3>?kejOK*OrjN5a;MpS;rH5xSfffs^t8 zC4B#VAWx-0(lan@I(1Zy!#uDr-dC?%{fM@~zNz+iETWB{@+qEAyJIhxxBubJZJPBp z2paBPo8aARf!mlplIL$|oL>bA2;916UATP`{dYxXU5OJ6DBBdkvZWip3)u3t5j2ND zVXL&Sr-1I2^`x(4`1MdW|dSqEzN$bntF6SDsVk>V5+eAUBo6;>RZ?T zF1&LAjYQDDvmJUq`$yGforwP{;C`831f%2FFxjA$4n;<`&+OXt4GJF_g(esNR3$7^ zSb`;eGw*qYrX#=6b(d;YAyhvW`ldNO);TyO+|u4v``R`x*mFJIIW^yB%!WxEj%m~5 zSb!?1$f$uESs}tT$=}^gdA&SURHt35te`5Z`o`O$+TXU2PwZ*QT8wj>;=N;-vdFWh zkiE*|I|T6Uw>z)}qBZYYoD^KznKpH^g45F~ADTL}N zDi{I+m7f9xtm%n(2E}lt8cctE&Ujwn2XQFX*Y*kw8JSF6wPFL~C8K-Euz`rz&iH-r zB|RNBEwVR&nV0TKwo)YM4zw6bLbfhs+~OurTioC45KYIge~#Bz`T!>_&tcVY8_`7B z=2%#Q*>q_|&f5HO^-aaw3Fx)_7ebmTIyVX#5;o#D1nEHf9z3J}cjX8WrS;QT<>Jli z`iq`vt@~N(57h@I2u25bBpRxLC=hc9y5F)OU`3r<#v;KuqL~L;ajJgryI~%sJ(hnk z44iUFm?YYUs3FQAqK66}Jw$C_CMD4P(4~$zBR%f{rIEJei=RFRU^k(fa_Unxk0i2(7a*=%q$BUv{Z{ z0%@D}g~Tp?07(#?vmFNqNqYo82TZt$QPS2~+^0 zr^`%zLdT~sAlB!YdOhpag<>^Bw=bxl|E7@Pj5H*)e<_5F>@sr=kA{3u`Wj|nFfh*h zUHe`L1BDEnh+xIX)nzCOJGB*m+my~(bP(q7Pns=8AXYst_^3a3acF;WcHvj=o_)7D z{j2}^rA%-XPK)EJZ?q_<92|&WPKO=?Wp;|U3;)jkVZzO4dv{OWtXo!!&LEqy%Vc^; zDzE!5y|&NpxPyqRn*__~iJ1=c ztfJ84wN6A8*PsugKDsR73)^CsM?#9G!!QE+D2=$Nx0;@~2p=SATZo{`tG|5s{OcB( zG}*3GkiiPm#j7@LWY^~vNkUNo$1Kq)k6%$>B2WUz5ZaH2F=|BRnX=B6kBWb(m@zUs znAz5?L;4}Cy<+qBl=^))T;Salp;Mz;UJ=>kwg^UtsMiWo>vO@c#}Gk}?rA!x#_37y?U|kl_>LO;7)hxa#IR{!SMS;Bwaw4NOI1CN?Lk&FVm`4vQg= zDZ|BOAp@%UCo#r|vDKdqO5vMqk1GD|=$=9Twn-5PaI#w}qBhTl587T-((5s)q89SR z8+G%$7==5vO<3MnUc<19Z?Et5;RoX>9X7d8JEG@4}VNb4J7@V;ql z!xmAyk+CP`m8O{9{MywvQz(0~+eYL5F}diWUa4G+yZWAd#Aftv;+RS6RYsJNf8Q>? zgZWngYh%riU@UqtXniIe+#v^uRnitPc+@~&?9@6sO4CcD?1dR~3Vi!$)b>f1#<@W# zEuD7KDFa>s&z`KI@x!oX9Om!J{TN(Yxhc5}Wpl>QJibC4Ysc41xKR{-9?RScx=NI} zK>W+3eP${7ve+S3M`AX>`lm3H6FMk!{8q>!*pvCGugu=WcY=sfY@f-%wU1s9>yFLc znezU3j)4oaQBoPlFnU3KcjQizr%JvU{_og+Nfk<5(jWch`%67pbqtbqce49VLxPNu zygZ}A_fXhr3`}B*EX4J+n>QDQ!aqHiI0D~jLJM!VfHs&tJW$vBynDpg(=ilSa>po( zm;5`*Iufwp6smCy&+D1}T6Jy0d2;Y!d%G@?1X9LGmCrXI$woCv>Ru-g#5BNmA8O5S zfk$;vSvW+_#=ks90-CPtJ!|#0>O^^4HO-iJyZ(0>QUyoHjB3@a!MrkMe2gW!_=p|4 zwa1i#z3a1yho1Q&1&%@h^2uTJX{(SB>_~_t0L_$FCjnyUdR0gOd#)ekyMK{1Nel7; zjmD><5|cs^l-}i7ZKvWx%sHmp&T6%_wv=lOWA@$ygL{vz6RsJioHhs^jr%fcDZ^*JLBgU&@*bt}dg%JoE|1PCPN`516s_e5(*6T)^%!q^Y6IB&Joy;~GgwP8 zpD30-s6Cd5v@1kO1TyL+_bD;PH8E%fO<7iTzG<~O7NH=cde8~0(&m?A^roq%nZDK1 z3*RlNoYwpC#;bS<)t2!2eDiv@m*e3BOrmq>zaP0Hp~0C&zX{5}f_ z6I2tGHH)H>qT`pAh2IlW&qVaXSs@7tU^4J~RXc5Qbb~AvxV=ruIw5w!7E^@j$Jsfp z0pngRGc@*qt;OXn4;^lUn#~K`t3O4<^n6%k%z!?bD17Y5gsS2yOj_2WUG%2d=J+le zRPh&4*;rldn&#aSfzigRY4nf1<*kq6%cS3XlI%7rSmfM!eneO>rioL= ztB=NIyJ?HY^Un!mz{yq7rLhcR<8Jg~C4tWU%#x9liF>O|z)(jxr5U(mf6 zbpYjEYsCz{o(O*{8UOUMIf18%g1IjZCZW(e3Uv$zk2IbsdmxpFL;7P1z8Qfvj>M`f z^5AFC`q;LABYQzNaW8q5)RgBL!y}Kye>&Nh1&x!t{mXUu3S;@&$OOPO#IHw&f_U$b zPIz_)QX-qV6^4m%*W*CBT}?B=Ooba2my%h!kJK-Hmie`XsR@qRKIh{P?z9=qa(++s z1ZuzphaH@-cVpEX;MAD7lY>pY{_5S0=YyfaJNd}rC)#fuF@ZP5mR zIGMa^NrTpNNRIodMH`T(O$aBgd0w`j2heW(z4|yTZ>Iqh!efkGPIV^We#bW36`qHI zJja<*yk1+AT&zgLbvg<)%yhAp-p}><-1(f2C)_&tezEIlhB_b;SMUtG!{egLum9?% z@M3aI8I3aQZxv?fR@u3Mrg=rW^57ykPD-pHV#%wQtb3Sk_@d-QFJW&`)sUp7n6 zD?>x!ico_kY;7`+SA56qFS;x@KcDiiZKEFkbIyAz%y66XQQPLJ_PmR_v{@jveYo8% z@zCGIP!ILDvs}U)W)PI{7StEJ{l=J;=d&igwEun-pDIsdUgE13@*d6rW4Jke@|B!W zWzQ_(%-MMy&eXIHwJ6@3EBrS*n4*Ft0D86b(loP@bW-b7a-XabSu7Pnx}m5fD6_xA zwwTyy`BUNg#qjK;*zI}oaDqmOjMES?C;|z$#hn$3$glA$t(b-sCFS6z{R7E>ZbI*Q z4f;V94ee(7DUt$yYT_2zfZASgVsB@`Ib~UKd$*s?H|ZG3Ns?KT(1JqN5{g<_pvw4E zG84T6eEvx#_mAbkAMC38gmUrMtn=CrqlZ2F*cp^)Ta01@LzPyehx&$MgR7cij2(2MaiDM>+pHL;^o z3|g3W!$hb%;d5sml~u4-bP*Ft^3dx%5d2rnr#saS#=(aZZ`!F?A{EYX(1>L@6y9oL z;cFn+?d4iu88vLDmx+IMDp##3_MCA~OO z)LhygJ1~B_TbF*fv@fdhtOdi;Z-pHKJ{&N84QCy1RjprA3XWkm-E(B7bOk-lQ-N3S z#Vp?AKYWZ*n`8kQ?6c&#_G{Edh5O1qat0JR_)JoaZ>5T>aUU&kxi`_+TxcEp`hT*K zTp$#SasF&~sx=jQ(jsBwzq5QWen|a?`>h1@#|4>sByM>wn+EqFsTq>YD`exOGVKwS z#qp3z9D2)lp%4}~%shXm?YCv)_K(5auHJ=#T~II@{WKY_d*{G@?3XFBS4X*%n8x>?n@s6(^Da@Z7P z0PS22f#dNf*XW_L1#}Ph*)aJvaK5*+fb#rc6NeHBd>&AmBDTUw03Xx9VqxN3*ZD%m zLv)asHi*w}_@=|jO_PVi)OFOQNIFDPG>mMp7g^kR7|Cr_joJ&XL|jiPFW}8VU$S~& zadq<1jyFvWAUk*Pgf);-#m@VJ7fLJ7UHeJl*6q4ZK{T5BMRZ#whtg-8YPP&H-6R`p z)uUJM29;b;dd2pc3ow@oKLKP+3@ujgM*G1|)gkNj( zEf!eyqS29yhx82v`oMj&Mxy1MRV-bDgV+K^jB_qZnT4)Qs{bcgPiblI5mkv~a`aO8 zbDNuw0gXyxl$K1(JL&aYZ^sqSIcOCRVfId{&inN>6)uoaNryTgj86_vzf$f}KIlJ@ zbqM_WwhjWEYO$s2JUn#Nru01jDSgtLK?cF&_;q?rFNXwnYtW5!%Bu2V2h4D$lt@^B zgGZj)Ym#v2QEJ%C;?O3tpUkO3U|PE6p5CDJt(r;1~51n47w z5IASZ1W-i+HVfcfJe-EldpTk$0oa`TocLKP}A0I zVq|)EZl##3vqUP!0wkziLkK>{R4VtVB??v2>BRIZhL37zgfOc|{_rg$a6s}>qikHN z=&AN|%qvC|Fws#4ERci06&1gWtS5}D|2d5;#?mW;Mm_o+9~D&>E_Ib3!8*#Kc0{~( z4Z3CXyNK_1eZFMoceK1P_Qd7&{ziX#JNdG=AC15FiM<1Np+8p8qelyHX9kMY%)lA= zL}0HvKh&4HK6?KIhlPH;+2@g}u@PDn@zdFEkq(SU#}=(qB?=*ec(v zgOyKa?3WtdTxk&9_~kMqFV1j{2vZuoR#V)fgL#6Z5vO1^x}bb`RraOUSH#QrVqa}} z9Fd@tA{@|89cGc_qhefRJ#M7%5_92U)_-SS2qESY)VWVv%fAM=)8cKzjDcj9J$m z{zln~JS;CVb!4UfRVcgM1*ss%duBRTDsBz;V#2jIOg{1JE&FYx^`^%3BrqHjJi}xf z!>r(_;r8ikX(*{TG5~AuJiLrTZAB_wq*?88#2a#WaAP>PxYpc|bqu^hk zE^KCrk&}PwU!AJAdSPxUp0JREi%Y#z#4&SkpSKehIfW@?kZs9NxKCWE)MK?GK18)q z)=_1wnhm3W3`^{IMD!e_#WU0}=eEbZNpfBg0ywSCQd#5cM>tJ<63)TKE-kC+9l4Wk zJ%E0|EX-+t+K%{z!U2Yrm8mpZmO?PDt~$52!JEaTmDC2NYR~I7OIzLO>#T`I^O^OA z=eVvlebBJ#NDC`@GAeo~i`dS{d?a-N)TgbZmC@zLf!AMBSw_u?3TzVU3M4KB~_ zq>EfBr9P1E2O05Qks2*sxw)C7v~}|<#s8g*x`-F+c>Q5|HrbCFUI#xcw0ejj*ml}) z`Jxs7dKwN_Ho^pes?F_3Cr&&1hu z3I~;wrij9YSb~ejhhEg-6NXTFaOvlck1$n3xRi zoxC=Zq89i~cs+;T&-e2vqhM6#{NMRLQb(N9CCbgC^&T{5i4hD0j7c3@jnPgebl~<* z6EZn^GX-$kbF0(V6V&m+^tVh%Xqf(!9=ZvH16`d^QV9n+kI%+qS zF;%j^SjeTb0sWBGjcB&^V)VJ0dt1b5)DNz=aTZ@;PPQ6PUc!1z zN^I21_$bMN^xiKNRZ!5m8K*ExyEl>Ne;)oK%tzpw@WRos44Q7f--AfmZDMRL)RqCM zJV5fN!5VoMV1M&8wO9x4;lt`DDM;e2PZ9Z|6hEE@q&$aWqEcdPhtw z{czY7#Q4U(;!xX$OLT5_7hUI50lr?GH5l&?`E_NCh_ZcK*FEMVH$~`EfxHE-u3ZU4 zq4TK3G*EO@blS`-GC59CsHi7@*Z1~kd0$dMS%#k|L#A00nA>Q4)`XiZ$Ma#sql33C zt!&Rv5=9*&bJp0tjUcN9iW-omjp&g~d{ZzOI!rmRrqTYL^bDMDx7u1SDsD!eMpKNo zQz{kzIyVWCdP%jOey1QatMHJo(s^&?QGNyFa@b7WXfponB&QcAX32lm2^<`mjX?1- zf&aaG$J^egG0|y(K`^Y;a;vV>WJa?NHNZYxsWO5Xu!u{z_cG$$qtUIOgp~$|Z>zt_ zqfgl*j#|i?x1oSU0pMKocgWRfi+Yn;f2@(FY_}ZGh&inx4Ng^O4*-H);zG zjjjk~e;(YVEK!PuHcG=JD< zlvIOu39!a+eiP02XCQmd77jCb;;`4`ZKzWl$m%PbI0^UKHwC+V!@9O03be5h-%BNt zG)Tb8s%D=GW!`YM*u}F#(@S%aA*pn-Zpx+!f?pvvJnWwha_~=nHAn*QVS9t6q%MUP zOM8<$xVj(y_+(x_=u1+-vNt3qlO38$ z6s0C<5CH-X;oI`RQecJ&*w{5&^ZAm4Wu+~4rdgX^nIo`jzNStDMWs%Jb8&!ZIWsiS z26^?f%o;AaCvq+{6T%Kqw0GyUOb-t&rr){+3)||(&s)-ALefPcm-d|ob0>E-CLZ6L zvnB@djZlO;3gW{?C%b*c_1!jpI>L9n97j*pIYp*NL&)QDNZzehj{-3lxuL?21yK4L z+i@$OGk9W>XpP29X*LiYbgJU?W!YM^%k5G+`u@>4KM72b1&!K~tqzINzfHdzRK8f+>#(ZGpO4f$TZ&wwZMI#hr=d@a zUYMdz)JUs;e@CYgQIb*W_ew_vzvZ^|Hx)VUS*bTz|Dl;jn9SaI7NzII?8t%(Ki%HH zk-Pe#oBbwOlkz*@PukjlEtg0tzh*hV@EgQ23YY&j(~0sY2T%D`&9Brg!rf|#<8rF| z>^f*<-Vb_HJ8khfuctv0)1wI)Db(`FJ2X%JOdr3%DT!mC26`j*LqS3Sp_#5Cma*gV z^73k+>F-9t#X-gCV?)o2NyiJ0)c$%IQb%*9C(HCG8ZwG`h{-p(W4vuT(ddDr9zs!Z zF_Y^`ug%|oy`arYwnUDR-jzyW0^N_Bj64QP(+nm5Rg;0|8VoF}El;`~2i3Rzv*3UK z_N^2la8P2p^ExinWA0-*BlTITjYJinu6BgYbc2#wtPN9q0z&4avRP55S$l(ozWj}R zR?EFwXtrAuSKizy{%`94G$n8eleRlaOhe7XRzt`C!CAxUTriI( z`o{{}8)+y)h56DViR?s)URd`^H$-W{P_|1pz@+F@T=;)|m3~%g-zz#|c0Sls4H?c3 zQ|TpV4>$~QN$F}iZ0FV+aoUcQ*p}mO0?}RT-)77JL6g*SG3t-GWnM-oqdgfeZTc3O zlLE*Xs(nhn)dNZ(AO!EtUfSG{v0I|s{cM$$dm}N(sE(cY|C1^AfBAc4q(N`*+7`u? V01~ID|6EA~1sN4cwUklt{{Y4C_ZR>G literal 0 HcmV?d00001 diff --git a/Images/Allow2_516x389.png b/Images/Allow2_516x389.png new file mode 100644 index 0000000000000000000000000000000000000000..c7a5ddcb82c5436985c868a027751d58beb45acf GIT binary patch literal 45938 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 Date: Sat, 27 Jul 2019 13:45:04 +1000 Subject: [PATCH 17/21] add documentation --- .gitmodules | 0 Documentation | 1 + 2 files changed, 1 insertion(+) create mode 100644 .gitmodules create mode 160000 Documentation diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..e69de29 diff --git a/Documentation b/Documentation new file mode 160000 index 0000000..3066f7e --- /dev/null +++ b/Documentation @@ -0,0 +1 @@ +Subproject commit 3066f7efcd8c000b7c282d115d595ced731bb172 From 1d5f197e96031b43459cdcd91eea4af14d4c28af Mon Sep 17 00:00:00 2001 From: Allow2CEO Date: Thu, 12 Mar 2026 00:08:43 +0000 Subject: [PATCH 18/21] =?UTF-8?q?Allow2=20Unity=20SDK=20v2=20=E2=80=94=20f?= =?UTF-8?q?ull=20UPM=20package=20implementation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Complete rewrite of the Unity SDK as a UPM package (com.allow2.sdk). Pure C# core with MonoBehaviour bridge pattern, porting the gold standard Device Operational Lifecycle from the C# SDK. 27 files, 5,108 lines: - 11 core modules (Daemon, Api, Checker, ChildShield, Warnings, Offline, Pairing, Request, Updates, Feedback, VoiceCode) - UnityWebRequest-based API client with built-in MiniJson - PlayerPrefs credential store with XOR device-key obfuscation - Allow2Manager singleton (DontDestroyOnLoad) with Inspector config - Coroutine wrappers for WebGL compatibility - UnityEvent bindings for all 14 lifecycle events - Progressive warnings: 15min→5min→1min→30sec→BLOCKED - Offline: cache + 5min grace + deny-by-default - HMAC-SHA256 voice code challenge-response Co-Authored-By: claude-flow --- com.allow2.sdk/Runtime/Allow2.Runtime.asmdef | 14 + .../Runtime/Bridge/Allow2Coroutines.cs | 178 ++++ .../Runtime/Bridge/Allow2Manager.cs | 802 ++++++++++++++++++ com.allow2.sdk/Runtime/Core/Allow2Api.cs | 659 ++++++++++++++ com.allow2.sdk/Runtime/Core/Allow2Checker.cs | 481 +++++++++++ .../Runtime/Core/Allow2ChildShield.cs | 443 ++++++++++ com.allow2.sdk/Runtime/Core/Allow2Daemon.cs | 493 +++++++++++ com.allow2.sdk/Runtime/Core/Allow2Feedback.cs | 143 ++++ com.allow2.sdk/Runtime/Core/Allow2Offline.cs | 160 ++++ com.allow2.sdk/Runtime/Core/Allow2Pairing.cs | 325 +++++++ com.allow2.sdk/Runtime/Core/Allow2Request.cs | 147 ++++ com.allow2.sdk/Runtime/Core/Allow2Updates.cs | 224 +++++ .../Runtime/Core/Allow2VoiceCode.cs | 154 ++++ com.allow2.sdk/Runtime/Core/Allow2Warnings.cs | 103 +++ .../Runtime/Credentials/ICredentialStore.cs | 37 + .../Runtime/Credentials/PlayerPrefsStore.cs | 126 +++ com.allow2.sdk/Runtime/Events/Allow2Events.cs | 131 +++ .../Runtime/Events/Allow2UnityEvents.cs | 56 ++ .../Runtime/Models/Allow2Activity.cs | 38 + .../Runtime/Models/Allow2CheckResult.cs | 133 +++ com.allow2.sdk/Runtime/Models/Allow2Child.cs | 47 + com.allow2.sdk/Runtime/Models/Allow2Config.cs | 86 ++ .../Runtime/Models/Allow2Credentials.cs | 28 + .../Runtime/Models/Allow2RequestResult.cs | 32 + com.allow2.sdk/Runtime/Models/Allow2State.cs | 26 + .../Runtime/Models/Allow2Warning.cs | 56 ++ com.allow2.sdk/package.json | 27 + docs/ALLOW2UNITY_DESIGN.md | 393 +++++++++ 28 files changed, 5542 insertions(+) create mode 100644 com.allow2.sdk/Runtime/Allow2.Runtime.asmdef create mode 100644 com.allow2.sdk/Runtime/Bridge/Allow2Coroutines.cs create mode 100644 com.allow2.sdk/Runtime/Bridge/Allow2Manager.cs create mode 100644 com.allow2.sdk/Runtime/Core/Allow2Api.cs create mode 100644 com.allow2.sdk/Runtime/Core/Allow2Checker.cs create mode 100644 com.allow2.sdk/Runtime/Core/Allow2ChildShield.cs create mode 100644 com.allow2.sdk/Runtime/Core/Allow2Daemon.cs create mode 100644 com.allow2.sdk/Runtime/Core/Allow2Feedback.cs create mode 100644 com.allow2.sdk/Runtime/Core/Allow2Offline.cs create mode 100644 com.allow2.sdk/Runtime/Core/Allow2Pairing.cs create mode 100644 com.allow2.sdk/Runtime/Core/Allow2Request.cs create mode 100644 com.allow2.sdk/Runtime/Core/Allow2Updates.cs create mode 100644 com.allow2.sdk/Runtime/Core/Allow2VoiceCode.cs create mode 100644 com.allow2.sdk/Runtime/Core/Allow2Warnings.cs create mode 100644 com.allow2.sdk/Runtime/Credentials/ICredentialStore.cs create mode 100644 com.allow2.sdk/Runtime/Credentials/PlayerPrefsStore.cs create mode 100644 com.allow2.sdk/Runtime/Events/Allow2Events.cs create mode 100644 com.allow2.sdk/Runtime/Events/Allow2UnityEvents.cs create mode 100644 com.allow2.sdk/Runtime/Models/Allow2Activity.cs create mode 100644 com.allow2.sdk/Runtime/Models/Allow2CheckResult.cs create mode 100644 com.allow2.sdk/Runtime/Models/Allow2Child.cs create mode 100644 com.allow2.sdk/Runtime/Models/Allow2Config.cs create mode 100644 com.allow2.sdk/Runtime/Models/Allow2Credentials.cs create mode 100644 com.allow2.sdk/Runtime/Models/Allow2RequestResult.cs create mode 100644 com.allow2.sdk/Runtime/Models/Allow2State.cs create mode 100644 com.allow2.sdk/Runtime/Models/Allow2Warning.cs create mode 100644 com.allow2.sdk/package.json create mode 100644 docs/ALLOW2UNITY_DESIGN.md 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) From d8e37dba2d032685cbfe33657895d9bd236f8df7 Mon Sep 17 00:00:00 2001 From: Allow2CEO Date: Thu, 12 Mar 2026 00:20:22 +0000 Subject: [PATCH 19/21] Add CI/CD workflows for Unity SDK - ci.yml: .NET 8 compilation check with Unity engine stubs, package.json validation - release.yml: GitHub Release on v* tags with UPM install instructions and OpenUPM notification - version-check.yml: PR gate ensuring package.json version bump when Runtime/ files change Co-Authored-By: claude-flow --- .github/workflows/ci.yml | 343 ++++++++++++++++++++++++++++ .github/workflows/release.yml | 299 ++++++++++++++++++++++++ .github/workflows/version-check.yml | 77 +++++++ 3 files changed, 719 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release.yml create mode 100644 .github/workflows/version-check.yml 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/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." From fdb868363b9a2955c1d888f4fdfd628cce57e365 Mon Sep 17 00:00:00 2001 From: Allow2CEO Date: Thu, 12 Mar 2026 00:26:59 +0000 Subject: [PATCH 20/21] Add Asset Store packaging, store workflow, and version bumping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - packaging/asset-store/export-unitypackage.sh: builds .unitypackage without Unity Editor (gzipped tar with GUIDs) - packaging/asset-store/asset-store-metadata.json: submission template for Unity Asset Store - store-publish.yml: build .unitypackage on v* tags, attach to GitHub Release, optional Asset Store submission - scripts/bump-version.sh: single entry point for releasing, updates package.json, commits and tags All publishing is tag-driven: bump-version.sh → git push --tags → release.yml (UPM) + store-publish.yml (Asset Store) Co-Authored-By: claude-flow --- .github/workflows/store-publish.yml | 197 ++++++++++++++++++ .gitignore | 1 + .../asset-store/asset-store-metadata.json | 53 +++++ packaging/asset-store/export-unitypackage.sh | 188 +++++++++++++++++ scripts/bump-version.sh | 146 +++++++++++++ 5 files changed, 585 insertions(+) create mode 100644 .github/workflows/store-publish.yml create mode 100644 packaging/asset-store/asset-store-metadata.json create mode 100755 packaging/asset-store/export-unitypackage.sh create mode 100755 scripts/bump-version.sh 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/.gitignore b/.gitignore index 11596a5..22ad11b 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ # build [Oo]bj/ [Bb]in/ +*.unitypackage packages/ TestResults/ 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" From d2c7e8972f0ac3591f00876c0fd65fba98b77e3f Mon Sep 17 00:00:00 2001 From: Allow2CEO Date: Thu, 12 Mar 2026 03:34:08 +0000 Subject: [PATCH 21/21] fix: strip UTF-8 BOM and update root package.json metadata MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root package.json had a UTF-8 BOM (EF BB BF) causing OpenUPM JSON parse error "Unrecognized token 'ï'". Also replaced stale Unity Ads template metadata with correct Allow2 SDK values matching com.allow2.sdk/package.json. Co-Authored-By: claude-flow --- package.json | 38 ++++++++++++++++++-------------------- 1 file changed, 18 insertions(+), 20 deletions(-) diff --git a/package.json b/package.json index 7198731..c866a7c 100644 --- a/package.json +++ b/package.json @@ -1,32 +1,30 @@ -{ - "category": "AssetStore/Unity", - "description": "Allow2 Parental Freedom.", - "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", + "parental", + "freedom", "child", "children", - "control", "family", - "freedom", "gaming", - "limit", - "limits", - "parental", - "parental-control", - "quota", - "social", - "social-network", - "time", "time-limit", - "unity" + "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": "0.1.0" + "unity": "2021.3", + "license": "MIT" }