Skip to content

Commit 892c84e

Browse files
committed
fix(flash): Improve sparse flashing, download handling & tests
Add tests and mocks for multipart downloads and resparse/merge scenarios, including a new SparseFileResparseTests and MultiPartDownloadCaptureTransport. Improve robustness of download/flash logic: tighten DATA-response handling (Bytes returns a Fail response; Stream throws on protocol mismatch), add debug logs (FastbootDebug.Log) for response handling and steps, and refine FlashSparseFile to choose part sizes via FindMaxBlockCountWithinLimit (binary search) while preserving original total blocks and providing a fallback for tiny limits. Also adjust NotifyCurrentStep/NotifyProgress to print default console output when no handlers are attached and add related unit tests. Minor comment and using cleanup in FastbootDriver.
1 parent d58aeea commit 892c84e

File tree

8 files changed

+424
-29
lines changed

8 files changed

+424
-29
lines changed

FirmwareKit.Comm.Fastboot.Tests/FastbootProtocolTests.cs

Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using FirmwareKit.Comm.Fastboot.Usb;
22
using FirmwareKit.Sparse.Core;
3+
using FirmwareKit.Sparse.Models;
34
using System.Globalization;
45
using System.Text;
56

@@ -193,6 +194,78 @@ public long Write(byte[] data, int length)
193194
public void Dispose() { }
194195
}
195196

197+
private sealed class MultiPartDownloadCaptureTransport : IFastbootTransport
198+
{
199+
private readonly Dictionary<string, string> _responses;
200+
private readonly Queue<byte[]> _readQueue = new();
201+
private MemoryStream? _currentPayload;
202+
private int _pendingDownloadBytes;
203+
204+
public List<string> Commands { get; } = new();
205+
public List<byte[]> DownloadPayloads { get; } = new();
206+
207+
public MultiPartDownloadCaptureTransport(Dictionary<string, string>? responses = null)
208+
{
209+
_responses = responses ?? new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
210+
}
211+
212+
public byte[] Read(int length)
213+
{
214+
if (_readQueue.Count == 0)
215+
{
216+
return Encoding.UTF8.GetBytes("OKAY");
217+
}
218+
219+
return _readQueue.Dequeue();
220+
}
221+
222+
public long Write(byte[] data, int length)
223+
{
224+
if (_pendingDownloadBytes > 0)
225+
{
226+
_currentPayload ??= new MemoryStream();
227+
_currentPayload.Write(data, 0, length);
228+
_pendingDownloadBytes -= length;
229+
if (_pendingDownloadBytes <= 0)
230+
{
231+
DownloadPayloads.Add(_currentPayload.ToArray());
232+
_currentPayload.Dispose();
233+
_currentPayload = null;
234+
_readQueue.Enqueue(Encoding.UTF8.GetBytes("OKAY"));
235+
}
236+
237+
return length;
238+
}
239+
240+
string command = Encoding.UTF8.GetString(data, 0, length);
241+
Commands.Add(command);
242+
243+
if (command.StartsWith("download:", StringComparison.OrdinalIgnoreCase))
244+
{
245+
string hex = command.Substring("download:".Length);
246+
int size = int.Parse(hex, NumberStyles.HexNumber, CultureInfo.InvariantCulture);
247+
_pendingDownloadBytes = size;
248+
_currentPayload = new MemoryStream(size);
249+
_readQueue.Enqueue(Encoding.UTF8.GetBytes($"DATA{size:x8}"));
250+
return length;
251+
}
252+
253+
if (_responses.TryGetValue(command, out string? response))
254+
{
255+
_readQueue.Enqueue(Encoding.UTF8.GetBytes(response));
256+
return length;
257+
}
258+
259+
_readQueue.Enqueue(Encoding.UTF8.GetBytes("OKAY"));
260+
return length;
261+
}
262+
263+
public void Dispose()
264+
{
265+
_currentPayload?.Dispose();
266+
}
267+
}
268+
196269
private sealed class NonSeekableStream : Stream
197270
{
198271
private readonly Stream _inner;
@@ -408,6 +481,23 @@ public void DownloadDataStream_Retry_UsesInitialStreamPosition()
408481
Assert.Equal(new byte[] { 0x01, 0x02, 0x03, 0x04 }, transport.DownloadPayload.ToArray());
409482
}
410483

