diff --git a/src/Xamarin.Android.Build.Tasks/Tasks/BuildApk.cs b/src/Xamarin.Android.Build.Tasks/Tasks/BuildApk.cs index d1075ef0376..1b75f8fe840 100644 --- a/src/Xamarin.Android.Build.Tasks/Tasks/BuildApk.cs +++ b/src/Xamarin.Android.Build.Tasks/Tasks/BuildApk.cs @@ -122,46 +122,23 @@ void ExecuteWithAbi (string [] supportedAbis, string apkInputPath, string apkOut } ArchiveFileList files = new ArchiveFileList (); - bool refresh = true; - if (apkInputPath != null && File.Exists (apkInputPath) && !File.Exists (apkOutputPath)) { - Log.LogDebugMessage ($"Copying {apkInputPath} to {apkInputPath}"); - File.Copy (apkInputPath, apkOutputPath, overwrite: true); - refresh = false; - } + using (var notice = Assembly.GetExecutingAssembly ().GetManifestResourceStream ("NOTICE.txt")) using (var apk = new ZipArchiveEx (apkOutputPath, File.Exists (apkOutputPath) ? FileMode.Open : FileMode.Create )) { - if (refresh) { - for (long i = 0; i < apk.Archive.EntryCount; i++) { - ZipEntry e = apk.Archive.ReadEntry ((ulong) i); - Log.LogDebugMessage ($"Registering item {e.FullName}"); - existingEntries.Add (e.FullName); - } + bool apkInputPathExists = apkInputPath != null && File.Exists (apkInputPath); + + DateTime lastWriteOutput = DateTime.MinValue; + DateTime lastWriteInput = DateTime.MinValue; + if (apkInputPathExists) { + lastWriteOutput = File.Exists (apkOutputPath) ? File.GetLastWriteTimeUtc (apkOutputPath) : DateTime.MinValue; + lastWriteInput = File.GetLastWriteTimeUtc (apkInputPath); } - if (apkInputPath != null && File.Exists (apkInputPath) && refresh) { - var lastWriteOutput = File.Exists (apkOutputPath) ? File.GetLastWriteTimeUtc (apkOutputPath) : DateTime.MinValue; - var lastWriteInput = File.GetLastWriteTimeUtc (apkInputPath); - using (var packaged = new ZipArchiveEx (apkInputPath, FileMode.Open)) { - foreach (var entry in packaged.Archive) { - Log.LogDebugMessage ($"Deregistering item {entry.FullName}"); - existingEntries.Remove (entry.FullName); - if (lastWriteInput <= lastWriteOutput) - continue; - if (apk.Archive.ContainsEntry (entry.FullName)) { - ZipEntry e = apk.Archive.ReadEntry (entry.FullName); - // check the CRC values as the ModifiedDate is always 01/01/1980 in the aapt generated file. - if (entry.CRC == e.CRC) { - Log.LogDebugMessage ($"Skipping {entry.FullName} from {apkInputPath} as its up to date."); - continue; - } - } - var ms = new MemoryStream (); - entry.Extract (ms); - Log.LogDebugMessage ($"Refreshing {entry.FullName} from {apkInputPath}"); - apk.Archive.AddStream (ms, entry.FullName, compressionMethod: entry.CompressionMethod); - } - } + + for (long i = 0; i < apk.Archive.EntryCount; i++) { + ZipEntry e = apk.Archive.ReadEntry ((ulong) i); + Log.LogDebugMessage ($"Registering item {e.FullName}"); + existingEntries.Add (e.FullName); } - apk.FixupWindowsPathSeparators ((a, b) => Log.LogDebugMessage ($"Fixing up malformed entry `{a}` -> `{b}`")); string noticeName = RootPath + "NOTICE"; existingEntries.Remove (noticeName); if (!apk.Archive.ContainsEntry (noticeName)) @@ -248,6 +225,16 @@ void ExecuteWithAbi (string [] supportedAbis, string apkInputPath, string apkOut count = 0; } } + + // For now, we keep track of deleted entry indices & skip them ourselves when iterating zip contents in FixupWindowsPathSeparators. + // For this to work, no one shoud call Flush on the zip before calling FixupWindowsPathSeparators, since Flush changes all the indices + // (and gets rid of the deleted stuff). Later when we move to a newer LibZipSharp, with the fix to skip deleted entries itself, + // we can remove this workaround. See FixupWindowsPathSeparators for more info. + HashSet deletedEntries = new HashSet (); + if (apkInputPathExists) + UpdateEntriesFromInputApk (apkInputPath, apk, lastWriteOutput, lastWriteInput, deletedEntries); + apk.FixupWindowsPathSeparators (deletedEntries, (a, b) => Log.LogDebugMessage ($"Fixing up malformed entry `{a}` -> `{b}`")); + // Clean up Removed files. foreach (var entry in existingEntries) { Log.LogDebugMessage ($"Removing {entry} as it is not longer required."); @@ -258,6 +245,49 @@ void ExecuteWithAbi (string [] supportedAbis, string apkInputPath, string apkOut } } + private void UpdateEntriesFromInputApk (string apkInputPath, ZipArchiveEx apk, DateTime lastWriteOutput, DateTime lastWriteInput, HashSet deletedEntries) + { + using (var packaged = new ZipArchiveEx (apkInputPath, FileMode.Open)) { + foreach (var entry in packaged.Archive) { + Log.LogDebugMessage ($"Deregistering item {entry.FullName}"); + existingEntries.Remove (entry.FullName); + if (lastWriteInput <= lastWriteOutput) + continue; + + long entryIndexInOutput; + if (apk.Archive.ContainsEntry (entry.FullName, out entryIndexInOutput)) { + ZipEntry e = apk.Archive.ReadEntry (entry.FullName); + // check the CRC values as the ModifiedDate is always 01/01/1980 in the aapt generated file. + if (entry.CRC == e.CRC) { + Log.LogDebugMessage ($"Skipping {entry.FullName} from {apkInputPath} as its up to date."); + continue; + } + } else { + entryIndexInOutput = -1; + } + + var ms = new MemoryStream (); + entry.Extract (ms); + + if (entryIndexInOutput != -1) { + Log.LogDebugMessage ($"Refreshing {entry.FullName} from {apkInputPath}"); + + // Force the modified resource to move to the end of the file, by deleting it first so that AddStream adds it + // back with a new index. Keeping modified resources toward the end optimizes the delta install for the typical + // dev scenario where the user is editing a few resources but most of the APK contents (e.g. the native libs) + // don't change and we want to keep their byte offset in the APK fixed. Delta install need not update APK + // contents that don't change and don't move. + apk.Archive.DeleteEntry ((ulong) entryIndexInOutput); + deletedEntries.Add ((ulong) entryIndexInOutput); + } else { + Log.LogDebugMessage ($"Adding {entry.FullName} from {apkInputPath}"); + } + + apk.Archive.AddStream (ms, entry.FullName, compressionMethod: entry.CompressionMethod); + } + } + } + public override bool RunTask () { Aot.TryGetSequencePointsMode (AndroidSequencePointsMode, out sequencePointsMode); diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/IncrementalBuildTest.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/IncrementalBuildTest.cs index c1d6353aac5..a6dd14f62b5 100644 --- a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/IncrementalBuildTest.cs +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/IncrementalBuildTest.cs @@ -8,6 +8,7 @@ using System.Text; using Xamarin.Android.Tasks; using Xamarin.ProjectTools; +using Xamarin.Tools.Zip; namespace Xamarin.Android.Build.Tests { @@ -1088,23 +1089,119 @@ public void AndroidResourceChange () using (var builder = CreateApkBuilder ()) { Assert.IsTrue (builder.Build (proj), "first build should succeed"); - // AndroidResource change - proj.LayoutMain += $"{Environment.NewLine}"; + // Change the AndroidResource body; just adding a comment isn't enough to trigger an APK update + string orientationTag = "android:orientation=\"vertical\""; + proj.LayoutMain = proj.LayoutMain.Replace (orientationTag, $"{orientationTag} android:paddingLeft=\"16dp\""); proj.Touch ("Resources\\layout\\Main.axml"); Assert.IsTrue (builder.Build (proj), "second build should succeed"); - var targets = new [] { + AssertNonResourceTargetsSkipped (builder); + } + } + + void AssertNonResourceTargetsSkipped(ProjectBuilder builder) + { + var targets = new [] { "_ResolveLibraryProjectImports", "_GenerateJavaStubs", "_CompileJava", "_CompileToDalvik", }; - foreach (var target in targets) { - Assert.IsTrue (builder.Output.IsTargetSkipped (target), $"`{target}` should be skipped!"); + foreach (var target in targets) { + Assert.IsTrue (builder.Output.IsTargetSkipped (target), $"`{target}` should be skipped!"); + } + } + + [Test] + public void AndroidChangedResourcesAtEnd () + { + var path = Path.Combine (Root, "temp", TestName); + + var proj = new XamarinAndroidApplicationProject (); + using (var builder = CreateApkBuilder ()) { + Assert.IsTrue (builder.Build (proj), "first build should succeed"); + + string signedApkPath = Path.Combine (path, proj.OutputPath, "UnnamedProject.UnnamedProject-Signed.apk"); + + // Initial resources should at the end of the zip + using (var zip = ZipArchive.Open (signedApkPath, FileMode.Open)) { + Assert.AreEqual (37, zip.EntryCount, "Zip entry count"); + AssertZipEntryHasPosition (zip, "res/layout/main.xml", 32); + AssertZipEntryHasPosition (zip, "resources.arsc", 33); + } + + AndroidItem.AndroidResource layout2Resource = AddLayout2 (proj); + Assert.IsTrue (builder.Build (proj), "second build should succeed"); + + // Added resources should go at the end of the zip + using (var zip = ZipArchive.Open (signedApkPath, FileMode.Open)) { + Assert.AreEqual (38, zip.EntryCount, "Zip entry count"); + AssertZipEntryHasPosition (zip, "res/layout/main.xml", 32); + AssertZipEntryHasPosition (zip, "res/layout/layout2.xml", 33); + AssertZipEntryHasPosition (zip, "resources.arsc", 34); + } + + string orientationTag = "android:orientation=\"vertical\""; + proj.LayoutMain = proj.LayoutMain.Replace (orientationTag, $"{orientationTag} android:paddingLeft=\"16dp\""); + proj.Touch ("Resources\\layout\\Main.axml"); + Assert.IsTrue (builder.Build (proj), "third build should succeed"); + AssertNonResourceTargetsSkipped (builder); + + // Updated resources should go at the end of the zip + using (var zip = ZipArchive.Open (signedApkPath, FileMode.Open)) { + + Assert.AreEqual (38, zip.EntryCount, "Zip entry count"); + AssertZipEntryHasPosition (zip, "res/layout/layout2.xml", 32); + AssertZipEntryHasPosition (zip, "resources.arsc", 33); + AssertZipEntryHasPosition (zip, "res/layout/main.xml", 34); + } + + proj.AndroidResources.Remove (layout2Resource); + Assert.IsTrue (builder.Build (proj), "fourth build should succeed"); + + // Removed resources should go away + using (var zip = ZipArchive.Open (signedApkPath, FileMode.Open)) { + Assert.AreEqual (37, zip.EntryCount, "Zip entry count"); + AssertZipEntryHasPosition (zip, "res/layout/main.xml", 32); + AssertZipEntryHasPosition (zip, "resources.arsc", 33); } } } + AndroidItem.AndroidResource AddLayout2 (XamarinAndroidApplicationProject proj) + { + const string layout2 = @" + +