Created
November 17, 2023 16:30
-
-
Save shane-harper/61205c1e66ed18a6e03211796c564b0c to your computer and use it in GitHub Desktop.
An editor window in Unity for installing and uninstalling apps via ADB. I had started to use SideQuest out of convenience over adb command line, but thought it'd be nice to not leave Unity and void the adverts. I had originally planned to do the file explorer too, hence the View abstract class, but felt it wasn't worth the time investment for ho…
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
using System; | |
using System.Collections.Generic; | |
using System.Diagnostics; | |
using System.IO; | |
using System.Linq; | |
using System.Text.RegularExpressions; | |
using System.Threading.Tasks; | |
using UnityEditor; | |
using UnityEngine; | |
using Debug = UnityEngine.Debug; | |
/// <summary> | |
/// Install/Uninstall apps on android devices using ADB | |
/// </summary> | |
public class AppManager : EditorWindow | |
{ | |
private const string Name = "App Manager"; | |
[SerializeField] private Vector2 _scroll = Vector2.zero; | |
private readonly View _currentView = new AppView(); | |
[MenuItem("Window/" + Name)] | |
private static void OpenWindow() | |
{ | |
var window = GetWindow<AppManager>(); | |
window.titleContent = new GUIContent(EditorGUIUtility.IconContent("BuildSettings.Android.Small")) | |
{ | |
text = Name | |
}; | |
window.Show(); | |
} | |
private void OnEnable() | |
{ | |
_currentView.Repaint = Repaint; | |
_currentView.OnEnable(); | |
} | |
private void OnGUI() | |
{ | |
_currentView.DrawHeader(); | |
using (var scroll = new EditorGUILayout.ScrollViewScope(_scroll)) | |
{ | |
_scroll = scroll.scrollPosition; | |
_currentView.Draw(); | |
GUILayout.FlexibleSpace(); | |
} | |
} | |
public abstract class View | |
{ | |
internal Action Repaint; | |
protected string Search { get; private set; } = ""; | |
public virtual void OnEnable() | |
{ | |
Refresh(); | |
} | |
public void DrawHeader() | |
{ | |
using (new EditorGUILayout.HorizontalScope(EditorStyles.toolbar)) | |
{ | |
// Draw refresh button | |
if (GUILayout.Button(EditorGUIUtility.IconContent("d_Refresh"), EditorStyles.toolbarButton, | |
GUILayout.Width(24))) | |
{ | |
Refresh(); | |
} | |
GUILayout.Space(4); | |
Search = EditorGUILayout.TextField(Search, EditorStyles.toolbarSearchField); | |
} | |
} | |
public abstract void Draw(); | |
protected void DrawProcessing(string message) | |
{ | |
using (new EditorGUILayout.HorizontalScope()) | |
{ | |
const int spinRate = 12; | |
var name = $"WaitSpin{EditorApplication.timeSinceStartup * spinRate % 11:00}"; | |
var icon = EditorGUIUtility.IconContent(name); | |
GUILayout.Label(new GUIContent(icon) { text = $" {message} ..." }, EditorStyles.centeredGreyMiniLabel); | |
} | |
Repaint.Invoke(); | |
} | |
protected abstract void Refresh(); | |
protected static Process AdbProcess(string args) | |
{ | |
return new Process | |
{ | |
StartInfo = new ProcessStartInfo | |
{ | |
FileName = GetAdbPath(), | |
Arguments = args, | |
RedirectStandardOutput = true, | |
RedirectStandardError = true, | |
UseShellExecute = false, | |
CreateNoWindow = true | |
} | |
}; | |
static string GetAdbPath() | |
{ | |
var unityDirectory = Path.GetDirectoryName(EditorApplication.applicationPath); | |
const string platformTools = @"Data\PlaybackEngines\AndroidPlayer\SDK\platform-tools"; | |
var adbPath = Path.Combine(unityDirectory, platformTools, "adb.exe"); | |
return File.Exists(adbPath) ? adbPath : "adb.exe"; | |
} | |
} | |
} | |
} | |
public class AppView : AppManager.View | |
{ | |
private readonly HashSet<string> _ignoredPackages = new() | |
{ | |
"android", | |
"com.qualcomm.timeservice", | |
}; | |
private readonly HashSet<string> _ignoredPackageNamespaces = new() | |
{ | |
"oculus.", | |
"com.oculus", | |
"com.meta", | |
"com.facebook", | |
"com.android", | |
}; | |
private AppInfo[] _apps = Array.Empty<AppInfo>(); | |
private State _state = State.Ready; | |
private State CurrentState | |
{ | |
get => _state; | |
set | |
{ | |
_state = value; | |
Repaint.Invoke(); | |
} | |
} | |
public override void Draw() | |
{ | |
switch (CurrentState) | |
{ | |
case State.Ready: | |
{ | |
// Allow drag and drop | |
DragAndDrop.visualMode = DragAndDropVisualMode.Generic; | |
if (Event.current.type == EventType.DragExited) | |
{ | |
Install(DragAndDrop.paths); | |
} | |
// Draw packages | |
var deleteIcon = new GUIContent(EditorGUIUtility.IconContent("Toolbar Minus")) | |
{ tooltip = "Uninstall" }; | |
foreach (var app in _apps) | |
{ | |
if (!Regex.Match(app.PackageName, Search, RegexOptions.IgnoreCase).Success) | |
{ | |
continue; | |
} | |
using (new EditorGUILayout.HorizontalScope()) | |
{ | |
EditorGUILayout.LabelField(new GUIContent(app.PackageName, | |
$"{app.VersionName} ({app.VersionCode})")); | |
if (GUILayout.Button(deleteIcon, EditorStyles.iconButton, GUILayout.Width(24))) | |
{ | |
Uninstall(app.PackageName); | |
} | |
} | |
} | |
break; | |
} | |
case State.Installing: | |
DrawProcessing("Installing"); | |
break; | |
case State.Uninstalling: | |
DrawProcessing("Uninstalling"); | |
break; | |
case State.Refreshing: | |
DrawProcessing("Retrieving app info"); | |
break; | |
} | |
} | |
private async void Install(IEnumerable<string> packagePaths) | |
{ | |
CurrentState = State.Installing; | |
foreach (var path in packagePaths) | |
{ | |
if (!path.EndsWith(".apk")) | |
{ | |
continue; | |
} | |
CurrentState = State.Installing; | |
var process = AdbProcess($"install \"{path}\""); | |
process.Start(); | |
while (!process.HasExited) | |
{ | |
await Task.Delay(100); | |
} | |
} | |
Refresh(); | |
} | |
private async void Uninstall(string packageName) | |
{ | |
CurrentState = State.Uninstalling; | |
var process = AdbProcess($"uninstall {packageName}"); | |
process.Start(); | |
while (!process.HasExited) | |
{ | |
await Task.Delay(100); | |
} | |
Refresh(); | |
} | |
protected override async void Refresh() | |
{ | |
CurrentState = State.Refreshing; | |
var process = AdbProcess("shell pm list packages"); | |
process.Start(); | |
var apps = new List<string>(); | |
while (!process.StandardOutput.EndOfStream) | |
{ | |
var line = await process.StandardOutput.ReadLineAsync(); | |
var split = line.Split(':'); | |
if (split.Length != 2) | |
{ | |
continue; | |
} | |
// Ignore invalid and ignored packages | |
var packageName = split[1]; | |
if (string.IsNullOrEmpty(packageName) ||_ignoredPackages.Contains(packageName)) | |
{ | |
continue; | |
} | |
// Ignore ignored bundle namespaces | |
if (_ignoredPackageNamespaces.Any(x => packageName.StartsWith(x))) | |
{ | |
continue; | |
} | |
apps.Add(packageName); | |
} | |
var stdErr = (await process.StandardError.ReadToEndAsync()).Trim(); | |
if (!string.IsNullOrEmpty(stdErr)) | |
{ | |
Debug.LogErrorFormat("An error occurred while trying to refresh the package list. {0}", stdErr); | |
_apps = null; | |
CurrentState = State.Ready; | |
} | |
_apps = new AppInfo[apps.Count]; | |
for (var i = 0; i < apps.Count; ++i) | |
{ | |
_apps[i] = await GetAppInfo(apps[i]); | |
} | |
CurrentState = State.Ready; | |
} | |
private static async Task<AppInfo> GetAppInfo(string packageName) | |
{ | |
var process = AdbProcess($"shell dumpsys package {packageName}"); | |
process.Start(); | |
string versionName = null, versionCode = null; | |
while (!process.StandardOutput.EndOfStream) | |
{ | |
var line = (await process.StandardOutput.ReadLineAsync()).Trim(); | |
if (line.StartsWith("versionName")) | |
{ | |
var value = line.Split("=")[1]; | |
versionName = value; | |
} | |
if (line.StartsWith("versionCode")) | |
{ | |
var value = line.Split("=")[1].Split(" ")[0]; | |
versionCode = value; | |
} | |
} | |
return new AppInfo(packageName, versionName, versionCode); | |
} | |
[Serializable] | |
private struct AppInfo | |
{ | |
[SerializeField] private string _packageName; | |
[SerializeField] private string _versionName; | |
[SerializeField] private string _versionCode; | |
public string PackageName => _packageName; | |
public string VersionName => _versionName; | |
public string VersionCode => _versionCode; | |
public AppInfo(string packageName, string versionName, string versionCode) | |
{ | |
_packageName = packageName; | |
_versionName = versionName; | |
_versionCode = versionCode; | |
} | |
} | |
private enum State | |
{ | |
Ready, | |
Installing, | |
Uninstalling, | |
Refreshing | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment