Skip to content

Instantly share code, notes, and snippets.

@shane-harper
Created November 17, 2023 16:30
Show Gist options
  • Save shane-harper/61205c1e66ed18a6e03211796c564b0c to your computer and use it in GitHub Desktop.
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…
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