diff --git a/src/Xamarin.Android.Tools.AndroidSdk/AndroidAppManifest.cs b/src/Xamarin.Android.Tools.AndroidSdk/AndroidAppManifest.cs new file mode 100644 index 0000000..88ee2cb --- /dev/null +++ b/src/Xamarin.Android.Tools.AndroidSdk/AndroidAppManifest.cs @@ -0,0 +1,324 @@ +using System; +using System.Linq; +using System.Xml; +using System.Collections.Generic; +using System.Xml.Linq; +using System.Text.RegularExpressions; +using System.Text; +using System.IO; + +namespace Xamarin.Android.Tools +{ + public class AndroidAppManifest + { + AndroidVersions versions; + XDocument doc; + XElement manifest, application, usesSdk; + + static readonly XNamespace aNS = "http://schemas.android.com/apk/res/android"; + static readonly XName aName = aNS + "name"; + + AndroidAppManifest (AndroidVersions versions, XDocument doc) + { + if (versions == null) + throw new ArgumentNullException (nameof (versions)); + if (doc == null) + throw new ArgumentNullException (nameof (doc)); + this.versions = versions; + this.doc = doc; + manifest = doc.Root; + if (manifest.Name != "manifest") + throw new ArgumentException ("App manifest does not have 'manifest' root element", nameof (doc)); + + application = manifest.Element ("application"); + if (application == null) + manifest.Add (application = new XElement ("application")); + + usesSdk = manifest.Element ("uses-sdk"); + if (usesSdk == null) + manifest.Add (usesSdk = new XElement ("uses-sdk")); + } + + public static string CanonicalizePackageName (string packageNameOrAssemblyName) + { + if (packageNameOrAssemblyName == null) + throw new ArgumentNullException ("packageNameOrAssemblyName"); + if (string.IsNullOrEmpty (packageNameOrAssemblyName = packageNameOrAssemblyName.Trim ())) + throw new ArgumentException ("Must specify a package name or assembly name", "packageNameOrAssemblyName"); + + string[] packageParts = packageNameOrAssemblyName.Split (new[]{'.'}, StringSplitOptions.RemoveEmptyEntries); + for (int i = 0; i < packageParts.Length; ++i) { + packageParts [i] = Regex.Replace (packageParts [i], "[^A-Za-z0-9_]", "_"); + if (char.IsDigit (packageParts [i], 0) || packageParts [i][0] == '_') + packageParts [i] = "x" + packageParts [i]; + } + return packageParts.Length == 1 + ? packageParts [0] + "." + packageParts [0] + : string.Join (".", packageParts); + } + + public static AndroidAppManifest Create (string packageName, string appLabel, AndroidVersions versions) + { + return new AndroidAppManifest (versions, XDocument.Parse ( + @" + + + + +")) { + PackageName = packageName, + ApplicationLabel = appLabel, + }; + } + + public static AndroidAppManifest Load (string filename, AndroidVersions versions) + { + return Load (XDocument.Load (filename), versions); + } + + public static AndroidAppManifest Load (XDocument doc, AndroidVersions versions) + { + return new AndroidAppManifest (versions, doc); + } + + public void Write (XmlWriter writer) + { + doc.Save (writer); + } + + public void WriteToFile (string fileName) + { + var xmlSettings = new XmlWriterSettings () { + Encoding = Encoding.UTF8, + CloseOutput = false, + Indent = true, + IndentChars = "\t", + NewLineChars = "\n", + }; + + var tempFile = FileUtil.GetTempFilenameForWrite (fileName); + bool success = false; + try { + using (var writer = XmlTextWriter.Create (tempFile, xmlSettings)) { + Write (writer); + } + FileUtil.SystemRename (tempFile, fileName); + success = true; + } finally { + if (!success) { + try { + File.Delete (tempFile); + } catch { + //the original exception is more important than this one + } + } + } + } + + static string NullIfEmpty (string value) + { + return string.IsNullOrEmpty (value) ? null : value; + } + + public string PackageName { + get { return (string) manifest.Attribute ("package"); } + set { manifest.SetAttributeValue ("package", NullIfEmpty (value)); } + } + + public string ApplicationLabel { + get { return (string) application.Attribute (aNS + "label"); } + set { application.SetAttributeValue (aNS + "label", NullIfEmpty (value)); } + } + + public string ApplicationIcon { + get { return (string) application.Attribute (aNS + "icon"); } + set { application.SetAttributeValue (aNS + "icon", NullIfEmpty (value)); } + } + + public string ApplicationTheme { + get { return (string) application.Attribute (aNS + "theme"); } + set { application.SetAttributeValue (aNS + "theme", NullIfEmpty (value)); } + } + + public string VersionName { + get { return (string) manifest.Attribute (aNS + "versionName"); } + set { manifest.SetAttributeValue (aNS + "versionName", NullIfEmpty (value)); } + } + + public string VersionCode { + get { return (string) manifest.Attribute (aNS + "versionCode"); } + set { manifest.SetAttributeValue (aNS + "versionCode", NullIfEmpty (value)); } + } + + public string InstallLocation { + get { return (string) manifest.Attribute (aNS + "installLocation"); } + set { manifest.SetAttributeValue (aNS + "installLocation", NullIfEmpty (value)); } + } + + public int? MinSdkVersion { + get { return ParseSdkVersion (usesSdk.Attribute (aNS + "minSdkVersion")); } + set { usesSdk.SetAttributeValue (aNS + "minSdkVersion", value == null ? null : value.ToString ()); } + } + + public int? TargetSdkVersion { + get { return ParseSdkVersion (usesSdk.Attribute (aNS + "targetSdkVersion")); } + set { usesSdk.SetAttributeValue (aNS + "targetSdkVersion", value == null ? null : value.ToString ()); } + } + + int? ParseSdkVersion (XAttribute attribute) + { + var version = (string)attribute; + if (string.IsNullOrEmpty (version)) + return null; + int vn; + if (!int.TryParse (version, out vn)) { + int? apiLevel = versions.GetApiLevelFromId (version); + if (apiLevel.HasValue) + return apiLevel.Value; + return versions.MaxStableVersion.ApiLevel; + } + return vn; + } + + public IEnumerable AndroidPermissions { + get { + foreach (var el in manifest.Elements ("uses-permission")) { + var name = (string) el.Attribute (aName); + if (name == null) + continue; + var lastDot = name.LastIndexOf ('.'); + if (lastDot >= 0) + yield return name.Substring (lastDot + 1); + } + } + } + + public IEnumerable AndroidPermissionsQualified { + get { + foreach (var el in manifest.Elements ("uses-permission")) { + var name = (string) el.Attribute (aName); + if (name != null) + yield return name; + } + } + } + + public bool? Debuggable { + get { return (bool?) application.Attribute (aNS + "debuggable"); } + set { application.SetAttributeValue (aNS + "debuggable", value); } + } + + public void SetAndroidPermissions (IEnumerable permissions) + { + var newPerms = new HashSet (permissions.Select (FullyQualifyPermission)); + var current = new HashSet (AndroidPermissionsQualified); + AddAndroidPermissions (newPerms.Except (current)); + RemoveAndroidPermissions (current.Except (newPerms)); + } + + void AddAndroidPermissions (IEnumerable permissions) + { + var newElements = permissions.Select (p => new XElement ("uses-permission", new XAttribute (aName, p))); + + var lastPerm = manifest.Elements ("uses-permission").LastOrDefault (); + if (lastPerm != null) { + foreach (var el in newElements) { + lastPerm.AddAfterSelf (el); + lastPerm = el; + } + } else { + var parentNode = (XNode) manifest.Element ("application") ?? manifest.LastNode; + foreach (var el in newElements) + parentNode.AddBeforeSelf (el); + } + } + + string FullyQualifyPermission (string permission) + { + //if already qualified, don't mess with it + if (permission.IndexOf ('.') > -1) + return permission; + + switch (permission) { + case "READ_HISTORY_BOOKMARKS": + case "WRITE_HISTORY_BOOKMARKS": + return string.Format ("com.android.browser.permission.{0}", permission); + default: + return string.Format ("android.permission.{0}", permission); + } + } + + void RemoveAndroidPermissions (IEnumerable permissions) + { + var perms = new HashSet (permissions); + var list = manifest.Elements ("uses-permission") + .Where (el => perms.Contains ((string)el.Attribute (aName))).ToList (); + foreach (var el in list) + el.Remove (); + } + + [Obsolete ("Use GetLaunchableFastdevActivityName or GetLaunchableUserActivityName")] + public string GetLaunchableActivityName () + { + return GetLaunchableFastDevActivityName (); + } + + /// Gets an activity that can be used to initialize the override directory for fastdev. + [Obsolete ("This should not be needed anymore; Activity execution is not part of installation.")] + public string GetLaunchableFastDevActivityName () + { + string first = null; + foreach (var a in GetLaunchableActivities ()) { + var name = (string) a.Attribute (aName); + //prefer the fastdev launcher, it's quicker + if (name == "mono.android.__FastDevLauncher") { + return name; + } + //else just use the first other launchable activity + if (first == null) { + first = name; + } + } + + return string.IsNullOrEmpty (first)? null : first; + } + + // We add a fake launchable activity for FastDev, but we don't want + // to launch that one when the user does Run or Debug + public string GetLaunchableUserActivityName () + { + return GetLaunchableActivities () + .Select (a => (string) a.Attribute (aName)) + .FirstOrDefault (name => !string.IsNullOrEmpty (name) && name != "mono.android.__FastDevLauncher"); + } + + IEnumerable GetLaunchableActivities () + { + foreach (var activity in application.Elements ("activity")) { + var filter = activity.Element ("intent-filter"); + if (filter != null) { + foreach (var category in filter.Elements ("category")) + if (category != null && (string)category.Attribute (aName) == "android.intent.category.LAUNCHER") + yield return activity; + } + } + } + + public IEnumerable GetAllActivityNames () + { + foreach (var activity in application.Elements ("activity")) { + var activityName = (string) activity.Attribute (aName); + if (activityName != "mono.android.__FastDevLauncher") + yield return activityName; + } + } + + public IEnumerable GetLaunchableActivityNames () + { + return GetLaunchableActivities () + .Select (a => (string) a.Attribute (aName)) + .Where (name => !string.IsNullOrEmpty (name) && name != "mono.android.__FastDevLauncher"); + } + } +} + diff --git a/src/Xamarin.Android.Tools.AndroidSdk/FileUtil.cs b/src/Xamarin.Android.Tools.AndroidSdk/FileUtil.cs new file mode 100644 index 0000000..6fe8487 --- /dev/null +++ b/src/Xamarin.Android.Tools.AndroidSdk/FileUtil.cs @@ -0,0 +1,58 @@ +using System; +using System.IO; +using System.Runtime.InteropServices; + +namespace Xamarin.Android.Tools +{ + class FileUtil + { + public static string GetTempFilenameForWrite (string fileName) + { + return Path.GetDirectoryName (fileName) + Path.DirectorySeparatorChar + ".#" + Path.GetFileName (fileName); + } + + //From MonoDevelop.Core.FileService + public static void SystemRename (string sourceFile, string destFile) + { + //FIXME: use the atomic System.IO.File.Replace on NTFS + if (OS.IsWindows) { + string wtmp = null; + if (File.Exists (destFile)) { + do { + wtmp = Path.Combine (Path.GetTempPath (), Guid.NewGuid ().ToString ()); + } while (File.Exists (wtmp)); + + File.Move (destFile, wtmp); + } + try { + File.Move (sourceFile, destFile); + } + catch { + try { + if (wtmp != null) + File.Move (wtmp, destFile); + } + catch { + wtmp = null; + } + throw; + } + finally { + if (wtmp != null) { + try { + File.Delete (wtmp); + } + catch { } + } + } + } + else { + rename (sourceFile, destFile); + } + } + + [DllImport ("libc", SetLastError=true)] + static extern int rename (string old, string @new); + } +} + diff --git a/src/Xamarin.Android.Tools.AndroidSdk/Tests/AndroidAppManifestTests.cs b/src/Xamarin.Android.Tools.AndroidSdk/Tests/AndroidAppManifestTests.cs new file mode 100644 index 0000000..cefd8e3 --- /dev/null +++ b/src/Xamarin.Android.Tools.AndroidSdk/Tests/AndroidAppManifestTests.cs @@ -0,0 +1,166 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Text; +using System.Xml; +using System.Xml.Linq; + +using NUnit.Framework; + +namespace Xamarin.Android.Tools.Tests +{ + [TestFixture] + public class AndroidAppManifestTests + { + [Test] + public void Load () + { + var versions = new AndroidVersions (new AndroidVersion [0]); + Assert.Throws (() => AndroidAppManifest.Load ((string) null, versions)); + Assert.Throws (() => AndroidAppManifest.Load ("filename", null)); + Assert.Throws (() => AndroidAppManifest.Load ((XDocument) null, versions)); + Assert.Throws (() => AndroidAppManifest.Load (GetTestAppManifest (), versions)); + + Assert.Throws (() => AndroidAppManifest.Load (XDocument.Parse (""), versions)); + } + + [Test] + public void ParsePermissions () + { + var versions = new AndroidVersions (new AndroidVersion [0]); + var manifest = AndroidAppManifest.Load (GetTestAppManifest (), versions); + var permissions = manifest.AndroidPermissions.ToArray (); + Assert.AreEqual (3, permissions.Length, "#1"); + Assert.IsTrue (permissions.Contains ("INTERNET"), "#2"); + Assert.IsTrue (permissions.Contains ("READ_CONTACTS"), "#3"); + Assert.IsTrue (permissions.Contains ("WRITE_CONTACTS"), "#4"); + } + + static XDocument GetTestAppManifest () + { + using (var xml = typeof (AndroidAppManifestTests).Assembly.GetManifestResourceStream ("manifest-simplewidget.xml")) { + return XDocument.Load (xml); + } + } + + [Test] + public void SetNewPermissions () + { + var versions = new AndroidVersions (new AndroidVersion [0]); + var manifest = AndroidAppManifest.Load (GetTestAppManifest (), versions); + manifest.SetAndroidPermissions (new [] { "FOO" }); + + var sb = new StringBuilder (); + using (var writer = XmlWriter.Create (sb)) { + manifest.Write (writer); + } + + manifest = AndroidAppManifest.Load (XDocument.Parse (sb.ToString ()), versions); + Assert.AreEqual (1, manifest.AndroidPermissions.Count (), "#1"); + Assert.AreEqual ("FOO", manifest.AndroidPermissions.ElementAt (0)); + } + + [Test] + public void CanonicalizePackageName () + { + Assert.Throws(() => AndroidAppManifest.CanonicalizePackageName (null)); + Assert.Throws(() => AndroidAppManifest.CanonicalizePackageName ("")); + Assert.Throws(() => AndroidAppManifest.CanonicalizePackageName (" ")); + + Assert.AreEqual ("A.A", + AndroidAppManifest.CanonicalizePackageName ("A")); + Assert.AreEqual ("Foo.Bar", + AndroidAppManifest.CanonicalizePackageName ("Foo.Bar")); + Assert.AreEqual ("foo_bar.foo_bar", + AndroidAppManifest.CanonicalizePackageName ("foo-bar")); + Assert.AreEqual ("x1.x1", + AndroidAppManifest.CanonicalizePackageName ("1")); + Assert.AreEqual ("x_1.x_2", + AndroidAppManifest.CanonicalizePackageName ("_1._2")); + Assert.AreEqual ("mfa1.x0.x2_2", + AndroidAppManifest.CanonicalizePackageName ("mfa1.0.2_2")); + Assert.AreEqual ("My.Cool_Assembly", + AndroidAppManifest.CanonicalizePackageName ("My.Cool Assembly")); + Assert.AreEqual ("x7Cats.x7Cats", + AndroidAppManifest.CanonicalizePackageName ("7Cats")); + } + + [Test] + public void CanParseNonNumericSdkVersion () + { + var versions = new AndroidVersions (new AndroidVersion [0]); + var doc = XDocument.Parse (@" + + + + + "); + var manifest = AndroidAppManifest.Load (doc, versions); + + var mininum = manifest.MinSdkVersion; + var target = manifest.TargetSdkVersion; + + Assert.IsTrue (mininum.HasValue); + Assert.IsTrue (target.HasValue); + Assert.AreEqual (21, mininum.Value); + Assert.AreEqual (21, target.Value); + } + + [Test] + public void EnsureMinAndTargetSdkVersionsAreReadIndependently () + { + // Regression test for https://bugzilla.xamarin.com/show_bug.cgi?id=21296 + var versions = new AndroidVersions (new AndroidVersion [0]); + var doc = XDocument.Parse (@" + + + + + "); + var manifest = AndroidAppManifest.Load (doc, versions); + + var mininum = manifest.MinSdkVersion; + var target = manifest.TargetSdkVersion; + + Assert.IsTrue (mininum.HasValue); + Assert.IsTrue (target.HasValue); + Assert.AreEqual (8, mininum.Value); + Assert.AreEqual (12, target.Value); + } + + [Test] + public void EnsureUsesPermissionElementOrder () + { + var versions = new AndroidVersions (new AndroidVersion [0]); + var manifest = AndroidAppManifest.Create ("com.xamarin.test", "Xamarin Test", versions); + manifest.SetAndroidPermissions (new string[] { "FOO" }); + var sb = new StringBuilder (); + using (var writer = XmlWriter.Create (sb)) { + manifest.Write (writer); + } + + var doc = XDocument.Parse (sb.ToString ()); + var app = doc.Element ("manifest").Element ("application"); + Assert.IsNotNull (app, "Application element should exist"); + Assert.IsFalse (app.ElementsAfterSelf ().Any (x => x.Name == "uses-permission")); + Assert.IsTrue (app.ElementsBeforeSelf ().Any (x => x.Name == "uses-permission")); + } + + [Test] + public void CanGetAppTheme () + { + var versions = new AndroidVersions (new AndroidVersion [0]); + var doc = XDocument.Parse (@" + + + + + "); + var manifest = AndroidAppManifest.Load (doc, versions); + + Assert.AreEqual ("@android:style/Theme.Material.Light", manifest.ApplicationTheme); + } + } +} diff --git a/src/Xamarin.Android.Tools.AndroidSdk/Tests/Resources/manifest-simplewidget.xml b/src/Xamarin.Android.Tools.AndroidSdk/Tests/Resources/manifest-simplewidget.xml new file mode 100644 index 0000000..966639d --- /dev/null +++ b/src/Xamarin.Android.Tools.AndroidSdk/Tests/Resources/manifest-simplewidget.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Xamarin.Android.Tools.AndroidSdk/Tests/Xamarin.Android.Tools.AndroidSdk-Tests.csproj b/src/Xamarin.Android.Tools.AndroidSdk/Tests/Xamarin.Android.Tools.AndroidSdk-Tests.csproj index 9e65e90..3c56d14 100644 --- a/src/Xamarin.Android.Tools.AndroidSdk/Tests/Xamarin.Android.Tools.AndroidSdk-Tests.csproj +++ b/src/Xamarin.Android.Tools.AndroidSdk/Tests/Xamarin.Android.Tools.AndroidSdk-Tests.csproj @@ -45,6 +45,7 @@ + @@ -58,5 +59,10 @@ + + + manifest-simplewidget.xml + + \ No newline at end of file diff --git a/src/Xamarin.Android.Tools.AndroidSdk/Xamarin.Android.Tools.AndroidSdk.csproj b/src/Xamarin.Android.Tools.AndroidSdk/Xamarin.Android.Tools.AndroidSdk.csproj index d951470..dacb3b1 100644 --- a/src/Xamarin.Android.Tools.AndroidSdk/Xamarin.Android.Tools.AndroidSdk.csproj +++ b/src/Xamarin.Android.Tools.AndroidSdk/Xamarin.Android.Tools.AndroidSdk.csproj @@ -45,6 +45,8 @@ + +