11using FirmwareKit . Comm . Fastboot . Usb ;
22using FirmwareKit . Sparse . Core ;
3+ using FirmwareKit . Sparse . Models ;
34using System . Globalization ;
45using 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}
0 commit comments