BSA_BetterStreamingAssets.cs 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668
  1. // Better Streaming Assets, Piotr Gwiazdowski <gwiazdorrr+github at gmail.com>, 2017
  2. using System;
  3. using System.Collections.Generic;
  4. using System.Linq;
  5. using System.Text;
  6. using UnityEngine;
  7. using System.IO;
  8. using System.Text.RegularExpressions;
  9. using Better;
  10. using Better.StreamingAssets;
  11. using Better.StreamingAssets.ZipArchive;
  12. #if UNITY_EDITOR
  13. using BetterStreamingAssetsImp = BetterStreamingAssets.EditorImpl;
  14. #elif UNITY_ANDROID
  15. using BetterStreamingAssetsImp = BetterStreamingAssets.ApkImpl;
  16. #else
  17. using BetterStreamingAssetsImp = BetterStreamingAssets.LooseFilesImpl;
  18. #endif
  19. public static partial class BetterStreamingAssets
  20. {
  21. internal struct ReadInfo
  22. {
  23. public string readPath;
  24. public long size;
  25. public long offset;
  26. public uint crc32;
  27. }
  28. public static string Root
  29. {
  30. get { return BetterStreamingAssetsImp.s_root; }
  31. }
  32. public static void Initialize()
  33. {
  34. BetterStreamingAssetsImp.Initialize(Application.dataPath, Application.streamingAssetsPath);
  35. }
  36. /// <summary>
  37. /// Android only: raised when there's a Streaming Asset that is compressed. If there is no handler
  38. /// or it returns false, a warning will be logged.
  39. /// </summary>
  40. public static event Func<string, bool> CompressedStreamingAssetFound;
  41. #if UNITY_EDITOR
  42. public static void InitializeWithExternalApk(string apkPath)
  43. {
  44. BetterStreamingAssetsImp.ApkMode = true;
  45. BetterStreamingAssetsImp.Initialize(apkPath, "jar:file://" + apkPath + "!/assets/");
  46. }
  47. public static void InitializeWithExternalDirectories(string dataPath, string streamingAssetsPath)
  48. {
  49. BetterStreamingAssetsImp.ApkMode = false;
  50. BetterStreamingAssetsImp.Initialize(dataPath, streamingAssetsPath);
  51. }
  52. #endif
  53. public static bool FileExists(string path)
  54. {
  55. ReadInfo info;
  56. return BetterStreamingAssetsImp.TryGetInfo(path, out info);
  57. }
  58. public static bool DirectoryExists(string path)
  59. {
  60. return BetterStreamingAssetsImp.DirectoryExists(path);
  61. }
  62. public static AssetBundleCreateRequest LoadAssetBundleAsync(string path, uint crc = 0)
  63. {
  64. var info = GetInfoOrThrow(path);
  65. return AssetBundle.LoadFromFileAsync(info.readPath, crc, (ulong)info.offset);
  66. }
  67. public static AssetBundle LoadAssetBundle(string path, uint crc = 0)
  68. {
  69. var info = GetInfoOrThrow(path);
  70. return AssetBundle.LoadFromFile(info.readPath, crc, (ulong)info.offset);
  71. }
  72. public static System.IO.Stream OpenRead(string path)
  73. {
  74. if ( path == null )
  75. throw new ArgumentNullException("path");
  76. if ( path.Length == 0 )
  77. throw new ArgumentException("Empty path", "path");
  78. return BetterStreamingAssetsImp.OpenRead(path);
  79. }
  80. public static System.IO.StreamReader OpenText(string path)
  81. {
  82. Stream str = OpenRead(path);
  83. try
  84. {
  85. return new StreamReader(str);
  86. }
  87. catch (System.Exception)
  88. {
  89. if (str != null)
  90. str.Dispose();
  91. throw;
  92. }
  93. }
  94. public static string ReadAllText(string path)
  95. {
  96. using ( var sr = OpenText(path) )
  97. {
  98. return sr.ReadToEnd();
  99. }
  100. }
  101. public static string[] ReadAllLines(string path)
  102. {
  103. string line;
  104. var lines = new List<string>();
  105. using ( var sr = OpenText(path) )
  106. {
  107. while ( ( line = sr.ReadLine() ) != null )
  108. {
  109. lines.Add(line);
  110. }
  111. }
  112. return lines.ToArray();
  113. }
  114. public static byte[] ReadAllBytes(string path)
  115. {
  116. if ( path == null )
  117. throw new ArgumentNullException("path");
  118. if ( path.Length == 0 )
  119. throw new ArgumentException("Empty path", "path");
  120. return BetterStreamingAssetsImp.ReadAllBytes(path);
  121. }
  122. public static string[] GetFiles(string path, string searchPattern, SearchOption searchOption)
  123. {
  124. return BetterStreamingAssetsImp.GetFiles(path, searchPattern, searchOption);
  125. }
  126. public static string[] GetFiles(string path)
  127. {
  128. return GetFiles(path, null);
  129. }
  130. public static string[] GetFiles(string path, string searchPattern)
  131. {
  132. return GetFiles(path, searchPattern, SearchOption.TopDirectoryOnly);
  133. }
  134. private static ReadInfo GetInfoOrThrow(string path)
  135. {
  136. ReadInfo result;
  137. if ( !BetterStreamingAssetsImp.TryGetInfo(path, out result) )
  138. ThrowFileNotFound(path);
  139. return result;
  140. }
  141. private static void ThrowFileNotFound(string path)
  142. {
  143. throw new FileNotFoundException("File not found", path);
  144. }
  145. static partial void AndroidIsCompressedFileStreamingAsset(string path, ref bool result);
  146. #if UNITY_EDITOR
  147. internal static class EditorImpl
  148. {
  149. public static bool ApkMode = false;
  150. public static string s_root
  151. {
  152. get { return ApkMode ? ApkImpl.s_root : LooseFilesImpl.s_root; }
  153. }
  154. internal static void Initialize(string dataPath, string streamingAssetsPath)
  155. {
  156. if ( ApkMode )
  157. {
  158. ApkImpl.Initialize(dataPath, streamingAssetsPath);
  159. }
  160. else
  161. {
  162. LooseFilesImpl.Initialize(dataPath, streamingAssetsPath);
  163. }
  164. }
  165. internal static bool TryGetInfo(string path, out ReadInfo info)
  166. {
  167. if ( ApkMode )
  168. return ApkImpl.TryGetInfo(path, out info);
  169. else
  170. return LooseFilesImpl.TryGetInfo(path, out info);
  171. }
  172. internal static bool DirectoryExists(string path)
  173. {
  174. if ( ApkMode )
  175. return ApkImpl.DirectoryExists(path);
  176. else
  177. return LooseFilesImpl.DirectoryExists(path);
  178. }
  179. internal static Stream OpenRead(string path)
  180. {
  181. if ( ApkMode )
  182. return ApkImpl.OpenRead(path);
  183. else
  184. return LooseFilesImpl.OpenRead(path);
  185. }
  186. internal static byte[] ReadAllBytes(string path)
  187. {
  188. if ( ApkMode )
  189. return ApkImpl.ReadAllBytes(path);
  190. else
  191. return LooseFilesImpl.ReadAllBytes(path);
  192. }
  193. internal static string[] GetFiles(string path, string searchPattern, SearchOption searchOption)
  194. {
  195. if ( ApkMode )
  196. return ApkImpl.GetFiles(path, searchPattern, searchOption);
  197. else
  198. return LooseFilesImpl.GetFiles(path, searchPattern, searchOption);
  199. }
  200. }
  201. #endif
  202. #if UNITY_EDITOR || !UNITY_ANDROID
  203. internal static class LooseFilesImpl
  204. {
  205. public static string s_root;
  206. private static string[] s_emptyArray = new string[0];
  207. public static void Initialize(string dataPath, string streamingAssetsPath)
  208. {
  209. s_root = Path.GetFullPath(streamingAssetsPath).Replace('\\', '/').TrimEnd('/');
  210. }
  211. public static string[] GetFiles(string path, string searchPattern, SearchOption searchOption)
  212. {
  213. if (!Directory.Exists(s_root))
  214. return s_emptyArray;
  215. // this will throw if something is fishy
  216. path = PathUtil.NormalizeRelativePath(path, forceTrailingSlash : true);
  217. Debug.Assert(s_root.Last() != '\\' && s_root.Last() != '/' && path.StartsWith("/"));
  218. var files = Directory.GetFiles(s_root + path, searchPattern ?? "*", searchOption);
  219. for ( int i = 0; i < files.Length; ++i )
  220. {
  221. Debug.Assert(files[i].StartsWith(s_root));
  222. files[i] = files[i].Substring(s_root.Length + 1).Replace('\\', '/');
  223. }
  224. #if UNITY_EDITOR
  225. // purge meta files
  226. {
  227. int j = 0;
  228. for ( int i = 0; i < files.Length; ++i )
  229. {
  230. if ( !files[i].EndsWith(".meta") )
  231. {
  232. files[j++] = files[i];
  233. }
  234. }
  235. Array.Resize(ref files, j);
  236. }
  237. #endif
  238. return files;
  239. }
  240. public static bool TryGetInfo(string path, out ReadInfo info)
  241. {
  242. path = PathUtil.NormalizeRelativePath(path);
  243. info = new ReadInfo();
  244. var fullPath = s_root + path;
  245. if ( !File.Exists(fullPath) )
  246. return false;
  247. info.readPath = fullPath;
  248. return true;
  249. }
  250. public static bool DirectoryExists(string path)
  251. {
  252. var normalized = PathUtil.NormalizeRelativePath(path);
  253. return Directory.Exists(s_root + normalized);
  254. }
  255. public static byte[] ReadAllBytes(string path)
  256. {
  257. ReadInfo info;
  258. if ( !TryGetInfo(path, out info) )
  259. ThrowFileNotFound(path);
  260. return File.ReadAllBytes(info.readPath);
  261. }
  262. public static System.IO.Stream OpenRead(string path)
  263. {
  264. ReadInfo info;
  265. if ( !TryGetInfo(path, out info) )
  266. ThrowFileNotFound(path);
  267. Stream fileStream = File.OpenRead(info.readPath);
  268. try
  269. {
  270. return new SubReadOnlyStream(fileStream, leaveOpen: false);
  271. }
  272. catch ( System.Exception )
  273. {
  274. fileStream.Dispose();
  275. throw;
  276. }
  277. }
  278. }
  279. #endif
  280. #if UNITY_EDITOR || UNITY_ANDROID
  281. internal static class ApkImpl
  282. {
  283. private static string[] s_paths;
  284. private static PartInfo[] s_streamingAssets;
  285. public static string s_root;
  286. private struct PartInfo
  287. {
  288. public long size;
  289. public long offset;
  290. public uint crc32;
  291. }
  292. public static void Initialize(string dataPath, string streamingAssetsPath)
  293. {
  294. s_root = dataPath;
  295. List<string> paths = new List<string>();
  296. List<PartInfo> parts = new List<PartInfo>();
  297. GetStreamingAssetsInfoFromJar(s_root, paths, parts);
  298. if (paths.Count == 0 && !Application.isEditor && Path.GetFileName(dataPath) != "base.apk")
  299. {
  300. // maybe split?
  301. var newDataPath = Path.GetDirectoryName(dataPath) + "/base.apk";
  302. if (File.Exists(newDataPath))
  303. {
  304. s_root = newDataPath;
  305. GetStreamingAssetsInfoFromJar(newDataPath, paths, parts);
  306. }
  307. }
  308. s_paths = paths.ToArray();
  309. s_streamingAssets = parts.ToArray();
  310. }
  311. public static bool TryGetInfo(string path, out ReadInfo info)
  312. {
  313. path = PathUtil.NormalizeRelativePath(path);
  314. info = new ReadInfo();
  315. var index = Array.BinarySearch(s_paths, path, StringComparer.OrdinalIgnoreCase);
  316. if ( index < 0 )
  317. return false;
  318. var dataInfo = s_streamingAssets[index];
  319. info.crc32 = dataInfo.crc32;
  320. info.offset = dataInfo.offset;
  321. info.size = dataInfo.size;
  322. info.readPath = s_root;
  323. return true;
  324. }
  325. public static bool DirectoryExists(string path)
  326. {
  327. var normalized = PathUtil.NormalizeRelativePath(path, forceTrailingSlash : true);
  328. var dirIndex = GetDirectoryIndex(normalized);
  329. return dirIndex >= 0 && dirIndex < s_paths.Length;
  330. }
  331. public static string[] GetFiles(string path, string searchPattern, SearchOption searchOption)
  332. {
  333. if ( path == null )
  334. throw new ArgumentNullException("path");
  335. var actualDirPath = PathUtil.NormalizeRelativePath(path, forceTrailingSlash : true);
  336. // find first file there
  337. var index = GetDirectoryIndex(actualDirPath);
  338. if ( index < 0 )
  339. throw new IOException();
  340. if ( index == s_paths.Length )
  341. throw new DirectoryNotFoundException();
  342. Predicate<string> filter;
  343. if ( string.IsNullOrEmpty(searchPattern) || searchPattern == "*" )
  344. {
  345. filter = null;
  346. }
  347. else if ( searchPattern.IndexOf('*') >= 0 || searchPattern.IndexOf('?') >= 0 )
  348. {
  349. var regex = PathUtil.WildcardToRegex(searchPattern);
  350. filter = (x) => regex.IsMatch(x);
  351. }
  352. else
  353. {
  354. filter = (x) => string.Compare(x, searchPattern, true) == 0;
  355. }
  356. List<string> results = new List<string>();
  357. for ( int i = index; i < s_paths.Length; ++i )
  358. {
  359. var filePath = s_paths[i];
  360. if ( !filePath.StartsWith(actualDirPath) )
  361. break;
  362. string fileName;
  363. var dirSeparatorIndex = filePath.LastIndexOf('/', filePath.Length - 1, filePath.Length - actualDirPath.Length);
  364. if ( dirSeparatorIndex >= 0 )
  365. {
  366. if ( searchOption == SearchOption.TopDirectoryOnly )
  367. continue;
  368. fileName = filePath.Substring(dirSeparatorIndex + 1);
  369. }
  370. else
  371. {
  372. fileName = filePath.Substring(actualDirPath.Length);
  373. }
  374. // now do a match
  375. if ( filter == null || filter(fileName) )
  376. {
  377. Debug.Assert(filePath[0] == '/');
  378. results.Add(filePath.Substring(1));
  379. }
  380. }
  381. return results.ToArray();
  382. }
  383. public static byte[] ReadAllBytes(string path)
  384. {
  385. ReadInfo info;
  386. if ( !TryGetInfo(path, out info) )
  387. ThrowFileNotFound(path);
  388. byte[] buffer;
  389. using ( var fileStream = File.OpenRead(info.readPath) )
  390. {
  391. if ( info.offset != 0 )
  392. {
  393. if ( fileStream.Seek(info.offset, SeekOrigin.Begin) != info.offset )
  394. throw new IOException();
  395. }
  396. if ( info.size > (long)int.MaxValue )
  397. throw new IOException();
  398. int count = (int)info.size;
  399. int offset = 0;
  400. buffer = new byte[count];
  401. while ( count > 0 )
  402. {
  403. int num = fileStream.Read(buffer, offset, count);
  404. if ( num == 0 )
  405. throw new EndOfStreamException();
  406. offset += num;
  407. count -= num;
  408. }
  409. }
  410. return buffer;
  411. }
  412. public static System.IO.Stream OpenRead(string path)
  413. {
  414. ReadInfo info;
  415. if ( !TryGetInfo(path, out info) )
  416. ThrowFileNotFound(path);
  417. Stream fileStream = File.OpenRead(info.readPath);
  418. try
  419. {
  420. return new SubReadOnlyStream(fileStream, info.offset, info.size, leaveOpen : false);
  421. }
  422. catch ( System.Exception )
  423. {
  424. fileStream.Dispose();
  425. throw;
  426. }
  427. }
  428. private static int GetDirectoryIndex(string path)
  429. {
  430. Debug.Assert(s_paths != null);
  431. // find first file there
  432. var index = Array.BinarySearch(s_paths, path, StringComparer.OrdinalIgnoreCase);
  433. if ( index >= 0 )
  434. return ~index;
  435. // if the end, no such directory exists
  436. index = ~index;
  437. if ( index == s_paths.Length )
  438. return index;
  439. for ( int i = index; i < s_paths.Length && s_paths[i].StartsWith(path); ++i )
  440. {
  441. // because otherwise there would be a match
  442. Debug.Assert(s_paths[i].Length > path.Length);
  443. if ( path[path.Length - 1] == '/' )
  444. return i;
  445. if ( s_paths[i][path.Length] == '/' )
  446. return i;
  447. }
  448. return s_paths.Length;
  449. }
  450. private static void GetStreamingAssetsInfoFromJar(string apkPath, List<string> paths, List<PartInfo> parts)
  451. {
  452. using ( var stream = File.OpenRead(apkPath) )
  453. using ( var reader = new BinaryReader(stream) )
  454. {
  455. if ( !stream.CanRead )
  456. throw new ArgumentException();
  457. if ( !stream.CanSeek )
  458. throw new ArgumentException();
  459. long expectedNumberOfEntries;
  460. long centralDirectoryStart;
  461. ZipArchiveUtils.ReadEndOfCentralDirectory(stream, reader, out expectedNumberOfEntries, out centralDirectoryStart);
  462. try
  463. {
  464. stream.Seek(centralDirectoryStart, SeekOrigin.Begin);
  465. long numberOfEntries = 0;
  466. ZipCentralDirectoryFileHeader header;
  467. const int prefixLength = 7;
  468. const string prefix = "assets/";
  469. const string assetsPrefix = "assets/bin/";
  470. Debug.Assert(prefixLength == prefix.Length);
  471. while ( ZipCentralDirectoryFileHeader.TryReadBlock(reader, out header) )
  472. {
  473. if ( header.CompressedSize != header.UncompressedSize )
  474. {
  475. #if UNITY_ASSERTIONS
  476. var fileName = Encoding.UTF8.GetString(header.Filename);
  477. if (fileName.StartsWith(prefix) && !fileName.StartsWith(assetsPrefix))
  478. {
  479. bool isStreamingAsset = true;
  480. AndroidIsCompressedFileStreamingAsset(fileName, ref isStreamingAsset);
  481. if (!isStreamingAsset)
  482. {
  483. // partial method ignored it
  484. }
  485. else if (CompressedStreamingAssetFound?.Invoke(fileName) == true)
  486. {
  487. // handler ignored it
  488. }
  489. else
  490. {
  491. Debug.LogAssertionFormat($"BetterStreamingAssets: file {fileName} is where Streaming Assets are put, but is compressed. " +
  492. $"If this is a App Bundle build, see README.md for a possible workaround. " +
  493. $"If this file is not a Streaming Asset (has been on purpose by hand or by another plug-in), handle " +
  494. $"{nameof(CompressedStreamingAssetFound)} event or implement " +
  495. $"{nameof(AndroidIsCompressedFileStreamingAsset)} partial method to prevent " +
  496. $"this message from appearing again. ");
  497. }
  498. }
  499. #endif
  500. // we only want uncompressed files
  501. }
  502. else
  503. {
  504. var fileName = Encoding.UTF8.GetString(header.Filename);
  505. if (fileName.EndsWith("/"))
  506. {
  507. // there's some strangeness when it comes to OBB: directories are listed as files
  508. // simply ignoring them should be enough
  509. Debug.Assert(header.UncompressedSize == 0);
  510. }
  511. else if ( fileName.StartsWith(prefix) )
  512. {
  513. // ignore normal assets...
  514. if ( fileName.StartsWith(assetsPrefix) )
  515. {
  516. // Note: if you put bin directory in your StreamingAssets you will get false negative here
  517. }
  518. else
  519. {
  520. var relativePath = fileName.Substring(prefixLength - 1);
  521. var entry = new PartInfo()
  522. {
  523. crc32 = header.Crc32,
  524. offset = header.RelativeOffsetOfLocalHeader, // this offset will need fixing later on
  525. size = header.UncompressedSize
  526. };
  527. var index = paths.BinarySearch(relativePath, StringComparer.OrdinalIgnoreCase);
  528. if ( index >= 0 )
  529. throw new System.InvalidOperationException("Paths duplicate! " + fileName);
  530. paths.Insert(~index, relativePath);
  531. parts.Insert(~index, entry);
  532. }
  533. }
  534. }
  535. numberOfEntries++;
  536. }
  537. if ( numberOfEntries != expectedNumberOfEntries )
  538. throw new ZipArchiveException("Number of entries does not match");
  539. }
  540. catch ( EndOfStreamException ex )
  541. {
  542. throw new ZipArchiveException("CentralDirectoryInvalid", ex);
  543. }
  544. // now fix offsets
  545. for ( int i = 0; i < parts.Count; ++i )
  546. {
  547. var entry = parts[i];
  548. stream.Seek(entry.offset, SeekOrigin.Begin);
  549. if ( !ZipLocalFileHeader.TrySkipBlock(reader) )
  550. throw new ZipArchiveException("Local file header corrupt");
  551. entry.offset = stream.Position;
  552. parts[i] = entry;
  553. }
  554. }
  555. }
  556. }
  557. #endif
  558. }