484+
[Fact]
485+
public void DownloadDataStream_Retry_WhenDownloadHandshakeIsNotData()
486+
{
487+
var transport = new MockTransport();
488+
transport.EnqueueResponse("OKAYno");
489+
transport.EnqueueResponse("OKAY");
490+
transport.EnqueueResponse("DATA00000004");
491+
transport.EnqueueResponse("OKAY");
492+
var util = new FastbootDriver(transport);
493+
494+
using var stream = new MemoryStream(new byte[] { 0x01, 0x02, 0x03, 0x04 });
495+
var response = util.DownloadData(stream, 4);
496+
497+
Assert.Equal(FastbootState.Success, response.Result);
498+
Assert.True(transport.Commands.Count(c => c == "download:00000004") >= 2);
499+
}
500+
411501
[Fact]
412502
public void DownloadDataStream_NonSeekable_DoesNotRetry()
413503
{
@@ -617,6 +707,107 @@ public void FlashUnsparseImage_LogicalPartition_ResizesBeforeFlash()
617707
Assert.Contains("flash:system_b", transport.Commands);
618708
}
619709

710+
[Fact]
711+
public void FlashUnsparseImage_JustUnderLimit_UsesRaw()
712+
{
713+
// size one byte less than max-download-size (0x40 from protocol stub)
714+
byte[] imageBytes = new byte[0x3F];
715+
var transport = new ProtocolDownloadCaptureTransport(new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
716+
{
717+
["getvar:has-slot:boot"] = "OKAYno",
718+
["getvar:is-logical:boot"] = "OKAYno",
719+
["getvar:max-download-size"] = "OKAY0x40",
720+
["flash:boot"] = "OKAY"
721+
});
722+
var util = new FastbootDriver(transport);
723+
using var stream = new MemoryStream(imageBytes);
724+
725+
var response = util.FlashUnsparseImage("boot", stream, stream.Length);
726+
Assert.Equal(FastbootState.Success, response.Result);
727+
728+
// ensure downloaded payload matches original bytes and not a sparse header
729+
var payload = transport.DownloadPayload.ToArray();
730+
Assert.Equal(imageBytes.Length, payload.Length);
731+
Assert.NotEqual(0xed, payload[0]); // first byte of sparse magic is 0x3a? actually magic is 0xed26ff3a; just ensure not starting with ED
732+
}
733+
734+
[Fact]
735+
public void FlashUnsparseImage_ExactLimit_CompressesIfNeeded()
736+
{
737+
// image equal to limit - will trigger sparse conversion because size > max
738+
byte[] imageBytes = new byte[0x41];
739+
// put some zeros to make sparse compressible
740+
for (int i = 1; i < imageBytes.Length; i++) imageBytes[i] = 0;
741+
742+
var transport = new ProtocolDownloadCaptureTransport(new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
743+
{
744+
["getvar:has-slot:boot"] = "OKAYno",
745+
["getvar:is-logical:boot"] = "OKAYno",
746+
["getvar:max-download-size"] = "OKAY0x40",
747+
["flash:boot"] = "OKAY"
748+
});
749+
var util = new FastbootDriver(transport);
750+
using var stream = new MemoryStream(imageBytes);
751+
752+
var response = util.FlashUnsparseImage("boot", stream, stream.Length);
753+
Assert.Equal(FastbootState.Success, response.Result);
754+
755+
var payload = transport.DownloadPayload.ToArray();
756+
// sparse images always start with the 32‑bit magic value 0xED26FF3A
757+
// the header is written little‑endian so first four bytes should be 3A FF 26 ED
758+
Assert.True(payload.Length >= 4);
759+
Assert.Equal(0x3a, payload[0]);
760+
Assert.Equal(0xff, payload[1]);
761+
Assert.Equal(0x26, payload[2]);
762+
Assert.Equal(0xed, payload[3]);
763+
}
764+
765+
[Fact]
766+
public void NotifyCurrentStep_DefaultWritesToConsole()
767+
{
768+
var util = new FastbootDriver(new ProtocolDownloadCaptureTransport());
769+
using var err = new StringWriter();
770+
var orig = Console.Error;
771+
Console.SetError(err);
772+
util.NotifyCurrentStep("example step");
773+
Console.SetError(orig);
774+
775+
string output = err.ToString();
776+
Assert.Contains("example step", output);
777+
}
778+
779+
[Fact]
780+
public void NotifyProgress_DefaultWritesToConsole()
781+
{
782+
var util = new FastbootDriver(new ProtocolDownloadCaptureTransport());
783+
using var err = new StringWriter();
784+
var orig = Console.Error;
785+
Console.SetError(err);
786+
util.NotifyProgress(50, 200);
787+
Console.SetError(orig);
788+
789+
string output = err.ToString();
790+
Assert.Contains("50/200", output);
791+
Assert.Contains("25%", output);
792+
}
793+
794+
[Fact]
795+
public void Progress_WithHandler_DoesNotDoublePrint()
796+
{
797+
var util = new FastbootDriver(new ProtocolDownloadCaptureTransport());
798+
bool called = false;
799+
util.DataTransferProgressChanged += (s, e) => called = true;
800+
801+
using var err = new StringWriter();
802+
var orig = Console.Error;
803+
Console.SetError(err);
804+
util.NotifyProgress(1, 1);
805+
Console.SetError(orig);
806+
807+
Assert.True(called);
808+
Assert.Equal(string.Empty, err.ToString());
809+
}
810+
620811
[Fact]
621812
public void FlashSparseFile_TinyLimit_FallsBackToSingleSparseTransfer()
622813
{
@@ -635,5 +826,34 @@ public void FlashSparseFile_TinyLimit_FallsBackToSingleSparseTransfer()
635826
Assert.Contains(transport.Commands, c => c.StartsWith("download:", StringComparison.OrdinalIgnoreCase));
636827
Assert.Contains("flash:boot", transport.Commands);
637828
}
829+
830+
[Fact]
831+
public void FlashSparseFile_Multipart_PreservesOriginalTotalBlocksInEachSparseHeader()
832+
{
833+
var transport = new MultiPartDownloadCaptureTransport(new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
834+
{
835+
["flash:system_a"] = "OKAY"
836+
});
837+
var util = new FastbootDriver(transport);
838+
839+
using var sparse = new SparseFile(4096, 3 * 1024 * 1024);
840+
sparse.AddRawChunk(new byte[1536 * 1024]);
841+
sparse.AddDontCareChunk(512 * 1024);
842+
sparse.AddRawChunk(new byte[1024 * 1024]);
843+
844+
uint originalTotalBlocks = sparse.Header.TotalBlocks;
845+
846+
var response = util.FlashSparseFile("system_a", sparse, 1024 * 1024);
847+
848+
Assert.Equal(FastbootState.Success, response.Result);
849+
Assert.True(transport.DownloadPayloads.Count > 1);
850+
851+
foreach (var payload in transport.DownloadPayloads)
852+
{
853+
Assert.True(payload.Length >= SparseFormat.SparseHeaderSize);
854+
var header = SparseHeader.FromBytes(payload.AsSpan(0, SparseFormat.SparseHeaderSize));
855+
Assert.Equal(originalTotalBlocks, header.TotalBlocks);
856+
}
857+
}
638858
}
639859
}
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
using FirmwareKit.Sparse.Core;
2+
using FirmwareKit.Sparse.Models;
3+
4+
namespace FirmwareKit.Comm.Fastboot.Tests
5+
{
6+
public class SparseFileResparseTests
7+
{
8+
[Fact]
9+
public async Task ResparseAndMerge_ShouldProduceIdenticalSparseImage()
10+
{
11+
// Arrange: 创建一个临时稀疏文件
12+
var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
13+
Directory.CreateDirectory(tempDir);
14+
var originalPath = Path.Combine(tempDir, "original_sparse.img");
15+
var mergedPath = Path.Combine(tempDir, "merged_sparse.img");
16+
var blockSize = 4096u;
17+
var totalSize = 10 * 1024 * 1024; // 10MB
18+
using (var sparse = new SparseFile(blockSize, totalSize))
19+
{
20+
// 填充一些数据块
21+
sparse.AddFillChunk(0xAABBCCDD, 2 * 1024 * 1024); // 2MB填充
22+
sparse.AddDontCareChunk(1 * 1024 * 1024); // 1MB空洞
23+
sparse.AddRawChunk(new byte[blockSize * 10]); // 10块原始数据
24+
using (var fs = File.Create(originalPath))
25+
{
26+
sparse.WriteToStream(fs, sparse: true);
27+
}
28+
}
29+
30+
// Act: 读取稀疏文件并重稀疏化再合并
31+
using var original = SparseFile.FromImageFile(originalPath);
32+
var parts = original.Resparse(2 * 1024 * 1024).ToArray(); // 2MB分块
33+
34+
// 合并所有分块为一个新的SparseFile对象,使用公开API重建chunk
35+
var mergedSparse = new SparseFile(parts[0].Header.BlockSize, original.Header.TotalBlocks * parts[0].Header.BlockSize);
36+
foreach (var part in parts)
37+
{
38+
foreach (var chunk in part.Chunks)
39+
{
40+
switch ((ChunkType)chunk.Header.ChunkType)
41+
{
42+
case ChunkType.Raw:
43+
if (chunk.DataProvider != null)
44+
{
45+
var buffer = new byte[chunk.Header.ChunkSize * mergedSparse.Header.BlockSize];
46+
chunk.DataProvider.Read(0, buffer, 0, buffer.Length);
47+
mergedSparse.AddRawChunk(buffer);
48+
}
49+
break;
50+
case ChunkType.Fill:
51+
mergedSparse.AddFillChunk(chunk.FillValue, chunk.Header.ChunkSize * mergedSparse.Header.BlockSize);
52+
break;
53+
case ChunkType.DontCare:
54+
mergedSparse.AddDontCareChunk(chunk.Header.ChunkSize * mergedSparse.Header.BlockSize);
55+
break;
56+
}
57+
}
58+
part.Dispose();
59+
}
60+
// 合并后补齐DONT_CARE块,确保总块数一致
61+
if (mergedSparse.CurrentBlock < mergedSparse.Header.TotalBlocks)
62+
{
63+
var remainBlocks = mergedSparse.Header.TotalBlocks - mergedSparse.CurrentBlock;
64+
mergedSparse.AddDontCareChunk(remainBlocks * mergedSparse.Header.BlockSize);
65+
}
66+
using (var fs = File.Create(mergedPath))
67+
{
68+
mergedSparse.WriteToStream(fs, sparse: true);
69+
}
70+
mergedSparse.Dispose();
71+
72+
// Assert: 解包为raw image后比对内容
73+
var originalRaw = Path.Combine(tempDir, "original.raw");
74+
var mergedRaw = Path.Combine(tempDir, "merged.raw");
75+
using (var origSparse = SparseFile.FromImageFile(originalPath))
76+
using (var origRaw = File.Create(originalRaw))
77+
origSparse.WriteRawToStream(origRaw);
78+
79+
using (var mergedSparseFile = SparseFile.FromImageFile(mergedPath))
80+
using (var mergedRawStream = File.Create(mergedRaw))
81+
mergedSparseFile.WriteRawToStream(mergedRawStream);
82+
83+
var originalRawBytes = await File.ReadAllBytesAsync(originalRaw);
84+
var mergedRawBytes = await File.ReadAllBytesAsync(mergedRaw);
85+
Assert.Equal(originalRawBytes, mergedRawBytes);
86+
87+
// 清理
88+
File.Delete(originalPath);
89+
File.Delete(mergedPath);
90+
File.Delete(originalRaw);
91+
File.Delete(mergedRaw);
92+
Directory.Delete(tempDir);
93+
}
94+
}
95+
}

