Added support for downloading assets (including auto-update)

- Added support for downloading assets (including auto-update)
- By default, auto-update is turned off. Enable it in settings.
- Default repository for app icons and names is [https://github.com/tverona1/QuestAppLauncher_assets]. This can be configured in config.json.
This commit is contained in:
tverona1
2019-08-17 17:13:07 -07:00
parent f0c170b519
commit dd7b8ff6a4
40 changed files with 26886 additions and 88 deletions
@@ -1,5 +1,5 @@
fileFormatVersion: 2
guid: 6269b6df21455a244a60057eaa3ff7af
guid: 6da7b442ff875544cbb8aff0c6ca585a
folderAsset: yes
DefaultImporter:
externalObjects: {}
+9
View File
@@ -0,0 +1,9 @@
fileFormatVersion: 2
guid: 577d9725f58264943855b8ac185531fe
folderAsset: yes
timeCreated: 1466788344
licenseType: Store
DefaultImporter:
userData:
assetBundleName:
assetBundleVariant:
+9
View File
@@ -0,0 +1,9 @@
fileFormatVersion: 2
guid: 14f21d7a1e53a8c4e87b25526a7eb63c
folderAsset: yes
timeCreated: 1466788345
licenseType: Store
DefaultImporter:
userData:
assetBundleName:
assetBundleVariant:
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: aadad8ac54f29e44583510294ac5c312
timeCreated: 1466788355
licenseType: Store
TextScriptImporter:
userData:
assetBundleName:
assetBundleVariant:
Binary file not shown.
@@ -0,0 +1,76 @@
fileFormatVersion: 2
guid: 6a3c684705042f345975d924f6983e36
timeCreated: 1466788352
licenseType: Store
PluginImporter:
serializedVersion: 1
iconMap: {}
executionOrder: {}
isPreloaded: 0
platformData:
Android:
enabled: 1
settings:
CPU: AnyCPU
Any:
enabled: 0
settings: {}
Editor:
enabled: 0
settings:
CPU: AnyCPU
DefaultValueInitialized: true
OS: AnyOS
Linux:
enabled: 0
settings:
CPU: x86
Linux64:
enabled: 0
settings:
CPU: x86_64
OSXIntel:
enabled: 0
settings:
CPU: AnyCPU
OSXIntel64:
enabled: 0
settings:
CPU: AnyCPU
SamsungTV:
enabled: 1
settings:
STV_MODEL: STANDARD_13
Tizen:
enabled: 1
settings: {}
WebGL:
enabled: 1
settings: {}
Win:
enabled: 0
settings:
CPU: AnyCPU
Win64:
enabled: 0
settings:
CPU: AnyCPU
WindowsStoreApps:
enabled: 1
settings:
CPU: AnyCPU
DontProcess: False
PlaceholderPath: Assets/JsonDotNet/Assemblies/Standalone/Newtonsoft.Json.dll
SDK: AnySDK
ScriptingBackend: Il2Cpp
iOS:
enabled: 1
settings:
CompileFlags:
FrameworkDependencies:
tvOS:
enabled: 1
settings: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,9 @@
fileFormatVersion: 2
guid: 01ef782d02bb1994dbe418b69432552b
folderAsset: yes
timeCreated: 1466788344
licenseType: Store
DefaultImporter:
userData:
assetBundleName:
assetBundleVariant:
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: d6807fedb8dcaf04682d2c84f0ab753f
timeCreated: 1466788355
licenseType: Store
TextScriptImporter:
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,75 @@
fileFormatVersion: 2
guid: 17aef65a15b471f468b5fbeb4ff0c6a1
timeCreated: 1466788349
licenseType: Store
PluginImporter:
serializedVersion: 1
iconMap: {}
executionOrder: {}
isPreloaded: 0
platformData:
Android:
enabled: 0
settings:
CPU: AnyCPU
Any:
enabled: 0
settings: {}
Editor:
enabled: 1
settings:
CPU: AnyCPU
DefaultValueInitialized: true
OS: AnyOS
Linux:
enabled: 1
settings:
CPU: x86
Linux64:
enabled: 1
settings:
CPU: x86_64
LinuxUniversal:
enabled: 1
settings:
CPU: AnyCPU
OSXIntel:
enabled: 1
settings:
CPU: AnyCPU
OSXIntel64:
enabled: 1
settings:
CPU: AnyCPU
OSXUniversal:
enabled: 1
settings:
CPU: AnyCPU
SamsungTV:
enabled: 0
settings:
STV_MODEL: STANDARD_13
Win:
enabled: 1
settings:
CPU: AnyCPU
Win64:
enabled: 1
settings:
CPU: AnyCPU
WindowsStoreApps:
enabled: 0
settings:
CPU: AnyCPU
DontProcess: False
PlaceholderPath:
SDK: AnySDK
ScriptingBackend: Il2Cpp
iOS:
enabled: 0
settings:
CompileFlags:
FrameworkDependencies:
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,9 @@
fileFormatVersion: 2
guid: 1418141139a6ac443b18cb05c0643a29
folderAsset: yes
timeCreated: 1466788345
licenseType: Store
DefaultImporter:
userData:
assetBundleName:
assetBundleVariant:
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 36f7323c55864364d8bb88c736e4bca6
timeCreated: 1466788355
licenseType: Store
TextScriptImporter:
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,67 @@
fileFormatVersion: 2
guid: 9b6ba260dada0ea4a871a42011f8b87d
timeCreated: 1466788355
licenseType: Store
PluginImporter:
serializedVersion: 1
iconMap: {}
executionOrder: {}
isPreloaded: 0
platformData:
Android:
enabled: 0
settings:
CPU: AnyCPU
Any:
enabled: 0
settings: {}
Editor:
enabled: 0
settings:
CPU: AnyCPU
DefaultValueInitialized: true
OS: AnyOS
Linux:
enabled: 0
settings:
CPU: x86
Linux64:
enabled: 0
settings:
CPU: x86_64
OSXIntel:
enabled: 0
settings:
CPU: AnyCPU
OSXIntel64:
enabled: 0
settings:
CPU: AnyCPU
SamsungTV:
enabled: 0
settings:
STV_MODEL: STANDARD_13
Win:
enabled: 0
settings:
CPU: AnyCPU
Win64:
enabled: 0
settings:
CPU: AnyCPU
WindowsStoreApps:
enabled: 1
settings:
CPU: AnyCPU
DontProcess: False
PlaceholderPath: Assets/JsonDotNet/Assemblies/Standalone/Newtonsoft.Json.dll
SDK: AnySDK
ScriptingBackend: DotNet
iOS:
enabled: 0
settings:
CompileFlags:
FrameworkDependencies:
userData:
assetBundleName:
assetBundleVariant:
+1 -1
View File
@@ -12,6 +12,6 @@ MonoBehaviour:
m_Script: {fileID: 11500000, guid: 20553fac56ec59645857c0732b787431, type: 3}
m_Name: OVRBuildConfig
m_EditorClassIdentifier:
androidSDKPath:
androidSDKPath: C:\Users\tvero\AppData\Local\Android\Sdk
gradlePath:
jdkPath:
File diff suppressed because it is too large Load Diff
+28 -19
View File
@@ -3,6 +3,7 @@ using System.Collections;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using UnityEngine;
namespace QuestAppLauncher
@@ -23,12 +24,12 @@ namespace QuestAppLauncher
// File name of app name overrides
const string AppNameOverrideFileSearch = "appnames*.txt";
// File name of excluded package names
const string ExcludedPackagesFile = "excludedpackages.txt";
// Icon pack search string
const string IconPackSearch = "iconpack*.zip";
// File name of excluded package names
const string ExcludedPackagesFile = "excludedpackages.txt";
// Icon pack extraction dir
const string IconPackExtractionDir = "cache";
@@ -117,18 +118,17 @@ namespace QuestAppLauncher
Debug.LogFormat("[{0}] package: {1}, name: {2}, auto tab: {3}", i, packageName, appName, tabName);
}
// Process appname*.txt files, sorted by name
foreach (var appNameOverrideFilePath in Directory.GetFiles(
persistentDataPath, AppNameOverrideFileSearch).OrderBy(f => f))
{
ProcessAppNameOverrideFile(apps, appNameOverrideFilePath);
}
// Process app name overrides files (both downloaded & manually created)
ProcessAppNameOverrideFiles(apps, AssetsDownloader.GetOrCreateDownloadPath());
ProcessAppNameOverrideFiles(apps, persistentDataPath);
// Extract icon packs
ExtractIconPacks(currentActivity);
// Extract icon packs (both downloaded & manually created)
ExtractIconPacks(currentActivity, AssetsDownloader.GetOrCreateDownloadPath());
ExtractIconPacks(currentActivity, persistentDataPath);
// Process extracted icons
ProcessExtractedIcons(apps);
// Process extracted icons (both downloaded & manually created)
ProcessExtractedIcons(apps, AssetsDownloader.GetOrCreateDownloadPath());
ProcessExtractedIcons(apps, persistentDataPath);
// Process any individual icons
var iconOverridePath = persistentDataPath;
@@ -141,6 +141,16 @@ namespace QuestAppLauncher
return apps;
}
private static void ProcessAppNameOverrideFiles(Dictionary<string, ProcessedApp> apps, string path)
{
// Process appname*.txt files, sorted by name
foreach (var filePath in Directory.GetFiles(
path, AppNameOverrideFileSearch).OrderBy(f => f))
{
ProcessAppNameOverrideFile(apps, filePath);
}
}
private static void ProcessAppNameOverrideFile(Dictionary<string, ProcessedApp> apps, string appNameOverrideFilePath)
{
// Override app names, if any
@@ -222,16 +232,15 @@ namespace QuestAppLauncher
}
}
private static void ExtractIconPacks(AndroidJavaObject currentActivity)
private static void ExtractIconPacks(AndroidJavaObject currentActivity, string iconPacksPath)
{
var iconPackDestinationFolders = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
var iconPacksPath = UnityEngine.Application.persistentDataPath;
if (!Directory.Exists(iconPacksPath))
{
return;
}
var iconPackDestinationFolders = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
// Full path of extraction dir
var extractionDirPath = Path.Combine(iconPacksPath, IconPackExtractionDir);
Debug.LogFormat("Extraction dir: {0}", extractionDirPath);
@@ -278,10 +287,10 @@ namespace QuestAppLauncher
}
}
private static void ProcessExtractedIcons(Dictionary<string, ProcessedApp> apps)
private static void ProcessExtractedIcons(Dictionary<string, ProcessedApp> apps, string iconsPath)
{
// Full path of extraction dir
var extractionDirPath = Path.Combine(UnityEngine.Application.persistentDataPath, IconPackExtractionDir);
var extractionDirPath = Path.Combine(iconsPath, IconPackExtractionDir);
if (Directory.Exists(extractionDirPath))
{
+466
View File
@@ -0,0 +1,466 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.IO;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using UnityEngine;
using UnityEngine.Networking;
using UnityEngine.SceneManagement;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace QuestAppLauncher
{
/// <summary>
/// Downloads assets (app icons packs and names files) from configured repos.
/// </summary>
public class AssetsDownloader
{
// Download cache folder that contains downloaded files
const string DownloadCacheFolder = "download_cache";
// Manifest file to track what we've downloaded
const string DownloadManifestFile = "download_manifest.json";
// GitHub API url
const string GithubApiUrl = @"http://api.github.com/repos/";
// Rate limit in minutes
const int RateLimitInMins = 5;
/// <summary>
/// Class that tracks each file downloaded.
/// Used to determine whether an asset has been updated since last download.
/// </summary>
[Serializable]
public class AssetInfo
{
// Repo uri
public string RepoUri;
// Asset's url
public string Url;
// Asset's updated at timestamp
public string UpdatedAt;
// Asset's tag name
public string TagName;
}
/// <summary>
/// Mapping of asset name to asset info above. This is persisted in the download manifest.
/// </summary>
[Serializable]
public class AssetsManifest
{
// File name -> release metadata
public Dictionary<string, AssetInfo> Metadata = new Dictionary<string, AssetInfo>();
// Last updated timestamp
public DateTime LastUpdated;
}
/// <summary>
/// Helper method to download assets asynchronously. After completion, scene is automatically reloaded
/// if new assets have been downloaded.
/// </summary>
/// <param name="config">Current config</param>
/// <param name="downloadProgress">Download progress interface - used to indicate download progress</param>
/// <returns></returns>
public static async Task DownloadAssetsAsync(Config config, IDownloadProgress downloadProgress = null, bool forceCheck = false)
{
// Start background thread
Task.Run(async () =>
{
// Attach / detatch JNI. Required for any calls into JNI from background threads.
AndroidJNI.AttachCurrentThread();
try
{
// Download assets from repos.
AssetsDownloader assetsDownloader = new AssetsDownloader();
return await assetsDownloader.DownloadFromReposAsync(config, downloadProgress, forceCheck);
}
finally
{
AndroidJNI.DetachCurrentThread();
}
}).ContinueWith((downloadedAssets) =>
{
if (downloadedAssets.Result)
{
// We downloaded new assets, so re-load the scene
Debug.Log("Downloaded new assets. Re-populating panel");
SceneManager.LoadScene(SceneManager.GetActiveScene().name);
}
});
}
/// <summary>
/// Asynchronously download assets from configured repos.
/// </summary>
/// <param name="config">Current config</param>
/// <param name="downloadProgress">Download progress interface</param>
/// <returns></returns>
private async Task<bool> DownloadFromReposAsync(Config config, IDownloadProgress downloadProgress = null, bool forceCheck = false)
{
if (null == config.downloadRepos)
{
// No repos configured, so return
return false;
}
// Load the download manifest. This is used to compare if we're up-to-date or not.
AssetsManifest manifest = LoadManifest();
if (null == manifest)
{
manifest = new AssetsManifest();
}
// Rate limit update checks to one per couple of minutes, to avoid GitHub's rate limit
if (!forceCheck && DateTime.Now.Subtract(manifest.LastUpdated).TotalMinutes < RateLimitInMins)
{
Debug.LogFormat("Exceeded rate limit of {0} - last checked for update on {1}", RateLimitInMins, manifest.LastUpdated);
return false;
}
// Mark that we've just checked for updates & update manifest
manifest.LastUpdated = DateTime.Now;
SaveManifest(manifest);
if (null != downloadProgress)
{
downloadProgress.OnCheckingForUpdates();
}
// Download the assets metadata - used to determine whether we are up-to-date or not
var assetsInfo = await DownloadAssetsMetadata(config, manifest, downloadProgress, forceCheck);
if (assetsInfo.Count == 0)
{
// No updates have been found, so return
Debug.Log("No updates found");
if (null != downloadProgress)
{
downloadProgress.OnNoUpdatesAvailable();
}
return false;
}
// Download the assets
var downloadedAssets = await DownloadFromReposInternalAsync(manifest, assetsInfo, downloadProgress);
if (null != downloadProgress)
{
downloadProgress.OnUpdateFinish();
}
return downloadedAssets;
}
/// <summary>
/// Downloads assets metadata. This is used to determine whether our assets are up-to-date.
/// </summary>
/// <param name="config">Current config</param>
/// <param name="downloadProgress">Download progress interface</param>
/// <returns></returns>
private async Task<Dictionary<string, AssetInfo>> DownloadAssetsMetadata(
Config config, AssetsManifest manifest, IDownloadProgress downloadProgress = null, bool forceCheck = false)
{
// Get asset info from repos
var assetsInfo = new Dictionary<string, AssetInfo>(StringComparer.OrdinalIgnoreCase);
var reposLoaded = new HashSet<string>();
foreach (var item in config.downloadRepos)
{
if (null == item.type || !string.Equals(item.type, Config.DownloadRepo_Type_GitHub, StringComparison.OrdinalIgnoreCase))
{
// For now, we only support GitHub repos
continue;
}
// Get assets from the GitHub repo
var repoLoaded = await GetAssetsInfoFromGithubRepoAsync(item.repoUri, assetsInfo, downloadProgress);
if (repoLoaded)
{
reposLoaded.Add(item.repoUri);
}
}
Debug.LogFormat("Assets info contains {0} entries", assetsInfo.Count);
// Enumerate asset metadata in our download manifest
var deletedAssets = new HashSet<string>();
foreach (var entry in manifest.Metadata)
{
if (assetsInfo.ContainsKey(entry.Key))
{
if (string.Equals(entry.Value.UpdatedAt, assetsInfo[entry.Key].UpdatedAt, StringComparison.OrdinalIgnoreCase) &&
File.Exists(Path.Combine(GetOrCreateDownloadPath(), entry.Key)))
{
// Matched on file name and updated-at timestamp, so we're up to date. Skip this one.
Debug.LogFormat("Asset {0} is already up to date ({1})", entry.Key, entry.Value.UpdatedAt);
assetsInfo.Remove(entry.Key);
}
}
else if (reposLoaded.Contains(entry.Value.RepoUri))
{
// The repo no longer has the cached file, so nuke it to keep things in sync
var filePath = Path.Combine(GetOrCreateDownloadPath(), entry.Key);
if (File.Exists(filePath))
{
Debug.LogFormat("Deleting cached file that no longer exists on server: {0} @ {1}", filePath, entry.Value.RepoUri);
File.Delete(filePath);
}
deletedAssets.Add(entry.Key);
}
}
// Remove old cached files
foreach (var entry in deletedAssets)
{
manifest.Metadata.Remove(entry);
}
if (deletedAssets.Count > 0)
{
// Persist manifest
SaveManifest(manifest);
}
return assetsInfo;
}
/// <summary>
/// Download assets from repos. The download manifest is also updated.
/// </summary>
/// <param name="manifest">Download manifest</param>
/// <param name="assetsInfo">Assets to download</param>
/// <param name="downloadProgress">Download progress interface</param>
/// <returns></returns>
private async Task<bool> DownloadFromReposInternalAsync(
AssetsManifest manifest, Dictionary<string, AssetInfo> assetsInfo, IDownloadProgress downloadProgress = null)
{
var downloadedAsset = false;
foreach (var entry in assetsInfo)
{
// Download asset
var success = await DownloadAssetFromGitHubRepoAsync(entry.Key, entry.Value, downloadProgress);
if (success)
{
// Update manifest
manifest.Metadata[entry.Key] = entry.Value;
downloadedAsset = true;
}
}
if (downloadedAsset)
{
// Persist manifest
SaveManifest(manifest);
}
return downloadedAsset;
}
/// <summary>
/// Download asset metadata from given uri. The assets info parameter is populate with the metadata.
/// </summary>
/// <param name="repoUri">The repo uri to download</param>
/// <param name="assetsInfo">Assets info mapping that is populate by this function</param>
/// <param name="downloadProgress">Download progress interface</param>
/// <returns></returns>
private async Task<bool> GetAssetsInfoFromGithubRepoAsync(string repoUri,
Dictionary<string, AssetInfo> assetsInfo, IDownloadProgress downloadProgress = null)
{
var requestUrl = GithubApiUrl + repoUri;
Debug.LogFormat("Reading assets from {0}", requestUrl);
try
{
// Request asset url
using (var req = new UnityWebRequest(requestUrl))
{
req.downloadHandler = new DownloadHandlerBuffer();
await req.SendWebRequest();
if (req.isNetworkError || req.isHttpError)
{
// Error reading asset metadata, so return.
Debug.LogFormat("Error reading asset info: {0}", req.error);
var responseHeaders = req.GetResponseHeaders();
if (null != responseHeaders && responseHeaders.ContainsKey("X-RateLimit-Remaining") &&
responseHeaders["X-RateLimit-Remaining"] == "0")
{
// Github request limit reached. Display a friendly error message.
Debug.LogFormat("Request limit reached");
if (null != downloadProgress)
{
downloadProgress.OnError(string.Format("Error updating: Request Limit Reached - try again later. ({1})",
req.error, requestUrl));
}
}
else
{
if (null != downloadProgress)
{
downloadProgress.OnError(string.Format("Error updating: {0} ({1})",
req.error, requestUrl));
}
}
return false;
}
// Parse the returned asset metadata
var jObject = JObject.Parse(req.downloadHandler.text);
var tagName = jObject["tag_name"].Value<string>();
foreach (var property in jObject["assets"])
{
var updatedAt = property["updated_at"].Value<string>();
var url = property["url"].Value<string>();
var name = property["name"].Value<string>();
// For now, simply accept any iconpack*.zip and appnames*.txt.
if ((name.StartsWith("iconpack") && name.EndsWith(".zip")) ||
(name.StartsWith("appnames") && name.EndsWith(".txt")))
{
assetsInfo[name] = new AssetInfo { RepoUri = repoUri, Url = url, UpdatedAt = updatedAt, TagName = tagName };
}
}
}
}
catch (Exception e)
{
Debug.LogFormat("Exception reading asset info: {0} ", e.Message);
if (null != downloadProgress)
{
downloadProgress.OnError(string.Format("Error updating: {0} ({1})",
e.Message, requestUrl));
}
return false;
}
return true;
}
/// <summary>
/// Download a single asset (file) from repo
/// </summary>
/// <param name="name">Name of the asset (file) to download</param>
/// <param name="assetInfo">Asset info (metadata)</param>
/// <param name="downloadProgress">Download progress interface</param>
/// <returns></returns>
private async Task<bool> DownloadAssetFromGitHubRepoAsync(string name, AssetInfo assetInfo,
IDownloadProgress downloadProgress)
{
var filePath = Path.Combine(GetOrCreateDownloadPath(), name);
Debug.LogFormat("Downloading asset {0} from {1}", filePath, assetInfo.Url);
try
{
// Request asset url
using (var req = new UnityWebRequest(assetInfo.Url))
{
req.SetRequestHeader("Accept", "application/octet-stream");
if (null != downloadProgress)
{
downloadProgress.OnDownloadStart(name);
}
var downloadHandler = new DownloadHandlerFileWithProgress(filePath, downloadProgress.OnDownloadProgress);
downloadHandler.removeFileOnAbort = true;
req.downloadHandler = downloadHandler;
await req.SendWebRequest();
if (req.isNetworkError || req.isHttpError)
{
// Error reading asset metadata, so return.
Debug.LogFormat("Error downloading asset: {0}", req.error);
if (null != downloadProgress)
{
downloadProgress.OnError(string.Format("Error updating: {0} ({1})",
req.error, assetInfo.Url));
}
return false;
}
if (null != downloadProgress)
{
downloadProgress.OnDownloadFinish();
}
return true;
}
}
catch (Exception e)
{
Debug.LogFormat("Exception downloading asset: {0} ", e.Message);
if (null != downloadProgress)
{
downloadProgress.OnError(string.Format("Error updating: {0} ({1})",
e.Message, assetInfo.Url));
}
}
return false;
}
/// <summary>
/// Load download manifest
/// </summary>
/// <returns>Download manifest</returns>
static private AssetsManifest LoadManifest()
{
var manifestPath = Path.Combine(GetOrCreateDownloadPath(), DownloadManifestFile);
if (!File.Exists(manifestPath))
{
return null;
}
Debug.Log("Found manifest: " + manifestPath);
var jsonManifest = File.ReadAllText(manifestPath);
try
{
AssetsManifest manifest = new AssetsManifest();
return JsonConvert.DeserializeObject<AssetsManifest>(jsonManifest);
}
catch (Exception e)
{
Debug.Log(string.Format("Failed to read manifest: {0}", e.Message));
}
return null;
}
/// <summary>
/// Persist download manifest
/// </summary>
/// <param name="manifest">Download manifest to persist</param>
static private void SaveManifest(AssetsManifest manifest)
{
var manifestPath = Path.Combine(GetOrCreateDownloadPath(), DownloadManifestFile);
Debug.Log("Saving manifest: " + manifestPath);
try
{
File.WriteAllText(manifestPath, JsonConvert.SerializeObject(manifest, Formatting.Indented));
}
catch (Exception e)
{
Debug.Log(string.Format("Failed to write manifest: {0}", e.Message));
}
}
static public string GetOrCreateDownloadPath()
{
string path = Path.Combine(UnityEngine.Application.persistentDataPath, DownloadCacheFolder);
Directory.CreateDirectory(path);
return path;
}
}
}
+11
View File
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: f6d9bad1aeba3c1448c9d931adb9ee97
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+34 -5
View File
@@ -3,6 +3,8 @@ using System.Collections;
using System.Collections.Generic;
using System.IO;
using UnityEngine;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace QuestAppLauncher
{
@@ -18,6 +20,10 @@ namespace QuestAppLauncher
public const string Category_Left = "left";
public const string Category_Right = "right";
// Download repos
public const string DownloadRepo_Type_GitHub = "github";
public const string DownloadRepo_Default = @"tverona1/QuestAppLauncher_Assets/releases/latest";
/// <summary>
/// Grid size
/// </summary>
@@ -28,6 +34,16 @@ namespace QuestAppLauncher
public int cols = 3;
}
/// <summary>
/// Download repo type
/// </summary>
[Serializable]
public class DownloadRepo
{
public string repoUri;
public string type;
}
// Grid size, specified as cols x rows
public GridSize gridSize = new GridSize();
@@ -39,6 +55,15 @@ namespace QuestAppLauncher
// Custom Category: Apps are categorized according to appnames.txt file
public string customCategory = Category_Right;
// Whether to auto-download updates
public bool autoUpdate = false;
// Github download repos
public List<DownloadRepo> downloadRepos = new List<DownloadRepo>()
{
new DownloadRepo { repoUri = DownloadRepo_Default, type = DownloadRepo_Type_GitHub }
};
}
/// <summary>
@@ -52,8 +77,9 @@ namespace QuestAppLauncher
/// <summary>
/// Load config from file
/// </summary>
/// <param name="config">Config object that will be overwritten</param>
static public void LoadConfig(Config config)
/// <param name="config"></param>
/// <returns>Config object</returns>
static public Config LoadConfig()
{
var configFilePath = Path.Combine(UnityEngine.Application.persistentDataPath, ConfigFileName);
if (File.Exists(configFilePath))
@@ -63,7 +89,7 @@ namespace QuestAppLauncher
try
{
JsonUtility.FromJsonOverwrite(jsonConfig, config);
return JsonConvert.DeserializeObject<Config>(jsonConfig);
}
catch (Exception e)
{
@@ -74,6 +100,9 @@ namespace QuestAppLauncher
{
Debug.Log("Did not find config file: " + configFilePath);
}
// Return default config
return new Config();
}
/// <summary>
@@ -87,11 +116,11 @@ namespace QuestAppLauncher
try
{
File.WriteAllText(configFilePath, JsonUtility.ToJson(config, true));
File.WriteAllText(configFilePath, JsonConvert.SerializeObject(config, Formatting.Indented));
}
catch (Exception e)
{
Debug.Log(string.Format("Failed to read config: {0}", e.Message));
Debug.Log(string.Format("Failed to write config: {0}", e.Message));
}
}
@@ -0,0 +1,112 @@
using System;
using System.IO;
using UnityEngine;
using UnityEngine.Networking;
namespace QuestAppLauncher
{
/// <summary>
/// File download handler class with callback for progress.
/// This is a download hanlder that can be assocaited with a UnityWebRequest.
/// Based on: DownloadHandlerFile.cs by Luke Holland
/// (https://gist.github.com/luke161/a251b01c00f58d65a252812be8dce670)
/// </summary>
public class DownloadHandlerFileWithProgress : DownloadHandlerScript
{
// Whether to remove file on error
public bool removeFileOnAbort = false;
// Total content length in bytes
private int contentLength;
// Bytes recieved so far
private int received;
// File stream to write to
private FileStream stream;
// Callback to incidate progress: Percentage, total content length and current received
private Action<float, int, int> downloadProgress;
// Whether we have successfully received all the data
private bool receivedAllData = false;
/// <summary>
/// Constructor for the file download handler. Instantiates base class in pre-allocated mode.
/// </summary>
/// <param name="localFilePath">Local path to save file to</param>
/// <param name="downloadProgress">Download progress indicator</param>
/// <param name="bufferSize">Buffer size</param>
/// <param name="fileShare">File sharing mode</param>
public DownloadHandlerFileWithProgress(string localFilePath, Action<float, int, int> downloadProgress = null,
int bufferSize = 32768, FileShare fileShare = FileShare.ReadWrite) : base(new byte[bufferSize])
{
string directory = Path.GetDirectoryName(localFilePath);
if (!Directory.Exists(directory))
{
Directory.CreateDirectory(directory);
}
this.downloadProgress = downloadProgress;
this.contentLength = -1;
this.received = 0;
this.stream = new FileStream(localFilePath, FileMode.OpenOrCreate, FileAccess.Write, fileShare, bufferSize);
}
protected override float GetProgress()
{
return contentLength <= 0 ? 0 : Mathf.Clamp01((float)this.received / (float)contentLength);
}
protected override void ReceiveContentLength(int contentLength)
{
this.contentLength = contentLength;
}
protected override bool ReceiveData(byte[] data, int dataLength)
{
if (data == null || data.Length == 0)
{
return false;
}
this.received += dataLength;
this.stream.Write(data, 0, dataLength);
if (null != this.downloadProgress)
{
this.downloadProgress(GetProgress(), this.contentLength, this.received);
}
return true;
}
protected override void CompleteContent()
{
this.receivedAllData = true;
CloseStream();
}
public new void Dispose()
{
CloseStream();
base.Dispose();
}
private void CloseStream()
{
string filePath = this.stream.Name;
if (this.stream != null)
{
this.stream.Dispose();
this.stream = null;
}
if (this.removeFileOnAbort && !this.receivedAllData && File.Exists(filePath))
{
// We have not successfully received all the content, so delete the file
File.Delete(filePath);
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 12b34f1926e99e742a01d3c725d778df
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+182
View File
@@ -0,0 +1,182 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Threading.Tasks;
using UnityEngine;
using TMPro;
namespace QuestAppLauncher
{
/// <summary>
/// Script to display download status
/// </summary>
public class DownloadStatusIndicator : MonoBehaviour, IDownloadProgress
{
/// <summary>
/// State of the download
/// </summary>
enum State
{
None,
DownloadStart,
DownloadProgress,
DownloadProgressUpdated,
DownloadFinish,
Error,
Checking,
NoUpdates,
UpdateFinished
};
// Text gui
private TextMeshProUGUI textGui;
// Current & previous state. The callback may be invoked from non-main thread, so we use a simple
// state machine to track the state.
private State prevState = State.None;
private State state = State.None;
private readonly object stateLock = new object();
// State information
private string name;
private float progress;
private string errorMessage;
// Start is called before the first frame update
void Start()
{
this.textGui = GetComponent<TextMeshProUGUI>();
}
// Update is called once per frame
async void Update()
{
bool clearText = false;
if (this.state == this.prevState)
{
return;
}
lock (this.stateLock)
{
// Update UI based on state
switch (this.state)
{
case State.DownloadStart:
this.textGui.text = string.Format("Downloading {0}", this.name);
break;
case State.DownloadFinish:
this.textGui.text = string.Format("Downloading {0} [100%]", this.name);
break;
case State.Checking:
this.textGui.text = string.Format("Checking for updates");
break;
case State.UpdateFinished:
// End state - display last error if available & clear text after a delay
if (null == this.errorMessage)
{
this.textGui.text = string.Format("Update complete");
}
else
{
this.textGui.text = this.errorMessage;
}
clearText = true;
break;
case State.NoUpdates:
// End state - display last error if available & clear text after a delay
if (null == this.errorMessage)
{
this.textGui.text = string.Format("No updates available");
}
else
{
this.textGui.text = this.errorMessage;
}
clearText = true;
break;
case State.Error:
this.textGui.text = this.errorMessage;
break;
case State.DownloadProgress:
this.textGui.text = string.Format("Downloading {0} [{1:0}%]", this.name, this.progress * 100);
this.state = State.DownloadProgressUpdated;
break;
}
this.prevState = this.state;
}
if (clearText)
{
await ClearText();
}
}
public void OnDownloadStart(string name)
{
lock (this.stateLock)
{
this.name = name;
this.state = State.DownloadStart;
}
}
public void OnDownloadProgress(float progressPercentage, int totalContent, int receivedContent)
{
lock (this.stateLock)
{
this.state = State.DownloadProgress;
this.progress = progressPercentage;
}
}
public void OnDownloadFinish()
{
lock (this.stateLock)
{
this.state = State.DownloadFinish;
}
}
public void OnError(string message)
{
lock (this.stateLock)
{
this.state = State.Error;
this.errorMessage = message;
}
}
public void OnNoUpdatesAvailable()
{
lock (this.stateLock)
{
this.state = State.NoUpdates;
}
}
public void OnCheckingForUpdates()
{
lock (this.stateLock)
{
this.state = State.Checking;
}
}
public void OnUpdateFinish()
{
lock (this.stateLock)
{
this.state = State.UpdateFinished;
}
}
private async Task ClearText()
{
await Task.Delay(TimeSpan.FromSeconds(5));
this.textGui.text = "";
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 91f098ea5c7fe084c8f890ad453a103c
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+35
View File
@@ -0,0 +1,35 @@
using UnityEngine;
using System.Collections;
namespace QuestAppLauncher
{
/// <summary>
/// Simple class that holds state that survives scene reloads.
/// Follows Unity's DontDestroyOnLoad singleton pattern.
/// </summary>
public class GlobalState : MonoBehaviour
{
public static GlobalState Instance;
// State to indicate whether we already checked for update
public bool CheckedForUpdate = false;
void Awake()
{
this.InstantiateController();
}
private void InstantiateController()
{
if (Instance == null)
{
Instance = this;
DontDestroyOnLoad(this);
}
else if (this != Instance)
{
Destroy(this.gameObject);
}
}
}
}
+11
View File
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 8f8767936bbc48140a66cc60c561da2f
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+11 -2
View File
@@ -36,6 +36,9 @@ namespace QuestAppLauncher
// Tracking space
public GameObject trackingSpace;
// Download status indicator
public DownloadStatusIndicator downloadStatusIndicator;
// App info prefab (a cell in the grid content)
public GameObject prefabCell;
@@ -73,8 +76,7 @@ namespace QuestAppLauncher
private async Task PopulateAsync()
{
// Load configuration
Config config = new Config();
ConfigPersistence.LoadConfig(config);
var config = ConfigPersistence.LoadConfig();
// Process apps in background
var apps = await Task.Run(() =>
@@ -91,6 +93,13 @@ namespace QuestAppLauncher
}
});
// Download updates in the background
if (config.autoUpdate && !GlobalState.Instance.CheckedForUpdate)
{
GlobalState.Instance.CheckedForUpdate = true;
AssetsDownloader.DownloadAssetsAsync(config, this.downloadStatusIndicator);
}
// Populate the panel content
await PopulatePanelContentAsync(config, apps);
}
+18
View File
@@ -0,0 +1,18 @@
namespace QuestAppLauncher
{
/// <summary>
/// Interface to determine download progress
/// </summary>
public interface IDownloadProgress
{
void OnCheckingForUpdates();
void OnNoUpdatesAvailable();
void OnUpdateFinish();
void OnDownloadStart(string name);
void OnDownloadProgress(float progressPercentage, int totalContent, int receivedContent);
void OnDownloadFinish();
void OnError(string message);
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: b2df4b12ce2208543a80ccef9c388114
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+20 -2
View File
@@ -20,6 +20,8 @@ namespace QuestAppLauncher
public GameObject gridRowsText;
public GameObject gridPopulation;
public GameObject show2DToggle;
public GameObject autoUpdateToggle;
public DownloadStatusIndicator downloadStatusIndicator;
public Toggle tabsAutoOff;
public Toggle tabsAutoTop;
@@ -33,7 +35,7 @@ namespace QuestAppLauncher
private bool deletedHiddenAppsFile = false;
private Config config = new Config();
private Config config = null;
public void OpenSettings()
{
@@ -44,7 +46,7 @@ namespace QuestAppLauncher
this.settingsContainer.SetActive(true);
// Load config
ConfigPersistence.LoadConfig(this.config);
this.config = ConfigPersistence.LoadConfig();
// Set current cols & rows
var colsSlider = this.gridCols.GetComponent<Slider>();
@@ -61,6 +63,9 @@ namespace QuestAppLauncher
// Set 2D toggle
this.show2DToggle.GetComponent<Toggle>().SetIsOnWithoutNotify(this.config.show2D);
// Set auto-update toggle
this.autoUpdateToggle.GetComponent<Toggle>().SetIsOnWithoutNotify(this.config.autoUpdate);
// Set auto tab mode
if (this.config.autoCategory.Equals(Config.Category_Top, StringComparison.OrdinalIgnoreCase))
{
@@ -110,6 +115,11 @@ namespace QuestAppLauncher
this.settingsContainer.SetActive(false);
}
public void UpdateAssetsNow()
{
AssetsDownloader.DownloadAssetsAsync(config, this.downloadStatusIndicator, true);
}
public void DeleteExcludedApksFile()
{
Debug.Log("Delete Excluded App List");
@@ -158,6 +168,14 @@ namespace QuestAppLauncher
saveConfig = true;
}
// Update auto-update toggle
var autoUpdate = this.autoUpdateToggle.GetComponent<Toggle>().isOn;
if (autoUpdate != this.config.autoUpdate)
{
this.config.autoUpdate = autoUpdate;
saveConfig = true;
}
// Update auto tab mode
string tabAutoMode;
if (this.tabsAutoTop.isOn)
+44
View File
@@ -0,0 +1,44 @@
using System;
using System.Runtime.CompilerServices;
using UnityEngine;
using UnityEngine.Networking;
namespace QuestAppLauncher
{
/// <summary>
/// Awaiter implementation for UnityWebRequest - allows us to use async / await pattern on UnityWebRequest.
/// </summary>
public class UnityWebRequestAwaiter : INotifyCompletion
{
private UnityWebRequestAsyncOperation asyncOp;
private Action continuation;
public UnityWebRequestAwaiter(UnityWebRequestAsyncOperation asyncOp)
{
this.asyncOp = asyncOp;
asyncOp.completed += OnRequestCompleted;
}
public bool IsCompleted { get { return asyncOp.isDone; } }
public void GetResult() { }
public void OnCompleted(Action continuation)
{
this.continuation = continuation;
}
private void OnRequestCompleted(AsyncOperation obj)
{
continuation();
}
}
public static class ExtensionMethods
{
public static UnityWebRequestAwaiter GetAwaiter(this UnityWebRequestAsyncOperation asyncOp)
{
return new UnityWebRequestAwaiter(asyncOp);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: ab2a840feb98f634b9fdb7222ae7707c
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

+91
View File
@@ -0,0 +1,91 @@
fileFormatVersion: 2
guid: efc72c013d5d2734ea900bf079c4a1d6
TextureImporter:
internalIDToNameTable: []
externalObjects: {}
serializedVersion: 10
mipmaps:
mipMapMode: 0
enableMipMap: 1
sRGBTexture: 1
linearTexture: 0
fadeOut: 0
borderMipMap: 0
mipMapsPreserveCoverage: 0
alphaTestReferenceValue: 0.5
mipMapFadeDistanceStart: 1
mipMapFadeDistanceEnd: 3
bumpmap:
convertToNormalMap: 0
externalNormalMap: 0
heightScale: 0.25
normalMapFilter: 0
isReadable: 0
streamingMipmaps: 0
streamingMipmapsPriority: 0
grayScaleToAlpha: 0
generateCubemap: 6
cubemapConvolution: 0
seamlessCubemap: 0
textureFormat: 1
maxTextureSize: 2048
textureSettings:
serializedVersion: 2
filterMode: -1
aniso: -1
mipBias: -100
wrapU: -1
wrapV: -1
wrapW: -1
nPOTScale: 1
lightmap: 0
compressionQuality: 50
spriteMode: 0
spriteExtrude: 1
spriteMeshType: 1
alignment: 0
spritePivot: {x: 0.5, y: 0.5}
spritePixelsToUnits: 100
spriteBorder: {x: 0, y: 0, z: 0, w: 0}
spriteGenerateFallbackPhysicsShape: 1
alphaUsage: 1
alphaIsTransparency: 0
spriteTessellationDetail: -1
textureType: 0
textureShape: 1
singleChannelComponent: 0
maxTextureSizeSet: 0
compressionQualitySet: 0
textureFormatSet: 0
platformSettings:
- serializedVersion: 3
buildTarget: DefaultTexturePlatform
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
spriteSheet:
serializedVersion: 2
sprites: []
outline: []
physicsShape: []
bones: []
spriteID:
internalID: 0
vertices: []
indices:
edges: []
weights: []
secondaryTextures: []
spritePackingTag:
pSDRemoveMatte: 0
pSDShowRemoveMatteOption: 0
userData:
assetBundleName:
assetBundleVariant:
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

@@ -0,0 +1,115 @@
fileFormatVersion: 2
guid: 01674f533e98ba544a7bb592e2f52669
TextureImporter:
internalIDToNameTable: []
externalObjects: {}
serializedVersion: 10
mipmaps:
mipMapMode: 0
enableMipMap: 0
sRGBTexture: 1
linearTexture: 0
fadeOut: 0
borderMipMap: 0
mipMapsPreserveCoverage: 0
alphaTestReferenceValue: 0.5
mipMapFadeDistanceStart: 1
mipMapFadeDistanceEnd: 3
bumpmap:
convertToNormalMap: 0
externalNormalMap: 0
heightScale: 0.25
normalMapFilter: 0
isReadable: 0
streamingMipmaps: 0
streamingMipmapsPriority: 0
grayScaleToAlpha: 0
generateCubemap: 6
cubemapConvolution: 0
seamlessCubemap: 0
textureFormat: 1
maxTextureSize: 2048
textureSettings:
serializedVersion: 2
filterMode: 2
aniso: 1
mipBias: -100
wrapU: 1
wrapV: 1
wrapW: -1
nPOTScale: 0
lightmap: 0
compressionQuality: 50
spriteMode: 1
spriteExtrude: 1
spriteMeshType: 1
alignment: 0
spritePivot: {x: 0.5, y: 0.5}
spritePixelsToUnits: 100
spriteBorder: {x: 0, y: 0, z: 0, w: 0}
spriteGenerateFallbackPhysicsShape: 1
alphaUsage: 1
alphaIsTransparency: 1
spriteTessellationDetail: -1
textureType: 8
textureShape: 1
singleChannelComponent: 0
maxTextureSizeSet: 0
compressionQualitySet: 0
textureFormatSet: 0
platformSettings:
- serializedVersion: 3
buildTarget: DefaultTexturePlatform
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 2
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
- serializedVersion: 3
buildTarget: Standalone
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 2
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
- serializedVersion: 3
buildTarget: Android
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 2
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
spriteSheet:
serializedVersion: 2
sprites: []
outline: []
physicsShape: []
bones: []
spriteID: 5e97eb03825dee720800000000000000
internalID: 0
vertices: []
indices:
edges: []
weights: []
secondaryTextures: []
spritePackingTag:
pSDRemoveMatte: 0
pSDShowRemoveMatteOption: 0
userData:
assetBundleName:
assetBundleVariant:
+51 -37
View File
@@ -279,7 +279,45 @@ PlayerSettings:
m_BuildTargetPlatformIcons:
- m_BuildTarget: Android
m_Icons:
- m_Textures: []
- m_Textures:
- {fileID: 2800000, guid: efc72c013d5d2734ea900bf079c4a1d6, type: 3}
m_Width: 192
m_Height: 192
m_Kind: 0
m_SubKind:
- m_Textures:
- {fileID: 0}
m_Width: 144
m_Height: 144
m_Kind: 0
m_SubKind:
- m_Textures:
- {fileID: 0}
m_Width: 96
m_Height: 96
m_Kind: 0
m_SubKind:
- m_Textures:
- {fileID: 0}
m_Width: 72
m_Height: 72
m_Kind: 0
m_SubKind:
- m_Textures:
- {fileID: 0}
m_Width: 48
m_Height: 48
m_Kind: 0
m_SubKind:
- m_Textures:
- {fileID: 0}
m_Width: 36
m_Height: 36
m_Kind: 0
m_SubKind:
- m_Textures:
- {fileID: 0}
- {fileID: 2800000, guid: efc72c013d5d2734ea900bf079c4a1d6, type: 3}
m_Width: 432
m_Height: 432
m_Kind: 2
@@ -309,62 +347,38 @@ PlayerSettings:
m_Height: 81
m_Kind: 2
m_SubKind:
- m_Textures: []
m_Width: 192
m_Height: 192
m_Kind: 0
m_SubKind:
- m_Textures: []
m_Width: 144
m_Height: 144
m_Kind: 0
m_SubKind:
- m_Textures: []
m_Width: 96
m_Height: 96
m_Kind: 0
m_SubKind:
- m_Textures: []
m_Width: 72
m_Height: 72
m_Kind: 0
m_SubKind:
- m_Textures: []
m_Width: 48
m_Height: 48
m_Kind: 0
m_SubKind:
- m_Textures: []
m_Width: 36
m_Height: 36
m_Kind: 0
m_SubKind:
- m_Textures: []
- m_Textures:
- {fileID: 2800000, guid: efc72c013d5d2734ea900bf079c4a1d6, type: 3}
m_Width: 192
m_Height: 192
m_Kind: 1
m_SubKind:
- m_Textures: []
- m_Textures:
- {fileID: 0}
m_Width: 144
m_Height: 144
m_Kind: 1
m_SubKind:
- m_Textures: []
- m_Textures:
- {fileID: 0}
m_Width: 96
m_Height: 96
m_Kind: 1
m_SubKind:
- m_Textures: []
- m_Textures:
- {fileID: 0}
m_Width: 72
m_Height: 72
m_Kind: 1
m_SubKind:
- m_Textures: []
- m_Textures:
- {fileID: 0}
m_Width: 48
m_Height: 48
m_Kind: 1
m_SubKind:
- m_Textures: []
- m_Textures:
- {fileID: 0}
m_Width: 36
m_Height: 36
m_Kind: 1