diff --git a/src/SDK/Language/Unity.php b/src/SDK/Language/Unity.php index 9d9ca176c3..52fb65a62a 100644 --- a/src/SDK/Language/Unity.php +++ b/src/SDK/Language/Unity.php @@ -328,11 +328,6 @@ public function getFiles(): array 'destination' => 'Assets/Runtime/Core/Plugins/System.Text.Encodings.Web.dll.meta', 'template' => 'unity/Assets/Runtime/Core/Plugins/System.Text.Encodings.Web.dll.meta', ], - [ - 'scope' => 'default', - 'destination' => 'Assets/Runtime/Core/Plugins/Android/AndroidManifest.xml', - 'template' => 'unity/Assets/Runtime/Core/Plugins/Android/AndroidManifest.xml.twig', - ], [ 'scope' => 'copy', 'destination' => 'Assets/Runtime/Core/Plugins/WebGLCookies.jslib', @@ -389,6 +384,11 @@ public function getFiles(): array 'destination' => 'Assets/Editor/{{ spec.info.title | caseUcfirst }}SetupWindow.cs', 'template' => 'unity/Assets/Editor/AppwriteSetupWindow.cs.twig', ], + [ + 'scope' => 'default', + 'destination' => 'Assets/Editor/{{ spec.info.title | caseUcfirst }}AndroidManifestProcessor.cs', + 'template' => 'unity/Assets/Editor/AppwriteAndroidManifestProcessor.cs.twig', + ], // Samples [ 'scope' => 'default', @@ -550,6 +550,7 @@ public function getFiles(): array 'Assets/Editor/{{ spec.info.title | caseUcfirst }}.Editor.asmdef', 'Assets/Editor/{{ spec.info.title | caseUcfirst }}SetupAssistant.cs', 'Assets/Editor/{{ spec.info.title | caseUcfirst }}SetupWindow.cs', + 'Assets/Editor/{{ spec.info.title | caseUcfirst }}AndroidManifestProcessor.cs', ]; $files = array_filter($files, fn(array $file): bool => !in_array($file['destination'], $excludeInTest)); @@ -636,13 +637,6 @@ private function getMetaContents(string $relativePath, bool $isDir): string return implode("\n", $lines) . "\n"; } - // Plugins/Android/AndroidManifest.xml must import as an Android - // plugin so Unity merges it into the build manifest; a plain - // DefaultImporter would import it as an inert text asset. - if (str_ends_with($relativePath, 'Plugins/Android/AndroidManifest.xml')) { - return implode("\n", $this->pluginImporterMeta($guid, 'Android')) . "\n"; - } - $extension = strtolower(pathinfo($relativePath, PATHINFO_EXTENSION)); $lines = match ($extension) { @@ -680,7 +674,7 @@ private function getMetaContents(string $relativePath, bool $isDir): string * Build PluginImporter meta lines enabling a single target platform * (and the editor stub), with every other platform disabled. * - * @param string $platform Unity platform key (e.g. 'WebGL', 'Android'). + * @param string $platform Unity platform key (e.g. 'WebGL'). * @return array */ private function pluginImporterMeta(string $guid, string $platform): array diff --git a/templates/unity/Assets/Editor/AppwriteAndroidManifestProcessor.cs.twig b/templates/unity/Assets/Editor/AppwriteAndroidManifestProcessor.cs.twig new file mode 100644 index 0000000000..ed64e5b950 --- /dev/null +++ b/templates/unity/Assets/Editor/AppwriteAndroidManifestProcessor.cs.twig @@ -0,0 +1,107 @@ +#if UNITY_ANDROID +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Xml; +using UnityEditor; +using UnityEditor.Android; +using UnityEngine; + +namespace {{ spec.info.title | caseUcfirst }}.Editor +{ + public class {{ spec.info.title | caseUcfirst }}AndroidManifestProcessor : IPostGenerateGradleAndroidProject + { + private const string AndroidNamespace = "http://schemas.android.com/apk/res/android"; + private const string CallbackSchemePrefix = "{{ spec.info.title | caseLower }}-callback-"; + + public int callbackOrder => 0; + + public void OnPostGenerateGradleAndroidProject(string path) + { + var schemes = FindCallbackSchemes(); + if (schemes.Count == 0) + { + Debug.Log("{{ spec.info.title | caseUcfirst }}: No {{ spec.info.title | caseUcfirst }}Config with a project ID was found, so no OAuth callback was registered. To use OAuth on Android, set the project ID on a {{ spec.info.title | caseUcfirst }}Config asset, or add an intent filter for the {{ spec.info.title | caseLower }}-callback- scheme to a custom main manifest."); + return; + } + + var manifestPath = Path.Combine(path, "src", "main", "AndroidManifest.xml"); + var manifest = new XmlDocument(); + manifest.Load(manifestPath); + + var activity = FindUnityActivity(manifest); + if (activity == null) + { + Debug.LogWarning($"{{ spec.info.title | caseUcfirst }}: The Unity activity was not found in {manifestPath}, so no OAuth callback was registered."); + return; + } + + var registered = new HashSet(manifest.GetElementsByTagName("data") + .OfType() + .Select(data => data.GetAttribute("scheme", AndroidNamespace))); + + foreach (var scheme in schemes.Where(scheme => !registered.Contains(scheme))) + { + activity.AppendChild(CreateIntentFilter(manifest, scheme)); + } + + manifest.Save(manifestPath); + } + + private static HashSet FindCallbackSchemes() + { + var schemes = new HashSet(); + foreach (var guid in AssetDatabase.FindAssets("t:{{ spec.info.title | caseUcfirst }}Config")) + { + var config = AssetDatabase.LoadAssetAtPath(AssetDatabase.GUIDToAssetPath(guid)); + if (config == null) + { + continue; + } + + var projectId = new SerializedObject(config).FindProperty("projectId")?.stringValue; + if (!string.IsNullOrEmpty(projectId)) + { + schemes.Add(CallbackSchemePrefix + projectId); + } + } + + return schemes; + } + + private static XmlElement FindUnityActivity(XmlDocument manifest) + { + return manifest.GetElementsByTagName("activity") + .OfType() + .FirstOrDefault(activity => HasAndroidName(activity, "meta-data", "unityplayer.UnityActivity") + || HasAndroidName(activity, "category", "android.intent.category.LAUNCHER")); + } + + private static bool HasAndroidName(XmlElement parent, string tag, string name) + { + return parent.GetElementsByTagName(tag) + .OfType() + .Any(element => element.GetAttribute("name", AndroidNamespace) == name); + } + + private static XmlElement CreateIntentFilter(XmlDocument manifest, string scheme) + { + var filter = manifest.CreateElement("intent-filter"); + filter.AppendChild(CreateElement(manifest, "action", "name", "android.intent.action.VIEW")); + filter.AppendChild(CreateElement(manifest, "category", "name", "android.intent.category.DEFAULT")); + filter.AppendChild(CreateElement(manifest, "category", "name", "android.intent.category.BROWSABLE")); + filter.AppendChild(CreateElement(manifest, "data", "scheme", scheme)); + return filter; + } + + private static XmlElement CreateElement(XmlDocument manifest, string tag, string attribute, string value) + { + var element = manifest.CreateElement(tag); + var node = manifest.CreateAttribute("android", attribute, AndroidNamespace); + node.Value = value; + element.Attributes.Append(node); + return element; + } + } +} +#endif diff --git a/templates/unity/Assets/Editor/AppwriteSetupWindow.cs.twig b/templates/unity/Assets/Editor/AppwriteSetupWindow.cs.twig index 3ee3d6e828..7a6abc13bb 100644 --- a/templates/unity/Assets/Editor/AppwriteSetupWindow.cs.twig +++ b/templates/unity/Assets/Editor/AppwriteSetupWindow.cs.twig @@ -209,8 +209,10 @@ namespace {{ spec.info.title | caseUcfirst }}.Editor EditorGUILayout.BeginVertical(EditorStyles.helpBox); EditorGUILayout.BeginHorizontal(); var buttonStyle = new GUIStyle(GUI.skin.button) { padding = new RectOffset(15, 15, 8, 8), margin = new RectOffset(5, 5, 5, 5), fontSize = 11 }; - if (GUILayout.Button(new GUIContent(" 📖 Documentation", "Open {{ spec.info.title | caseUcfirst }} documentation"), buttonStyle)) { Application.OpenURL("{{ sdk.url }}"); } - if (GUILayout.Button(new GUIContent(" 💬 Discord Community", "Join our Discord community"), buttonStyle)) { Application.OpenURL("https://discord.gg/dJ9xrMr9hq"); } + if (GUILayout.Button(new GUIContent(" 📖 Documentation", "Open the {{ spec.info.title | caseUcfirst }} SDK for Unity documentation"), buttonStyle)) { Application.OpenURL("https://github.com/{{ sdk.gitUserName|url_encode }}/{{ sdk.gitRepoName|url_encode }}#readme"); } +{% if sdk.discordUrl %} + if (GUILayout.Button(new GUIContent(" 💬 Discord Community", "Join our Discord community"), buttonStyle)) { Application.OpenURL("{{ sdk.discordUrl }}"); } +{% endif %} EditorGUILayout.EndHorizontal(); EditorGUILayout.EndVertical(); } diff --git a/templates/unity/Assets/Runtime/Core/Plugins/Android/AndroidManifest.xml.twig b/templates/unity/Assets/Runtime/Core/Plugins/Android/AndroidManifest.xml.twig deleted file mode 100644 index a8d672d852..0000000000 --- a/templates/unity/Assets/Runtime/Core/Plugins/Android/AndroidManifest.xml.twig +++ /dev/null @@ -1,23 +0,0 @@ - - - - - - - - - - - - - - - - - - - diff --git a/templates/unity/README.md.twig b/templates/unity/README.md.twig index 911343ddae..b95e6be610 100644 --- a/templates/unity/README.md.twig +++ b/templates/unity/README.md.twig @@ -51,6 +51,21 @@ You can also add the package to `Packages/manifest.json` directly: 1. Download the source code archive of a release from [GitHub](https://github.com/{{ sdk.gitUserName|url_encode }}/{{ sdk.gitRepoName|url_encode }}/releases) and extract it. 2. In **Window > Package Manager**, click the **+** button and select **Add package from disk** (**Install package from disk** in Unity 6). 3. Select the `package.json` file in the extracted folder. + +## OAuth on Android + +OAuth sign-in returns to your app through the `{{ spec.info.title | caseLower }}-callback-` URL scheme. When you build for Android, the package registers this scheme on the Unity activity for the project ID set on each `{{ spec.info.title | caseUcfirst }}Config` asset in your project, so you do not need to edit any manifest. + +If you create the client in code without a `{{ spec.info.title | caseUcfirst }}Config` asset, enable **Custom Main Manifest** in **Project Settings > Player > Android > Publishing Settings** and add this intent filter to the Unity activity in `Assets/Plugins/Android/AndroidManifest.xml`, replacing `` with your project ID: + +```xml + + + + + + +``` {% if sdk.gettingStarted %} {{ sdk.gettingStarted|trim|raw }} diff --git a/tests/generation/GenerationTest.php b/tests/generation/GenerationTest.php index 0c7e488490..0c8edbf597 100644 --- a/tests/generation/GenerationTest.php +++ b/tests/generation/GenerationTest.php @@ -345,6 +345,44 @@ public function testUnityConfigReadsUntrackedAssetsAsKnowingTheReleasedFlags(): $this->assertStringContainsString("var known = knownservices == 0 ? {$mask} : knownservices;", $config); } + /** + * A package installed from a git URL is read-only, so a manifest shipped in + * it keeps a placeholder OAuth scheme that the app can never fill in. The + * scheme the runtime waits for is registered at build time instead. + */ + public function testUnityRegistersTheOAuthCallbackWithoutAPackageManifest(): void + { + $files = $this->generate('unity', 'client'); + + $manifests = \array_filter(\array_keys($files), fn(string $path): bool => \str_contains(\strtolower($path), 'androidmanifest.xml')); + $this->assertSame([], \array_values($manifests)); + + foreach ($files as $path => $contents) { + if (!\str_starts_with($path, 'docs~/')) { + $this->assertStringNotContainsString('', $contents, "{$path} ships a project ID placeholder"); + } + } + + $this->assertStringContainsString('$"appwrite-callback-{projectid}"', $files['Runtime/Core/WebAuthComponent.cs']); + $processor = $files['Editor/AppwriteAndroidManifestProcessor.cs'] ?? ''; + $this->assertStringContainsString(': ipostgenerategradleandroidproject', $processor); + $this->assertStringContainsString('callbackschemeprefix = "appwrite-callback-"', $processor); + $this->assertStringContainsString('"android.intent.category.browsable"', $processor); + } + + /** + * The setup window links to this SDK's documentation and the configured + * community, not the generator's homepage URL or a fixed invite. + */ + public function testUnitySetupWindowLinksToTheSdkDocumentation(): void + { + $window = $this->generate('unity', 'client')['Editor/AppwriteSetupWindow.cs']; + + $this->assertStringContainsString('application.openurl("https://github.com/', $window); + $this->assertStringContainsString('#readme")', $window); + $this->assertStringNotContainsString('discord.gg', $window); + } + /** * A plugin left out of the WebGL player is missing when that player compiles, * so its namespace may only be imported where WebGL players skip the code.