FirmwareKit.Comm.Fastboot/Command/Flash.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,13 +42,17 @@ static bool IsSparseHeader(Stream s)
4242
ResizeLogicalPartition(partition, imageSize);
4343
}
4444

45+
// log sizes so that callers can understand why we convert to sparse
46+
FastbootDebug.Log($"FlashUnsparseImage: imageSize={imageSize}, maxDownloadSize={maxDownloadSize}, isSparse={isSparse}");
47+
4548
if (isSparse)
4649
{
4750
NotifyCurrentStep($"Flashing sparse image to {partition}...");
4851
using var sparseImage = SparseFile.ImportAuto(stream, validateCrc: false, verbose: false);
4952
return FlashSparseFile(partition, sparseImage, maxDownloadSize);
5053
}
5154

55+
// Only send raw if the image will fit in a single transfer
5256
if (imageSize <= maxDownloadSize)
5357
{
5458
if (canSeek) stream.Seek(originalPosition, SeekOrigin.Begin);

FirmwareKit.Comm.Fastboot/Command/InternalDownloadDataBytes.cs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,14 @@ public FastbootResponse DownloadData(byte[] data)
2222

2323
FastbootResponse response = RawCommand("download:" + data.Length.ToString("x8"));
2424
if (response.Result != FastbootState.Data)
25-
return response;
25+
{
26+
return new FastbootResponse
27+
{
28+
Result = FastbootState.Fail,
29+
Response = "protocol error: expected DATA response to download command, got " + response.Result +
30+
(string.IsNullOrEmpty(response.Response) ? string.Empty : " (" + response.Response + ")")
31+
};
32+
}
2633
if (response.DataSize != data.Length)
2734
{
2835
return new FastbootResponse

FirmwareKit.Comm.Fastboot/Command/InternalDownloadDataStream.cs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,10 @@ private FastbootResponse DownloadDataInternal(Stream stream, long length, bool o
7979
// AOSP uses %08" PRIx32 which is 8 chars hex with leading zeros
8080
FastbootResponse response = RawCommand("download:" + length.ToString("x8"));
8181
if (response.Result != FastbootState.Data)
82-
return response;
82+
{
83+
throw new Exception("protocol error: expected DATA response to download command, got " + response.Result +
84+
(string.IsNullOrEmpty(response.Response) ? string.Empty : " (" + response.Response + ")"));
85+
}
8386
if (response.DataSize != length)
8487
{
8588
return new FastbootResponse

0 commit comments

Comments
 (0)