From 390e80fcb2187e9bff38425814f3fb53f2026bab Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Sat, 17 Oct 2026 09:42:00 +0100 Subject: [PATCH] fix(unity): register the Android OAuth callback at build time The package shipped Plugins/Android/AndroidManifest.xml with the literal scheme appwrite-callback-. A package installed from a git URL is read-only, so the scheme could never be filled in and OAuth on Android never returned to the app; the manifest also redeclared the launcher activity, competing with the project's own main manifest. An Editor IPostGenerateGradleAndroidProject processor now adds the intent filter to the Unity activity of the generated Gradle project, using the project ID of each AppwriteConfig asset, which works with Unity's manifest merging and needs no edits to package files. The README documents the custom main manifest route for clients created in code. The setup window's Documentation button opened sdk.url (the generator's homepage URL) and its Discord button a fixed invite; they now open the SDK README and sdk.discordUrl. --- src/SDK/Language/Unity.php | 20 ++-- .../AppwriteAndroidManifestProcessor.cs.twig | 107 ++++++++++++++++++ .../Assets/Editor/AppwriteSetupWindow.cs.twig | 6 +- .../Plugins/Android/AndroidManifest.xml.twig | 23 ---- templates/unity/README.md.twig | 15 +++ tests/generation/GenerationTest.php | 38 +++++++ 6 files changed, 171 insertions(+), 38 deletions(-) create mode 100644 templates/unity/Assets/Editor/AppwriteAndroidManifestProcessor.cs.twig delete mode 100644 templates/unity/Assets/Runtime/Core/Plugins/Android/AndroidManifest.xml.twig 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.