diff --git a/mobile/AppShell.xaml b/mobile/AppShell.xaml
index db76f1b..8218c1b 100644
--- a/mobile/AppShell.xaml
+++ b/mobile/AppShell.xaml
@@ -4,11 +4,94 @@
xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:local="clr-namespace:PS5UploadMobile"
- Shell.FlyoutBehavior="Disabled">
+ xmlns:pages="clr-namespace:PS5UploadMobile.Pages"
+ Shell.FlyoutBehavior="Flyout"
+ Shell.BackgroundColor="#1E1E1E"
+ Shell.ForegroundColor="White"
+ Shell.TitleColor="White"
+ Shell.FlyoutBackgroundColor="#FFFFFF"
+ Title="PS5 Upload Suite">
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/mobile/MainPage.xaml.cs b/mobile/MainPage.xaml.cs
index 86f31c7..0c777c9 100644
--- a/mobile/MainPage.xaml.cs
+++ b/mobile/MainPage.xaml.cs
@@ -310,6 +310,11 @@ public partial class MainPage : ContentPage
AddLog($"Connecting to {IpEntry.Text}:{PortEntry.Text}...");
bool connected = await _uploadService.ConnectAsync();
+ // Also establish shared protocol connection for other tabs (Games, Saves, Screenshots, Hardware, Shell, Mount)
+ int portNum = int.TryParse(PortEntry.Text, out var p) ? p : 9113;
+ bool sharedOk = await ConnectionManager.Instance.ConnectAsync(IpEntry.Text, portNum);
+ AddLog(sharedOk ? "Shared protocol connected (all tabs ready)" : "Shared protocol connection failed โ other tabs may not work");
+
if (connected)
{
StatusLabel.Text = "Connected โ";
@@ -332,6 +337,7 @@ public partial class MainPage : ContentPage
private async void OnDisconnectClicked(object sender, EventArgs e)
{
await _uploadService.DisconnectAsync();
+ ConnectionManager.Instance.Disconnect();
StatusLabel.Text = "Disconnected";
StatusLabel.TextColor = Colors.Orange;
diff --git a/mobile/PS5UploadMobile.csproj b/mobile/PS5UploadMobile.csproj
index 7da9080..36675f3 100644
--- a/mobile/PS5UploadMobile.csproj
+++ b/mobile/PS5UploadMobile.csproj
@@ -23,6 +23,11 @@
Platforms\Android\AndroidManifest.xml
+
+
+ false
+ true
+ $(NoWarn);NETSDK1202
diff --git a/mobile/Pages/GamesPage.xaml b/mobile/Pages/GamesPage.xaml
new file mode 100644
index 0000000..dc46bc0
--- /dev/null
+++ b/mobile/Pages/GamesPage.xaml
@@ -0,0 +1,49 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/mobile/Pages/GamesPage.xaml.cs b/mobile/Pages/GamesPage.xaml.cs
new file mode 100644
index 0000000..c0652ef
--- /dev/null
+++ b/mobile/Pages/GamesPage.xaml.cs
@@ -0,0 +1,164 @@
+#nullable enable
+using System;
+using System.Collections.ObjectModel;
+using System.IO;
+using System.Threading.Tasks;
+using Microsoft.Maui.Controls;
+using PS5Upload;
+
+namespace PS5UploadMobile.Pages;
+
+public partial class GamesPage : ContentPage
+{
+ private readonly ObservableCollection _games = new();
+
+ public GamesPage()
+ {
+ InitializeComponent();
+ GamesListView.ItemsSource = _games;
+ }
+
+ protected override async void OnAppearing()
+ {
+ base.OnAppearing();
+ if (_games.Count == 0) await LoadGamesAsync();
+ }
+
+ private async void OnRefreshClicked(object sender, EventArgs e) => await LoadGamesAsync();
+
+ private async void OnRefreshing(object sender, EventArgs e)
+ {
+ await LoadGamesAsync();
+ GamesRefresh.IsRefreshing = false;
+ }
+
+ private async Task LoadGamesAsync()
+ {
+ var proto = await PageHelper.EnsureConnectedAsync(this);
+ if (proto == null) return;
+
+ StatusLabel.Text = "Loading gamesโฆ";
+ try
+ {
+ var list = await proto.GetGameListAsync();
+ _games.Clear();
+ foreach (var g in list) _games.Add(g);
+ StatusLabel.Text = $"Loaded {_games.Count} games";
+
+ // Load icons in background
+ _ = Task.Run(async () =>
+ {
+ foreach (var game in list)
+ {
+ try
+ {
+ byte[]? iconBytes = await proto.GetGameIconAsync(game.TitleId);
+ if (iconBytes != null && iconBytes.Length > 0)
+ {
+ var ms = new MemoryStream(iconBytes);
+ await MainThread.InvokeOnMainThreadAsync(() =>
+ {
+ game.Icon = ImageSource.FromStream(() => new MemoryStream(iconBytes));
+ });
+ }
+ }
+ catch { }
+ }
+ });
+ }
+ catch (Exception ex)
+ {
+ StatusLabel.Text = $"Error: {ex.Message}";
+ }
+ }
+
+ private async void OnGameSelected(object sender, SelectionChangedEventArgs e)
+ {
+ if (e.CurrentSelection == null || e.CurrentSelection.Count == 0) return;
+ if (e.CurrentSelection[0] is not PS5MountedGame game) return;
+ GamesListView.SelectedItem = null;
+
+ string action = await DisplayActionSheet($"{game.Name}", "Cancel", null,
+ "โถ๏ธ Launch Game",
+ "๐ Game Details",
+ "โ๏ธ Unmount Game",
+ "๐ Copy Title ID");
+
+ switch (action)
+ {
+ case "โถ๏ธ Launch Game":
+ await LaunchGameAsync(game);
+ break;
+ case "๐ Game Details":
+ await ShowGameDetailsAsync(game);
+ break;
+ case "โ๏ธ Unmount Game":
+ await UnmountGameAsync(game);
+ break;
+ case "๐ Copy Title ID":
+ await Clipboard.SetTextAsync(game.TitleId);
+ await DisplayAlert("Copied", $"Copied {game.TitleId}", "OK");
+ break;
+ }
+ }
+
+ private async Task LaunchGameAsync(PS5MountedGame game)
+ {
+ var proto = await PageHelper.EnsureConnectedAsync(this);
+ if (proto == null) return;
+ bool confirm = await DisplayAlert("Launch Game", $"Launch '{game.Name}' on your PS5?", "Launch", "Cancel");
+ if (!confirm) return;
+ var (ok, msg) = await proto.LaunchGameAsync(game.TitleId);
+ await DisplayAlert(ok ? "Launching" : "Error", msg, "OK");
+ }
+
+ private async Task UnmountGameAsync(PS5MountedGame game)
+ {
+ var proto = await PageHelper.EnsureConnectedAsync(this);
+ if (proto == null) return;
+ bool confirm = await DisplayAlert("Unmount Game", $"Unmount '{game.Name}' from PS5?", "Unmount", "Cancel");
+ if (!confirm) return;
+ var (ok, msg) = await proto.UnmountGameAsync(game.TitleId);
+ await DisplayAlert(ok ? "Unmounted" : "Error", msg, "OK");
+ if (ok) await LoadGamesAsync();
+ }
+
+ private async Task ShowGameDetailsAsync(PS5MountedGame game)
+ {
+ var proto = await PageHelper.EnsureConnectedAsync(this);
+ if (proto == null) return;
+ StatusLabel.Text = "Loading game detailsโฆ";
+ var details = await proto.GetGameDetailsAsync(game.TitleId);
+ var sb = new System.Text.StringBuilder();
+ sb.AppendLine($"Title: {game.Name}");
+ sb.AppendLine($"Title ID: {game.TitleId}");
+ sb.AppendLine($"Region: {game.Region}");
+ sb.AppendLine($"Size: {game.SizeDisplay}");
+ sb.AppendLine($"Path: {game.Path}");
+ sb.AppendLine($"Status: {game.StatusDisplay}");
+ if (details != null)
+ {
+ sb.AppendLine();
+ sb.AppendLine("โโ param.json โโ");
+ foreach (var kv in details) sb.AppendLine($"{kv.Key}: {kv.Value}");
+ }
+ StatusLabel.Text = $"Loaded {_games.Count} games";
+ await DisplayAlert("Game Details", sb.ToString(), "Close");
+ }
+
+ private async void OnMountAllClicked(object sender, EventArgs e)
+ {
+ var proto = await PageHelper.EnsureConnectedAsync(this);
+ if (proto == null) return;
+ bool confirm = await DisplayAlert("Mount Games",
+ "This will scan all known locations (internal, USB, M.2) and mount unmounted games. Continue?",
+ "Mount", "Cancel");
+ if (!confirm) return;
+
+ StatusLabel.Text = "Mounting games (this can take a while)โฆ";
+ var (ok, summary) = await proto.MountGamesAsync(msg => MainThread.BeginInvokeOnMainThread(() => StatusLabel.Text = msg));
+ StatusLabel.Text = ok ? "Mount complete" : "Mount failed";
+ await DisplayAlert(ok ? "Mount Games - Done" : "Mount Games - Error", summary, "OK");
+ if (ok) await LoadGamesAsync();
+ }
+}
diff --git a/mobile/Pages/HardwarePage.xaml b/mobile/Pages/HardwarePage.xaml
new file mode 100644
index 0000000..ac7295b
--- /dev/null
+++ b/mobile/Pages/HardwarePage.xaml
@@ -0,0 +1,102 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/mobile/Pages/HardwarePage.xaml.cs b/mobile/Pages/HardwarePage.xaml.cs
new file mode 100644
index 0000000..b9b86ed
--- /dev/null
+++ b/mobile/Pages/HardwarePage.xaml.cs
@@ -0,0 +1,129 @@
+#nullable enable
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Maui.Controls;
+using Microsoft.Maui.Dispatching;
+using PS5Upload;
+
+namespace PS5UploadMobile.Pages;
+
+public partial class HardwarePage : ContentPage
+{
+ private IDispatcherTimer? _autoTimer;
+ private int _busy; // Interlocked guard
+
+ public HardwarePage()
+ {
+ InitializeComponent();
+ }
+
+ protected override async void OnAppearing()
+ {
+ base.OnAppearing();
+ await RefreshAllAsync();
+ }
+
+ protected override void OnDisappearing()
+ {
+ base.OnDisappearing();
+ StopAutoTimer();
+ }
+
+ private async void OnRefreshClicked(object sender, EventArgs e) => await RefreshAllAsync();
+
+ private void OnAutoRefreshChanged(object sender, CheckedChangedEventArgs e)
+ {
+ if (e.Value) StartAutoTimer();
+ else StopAutoTimer();
+ }
+
+ private void StartAutoTimer()
+ {
+ StopAutoTimer();
+ _autoTimer = Dispatcher.CreateTimer();
+ _autoTimer.Interval = TimeSpan.FromSeconds(5);
+ _autoTimer.Tick += async (_, __) => await RefreshSensorsAsync();
+ _autoTimer.Start();
+ }
+
+ private void StopAutoTimer()
+ {
+ if (_autoTimer != null) { _autoTimer.Stop(); _autoTimer = null; }
+ }
+
+ private async Task RefreshAllAsync()
+ {
+ if (Interlocked.Exchange(ref _busy, 1) == 1) return;
+ try
+ {
+ var proto = await PageHelper.EnsureConnectedAsync(this);
+ if (proto == null) { StatusLabel.Text = "Disconnected"; return; }
+ StatusLabel.Text = "Loading hardware infoโฆ";
+
+ var hw = await proto.GetHardwareInfoAsync();
+ if (hw != null)
+ {
+ ModelLabel.Text = hw.Model;
+ SerialLabel.Text = hw.Serial;
+ MachineLabel.Text = hw.HwMachine;
+ OsLabel.Text = hw.OsVersion;
+ CpuCoresLabel.Text = hw.NumCpu > 0 ? hw.NumCpu.ToString() : "โ";
+ RamLabel.Text = hw.PhysMemDisplay;
+ WlanLabel.Text = hw.WlanBtDisplay;
+ OpticalLabel.Text = hw.OpticalDisplay;
+ }
+
+ await UpdateSensorsAsync(proto);
+ await UpdatePowerAsync(proto);
+
+ StatusLabel.Text = $"Updated {DateTime.Now:HH:mm:ss}";
+ }
+ catch (Exception ex) { StatusLabel.Text = $"Error: {ex.Message}"; }
+ finally { Interlocked.Exchange(ref _busy, 0); }
+ }
+
+ private async Task RefreshSensorsAsync()
+ {
+ if (Interlocked.Exchange(ref _busy, 1) == 1) return;
+ try
+ {
+ var proto = PageHelper.Conn.Protocol;
+ if (proto == null || !proto.IsConnected) { StopAutoTimer(); return; }
+ await UpdateSensorsAsync(proto);
+ StatusLabel.Text = $"Updated {DateTime.Now:HH:mm:ss}";
+ }
+ catch { }
+ finally { Interlocked.Exchange(ref _busy, 0); }
+ }
+
+ private async Task UpdateSensorsAsync(PS5Protocol proto)
+ {
+ var temps = await proto.GetTemperatureInfoAsync();
+ if (temps == null) return;
+
+ CpuTempLabel.Text = temps.CpuTempDisplay;
+ SocTempLabel.Text = temps.SocTempDisplay;
+ CpuFreqLabel.Text = temps.CpuFreqDisplay;
+ SocPowerLabel.Text = temps.SocPowerDisplay;
+
+ // Progress bars - map sensible ranges
+ CpuTempBar.Progress = Math.Clamp(temps.CpuTemp / 100.0, 0, 1);
+ SocTempBar.Progress = Math.Clamp(temps.SocTemp / 100.0, 0, 1);
+ // PS5 CPU typical 3.5 GHz max
+ CpuFreqBar.Progress = Math.Clamp(temps.CpuFreqMhz / 3500.0, 0, 1);
+ // SoC power 0โ200W range
+ SocPowerBar.Progress = Math.Clamp(temps.SocPowerMw / 200000.0, 0, 1);
+ }
+
+ private async Task UpdatePowerAsync(PS5Protocol proto)
+ {
+ var power = await proto.GetPowerInfoAsync();
+ if (power == null) return;
+ OpTimeLabel.Text = $"{power.OperatingTimeHours}h {power.OperatingTimeMinutes}m";
+ BootCountLabel.Text = power.BootCount.ToString();
+ PowerConsLabel.Text = power.PowerConsumptionMw > 0
+ ? $"{power.PowerConsumptionMw / 1000.0:F2} kWยทs (lifetime)"
+ : "โ";
+ }
+}
diff --git a/mobile/Pages/MountGamesPage.xaml b/mobile/Pages/MountGamesPage.xaml
new file mode 100644
index 0000000..8d92be9
--- /dev/null
+++ b/mobile/Pages/MountGamesPage.xaml
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/mobile/Pages/MountGamesPage.xaml.cs b/mobile/Pages/MountGamesPage.xaml.cs
new file mode 100644
index 0000000..ac1e3f5
--- /dev/null
+++ b/mobile/Pages/MountGamesPage.xaml.cs
@@ -0,0 +1,48 @@
+#nullable enable
+using System;
+using System.Text;
+using Microsoft.Maui.Controls;
+
+namespace PS5UploadMobile.Pages;
+
+public partial class MountGamesPage : ContentPage
+{
+ private readonly StringBuilder _buffer = new();
+
+ public MountGamesPage()
+ {
+ InitializeComponent();
+ }
+
+ private void AppendLine(string s)
+ {
+ _buffer.AppendLine(s);
+ LogLabel.Text = _buffer.ToString();
+ MainThread.BeginInvokeOnMainThread(async () =>
+ {
+ await System.Threading.Tasks.Task.Delay(50);
+ await LogScroll.ScrollToAsync(0, double.MaxValue, false);
+ });
+ }
+
+ private async void OnStartClicked(object sender, EventArgs e)
+ {
+ var proto = await PageHelper.EnsureConnectedAsync(this);
+ if (proto == null) return;
+
+ StartBtn.IsEnabled = false;
+ _buffer.Clear();
+ AppendLine("๐ Starting mount scanโฆ");
+ AppendLine("Scanning: /data/etaHEN/games, /mnt/usb0-3, /mnt/ext0/games โฆ");
+
+ var (ok, summary) = await proto.MountGamesAsync(msg =>
+ MainThread.BeginInvokeOnMainThread(() => AppendLine(msg)));
+
+ AppendLine("");
+ AppendLine(ok ? "โ
Mount scan complete" : "โ Mount scan failed");
+ AppendLine("");
+ AppendLine("โโ Summary โโ");
+ AppendLine(summary);
+ StartBtn.IsEnabled = true;
+ }
+}
diff --git a/mobile/Pages/PageHelper.cs b/mobile/Pages/PageHelper.cs
new file mode 100644
index 0000000..6155f04
--- /dev/null
+++ b/mobile/Pages/PageHelper.cs
@@ -0,0 +1,48 @@
+#nullable enable
+using System;
+using System.IO;
+using System.Threading.Tasks;
+using Microsoft.Maui.Controls;
+using PS5Upload;
+using PS5UploadMobile.Services;
+
+namespace PS5UploadMobile.Pages
+{
+ internal static class PageHelper
+ {
+ public static ConnectionManager Conn => ConnectionManager.Instance;
+
+ public static async Task EnsureConnectedAsync(Page page)
+ {
+ var proto = await Conn.EnsureConnectedAsync();
+ if (proto == null)
+ {
+ await page.DisplayAlert("Not Connected",
+ $"Could not connect to {Conn.IpAddress}:{Conn.Port}. Please connect from the Files tab first.",
+ "OK");
+ }
+ return proto;
+ }
+
+ public static string DownloadsDir
+ {
+ get
+ {
+ string? root = Microsoft.Maui.Storage.FileSystem.AppDataDirectory;
+ string path = Path.Combine(root, "PS5Downloads");
+ Directory.CreateDirectory(path);
+ return path;
+ }
+ }
+
+ public static string ThumbnailCacheDir
+ {
+ get
+ {
+ string path = Path.Combine(Microsoft.Maui.Storage.FileSystem.CacheDirectory, "thumbnails");
+ Directory.CreateDirectory(path);
+ return path;
+ }
+ }
+ }
+}
diff --git a/mobile/Pages/SavesPage.xaml b/mobile/Pages/SavesPage.xaml
new file mode 100644
index 0000000..a322794
--- /dev/null
+++ b/mobile/Pages/SavesPage.xaml
@@ -0,0 +1,52 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/mobile/Pages/SavesPage.xaml.cs b/mobile/Pages/SavesPage.xaml.cs
new file mode 100644
index 0000000..fbf02a7
--- /dev/null
+++ b/mobile/Pages/SavesPage.xaml.cs
@@ -0,0 +1,115 @@
+#nullable enable
+using System;
+using System.Collections.ObjectModel;
+using System.IO;
+using System.Threading.Tasks;
+using Microsoft.Maui.Controls;
+using PS5Upload;
+
+namespace PS5UploadMobile.Pages;
+
+public partial class SavesPage : ContentPage
+{
+ private readonly ObservableCollection _saves = new();
+ private readonly System.Collections.Generic.Dictionary _gameNameCache = new();
+
+ public SavesPage()
+ {
+ InitializeComponent();
+ SavesListView.ItemsSource = _saves;
+ }
+
+ protected override async void OnAppearing()
+ {
+ base.OnAppearing();
+ if (_saves.Count == 0) await LoadSavesAsync();
+ }
+
+ private async void OnRefreshClicked(object sender, EventArgs e) => await LoadSavesAsync();
+
+ private async void OnRefreshing(object sender, EventArgs e)
+ {
+ await LoadSavesAsync();
+ SavesRefresh.IsRefreshing = false;
+ }
+
+ private async Task LoadSavesAsync()
+ {
+ var proto = await PageHelper.EnsureConnectedAsync(this);
+ if (proto == null) return;
+
+ StatusLabel.Text = "Loading savesโฆ";
+ try
+ {
+ var list = await proto.ListSavesAsync();
+ _saves.Clear();
+ foreach (var s in list)
+ {
+ s.GameName = _gameNameCache.TryGetValue(s.TitleId, out var nm) ? nm : s.TitleId;
+ _saves.Add(s);
+ }
+ StatusLabel.Text = $"Loaded {_saves.Count} saves";
+
+ // Background: fetch game names from GetGameList + icons
+ _ = Task.Run(async () =>
+ {
+ try
+ {
+ var games = await proto.GetGameListAsync();
+ var gameDict = new System.Collections.Generic.Dictionary();
+ foreach (var g in games) gameDict[g.TitleId] = g.Name;
+
+ foreach (var save in list)
+ {
+ if (gameDict.TryGetValue(save.TitleId, out var nm))
+ {
+ _gameNameCache[save.TitleId] = nm;
+ await MainThread.InvokeOnMainThreadAsync(() => save.GameName = nm);
+ }
+ byte[]? iconBytes = await proto.GetGameIconAsync(save.TitleId);
+ if (iconBytes != null && iconBytes.Length > 0)
+ {
+ await MainThread.InvokeOnMainThreadAsync(() =>
+ {
+ save.Icon = ImageSource.FromStream(() => new MemoryStream(iconBytes));
+ });
+ }
+ }
+ }
+ catch { }
+ });
+ }
+ catch (Exception ex)
+ {
+ StatusLabel.Text = $"Error: {ex.Message}";
+ }
+ }
+
+ private async void OnSaveSelected(object sender, SelectionChangedEventArgs e)
+ {
+ if (e.CurrentSelection == null || e.CurrentSelection.Count == 0) return;
+ if (e.CurrentSelection[0] is not PS5SaveGame save) return;
+ SavesListView.SelectedItem = null;
+
+ string action = await DisplayActionSheet($"{save.GameName} ({save.TitleId})", "Cancel", null,
+ "๐ Copy Save Path",
+ "โน๏ธ Save Info");
+
+ if (action == "๐ Copy Save Path")
+ {
+ await Clipboard.SetTextAsync(save.SavePath);
+ await DisplayAlert("Copied", $"Copied: {save.SavePath}", "OK");
+ }
+ else if (action == "โน๏ธ Save Info")
+ {
+ string info =
+ $"Game: {save.GameName}\n" +
+ $"Title ID: {save.TitleId}\n" +
+ $"User ID: {save.UserId}\n" +
+ $"Size: {save.SizeDisplay}\n" +
+ $"Modified: {save.ModifiedDisplay}\n" +
+ $"Path: {save.SavePath}";
+ await DisplayAlert("Save Details", info, "Close");
+ }
+ }
+}
diff --git a/mobile/Pages/ScreenshotsPage.xaml b/mobile/Pages/ScreenshotsPage.xaml
new file mode 100644
index 0000000..48e84ce
--- /dev/null
+++ b/mobile/Pages/ScreenshotsPage.xaml
@@ -0,0 +1,52 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/mobile/Pages/ScreenshotsPage.xaml.cs b/mobile/Pages/ScreenshotsPage.xaml.cs
new file mode 100644
index 0000000..6f90dc2
--- /dev/null
+++ b/mobile/Pages/ScreenshotsPage.xaml.cs
@@ -0,0 +1,173 @@
+#nullable enable
+using System;
+using System.Collections.ObjectModel;
+using System.IO;
+using System.Security.Cryptography;
+using System.Text;
+using System.Threading.Tasks;
+using Microsoft.Maui.Controls;
+using PS5Upload;
+
+namespace PS5UploadMobile.Pages;
+
+public partial class ScreenshotsPage : ContentPage
+{
+ private readonly ObservableCollection _shots = new();
+
+ public ScreenshotsPage()
+ {
+ InitializeComponent();
+ ShotsListView.ItemsSource = _shots;
+ }
+
+ protected override async void OnAppearing()
+ {
+ base.OnAppearing();
+ if (_shots.Count == 0) await LoadShotsAsync();
+ }
+
+ private async void OnRefreshClicked(object sender, EventArgs e) => await LoadShotsAsync();
+
+ private async void OnRefreshing(object sender, EventArgs e)
+ {
+ await LoadShotsAsync();
+ ShotsRefresh.IsRefreshing = false;
+ }
+
+ private async Task LoadShotsAsync()
+ {
+ var proto = await PageHelper.EnsureConnectedAsync(this);
+ if (proto == null) return;
+
+ StatusLabel.Text = "Loading screenshotsโฆ";
+ try
+ {
+ var list = await proto.ListScreenshotsAsync();
+ _shots.Clear();
+ foreach (var s in list) _shots.Add(s);
+ StatusLabel.Text = $"Loaded {_shots.Count} screenshots";
+
+ // Load thumbnails in background
+ _ = Task.Run(() => LoadThumbnailsAsync(list));
+ }
+ catch (Exception ex)
+ {
+ StatusLabel.Text = $"Error: {ex.Message}";
+ }
+ }
+
+ private async Task LoadThumbnailsAsync(System.Collections.Generic.List shots)
+ {
+ string cacheDir = PageHelper.ThumbnailCacheDir;
+ foreach (var shot in shots)
+ {
+ try
+ {
+ // Cache key
+ string hash;
+ using (var md5 = MD5.Create())
+ {
+ var bytes = md5.ComputeHash(Encoding.UTF8.GetBytes(shot.FullPath));
+ hash = BitConverter.ToString(bytes).Replace("-", "").ToLower();
+ }
+ string cacheFile = Path.Combine(cacheDir, hash + ".jpg");
+
+ if (!File.Exists(cacheFile))
+ {
+ // Need its own protocol - use shared connection
+ var proto = PageHelper.Conn.Protocol;
+ if (proto == null || !proto.IsConnected) return;
+ bool ok = await proto.DownloadFileAsync(shot.FullPath, cacheFile);
+ if (!ok) continue;
+ }
+
+ await MainThread.InvokeOnMainThreadAsync(() =>
+ {
+ shot.Thumbnail = ImageSource.FromFile(cacheFile);
+ });
+ }
+ catch { }
+ }
+ }
+
+ private async void OnShotSelected(object sender, SelectionChangedEventArgs e)
+ {
+ if (e.CurrentSelection == null || e.CurrentSelection.Count == 0) return;
+ if (e.CurrentSelection[0] is not PS5Screenshot shot) return;
+ ShotsListView.SelectedItem = null;
+
+ string action = await DisplayActionSheet(shot.FileName, "Cancel", null,
+ "๐๏ธ View",
+ "โฌ๏ธ Download",
+ "๐๏ธ Delete",
+ "๐ Copy Path");
+
+ switch (action)
+ {
+ case "๐๏ธ View":
+ await ViewShotAsync(shot);
+ break;
+ case "โฌ๏ธ Download":
+ await DownloadShotAsync(shot);
+ break;
+ case "๐๏ธ Delete":
+ await DeleteShotAsync(shot);
+ break;
+ case "๐ Copy Path":
+ await Clipboard.SetTextAsync(shot.FullPath);
+ await DisplayAlert("Copied", shot.FullPath, "OK");
+ break;
+ }
+ }
+
+ private async Task ViewShotAsync(PS5Screenshot shot)
+ {
+ var proto = await PageHelper.EnsureConnectedAsync(this);
+ if (proto == null) return;
+ StatusLabel.Text = "Downloading for previewโฆ";
+ string tmp = Path.Combine(PageHelper.ThumbnailCacheDir, "preview_" + Path.GetFileName(shot.FileName));
+ bool ok = await proto.DownloadFileAsync(shot.FullPath, tmp);
+ StatusLabel.Text = $"Loaded {_shots.Count} screenshots";
+ if (!ok) { await DisplayAlert("Error", "Download failed", "OK"); return; }
+ try
+ {
+ await Microsoft.Maui.ApplicationModel.Launcher.OpenAsync(
+ new Microsoft.Maui.ApplicationModel.OpenFileRequest { File = new ReadOnlyFile(tmp) });
+ }
+ catch (Exception ex) { await DisplayAlert("Open failed", ex.Message, "OK"); }
+ }
+
+ private async Task DownloadShotAsync(PS5Screenshot shot)
+ {
+ var proto = await PageHelper.EnsureConnectedAsync(this);
+ if (proto == null) return;
+ string dir = PageHelper.DownloadsDir;
+ string local = Path.Combine(dir, shot.FileName);
+ StatusLabel.Text = $"Downloading {shot.FileName}โฆ";
+ bool ok = await proto.DownloadFileAsync(shot.FullPath, local);
+ StatusLabel.Text = $"Loaded {_shots.Count} screenshots";
+ await DisplayAlert(ok ? "Downloaded" : "Error",
+ ok ? $"Saved to:\n{local}" : "Download failed", "OK");
+ }
+
+ private async Task DeleteShotAsync(PS5Screenshot shot)
+ {
+ var proto = await PageHelper.EnsureConnectedAsync(this);
+ if (proto == null) return;
+ bool confirm = await DisplayAlert("Delete Screenshot",
+ $"Delete {shot.FileName}? This also removes the thumbnail from PS5 Media Gallery.",
+ "Delete", "Cancel");
+ if (!confirm) return;
+
+ var (ok, msg) = await proto.DeleteScreenshotAsync(shot.FullPath);
+ if (ok)
+ {
+ _shots.Remove(shot);
+ StatusLabel.Text = $"Deleted. {_shots.Count} screenshots remaining.";
+ }
+ else
+ {
+ await DisplayAlert("Delete failed", msg, "OK");
+ }
+ }
+}
diff --git a/mobile/Pages/ShellPage.xaml b/mobile/Pages/ShellPage.xaml
new file mode 100644
index 0000000..5bfb5de
--- /dev/null
+++ b/mobile/Pages/ShellPage.xaml
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/mobile/Pages/ShellPage.xaml.cs b/mobile/Pages/ShellPage.xaml.cs
new file mode 100644
index 0000000..91eb176
--- /dev/null
+++ b/mobile/Pages/ShellPage.xaml.cs
@@ -0,0 +1,76 @@
+#nullable enable
+using System;
+using System.Text;
+using System.Threading.Tasks;
+using Microsoft.Maui.Controls;
+
+namespace PS5UploadMobile.Pages;
+
+public partial class ShellPage : ContentPage
+{
+ private readonly StringBuilder _buffer = new();
+ private bool _shellOpen;
+
+ public ShellPage()
+ {
+ InitializeComponent();
+ }
+
+ private void AppendLine(string s)
+ {
+ _buffer.AppendLine(s);
+ OutputLabel.Text = _buffer.ToString();
+ MainThread.BeginInvokeOnMainThread(async () =>
+ {
+ await Task.Delay(50);
+ await OutputScroll.ScrollToAsync(0, double.MaxValue, false);
+ });
+ }
+
+ private async void OnOpenClicked(object sender, EventArgs e)
+ {
+ var proto = await PageHelper.EnsureConnectedAsync(this);
+ if (proto == null) return;
+ bool ok = await proto.OpenShellAsync();
+ _shellOpen = ok;
+ AppendLine(ok ? "๐ข Shell opened" : "๐ด Failed to open shell");
+ }
+
+ private async void OnCloseClicked(object sender, EventArgs e)
+ {
+ var proto = PageHelper.Conn.Protocol;
+ if (proto == null) { AppendLine("(no connection)"); return; }
+ bool ok = await proto.CloseShellAsync();
+ _shellOpen = false;
+ AppendLine(ok ? "๐ก Shell closed" : "โ ๏ธ Close failed");
+ }
+
+ private void OnClearClicked(object sender, EventArgs e)
+ {
+ _buffer.Clear();
+ OutputLabel.Text = "";
+ }
+
+ private async void OnCmdCompleted(object sender, EventArgs e) => await RunCmdAsync();
+ private async void OnRunClicked(object sender, EventArgs e) => await RunCmdAsync();
+
+ private async Task RunCmdAsync()
+ {
+ string cmd = CmdEntry.Text?.Trim() ?? "";
+ if (string.IsNullOrEmpty(cmd)) return;
+
+ var proto = await PageHelper.EnsureConnectedAsync(this);
+ if (proto == null) return;
+
+ if (!_shellOpen)
+ {
+ bool opened = await proto.OpenShellAsync();
+ _shellOpen = opened;
+ if (!opened) { AppendLine("๐ด Shell not open"); return; }
+ }
+
+ AppendLine($"$ {cmd}");
+ string result = await proto.ExecuteShellCommandAsync(cmd);
+ if (!string.IsNullOrEmpty(result)) AppendLine(result.TrimEnd('\n', '\r'));
+ }
+}
diff --git a/mobile/Services/ConnectionManager.cs b/mobile/Services/ConnectionManager.cs
new file mode 100644
index 0000000..fd586a0
--- /dev/null
+++ b/mobile/Services/ConnectionManager.cs
@@ -0,0 +1,79 @@
+#nullable enable
+using System;
+using System.Threading.Tasks;
+using PS5Upload;
+
+namespace PS5UploadMobile.Services
+{
+ public class ConnectionManager
+ {
+ private static ConnectionManager? _instance;
+ private static readonly object _lock = new object();
+
+ public string IpAddress { get; set; } = "192.168.0.160";
+ public int Port { get; set; } = 9113;
+ public MainPage? MainPageReference { get; set; }
+
+ // Shared protocol instance used by all pages
+ private PS5Protocol? _protocol;
+ public PS5Protocol? Protocol => _protocol;
+ public bool IsConnected => _protocol?.IsConnected ?? false;
+
+ public event Action? ConnectionStateChanged;
+
+ private ConnectionManager() { }
+
+ public static ConnectionManager Instance
+ {
+ get
+ {
+ if (_instance == null)
+ {
+ lock (_lock) { if (_instance == null) _instance = new ConnectionManager(); }
+ }
+ return _instance;
+ }
+ }
+
+ public void SetConnection(string ipAddress, int port)
+ {
+ IpAddress = ipAddress;
+ Port = port;
+ }
+
+ public void SetMainPage(MainPage mainPage) => MainPageReference = mainPage;
+
+ public async Task ConnectAsync(string? ip = null, int? port = null)
+ {
+ if (!string.IsNullOrWhiteSpace(ip)) IpAddress = ip!;
+ if (port.HasValue) Port = port.Value;
+
+ try { _protocol?.Dispose(); } catch { }
+ _protocol = new PS5Protocol();
+ bool ok = await _protocol.ConnectAsync(IpAddress, Port);
+ if (!ok)
+ {
+ try { _protocol.Dispose(); } catch { }
+ _protocol = null;
+ }
+ ConnectionStateChanged?.Invoke();
+ return ok;
+ }
+
+ public void Disconnect()
+ {
+ try { _protocol?.Dispose(); } catch { }
+ _protocol = null;
+ ConnectionStateChanged?.Invoke();
+ }
+
+ ///
+ /// Ensure the shared protocol is connected, attempt reconnect if not.
+ ///
+ public async Task EnsureConnectedAsync()
+ {
+ if (_protocol != null && _protocol.IsConnected) return _protocol;
+ return await ConnectAsync() ? _protocol : null;
+ }
+ }
+}
diff --git a/mobile/Services/Protocol.cs b/mobile/Services/Protocol.cs
index dace843..78e7fc1 100644
--- a/mobile/Services/Protocol.cs
+++ b/mobile/Services/Protocol.cs
@@ -1,3 +1,4 @@
+#nullable enable
using System;
using System.Buffers;
using System.IO;
@@ -28,10 +29,29 @@ namespace PS5Upload
ShellExec = 0x21,
ShellInterrupt = 0x22,
ShellClose = 0x23,
+ MountGames = 0x30,
+ GetFileInfo = 0x31,
+ GetSystemInfo = 0x32,
+ VerifyFile = 0x33,
+ GetHwInfo = 0x34,
+ GetTemps = 0x35,
+ GetRunningApps = 0x36,
+ KillApp = 0x37,
+ LaunchBrowser = 0x38,
+ GetPowerInfo = 0x39,
+ GetGameList = 0x3A,
+ UnmountGame = 0x3B,
+ GetGameIcon = 0x3C,
+ GetGameDetails = 0x3D,
+ GetGamePic = 0x3E,
+ ListSaves = 0x3F,
IndexStart = 0x40,
IndexStatus = 0x41,
SearchIndex = 0x42,
IndexCancel = 0x43,
+ LaunchGame = 0x44,
+ ListScreenshots = 0x45,
+ DeleteScreenshot = 0x46,
Shutdown = 0xFF
}
@@ -45,12 +65,27 @@ namespace PS5Upload
Progress = 0x05
}
- public class PS5Protocol : IDisposable
+ public partial class PS5Protocol : IDisposable
{
private TcpClient? _client;
private NetworkStream? _stream;
+ private readonly SemaphoreSlim _commandLock = new SemaphoreSlim(1, 1);
private const int BufferSize = 4 * 1024 * 1024; // 4MB buffer - maximum throughput for chunk uploads
+ // Expose helpers for partial methods in ProtocolExtras.cs
+ internal Task SendCommandInternalAsync(Command cmd, byte[]? data = null) => SendCommandAsync(cmd, data);
+ internal Task<(Response response, byte[] data)> ReceiveResponseInternalAsync() => ReceiveResponseAsync();
+ internal async Task<(Response response, byte[] data)> RunCommandAsync(Command cmd, byte[]? data = null)
+ {
+ await _commandLock.WaitAsync();
+ try
+ {
+ await SendCommandAsync(cmd, data);
+ return await ReceiveResponseAsync();
+ }
+ finally { _commandLock.Release(); }
+ }
+
public bool IsConnected => _client?.Connected ?? false;
public async Task ConnectAsync(string ipAddress, int port = 9113)
diff --git a/mobile/Services/ProtocolExtras.cs b/mobile/Services/ProtocolExtras.cs
new file mode 100644
index 0000000..d217df7
--- /dev/null
+++ b/mobile/Services/ProtocolExtras.cs
@@ -0,0 +1,461 @@
+#nullable enable
+using System;
+using System.Collections.Generic;
+using System.ComponentModel;
+using System.IO;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+using Microsoft.Maui.Controls;
+
+namespace PS5Upload
+{
+ // ========== Additional commands: Hardware, Games, Saves, Screenshots, Mount, Shell, etc. ==========
+ public partial class PS5Protocol
+ {
+ // ---------------- Hardware info ----------------
+ public async Task GetHardwareInfoAsync()
+ {
+ try
+ {
+ var (response, data) = await RunCommandAsync(Command.GetHwInfo);
+ if (response != Response.Data) return null;
+ string text = Encoding.UTF8.GetString(data);
+ var info = new PS5HardwareInfo();
+ foreach (var line in text.Split('\n'))
+ {
+ var parts = line.Split('=', 2);
+ if (parts.Length != 2) continue;
+ var k = parts[0].Trim(); var v = parts[1].Trim();
+ switch (k)
+ {
+ case "model": info.Model = v; break;
+ case "serial": info.Serial = v; break;
+ case "has_wlan_bt": info.HasWlanBt = v == "1"; break;
+ case "has_optical_out": info.HasOpticalOut = v == "1"; break;
+ case "hw_machine": info.HwMachine = v; break;
+ case "os": info.OsVersion = v; break;
+ case "ncpu": if (int.TryParse(v, out var n)) info.NumCpu = n; break;
+ case "physmem": if (ulong.TryParse(v, out var pm)) info.PhysMem = pm; break;
+ }
+ }
+ return info;
+ }
+ catch { return null; }
+ }
+
+ // ---------------- Live sensors ----------------
+ public async Task GetTemperatureInfoAsync()
+ {
+ try
+ {
+ var (response, data) = await RunCommandAsync(Command.GetTemps);
+ if (response != Response.Data) return null;
+ string text = Encoding.UTF8.GetString(data);
+ var info = new PS5TemperatureInfo();
+ foreach (var line in text.Split('\n'))
+ {
+ var parts = line.Split('=', 2);
+ if (parts.Length != 2) continue;
+ var k = parts[0].Trim(); var v = parts[1].Trim();
+ switch (k)
+ {
+ case "cpu_temp": if (int.TryParse(v, out var ct)) info.CpuTemp = ct; break;
+ case "soc_temp": if (int.TryParse(v, out var st)) info.SocTemp = st; break;
+ case "cpu_freq_mhz": if (long.TryParse(v, out var cf)) info.CpuFreqMhz = cf; break;
+ case "soc_power_mw": if (uint.TryParse(v, out var pw)) info.SocPowerMw = pw; break;
+ }
+ }
+ return info;
+ }
+ catch { return null; }
+ }
+
+ // ---------------- Power ----------------
+ public async Task GetPowerInfoAsync()
+ {
+ try
+ {
+ var (response, data) = await RunCommandAsync(Command.GetPowerInfo);
+ if (response != Response.Data) return null;
+ string text = Encoding.UTF8.GetString(data);
+ var info = new PS5PowerInfo();
+ foreach (var line in text.Split('\n'))
+ {
+ var parts = line.Split('=', 2);
+ if (parts.Length != 2) continue;
+ var k = parts[0].Trim(); var v = parts[1].Trim();
+ switch (k)
+ {
+ case "operating_time_sec": if (ulong.TryParse(v, out var a)) info.OperatingTimeSec = a; break;
+ case "operating_time_hours": if (ulong.TryParse(v, out var b)) info.OperatingTimeHours = b; break;
+ case "operating_time_minutes": if (ulong.TryParse(v, out var c)) info.OperatingTimeMinutes = c; break;
+ case "boot_count": if (uint.TryParse(v, out var d)) info.BootCount = d; break;
+ case "power_consumption_mw": if (uint.TryParse(v, out var e)) info.PowerConsumptionMw = e; break;
+ }
+ }
+ return info;
+ }
+ catch { return null; }
+ }
+
+ // ---------------- Mounted games ----------------
+ public async Task> GetGameListAsync()
+ {
+ var result = new List();
+ try
+ {
+ var (response, data) = await RunCommandAsync(Command.GetGameList);
+ if (response != Response.Data) return result;
+ string text = Encoding.UTF8.GetString(data);
+ if (text.Trim() == "NO_GAMES") return result;
+ foreach (var line in text.Split('\n'))
+ {
+ if (string.IsNullOrWhiteSpace(line)) continue;
+ var parts = line.Split('|');
+ if (parts.Length < 6) continue;
+ var g = new PS5MountedGame
+ {
+ TitleId = parts[0],
+ Name = parts[1],
+ Path = parts[2],
+ Region = parts[4],
+ IsActive = parts[5] == "1"
+ };
+ if (ulong.TryParse(parts[3], out var sz)) g.Size = sz;
+ result.Add(g);
+ }
+ }
+ catch { }
+ return result;
+ }
+
+ public async Task<(bool success, string message)> UnmountGameAsync(string titleId)
+ {
+ try
+ {
+ var data = Encoding.UTF8.GetBytes(titleId + "\0");
+ var (response, respData) = await RunCommandAsync(Command.UnmountGame, data);
+ return (response == Response.Ok, Encoding.UTF8.GetString(respData));
+ }
+ catch (Exception ex) { return (false, ex.Message); }
+ }
+
+ public async Task<(bool success, string message)> LaunchGameAsync(string titleId)
+ {
+ try
+ {
+ var data = Encoding.UTF8.GetBytes(titleId + "\0");
+ var (response, respData) = await RunCommandAsync(Command.LaunchGame, data);
+ return (response == Response.Ok, Encoding.UTF8.GetString(respData));
+ }
+ catch (Exception ex) { return (false, ex.Message); }
+ }
+
+ public async Task GetGameIconAsync(string titleId)
+ {
+ try
+ {
+ var data = Encoding.UTF8.GetBytes(titleId + "\0");
+ var (response, respData) = await RunCommandAsync(Command.GetGameIcon, data);
+ if (response != Response.Data || respData == null || respData.Length == 0) return null;
+ return respData;
+ }
+ catch { return null; }
+ }
+
+ public async Task?> GetGameDetailsAsync(string titleId)
+ {
+ try
+ {
+ var data = Encoding.UTF8.GetBytes(titleId + "\0");
+ var (response, respData) = await RunCommandAsync(Command.GetGameDetails, data);
+ if (response != Response.Data) return null;
+ var dict = new Dictionary();
+ foreach (var line in Encoding.UTF8.GetString(respData).Split('\n'))
+ {
+ var p = line.Split('=', 2);
+ if (p.Length == 2) dict[p[0].Trim()] = p[1].Trim();
+ }
+ return dict;
+ }
+ catch { return null; }
+ }
+
+ // ---------------- Saves ----------------
+ public async Task> ListSavesAsync()
+ {
+ var saves = new List();
+ try
+ {
+ var (response, data) = await RunCommandAsync(Command.ListSaves);
+ if (response != Response.Data) return saves;
+ string text = Encoding.UTF8.GetString(data);
+ if (text.Trim() == "NO_SAVES") return saves;
+ foreach (var line in text.Split('\n'))
+ {
+ if (string.IsNullOrWhiteSpace(line)) continue;
+ var parts = line.Split('|');
+ if (parts.Length < 5) continue;
+ var s = new PS5SaveGame
+ {
+ TitleId = parts[0],
+ UserId = parts[1],
+ SavePath = parts[2]
+ };
+ if (ulong.TryParse(parts[3], out var sz)) s.Size = sz;
+ if (long.TryParse(parts[4], out var mt)) s.ModifiedUnixTime = mt;
+ saves.Add(s);
+ }
+ }
+ catch { }
+ return saves;
+ }
+
+ // ---------------- Screenshots ----------------
+ public async Task> ListScreenshotsAsync()
+ {
+ var shots = new List();
+ try
+ {
+ var (response, data) = await RunCommandAsync(Command.ListScreenshots);
+ if (response != Response.Data) return shots;
+ string body = Encoding.UTF8.GetString(data);
+ if (body.StartsWith("NO_SCREENSHOTS")) return shots;
+ foreach (var line in body.Split('\n'))
+ {
+ if (string.IsNullOrWhiteSpace(line)) continue;
+ var parts = line.Split('|');
+ if (parts.Length < 4) continue;
+ long.TryParse(parts[2], out var size);
+ long.TryParse(parts[3], out var mtime);
+ shots.Add(new PS5Screenshot
+ {
+ FullPath = parts[0],
+ FileName = parts[1],
+ Size = size,
+ ModifiedTime = DateTimeOffset.FromUnixTimeSeconds(mtime).LocalDateTime
+ });
+ }
+ }
+ catch { }
+ return shots.OrderByDescending(s => s.ModifiedTime).ToList();
+ }
+
+ public async Task<(bool success, string message)> DeleteScreenshotAsync(string fullPath)
+ {
+ try
+ {
+ var data = Encoding.UTF8.GetBytes(fullPath + "\0");
+ var (response, respData) = await RunCommandAsync(Command.DeleteScreenshot, data);
+ return (response == Response.Ok, Encoding.UTF8.GetString(respData));
+ }
+ catch (Exception ex) { return (false, ex.Message); }
+ }
+
+ // ---------------- Mount all games (long-running, progress) ----------------
+ public async Task<(bool success, string summary)> MountGamesAsync(Action? onProgress = null)
+ {
+ await _commandLock.WaitAsync();
+ try
+ {
+ await SendCommandInternalAsync(Command.MountGames);
+ // Server streams Progress responses, then final OK or Error
+ while (true)
+ {
+ var (resp, data) = await ReceiveResponseInternalAsync();
+ if (resp == Response.Progress)
+ {
+ onProgress?.Invoke(Encoding.UTF8.GetString(data).TrimEnd('\0'));
+ }
+ else if (resp == Response.Ok)
+ {
+ return (true, Encoding.UTF8.GetString(data));
+ }
+ else if (resp == Response.Error)
+ {
+ return (false, Encoding.UTF8.GetString(data));
+ }
+ else { return (false, $"Unexpected response {resp}"); }
+ }
+ }
+ catch (Exception ex) { return (false, ex.Message); }
+ finally { _commandLock.Release(); }
+ }
+
+ // ---------------- Launch Browser ----------------
+ public async Task<(bool success, string message)> LaunchBrowserAsync(string url)
+ {
+ try
+ {
+ var data = Encoding.UTF8.GetBytes(url + "\0");
+ var (response, respData) = await RunCommandAsync(Command.LaunchBrowser, data);
+ return (response == Response.Ok, Encoding.UTF8.GetString(respData));
+ }
+ catch (Exception ex) { return (false, ex.Message); }
+ }
+
+ // Shell methods (OpenShellAsync/ExecuteShellCommandAsync/CloseShellAsync)
+ // already exist in Protocol.cs โ not duplicated here.
+ }
+
+ // ========== Data models ==========
+
+ public class PS5HardwareInfo
+ {
+ public string Model { get; set; } = "";
+ public string Serial { get; set; } = "";
+ public bool HasWlanBt { get; set; }
+ public bool HasOpticalOut { get; set; }
+ public string HwMachine { get; set; } = "";
+ public string OsVersion { get; set; } = "";
+ public int NumCpu { get; set; }
+ public ulong PhysMem { get; set; }
+
+ public string PhysMemDisplay
+ {
+ get
+ {
+ if (PhysMem == 0) return "โ";
+ double gb = PhysMem / (1024.0 * 1024.0 * 1024.0);
+ return $"{gb:F1} GB";
+ }
+ }
+
+ public string WlanBtDisplay => HasWlanBt ? "โ Yes" : "โ None";
+ public string OpticalDisplay => HasOpticalOut ? "โ Yes" : "โ None";
+ }
+
+ public class PS5TemperatureInfo
+ {
+ public int CpuTemp { get; set; }
+ public int SocTemp { get; set; }
+ public long CpuFreqMhz { get; set; }
+ public uint SocPowerMw { get; set; }
+
+ public string CpuTempDisplay => CpuTemp > 0 ? $"{CpuTemp}ยฐC" : "โ";
+ public string SocTempDisplay => SocTemp > 0 ? $"{SocTemp}ยฐC" : "โ";
+ public string CpuFreqDisplay => CpuFreqMhz > 0 ? $"{CpuFreqMhz / 1000.0:F2} GHz" : "โ";
+ public string SocPowerDisplay => SocPowerMw > 0 ? $"{SocPowerMw / 1000.0:F1} W" : "โ";
+ }
+
+ public class PS5PowerInfo
+ {
+ public ulong OperatingTimeSec { get; set; }
+ public ulong OperatingTimeHours { get; set; }
+ public ulong OperatingTimeMinutes { get; set; }
+ public uint BootCount { get; set; }
+ public uint PowerConsumptionMw { get; set; }
+ }
+
+ public class PS5MountedGame : INotifyPropertyChanged
+ {
+ public string TitleId { get; set; } = "";
+ public string Name { get; set; } = "";
+ public string Path { get; set; } = "";
+ public ulong Size { get; set; }
+ public string Region { get; set; } = "";
+ public bool IsActive { get; set; }
+
+ private ImageSource? _icon;
+ public ImageSource? Icon
+ {
+ get => _icon;
+ set { _icon = value; PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Icon))); }
+ }
+
+ public string SizeDisplay
+ {
+ get
+ {
+ double mb = Size / (1024.0 * 1024.0);
+ if (mb < 1024) return $"{mb:F1} MB";
+ return $"{mb / 1024.0:F2} GB";
+ }
+ }
+
+ public string StatusDisplay => IsActive ? "โ Mounted" : "โ Not Mounted";
+
+ public event PropertyChangedEventHandler? PropertyChanged;
+ }
+
+ public class PS5SaveGame : INotifyPropertyChanged
+ {
+ public string TitleId { get; set; } = "";
+ public string UserId { get; set; } = "";
+ public string SavePath { get; set; } = "";
+ public ulong Size { get; set; }
+ public long ModifiedUnixTime { get; set; }
+
+ public string SizeDisplay
+ {
+ get
+ {
+ double mb = Size / (1024.0 * 1024.0);
+ if (mb < 1) return $"{Size / 1024.0:F1} KB";
+ if (mb < 1024) return $"{mb:F1} MB";
+ return $"{mb / 1024.0:F2} GB";
+ }
+ }
+
+ public string ModifiedDisplay
+ {
+ get
+ {
+ try
+ {
+ var dt = DateTimeOffset.FromUnixTimeSeconds(ModifiedUnixTime).LocalDateTime;
+ return dt.ToString("yyyy-MM-dd HH:mm");
+ }
+ catch { return "โ"; }
+ }
+ }
+
+ public string ShortUserId => UserId.Length > 8 ? UserId.Substring(0, 8) + "โฆ" : UserId;
+
+ private string _gameName = "";
+ public string GameName
+ {
+ get => _gameName;
+ set { _gameName = value; PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(GameName))); }
+ }
+
+ private ImageSource? _icon;
+ public ImageSource? Icon
+ {
+ get => _icon;
+ set { _icon = value; PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Icon))); }
+ }
+
+ public event PropertyChangedEventHandler? PropertyChanged;
+ }
+
+ public class PS5Screenshot : INotifyPropertyChanged
+ {
+ public string FullPath { get; set; } = "";
+ public string FileName { get; set; } = "";
+ public long Size { get; set; }
+ public DateTime ModifiedTime { get; set; }
+
+ private ImageSource? _thumbnail;
+ public ImageSource? Thumbnail
+ {
+ get => _thumbnail;
+ set { _thumbnail = value; PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Thumbnail))); }
+ }
+
+ public event PropertyChangedEventHandler? PropertyChanged;
+
+ public string SizeDisplay
+ {
+ get
+ {
+ if (Size < 1024) return $"{Size} B";
+ if (Size < 1024 * 1024) return $"{Size / 1024.0:0.0} KB";
+ return $"{Size / (1024.0 * 1024.0):0.0} MB";
+ }
+ }
+
+ public string DateDisplay => ModifiedTime.ToString("yyyy-MM-dd HH:mm");
+ }
+}
diff --git a/release/v5.0.0/RELEASE_NOTES.md b/release/v5.0.0/RELEASE_NOTES.md
index 7ab2e94..8397c2d 100644
--- a/release/v5.0.0/RELEASE_NOTES.md
+++ b/release/v5.0.0/RELEASE_NOTES.md
@@ -8,11 +8,14 @@ Complete all-in-one PS5 management platform โ **104+ MB/s** file transfers, li
| File | Size | Purpose |
|------|------|---------|
-| `PS5Upload.exe` | 146 MB | Windows GUI client (self-contained, no .NET install required) |
+| `PS5Upload.exe` | 147 MB | Windows GUI client (self-contained, no .NET install required) |
+| `PS5UploadMobile.apk` | 30 MB | **NEW:** Android client with full feature parity (8+ tabs) |
| `ps5_upload_server.elf` | 187 KB | PS5 payload โ load via elfldr, listens on port 9113 |
**Load the payload on your PS5:** copy `ps5_upload_server.elf` to `/data/etaHEN/payloads/` and run it. You will see a notification `PS5 Upload Server: 192.168.0.XXX:9113 - By Manos` confirming it's ready.
+**Install the Android app:** download `PS5UploadMobile.apk` and install it on your Android device (enable "Install from unknown sources").
+
---
## ๐ What's New in v5.0.0
@@ -113,7 +116,41 @@ Complete all-in-one PS5 management platform โ **104+ MB/s** file transfers, li
---
-## ๐ Quick Start
+---
+
+## ๐ฑ NEW: Android Mobile Client โ Full Feature Parity
+
+The Android app (`PS5UploadMobile.apk`) now has **every feature** of the desktop client, organized via a **hamburger flyout menu (โฐ)** with a clean light theme for maximum readability:
+
+### 7 Fully-Functional Tabs
+- ๐ **Files** โ Browse, upload, download, delete, rename, copy/move with drag-friendly UI
+- ๐ฎ **Games** โ Mounted games list with cover icons, **Launch Game**, Unmount, Game Details
+- ๐พ **Saves** โ Per-game save browsing with icons, metadata, user IDs
+- ๐ท **Screenshots** โ 2-column gallery with live thumbnails, batch download, clean delete
+- ๐ฅ๏ธ **Hardware** โ System Info + Live Sensors (CPU/SoC temps, freq, power) + Power Stats + auto-refresh
+- ๐ป **Shell** โ Terminal-style remote command execution with auto-scroll
+- ๐ฎ **Mount Games** โ Dedicated page for multi-path scan with streaming progress log
+
+### Mobile-Specific Features
+- โ
**Shared connection** โ Connect once in Files tab, all other tabs use it automatically
+- โ
**Thumbnail caching** โ Local cache in app cache directory, zero re-downloads
+- โ
**Pull-to-refresh** on games, saves, screenshots
+- โ
**Light flyout menu** โ white background, dark text, blue accent on selected item
+- โ
**Dark content pages** โ modern readable UI throughout
+- โ
**Interlocked guards** โ prevents overlapping refreshes on Hardware tab
+- โ
**Auto-stop timers** โ auto-refresh stops on disconnect
+- โ
**0 warnings, 0 errors** build
+
+### Mobile Quick Start
+1. Enable **"Install from unknown sources"** in Android settings
+2. Download and install `PS5UploadMobile.apk`
+3. Load the PS5 payload (`ps5_upload_server.elf`)
+4. Open the app, enter your PS5 IP, tap **Connect**
+5. Open the **โฐ menu** to access all 7 tabs
+
+---
+
+## ๐ Quick Start (Desktop)
1. **Load payload on PS5:** Copy `ps5_upload_server.elf` to `/data/etaHEN/payloads/` and execute it
2. **Run client on PC:** Launch `PS5Upload.exe` on Windows