diff --git a/.cursorrules b/.cursorrules new file mode 100644 index 0000000..03cc024 --- /dev/null +++ b/.cursorrules @@ -0,0 +1,32 @@ +# Invisible Gorilla XRay Client Notes + +- Всегда читать все файлы из `memory-bank/` в начале новой задачи. Если Memory Bank отсутствует, создать базовые файлы и поддерживать их в актуальном состоянии. +- Всегда отвечать пользователю на русском языке. +- Для Android VPN/TUN изменений проверять не только UI, но и реальную маршрутизацию через IP-проверку в браузере до и после `STOP`. +- После правок в `XRay-Wrapper/xray/android_tun2socks.go` обязательно пересобирать `libXRayCore.so` для `arm64-v8a` и `x86_64`. +- После правок в Android runtime/service публиковать как минимум `android-x64` для эмулятора и `android-arm64` для устройства. +- Для Android нативная библиотека должна идти через `AndroidNativeLibrary`, а не через копирование `.bin` в app-private storage. +- В `InvisibleGorilla-XRay.Android/Views/MainView.axaml.cs` для сложных overlay/dialog контролов не полагаться на auto-generated поля `x:Name`; на Android они могут оставаться `null` в runtime. Для обращений из code-behind использовать явные `GetRequiredControl(...)`-геттеры с именами, не совпадающими с `x:Name`. +- Если на Android проявляется зависание при `STOP`, сначала проверять блокировки вокруг `StopAndroidTun2Socks`, `AndroidVpnService`, `OnStartCommand`, `StopForeground`, и `logcat` на `Timeout executing service` или `ANR`. +- Для сложных `vless://` ссылок в adb помнить, что `&` и `#` могут ломаться shell-парсингом; при необходимости валидировать через прямую запись конфига в app data или через UI/clipboard flow. +- Для установки и runtime-проверки Android-сборок на эмуляторе использовать `Release` APK; `dotnet publish -c Debug` может давать fast-deployment APK с падением `No assemblies found ... Assuming this is part of Fast Deployment`. +- В `XRay-Wrapper/xray/android_tun2socks.go` нельзя занулять `bridge.tunFile`/`bridge.lwip` до завершения `runAndroidTunLoop`; корректный shutdown: закрыть `stop`, закрыть TUN FD, дождаться `bridge.done`, затем закрывать LWIP, иначе `STOP` может уронить Android процесс через Go panic `nil pointer dereference`. +- В Windows-сборке `InvisibleGorilla-XRay` runtime-пути (`Settings.json`, `TUN`, `Libraries`) завязаны на текущую рабочую директорию (`.`), а не на переназначаемый `SetRoot()`, поэтому любые desktop runtime-проверки вне UI нужно запускать с правильным `cwd`. +- В `InvisibleGorilla-XRay.Mac/Views` использовать абсолютные `using InvisibleGorillaXRay.Services...`, потому что после добавления `InvisibleGorillaXRay.Mac.Services` относительные `using Services...` начинают резолвиться не в тот namespace. +- Desktop app rules хранятся как полный список выбранных приложений; при сохранении настроек нужно сохранять и те выбранные rules, которые временно не попали в auto-discovery список, чтобы не терять скрытые/редкие приложения. +- В Avalonia-представлениях Android не называть собственные свойства/геттеры так же, как `x:Name` в `.axaml`: `Avalonia.NameGenerator` уже создаёт поля с этими именами, и совпадение приводит к `CS0102` при сборке. +- Исключения приложений в Android через `VpnService.Builder.AddDisallowedApplication(...)` меняют маршрут трафика, но не гарантируют, что сторонние приложения не увидят сам факт активной VPN-сессии; в Windows/macOS текущая реализация split tunneling тоже не делает TUN/VPN полностью невидимым на уровне ОС. +- Для TUN-режима локальный SOCKS listener теперь должен идти только через session-scoped auth contract (`InvisibleGorillaXRayCore` -> `XRayCoreWrapper` -> native `XRay-Wrapper` -> Android/desktop helper); не откатывать Android или desktop TUN path обратно на `NO_AUTH`. Desktop `system proxy` path пока сознательно остаётся legacy-веткой до отдельной совместимой реализации. +- При сохранении raw JSON-конфигов через `GeneralConfig` сначала санитизировать runtime-managed top-level секции (`api`, `stats`, `inbounds`), чтобы импорт не мог подменять локальные listener/API surface, который клиент должен контролировать сам. +- Linux-голова `InvisibleGorilla-XRay.Linux` намеренно не дублирует UI: Avalonia views и code-behind линкуются из `InvisibleGorilla-XRay.Mac/`. При линковке `.axaml` файлов в `.csproj` обязательно одновременно включать их и в `` (для runtime XAML loading), и в `` с дочерним `AvaloniaXaml` — без этой метаданной Avalonia name source generator пропускает связанные XAML и сборка валится сериями `CS0103: Имя "" не существует в текущем контексте` для всех `x:Name`-полей. +- В Linux-голове linked code-behind остаётся в namespace `InvisibleGorillaXRay.Mac.Views`; не переименовывать namespace при копировании/линковке. Любая Linux-специфичная логика идёт в отдельные файлы (`Managers/`, `Handlers/`, `Factories/`). +- `MacInstalledAppDiscovery` для Linux лежит в `InvisibleGorilla-XRay.Linux/Services/MacInstalledAppDiscovery.cs`, но под namespace `InvisibleGorillaXRay.Mac.Services`, чтобы линкованный App Rules UI цеплялся к Linux `.desktop`-парсеру без правок самих view-файлов. +- Linux TUN режим всегда fail-closed: privileged шаги (`ip tuntap`, `ip route`, `resolvectl`) идут через `pkexec` (предпочтительно) или `sudo -n`; если elevation не удался — TUN не поднимается, а не «продолжаем без VPN». +- На Linux GNOME proxy управляется только через `gsettings org.gnome.system.proxy*`. На не-GNOME окружениях `LinuxProxy` должен no-op'ом не ломая остальной flow; не пытаться поднять KDE/`environment.d` без отдельной задачи. +- Для Linux сборок единственная поддерживаемая точка входа — `./build.sh` в корне репо. При добавлении новых runtime-зависимостей обновлять и список deps в `build.sh`, и заметки в `memory-bank/techContext.md`. +- Linux app-rules сейчас только JSON-bridge (`LinuxAppRulesBridge`), без kernel enforcement (cgroups + `iptables -m owner`). Не помечать enforcement как готовый, пока не добавлен helper, реально применяющий правила на ядре. +- Для Linux native bridge имя файла обязано быть `Libraries/libXRayCore.so`: `XRayCoreWrapper` на Linux ищет именно это имя. Не собирать/копировать его как `XRayCore.so`, иначе приложение соберётся, но упадёт при первом обращении к native Xray core. +- Linux publish должен давать реальный single-file app binary `publish-linux//InvisibleGorilla-XRay.Linux` (`PublishSingleFile=true`, `IncludeNativeLibrariesForSelfExtract=true`). Bundle-этап должен проверять именно этот исполняемый файл, а не наличие `.dll`. +- Linux `tun2socks` нужно запускать по `InvisibleGorillaXRay.Values.Path.TUN_EXE` (`AppContext.BaseDirectory/TUN/tun2socks`), а не через относительный `./TUN/tun2socks`: `.desktop` запуск не гарантирует текущую рабочую директорию `bin/`. +- Windows `build.ps1` должен читать требуемый .NET SDK из `global.json` и устанавливать/проверять именно эту версию (сейчас `8.0.419`). Нельзя считать установленный SDK 7.x достаточным: `dotnet restore` всё равно упадёт из-за SDK resolution по `global.json`. +- Windows `build.ps1` по умолчанию должен restore/build/publish только `InvisibleGorilla-XRay/InvisibleGorilla-XRay.csproj`, а не весь `InvisibleGorilla-XRay.sln`: иначе на чистой Windows-машине сборка desktop-клиента требует Android workload из `InvisibleGorilla-XRay.Android.csproj`. diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..a8176c5 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,25 @@ +# Force LF for shell scripts and other POSIX-sensitive text files so CRLF +# line endings never sneak in via Windows editors or `core.autocrlf=true`. +# This is required for build.sh and any other *.sh helpers to run on Linux. +*.sh text eol=lf +*.bash text eol=lf +build.sh text eol=lf +install.sh text eol=lf +run-igxray text eol=lf + +# Linux desktop integration files are plain-text with LF endings. +*.desktop text eol=lf +*.service text eol=lf +*.policy text eol=lf + +# Avalonia / .NET text assets — keep cross-platform friendly defaults. +*.cs text +*.csproj text +*.sln text +*.axaml text +*.xaml text +*.xml text +*.json text +*.yaml text +*.yml text +*.md text diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index 1b76988..9293e98 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1 +1 @@ -custom: https://invisiblemanvpn.github.io/donation/ \ No newline at end of file +custom: https://invisiblegorilla.github.io/donation/ \ No newline at end of file diff --git a/.gitignore b/.gitignore index 3218e20..c0178a3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,42 @@ /Files -/InvisibleMan-XRay/bin -/InvisibleMan-XRay/obj +/InvisibleGorilla-XRay/bin +/InvisibleGorilla-XRay/obj +/InvisibleGorilla-XRay.Mac/bin +/InvisibleGorilla-XRay.Mac/obj +/InvisibleGorilla-XRay.Android/bin +/InvisibleGorilla-XRay.Android/obj +/InvisibleGorilla-XRay.Linux/bin +/InvisibleGorilla-XRay.Linux/obj +/InvisibleGorilla-XRay.Linux/TUN +/InvisibleGorilla.Core/bin +/InvisibleGorilla.Core/obj *.dll *.dat -*.exe \ No newline at end of file +*.exe +*.dylib +*.so + +# Build outputs +/publish +/publish-android +/publish-linux +/publish-macos +/dist +/dist-linux +/dist-macos +/.dotnet-sdk +/.dotnet-home +/.nuget +/XRay-Wrapper/gorilla-xray +.cursorrules +memory-bank/activeContext.md +memory-bank/progress.md +memory-bank/systemPatterns.md +.cursorrules +.cursorrules +/memory-bank +.cursorrules +/memory-bank +.cursorrules +/memory-bank +/memory-bank diff --git a/Images/image-1.png b/Images/image-1.png index ea37f07..cb451b9 100644 Binary files a/Images/image-1.png and b/Images/image-1.png differ diff --git a/Images/image-2.png b/Images/image-2.png index 9990d76..e78d309 100644 Binary files a/Images/image-2.png and b/Images/image-2.png differ diff --git a/InvisibleGorilla-XRay.Android/AndroidManifest.xml b/InvisibleGorilla-XRay.Android/AndroidManifest.xml new file mode 100644 index 0000000..333d814 --- /dev/null +++ b/InvisibleGorilla-XRay.Android/AndroidManifest.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/InvisibleGorilla-XRay.Android/App.axaml b/InvisibleGorilla-XRay.Android/App.axaml new file mode 100644 index 0000000..bcde4cc --- /dev/null +++ b/InvisibleGorilla-XRay.Android/App.axaml @@ -0,0 +1,8 @@ + + + + + diff --git a/InvisibleGorilla-XRay.Android/App.axaml.cs b/InvisibleGorilla-XRay.Android/App.axaml.cs new file mode 100644 index 0000000..eb2b2e0 --- /dev/null +++ b/InvisibleGorilla-XRay.Android/App.axaml.cs @@ -0,0 +1,86 @@ +using System; +using Avalonia; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Markup.Xaml; +using Avalonia.Threading; + +namespace InvisibleGorillaXRay.Android +{ + using InvisibleGorillaXRay.Android.Handlers; + using InvisibleGorillaXRay.Android.Managers; + using InvisibleGorillaXRay.Android.Platforms; + using InvisibleGorillaXRay.Android.Views; + + public partial class App : Application + { + private AndroidAppManager? appManager; + + public override void Initialize() + { + try + { + AvaloniaXamlLoader.Load(this); + InvisibleGorillaXRay.Core.DiagnosticLog.Write("AndroidApp", "App.Initialize: AvaloniaXamlLoader.Load completed"); + } + catch (Exception ex) + { + InvisibleGorillaXRay.Core.DiagnosticLog.WriteException("AndroidApp.Initialize", ex); + throw; + } + } + + public override void OnFrameworkInitializationCompleted() + { + try + { + if (ApplicationLifetime is ISingleViewApplicationLifetime singleView) + { + InvisibleGorillaXRay.Core.DiagnosticLog.Write("AndroidApp", "Single view lifetime detected"); + + AndroidAppStorage.ConfigureAppRoot(); + InvisibleGorillaXRay.Core.DiagnosticLog.Write( + "AndroidApp", + $"App root configured: {InvisibleGorillaXRay.Values.Directory.ROOT}"); + + AndroidAppStorage.EnsureRuntimeAssets(); + InvisibleGorillaXRay.Core.DiagnosticLog.Write("AndroidApp", "Runtime assets prepared"); + + appManager = new AndroidAppManager(); + InvisibleGorillaXRay.Core.DiagnosticLog.Write("AndroidApp", "App manager created"); + + appManager.Initialize(); + InvisibleGorillaXRay.Core.DiagnosticLog.Write("AndroidApp", "App manager initialized"); + + MainView mainView = new MainView(); + InvisibleGorillaXRay.Core.DiagnosticLog.Write("AndroidApp", "MainView constructed"); + + singleView.MainView = mainView; + InvisibleGorillaXRay.Core.DiagnosticLog.Write("AndroidApp", "MainView assigned to lifetime"); + + Dispatcher.UIThread.Post(() => + { + try + { + appManager.HandlersManager + .GetHandler() + .TryApplyCurrentLanguage(); + mainView.Setup(appManager); + InvisibleGorillaXRay.Core.DiagnosticLog.Write("AndroidApp", "MainView setup completed"); + } + catch (Exception ex) + { + InvisibleGorillaXRay.Core.DiagnosticLog.WriteException("AndroidApp.SetupPost", ex); + } + }, DispatcherPriority.ApplicationIdle); + } + } + catch (Exception ex) + { + InvisibleGorillaXRay.Core.DiagnosticLog.WriteException("AndroidApp", ex); + throw; + } + + base.OnFrameworkInitializationCompleted(); + } + } +} diff --git a/InvisibleGorilla-XRay.Android/Assets/Runtime/x86_64/libXRayCore.bin b/InvisibleGorilla-XRay.Android/Assets/Runtime/x86_64/libXRayCore.bin new file mode 100644 index 0000000..6d782ea Binary files /dev/null and b/InvisibleGorilla-XRay.Android/Assets/Runtime/x86_64/libXRayCore.bin differ diff --git a/InvisibleGorilla-XRay.Android/Handlers/AndroidLocalizationHandler.cs b/InvisibleGorilla-XRay.Android/Handlers/AndroidLocalizationHandler.cs new file mode 100644 index 0000000..f642cbe --- /dev/null +++ b/InvisibleGorilla-XRay.Android/Handlers/AndroidLocalizationHandler.cs @@ -0,0 +1,111 @@ +using System; +using System.Collections.Generic; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Markup.Xaml; + +namespace InvisibleGorillaXRay.Android.Handlers +{ + using InvisibleGorillaXRay.Handlers; + using InvisibleGorillaXRay.Values; + + public sealed class AndroidLocalizationHandler : Handler + { + private Func? getCurrentLanguage; + private readonly Dictionary terms = new(); + private bool isLanguageLoaded; + private ResourceDictionary? currentLangDict; + + public void Setup(Func getCurrentLanguage) + { + this.getCurrentLanguage = getCurrentLanguage; + } + + public string GetTerm(string key) + { + EnsureLanguageLoaded(); + + if (terms.TryGetValue(key, out string? value)) + return value; + + if (Application.Current != null && + Application.Current.TryFindResource(key, out object? res) && + res is string str) + { + terms[key] = str; + return str; + } + + return key; + } + + public void TryApplyCurrentLanguage() + { + try + { + ApplyLanguage(getCurrentLanguage?.Invoke() ?? Localization.DEFAULT_LANGUAGE); + isLanguageLoaded = true; + } + catch + { + ApplyLanguage(Localization.DEFAULT_LANGUAGE); + isLanguageLoaded = true; + } + } + + private void EnsureLanguageLoaded() + { + if (isLanguageLoaded) + return; + + TryApplyCurrentLanguage(); + } + + private void ApplyLanguage(string language) + { + terms.Clear(); + + try + { + ResourceDictionary dict = LoadDictionary(language); + if (currentLangDict != null && Application.Current != null) + Application.Current.Resources.MergedDictionaries.Remove(currentLangDict); + + currentLangDict = dict; + + if (Application.Current != null) + Application.Current.Resources.MergedDictionaries.Add(dict); + } + catch + { + } + } + + private static ResourceDictionary LoadDictionary(string language) + { + Exception? lastException = null; + + string[] candidates = + { + $"avares://InvisibleGorilla-XRay.Android/Assets/Localization/{language}.axaml", + $"avares://InvisibleGorillaXRay.Android/Assets/Localization/{language}.axaml", + $"avares://InvisibleGorilla-XRay.Mac/Assets/Localization/{language}.axaml", + $"avares://InvisibleGorillaXRay.Mac/Assets/Localization/{language}.axaml" + }; + + foreach (string candidate in candidates) + { + try + { + return (ResourceDictionary)AvaloniaXamlLoader.Load(new Uri(candidate)); + } + catch (Exception ex) + { + lastException = ex; + } + } + + throw lastException ?? new InvalidOperationException("Localization resource dictionary not found."); + } + } +} diff --git a/InvisibleGorilla-XRay.Android/Handlers/DeepLinks/AndroidDeepLink.cs b/InvisibleGorilla-XRay.Android/Handlers/DeepLinks/AndroidDeepLink.cs new file mode 100644 index 0000000..c7d15c6 --- /dev/null +++ b/InvisibleGorilla-XRay.Android/Handlers/DeepLinks/AndroidDeepLink.cs @@ -0,0 +1,170 @@ +using System; +using System.Collections.Generic; +using Avalonia.Threading; + +namespace InvisibleGorillaXRay.Android.Handlers.DeepLinks +{ + using InvisibleGorillaXRay.Handlers.DeepLinks; + + public sealed class AndroidDeepLink : IDeepLink + { + public void Register() + { + } + } + + internal enum AndroidImportKind + { + ConfigLink, + SubscriptionLink, + ConfigFile + } + + internal sealed class AndroidPendingImport + { + public AndroidPendingImport(AndroidImportKind kind, string value, string? displayName = null) + { + Kind = kind; + Value = value; + DisplayName = displayName; + } + + public AndroidImportKind Kind { get; } + public string Value { get; } + public string? DisplayName { get; } + } + + public static class AndroidDeepLinkDispatcher + { + private static readonly object SyncRoot = new(); + private static readonly Queue PendingImports = new(); + private static Action? onImportReceived; + + public static Action OnReceiveArg = _ => { }; + + internal static void Register(Action handler) + { + List queuedImports; + + lock (SyncRoot) + { + onImportReceived += handler; + queuedImports = new List(PendingImports); + PendingImports.Clear(); + } + + foreach (AndroidPendingImport pendingImport in queuedImports) + PostImport(handler, pendingImport); + } + + internal static void Unregister(Action handler) + { + lock (SyncRoot) + onImportReceived -= handler; + } + + internal static bool DispatchExternalValue(string? value) + { + string normalizedValue = value?.Trim() ?? string.Empty; + if (string.IsNullOrWhiteSpace(normalizedValue)) + return false; + + if (normalizedValue.StartsWith(InvisibleGorillaXRay.Values.DeepLink.CONFIG, StringComparison.OrdinalIgnoreCase)) + { + OnReceiveArg(normalizedValue); + return DispatchConfigLink(normalizedValue[InvisibleGorillaXRay.Values.DeepLink.CONFIG.Length..]); + } + + if (normalizedValue.StartsWith(InvisibleGorillaXRay.Values.DeepLink.CONFIG_DATA, StringComparison.OrdinalIgnoreCase)) + { + OnReceiveArg(normalizedValue); + return DispatchConfigLink(normalizedValue); + } + + if (normalizedValue.StartsWith(InvisibleGorillaXRay.Values.DeepLink.SUBSCRIPTION, StringComparison.OrdinalIgnoreCase)) + { + OnReceiveArg(normalizedValue); + return DispatchSubscriptionLink(normalizedValue[InvisibleGorillaXRay.Values.DeepLink.SUBSCRIPTION.Length..]); + } + + if (IsConfigLink(normalizedValue)) + return DispatchConfigLink(normalizedValue); + + if (IsSubscriptionLink(normalizedValue)) + return DispatchSubscriptionLink(normalizedValue); + + return false; + } + + internal static bool DispatchConfigFile(string? displayName, string? content) + { + string normalizedContent = content?.Trim() ?? string.Empty; + if (string.IsNullOrWhiteSpace(normalizedContent)) + return false; + + Dispatch(new AndroidPendingImport( + kind: AndroidImportKind.ConfigFile, + value: normalizedContent, + displayName: displayName)); + + return true; + } + + private static bool DispatchConfigLink(string? link) + { + string normalizedLink = link?.Trim() ?? string.Empty; + if (string.IsNullOrWhiteSpace(normalizedLink)) + return false; + + Dispatch(new AndroidPendingImport(AndroidImportKind.ConfigLink, normalizedLink)); + return true; + } + + private static bool DispatchSubscriptionLink(string? link) + { + string normalizedLink = link?.Trim() ?? string.Empty; + if (string.IsNullOrWhiteSpace(normalizedLink)) + return false; + + Dispatch(new AndroidPendingImport(AndroidImportKind.SubscriptionLink, normalizedLink)); + return true; + } + + private static void Dispatch(AndroidPendingImport pendingImport) + { + Action? handler; + + lock (SyncRoot) + { + handler = onImportReceived; + if (handler == null) + { + PendingImports.Enqueue(pendingImport); + return; + } + } + + PostImport(handler, pendingImport); + } + + private static void PostImport(Action handler, AndroidPendingImport pendingImport) + { + Dispatcher.UIThread.Post(() => handler(pendingImport)); + } + + private static bool IsConfigLink(string value) + { + return value.StartsWith("vless://", StringComparison.OrdinalIgnoreCase) + || value.StartsWith("vmess://", StringComparison.OrdinalIgnoreCase) + || value.StartsWith("trojan://", StringComparison.OrdinalIgnoreCase) + || value.StartsWith("ss://", StringComparison.OrdinalIgnoreCase); + } + + private static bool IsSubscriptionLink(string value) + { + return Uri.TryCreate(value, UriKind.Absolute, out Uri? uri) + && (uri.Scheme.Equals(Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) + || uri.Scheme.Equals(Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)); + } + } +} diff --git a/InvisibleGorilla-XRay.Android/Handlers/Proxies/AndroidProxy.cs b/InvisibleGorilla-XRay.Android/Handlers/Proxies/AndroidProxy.cs new file mode 100644 index 0000000..33d7bd4 --- /dev/null +++ b/InvisibleGorilla-XRay.Android/Handlers/Proxies/AndroidProxy.cs @@ -0,0 +1,32 @@ +namespace InvisibleGorillaXRay.Android.Handlers.Proxies +{ + using InvisibleGorillaXRay.Core; + using InvisibleGorillaXRay.Handlers.Proxies; + using InvisibleGorillaXRay.Models; + + public sealed class AndroidProxy : IProxy + { + public Status Enable(string address, int port) + { + DiagnosticLog.Write( + "AndroidProxy", + $"Proxy mode requested. Android keeps Xray as a local listener at {address}:{port}."); + + return new Status( + code: Code.SUCCESS, + subCode: SubCode.SUCCESS, + content: string.Empty + ); + } + + public void Disable() + { + DiagnosticLog.Write("AndroidProxy", "Disable requested"); + } + + public void Cancel() + { + DiagnosticLog.Write("AndroidProxy", "Cancel requested"); + } + } +} diff --git a/InvisibleGorilla-XRay.Android/Handlers/Settings/AndroidStartup.cs b/InvisibleGorilla-XRay.Android/Handlers/Settings/AndroidStartup.cs new file mode 100644 index 0000000..aa2a72d --- /dev/null +++ b/InvisibleGorilla-XRay.Android/Handlers/Settings/AndroidStartup.cs @@ -0,0 +1,15 @@ +namespace InvisibleGorillaXRay.Android.Handlers.Settings +{ + using InvisibleGorillaXRay.Handlers.Settings.Startup; + + public sealed class AndroidStartup : IStartupSetting + { + public void EnableRunAtStartup() + { + } + + public void DisableRunAtStartup() + { + } + } +} diff --git a/InvisibleGorilla-XRay.Android/Handlers/Tunnels/AndroidTunnel.cs b/InvisibleGorilla-XRay.Android/Handlers/Tunnels/AndroidTunnel.cs new file mode 100644 index 0000000..703c8a8 --- /dev/null +++ b/InvisibleGorilla-XRay.Android/Handlers/Tunnels/AndroidTunnel.cs @@ -0,0 +1,108 @@ +using System; +using System.Linq; + +namespace InvisibleGorillaXRay.Android.Handlers.Tunnels +{ + using InvisibleGorillaXRay.Core; + using InvisibleGorillaXRay.Handlers; + using InvisibleGorillaXRay.Android.Handlers.Settings; + using InvisibleGorillaXRay.Android.Services; + using InvisibleGorillaXRay.Handlers.Tunnels; + using InvisibleGorillaXRay.Models; + using InvisibleGorillaXRay.Services; + using InvisibleGorillaXRay.Values; + + public sealed class AndroidTunnel : ITunnel + { + private LocalizationService LocalizationService => ServiceLocator.Get(); + + public Status Enable(string ip, int port, string address, string server, string dns, LocalProxyCredentials localProxyCredentials) + { + DiagnosticLog.Write( + "AndroidTunnel", + $"TUN mode requested for proxy={ip}:{port}, address={address}, server={server}, dns={dns}, authEnabled={localProxyCredentials?.HasValue == true}"); + + (AppRulesMode appRulesMode, string[] appPackages) = GetAppRulePackages(); + + Status startStatus = AndroidVpnServiceController.Start(new AndroidVpnStartOptions + { + ProxyPort = port, + ProxyUsername = localProxyCredentials?.Username ?? string.Empty, + ProxyPassword = localProxyCredentials?.Password ?? string.Empty, + UdpEnabled = true, + TunAddress = address, + Dns = dns, + SessionName = "Invisible Gorilla XRay", + AppRulesMode = appRulesMode, + AppPackages = appPackages + }); + + if (startStatus.Code == Code.ERROR) + { + string detail = startStatus.Content?.ToString() + ?? AndroidVpnServiceController.LastError + ?? string.Empty; + + if (detail.Contains("permission", StringComparison.OrdinalIgnoreCase)) + { + return new Status( + code: Code.ERROR, + subCode: SubCode.CANT_TUNNEL, + content: LocalizationService.GetTerm("Lang.Android.Status.VpnPermissionDenied")); + } + + return new Status( + code: Code.ERROR, + subCode: SubCode.CANT_TUNNEL, + content: string.Format( + LocalizationService.GetTerm("Lang.Android.Status.VpnStartFailed"), + detail)); + } + + return new Status( + code: Code.SUCCESS, + subCode: SubCode.SUCCESS, + content: string.Empty); + } + + public void Disable() + { + DiagnosticLog.Write("AndroidTunnel", "Disable requested"); + AndroidVpnServiceController.Stop(); + } + + public void Cancel() + { + DiagnosticLog.Write("AndroidTunnel", "Cancel requested"); + AndroidVpnServiceController.Stop(); + } + + private static (AppRulesMode Mode, string[] Packages) GetAppRulePackages() + { + SettingsHandler settingsHandler = new(() => new AndroidStartup()); + UserSettings settings = settingsHandler.UserSettings; + + string configPath = settings.GetCurrentConfigPath(); + string boundTemplateId = settings.GetBoundAppRuleTemplateId(); + AppRulesMode mode = settings.GetEffectiveAppRulesMode(); + + DiagnosticLog.Write($"[AppRules] GetAppRulePackages: configPath={configPath}, boundTemplate={boundTemplateId}, mode={mode}"); + + if (mode == AppRulesMode.ALL_APPS) + { + DiagnosticLog.Write("[AppRules] Mode=ALL_APPS → no packages to pass"); + return (mode, Array.Empty()); + } + + string[] packages = settings.GetEffectiveEnabledAppRules() + .Select(rule => rule.AppId?.Trim()) + .Where(appId => !string.IsNullOrWhiteSpace(appId)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray()!; + + DiagnosticLog.Write($"[AppRules] Mode={mode}, packages count={packages.Length}: [{string.Join(", ", packages)}]"); + + return (mode, packages); + } + } +} diff --git a/InvisibleGorilla-XRay.Android/InvisibleGorilla-XRay.Android.csproj b/InvisibleGorilla-XRay.Android/InvisibleGorilla-XRay.Android.csproj new file mode 100644 index 0000000..821b6c4 --- /dev/null +++ b/InvisibleGorilla-XRay.Android/InvisibleGorilla-XRay.Android.csproj @@ -0,0 +1,60 @@ + + + + net8.0-android + Exe + InvisibleGorillaXRay.Android + InvisibleGorilla-XRay.Android + enable + 24 + io.invisiblegorilla.xray + Invisible Gorilla XRay + 1 + 0.1.0 + apk + false + AndroidManifest.xml + false + false + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + arm64-v8a + + + x86_64 + + + + diff --git a/InvisibleGorilla-XRay.Android/MainActivity.cs b/InvisibleGorilla-XRay.Android/MainActivity.cs new file mode 100644 index 0000000..0c2d776 --- /dev/null +++ b/InvisibleGorilla-XRay.Android/MainActivity.cs @@ -0,0 +1,316 @@ +using System; +using Android.App; +using Android.Content; +using Android.Content.PM; +using Android.OS; +using Android.Views; +using Avalonia; +using Avalonia.Android; +using System.Threading; +using System.Threading.Tasks; + +namespace InvisibleGorillaXRay.Android +{ + using InvisibleGorillaXRay.Android.Handlers.DeepLinks; + using InvisibleGorillaXRay.Core; + + [Activity( + Label = "Invisible Gorilla XRay", + Theme = "@style/Theme.AppCompat.DayNight.NoActionBar", + MainLauncher = true, + LaunchMode = LaunchMode.SingleTask, + WindowSoftInputMode = SoftInput.AdjustResize, + ConfigurationChanges = + ConfigChanges.Orientation | + ConfigChanges.ScreenSize | + ConfigChanges.UiMode | + ConfigChanges.ScreenLayout | + ConfigChanges.SmallestScreenSize | + ConfigChanges.Density)] + [IntentFilter( + new[] { Intent.ActionView }, + Categories = new[] { Intent.CategoryDefault, Intent.CategoryBrowsable }, + DataScheme = "invxray")] + [IntentFilter( + new[] { Intent.ActionView }, + Categories = new[] { Intent.CategoryDefault, Intent.CategoryBrowsable }, + DataScheme = "vless")] + [IntentFilter( + new[] { Intent.ActionView }, + Categories = new[] { Intent.CategoryDefault, Intent.CategoryBrowsable }, + DataScheme = "vmess")] + [IntentFilter( + new[] { Intent.ActionView }, + Categories = new[] { Intent.CategoryDefault, Intent.CategoryBrowsable }, + DataScheme = "trojan")] + [IntentFilter( + new[] { Intent.ActionView }, + Categories = new[] { Intent.CategoryDefault, Intent.CategoryBrowsable }, + DataScheme = "ss")] + [IntentFilter( + new[] { Intent.ActionSend }, + Categories = new[] { Intent.CategoryDefault }, + DataMimeType = "text/plain")] + public class MainActivity : AvaloniaMainActivity + { + private const int NotificationPermissionRequestCode = 1001; + private const int VpnPermissionRequestCode = 1002; + private static readonly object ActivitySync = new(); + private static MainActivity? currentActivity; + private static TaskCompletionSource? vpnPermissionRequest; + private static int globalHandlersRegistered; + + protected override void OnCreate(Bundle? savedInstanceState) + { + RegisterGlobalExceptionHandlersOnce(); + + try + { + base.OnCreate(savedInstanceState); + SetCurrentActivity(this); + RequestNotificationPermissionIfNeeded(); + DispatchIncomingIntent(Intent); + DiagnosticLog.Write("MainActivity", $"OnCreate completed, intent={Intent?.Action ?? ""}"); + } + catch (Exception ex) + { + DiagnosticLog.WriteException("MainActivity.OnCreate", ex); + throw; + } + } + + private static void RegisterGlobalExceptionHandlersOnce() + { + if (Interlocked.Exchange(ref globalHandlersRegistered, 1) == 1) + return; + + AppDomain.CurrentDomain.UnhandledException += (_, args) => + { + try + { + Exception? ex = args.ExceptionObject as Exception; + if (ex != null) + DiagnosticLog.WriteException("AppDomain.UnhandledException", ex); + else + DiagnosticLog.Write("AppDomain.UnhandledException", $"Unknown error: {args.ExceptionObject}"); + } + catch + { + // Crash handler must never throw. + } + }; + + TaskScheduler.UnobservedTaskException += (_, args) => + { + try + { + DiagnosticLog.WriteException("TaskScheduler.UnobservedTaskException", args.Exception); + args.SetObserved(); + } + catch + { + // Crash handler must never throw. + } + }; + + try + { + global::Java.Lang.Thread.DefaultUncaughtExceptionHandler = new JavaUncaughtExceptionHandler(); + } + catch (Exception ex) + { + DiagnosticLog.WriteException("MainActivity.RegisterJavaUncaughtHandler", ex); + } + + DiagnosticLog.Write("MainActivity", "Global exception handlers installed"); + } + + private sealed class JavaUncaughtExceptionHandler : global::Java.Lang.Object, global::Java.Lang.Thread.IUncaughtExceptionHandler + { + private readonly global::Java.Lang.Thread.IUncaughtExceptionHandler? previous; + + public JavaUncaughtExceptionHandler() + { + previous = global::Java.Lang.Thread.DefaultUncaughtExceptionHandler; + } + + public void UncaughtException(global::Java.Lang.Thread? t, global::Java.Lang.Throwable? e) + { + try + { + string threadName = t?.Name ?? ""; + string typeName = e?.GetType().FullName ?? ""; + string message = e?.Message ?? string.Empty; + DiagnosticLog.Write("Java.UncaughtException", $"thread={threadName}, type={typeName}, message={message}"); + if (e != null) + DiagnosticLog.Write("Java.UncaughtException", $"stack={e.ToString()}"); + } + catch + { + // Crash handler must never throw. + } + + previous?.UncaughtException(t, e); + } + } + + protected override void OnNewIntent(Intent? intent) + { + base.OnNewIntent(intent); + SetCurrentActivity(this); + + if (intent == null) + return; + + Intent = intent; + DispatchIncomingIntent(intent); + } + + protected override AppBuilder CustomizeAppBuilder(AppBuilder builder) + { + return base.CustomizeAppBuilder(builder) + .LogToTrace(); + } + + protected override void OnResume() + { + base.OnResume(); + SetCurrentActivity(this); + } + + protected override void OnDestroy() + { + lock (ActivitySync) + { + if (ReferenceEquals(currentActivity, this)) + currentActivity = null; + } + + base.OnDestroy(); + } + + protected override void OnActivityResult(int requestCode, Result resultCode, Intent? data) + { + if (requestCode == VpnPermissionRequestCode) + { + TaskCompletionSource? completion; + lock (ActivitySync) + { + completion = vpnPermissionRequest; + vpnPermissionRequest = null; + } + + bool granted = resultCode == Result.Ok || global::Android.Net.VpnService.Prepare(this) == null; + completion?.TrySetResult(granted); + return; + } + + base.OnActivityResult(requestCode, resultCode, data); + } + + internal static Task EnsureVpnPreparedAsync() + { + MainActivity? activity = GetCurrentActivity(); + if (activity == null) + return Task.FromResult(false); + + Intent? intent = global::Android.Net.VpnService.Prepare(activity); + if (intent == null) + return Task.FromResult(true); + + lock (ActivitySync) + { + if (vpnPermissionRequest != null) + return vpnPermissionRequest.Task; + + vpnPermissionRequest = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + } + + activity.RunOnUiThread(() => + { + try + { + activity.StartActivityForResult(intent, VpnPermissionRequestCode); + } + catch (Exception ex) + { + DiagnosticLog.WriteException("MainActivity.StartVpnConsent", ex); + + lock (ActivitySync) + { + vpnPermissionRequest?.TrySetResult(false); + vpnPermissionRequest = null; + } + } + }); + + return vpnPermissionRequest.Task; + } + + private static void DispatchIncomingIntent(Intent? intent) + { + if (intent == null) + return; + + try + { + if (TryDispatchViewIntent(intent)) + return; + + TryDispatchSharedText(intent); + } + catch (Exception ex) + { + DiagnosticLog.WriteException("MainActivity.DispatchIncomingIntent", ex); + } + } + + private static bool TryDispatchViewIntent(Intent intent) + { + if (!string.Equals(intent.Action, Intent.ActionView, StringComparison.Ordinal)) + return false; + + return AndroidDeepLinkDispatcher.DispatchExternalValue(intent.DataString); + } + + private static bool TryDispatchSharedText(Intent intent) + { + if (!string.Equals(intent.Action, Intent.ActionSend, StringComparison.Ordinal)) + return false; + + string? sharedText = intent.GetStringExtra(Intent.ExtraText); + return AndroidDeepLinkDispatcher.DispatchExternalValue(sharedText); + } + + private void RequestNotificationPermissionIfNeeded() + { + if (Build.VERSION.SdkInt < BuildVersionCodes.Tiramisu) + return; + + if (CheckSelfPermission(global::Android.Manifest.Permission.PostNotifications) == Permission.Granted) + return; + + RequestPermissions( + new[] { global::Android.Manifest.Permission.PostNotifications }, + NotificationPermissionRequestCode); + } + + private static MainActivity? GetCurrentActivity() + { + lock (ActivitySync) + { + return currentActivity; + } + } + + internal static MainActivity? CurrentActivity => GetCurrentActivity(); + + private static void SetCurrentActivity(MainActivity activity) + { + lock (ActivitySync) + { + currentActivity = activity; + } + } + } +} diff --git a/InvisibleGorilla-XRay.Android/Managers/AndroidAppManager.cs b/InvisibleGorilla-XRay.Android/Managers/AndroidAppManager.cs new file mode 100644 index 0000000..1747f15 --- /dev/null +++ b/InvisibleGorilla-XRay.Android/Managers/AndroidAppManager.cs @@ -0,0 +1,76 @@ +using InvisibleGorillaXRay.Core; +using InvisibleGorillaXRay.Handlers; +using InvisibleGorillaXRay.Managers; +using InvisibleGorillaXRay.Managers.Initializers; + +namespace InvisibleGorillaXRay.Android.Managers +{ + public sealed class AndroidAppManager + { + private readonly CoreInitializer coreInitializer; + private readonly AndroidHandlersInitializer handlersInitializer; + private readonly ServicesInitializer servicesInitializer; + + public AndroidAppManager() + { + coreInitializer = new CoreInitializer(); + handlersInitializer = new AndroidHandlersInitializer(); + servicesInitializer = new ServicesInitializer(); + } + + public InvisibleGorillaXRayCore Core => coreInitializer.Core; + public HandlersManager HandlersManager => handlersInitializer.HandlersManager; + + public void Initialize() + { + InvisibleGorillaXRay.Values.Directory.EnsureWritableDirectories(); + + coreInitializer.Register(); + handlersInitializer.Register(); + servicesInitializer.Register(); + + handlersInitializer.Setup(); + servicesInitializer.Setup( + handlersManager: handlersInitializer.HandlersManager, + getLocalizedTerm: handlersInitializer.LocalizationHandler.GetTerm + ); + coreInitializer.Setup(handlersInitializer.HandlersManager); + + ApplyAndroidDefaults(); + coreInitializer.Core.DisableMode(); + } + + private void ApplyAndroidDefaults() + { + SettingsHandler settingsHandler = handlersInitializer.HandlersManager.GetHandler(); + + if (string.IsNullOrWhiteSpace(settingsHandler.UserSettings.GetClientId())) + settingsHandler.GenerateClientId(); + + settingsHandler.UpdateUserSettings(new InvisibleGorillaXRay.Models.UserSettings + { + Language = settingsHandler.UserSettings.GetLanguage(), + Mode = InvisibleGorillaXRay.Models.Mode.TUN, + // Android VpnService routing uses the local SOCKS listener as the bridge target. + Protocol = InvisibleGorillaXRay.Models.Protocol.SOCKS, + LogLevel = settingsHandler.UserSettings.GetLogLevel(), + IsSystemProxyUse = false, + IsUdpEnable = settingsHandler.UserSettings.GetUdpEnabled(), + IsRunningAtStartup = false, + IsStartHidden = false, + IsAutoConnect = false, + IsSendingAnalytics = settingsHandler.UserSettings.GetSendingAnalyticsEnabled(), + ProxyPort = settingsHandler.UserSettings.GetProxyPort(), + TunPort = settingsHandler.UserSettings.GetTunPort(), + TestPort = settingsHandler.UserSettings.GetTestPort(), + TunIp = settingsHandler.UserSettings.GetTunIp(), + Dns = settingsHandler.UserSettings.GetDns(), + LogPath = settingsHandler.UserSettings.GetLogPath(), + AppRulesMode = settingsHandler.UserSettings.GetAppRulesMode(), + AppRules = settingsHandler.UserSettings.GetAppRules(), + AppRuleTemplates = settingsHandler.UserSettings.GetAppRuleTemplates(), + AppRuleTemplateBindings = settingsHandler.UserSettings.GetAppRuleTemplateBindings() + }); + } + } +} diff --git a/InvisibleGorilla-XRay.Android/Managers/AndroidHandlersInitializer.cs b/InvisibleGorilla-XRay.Android/Managers/AndroidHandlersInitializer.cs new file mode 100644 index 0000000..6d4c03f --- /dev/null +++ b/InvisibleGorilla-XRay.Android/Managers/AndroidHandlersInitializer.cs @@ -0,0 +1,89 @@ +using InvisibleGorillaXRay.Handlers; +using InvisibleGorillaXRay.Handlers.DeepLinks; +using InvisibleGorillaXRay.Handlers.Settings.Startup; +using InvisibleGorillaXRay.Managers; + +namespace InvisibleGorillaXRay.Android.Managers +{ + using InvisibleGorillaXRay.Android.Handlers; + using InvisibleGorillaXRay.Android.Handlers.DeepLinks; + using InvisibleGorillaXRay.Android.Handlers.Proxies; + using InvisibleGorillaXRay.Android.Handlers.Settings; + using InvisibleGorillaXRay.Android.Handlers.Tunnels; + using InvisibleGorillaXRay.Models; + + public sealed class AndroidHandlersInitializer + { + public HandlersManager HandlersManager { get; private set; } = null!; + public AndroidLocalizationHandler LocalizationHandler { get; private set; } = null!; + + public void Register() + { + HandlersManager = new HandlersManager(); + LocalizationHandler = new AndroidLocalizationHandler(); + + HandlersManager.AddHandler(new SettingsHandler(() => new AndroidStartup())); + HandlersManager.AddHandler(new TemplateHandler()); + HandlersManager.AddHandler(new ConfigHandler()); + HandlersManager.AddHandler(new ProxyHandler(() => new AndroidProxy())); + HandlersManager.AddHandler(new TunnelHandler(() => new AndroidTunnel())); + HandlersManager.AddHandler(new VersionHandler()); + HandlersManager.AddHandler(new UpdateHandler()); + HandlersManager.AddHandler(new BroadcastHandler()); + HandlersManager.AddHandler(new DeepLinkHandler(() => new AndroidDeepLink())); + HandlersManager.AddHandler(LocalizationHandler); + } + + public void Setup() + { + SetupTunnelHandler(); + SetupConfigHandler(); + SetupUpdateHandler(); + SetupDeepLinkHandler(); + SetupLocalizationHandler(); + + void SetupTunnelHandler() + { + HandlersManager.GetHandler().Setup( + onStartTunnelingService: () => { }, + isServiceRunning: () => false, + isServicePortActive: () => false, + connectTunnelingService: () => new Status(Code.ERROR, SubCode.CANT_TUNNEL, string.Empty), + executeCommand: command => new Status(Code.ERROR, SubCode.CANT_TUNNEL, command) + ); + } + + void SetupConfigHandler() + { + SettingsHandler settingsHandler = HandlersManager.GetHandler(); + HandlersManager.GetHandler().Setup( + getCurrentConfigPath: settingsHandler.UserSettings.GetCurrentConfigPath + ); + } + + void SetupUpdateHandler() + { + VersionHandler versionHandler = HandlersManager.GetHandler(); + HandlersManager.GetHandler().Setup( + getApplicationVersion: versionHandler.GetApplicationVersion, + convertToAppVersion: versionHandler.ConvertToAppVersion + ); + } + + void SetupDeepLinkHandler() + { + HandlersManager.GetHandler().Setup( + ref AndroidDeepLinkDispatcher.OnReceiveArg, + onConfigLinkFetched: _ => { }, + onSubscriptionLinkFetched: _ => { } + ); + } + + void SetupLocalizationHandler() + { + SettingsHandler settingsHandler = HandlersManager.GetHandler(); + LocalizationHandler.Setup(settingsHandler.UserSettings.GetLanguage); + } + } + } +} diff --git a/InvisibleGorilla-XRay.Android/Platforms/AndroidAppStorage.cs b/InvisibleGorilla-XRay.Android/Platforms/AndroidAppStorage.cs new file mode 100644 index 0000000..bbd5d97 --- /dev/null +++ b/InvisibleGorilla-XRay.Android/Platforms/AndroidAppStorage.cs @@ -0,0 +1,101 @@ +using System; +using System.IO; +using Android.App; + +namespace InvisibleGorillaXRay.Android.Platforms +{ + using InvisibleGorillaXRay.Core; + + internal static class AndroidAppStorage + { + private static readonly string AppRoot = ResolveAppRoot(); + + public static void ConfigureAppRoot() + { + InvisibleGorillaXRay.Values.Directory.SetRoot(AppRoot); + InvisibleGorillaXRay.Values.Directory.EnsureWritableDirectories(); + } + + public static void EnsureRuntimeAssets() + { + ConfigureAppRoot(); + + CopyAssetIfPresent("Runtime/geoip.dat", Path.Combine(InvisibleGorillaXRay.Values.Directory.ROOT, "geoip.dat")); + CopyAssetIfPresent("Runtime/geosite.dat", Path.Combine(InvisibleGorillaXRay.Values.Directory.ROOT, "geosite.dat")); + DeleteLegacyCopiedNativeRuntime(); + } + + private static bool CopyAssetIfPresent(string assetPath, string destinationPath, string? assetIdentity = null) + { + try + { + Directory.CreateDirectory(Path.GetDirectoryName(destinationPath) ?? InvisibleGorillaXRay.Values.Directory.ROOT); + + using Stream assetStream = Application.Context.Assets!.Open(assetPath); + string? identityPath = string.IsNullOrWhiteSpace(assetIdentity) + ? null + : destinationPath + ".asset-id"; + + if (File.Exists(destinationPath)) + { + try + { + FileInfo destinationInfo = new FileInfo(destinationPath); + string currentIdentity = identityPath != null && File.Exists(identityPath) + ? File.ReadAllText(identityPath) + : string.Empty; + + if (destinationInfo.Length == assetStream.Length && + (identityPath == null || string.Equals(currentIdentity, assetIdentity, StringComparison.Ordinal))) + { + return true; + } + } + catch + { + } + } + + using FileStream destinationStream = File.Create(destinationPath); + assetStream.CopyTo(destinationStream); + + if (identityPath != null) + File.WriteAllText(identityPath, assetIdentity); + + return true; + } + catch + { + // Asset is optional during development; build scripts populate it for APK packaging. + return false; + } + } + + private static string ResolveAppRoot() + { + string localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + if (string.IsNullOrWhiteSpace(localAppData)) + localAppData = AppContext.BaseDirectory; + + return Path.Combine(localAppData, "InvisibleGorilla-XRay"); + } + + private static void DeleteLegacyCopiedNativeRuntime() + { + try + { + string nativeLibPath = InvisibleGorillaXRay.Values.Path.XRAY_CORE_LIB; + if (File.Exists(nativeLibPath)) + File.Delete(nativeLibPath); + + string assetIdentityPath = nativeLibPath + ".asset-id"; + if (File.Exists(assetIdentityPath)) + File.Delete(assetIdentityPath); + } + catch + { + // The packaged AndroidNativeLibrary is the source of truth. + } + } + } +} diff --git a/InvisibleGorilla-XRay.Android/Resources/drawable/ic_gorilla_helmet.xml b/InvisibleGorilla-XRay.Android/Resources/drawable/ic_gorilla_helmet.xml new file mode 100644 index 0000000..b61f330 --- /dev/null +++ b/InvisibleGorilla-XRay.Android/Resources/drawable/ic_gorilla_helmet.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + diff --git a/InvisibleGorilla-XRay.Android/Resources/drawable/ic_launcher_windows.png b/InvisibleGorilla-XRay.Android/Resources/drawable/ic_launcher_windows.png new file mode 100644 index 0000000..64aec44 Binary files /dev/null and b/InvisibleGorilla-XRay.Android/Resources/drawable/ic_launcher_windows.png differ diff --git a/InvisibleGorilla-XRay.Android/Resources/drawable/ic_notification_connection.xml b/InvisibleGorilla-XRay.Android/Resources/drawable/ic_notification_connection.xml new file mode 100644 index 0000000..60e181d --- /dev/null +++ b/InvisibleGorilla-XRay.Android/Resources/drawable/ic_notification_connection.xml @@ -0,0 +1,10 @@ + + + + diff --git a/InvisibleGorilla-XRay.Android/Resources/xml/file_paths.xml b/InvisibleGorilla-XRay.Android/Resources/xml/file_paths.xml new file mode 100644 index 0000000..bc1e9b9 --- /dev/null +++ b/InvisibleGorilla-XRay.Android/Resources/xml/file_paths.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + diff --git a/InvisibleGorilla-XRay.Android/Services/AndroidConfigShareLinkStore.cs b/InvisibleGorilla-XRay.Android/Services/AndroidConfigShareLinkStore.cs new file mode 100644 index 0000000..78d267f --- /dev/null +++ b/InvisibleGorilla-XRay.Android/Services/AndroidConfigShareLinkStore.cs @@ -0,0 +1,59 @@ +using System; +using System.IO; +using System.Security.Cryptography; +using System.Text; + +namespace InvisibleGorillaXRay.Android.Services +{ + internal static class AndroidConfigShareLinkStore + { + private static string MetadataRoot => Path.Combine( + InvisibleGorillaXRay.Values.Directory.ROOT, + "Metadata", + "ConfigLinks"); + + public static void SaveSourceLink(string configPath, string sourceLink) + { + if (string.IsNullOrWhiteSpace(configPath) || string.IsNullOrWhiteSpace(sourceLink)) + return; + + Directory.CreateDirectory(MetadataRoot); + File.WriteAllText(GetMetadataPath(configPath), sourceLink.Trim()); + } + + public static bool TryGetSourceLink(string configPath, out string? sourceLink) + { + sourceLink = null; + if (string.IsNullOrWhiteSpace(configPath)) + return false; + + string metadataPath = GetMetadataPath(configPath); + if (!File.Exists(metadataPath)) + return false; + + string value = File.ReadAllText(metadataPath).Trim(); + if (string.IsNullOrWhiteSpace(value)) + return false; + + sourceLink = value; + return true; + } + + public static void DeleteSourceLink(string configPath) + { + if (string.IsNullOrWhiteSpace(configPath)) + return; + + string metadataPath = GetMetadataPath(configPath); + if (File.Exists(metadataPath)) + File.Delete(metadataPath); + } + + private static string GetMetadataPath(string configPath) + { + string normalizedPath = Path.GetFullPath(configPath); + byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes(normalizedPath)); + return Path.Combine(MetadataRoot, $"{Convert.ToHexString(hash)}.txt"); + } + } +} diff --git a/InvisibleGorilla-XRay.Android/Services/AndroidConnectionNotificationManager.cs b/InvisibleGorilla-XRay.Android/Services/AndroidConnectionNotificationManager.cs new file mode 100644 index 0000000..03a8510 --- /dev/null +++ b/InvisibleGorilla-XRay.Android/Services/AndroidConnectionNotificationManager.cs @@ -0,0 +1,385 @@ +using System; +using System.Text; +using System.Threading; +using Android.App; +using Android.Content; +using Android.Content.PM; +using Android.Net; +using Android.OS; +using InvisibleGorillaXRay.Core; + +namespace InvisibleGorillaXRay.Android.Services +{ + internal sealed class AndroidConnectionNotificationText + { + public string AppName { get; init; } = string.Empty; + public string ChannelName { get; init; } = string.Empty; + public string ChannelDescription { get; init; } = string.Empty; + public string StateStarting { get; init; } = string.Empty; + public string StateRunning { get; init; } = string.Empty; + public string StateStopping { get; init; } = string.Empty; + public string StateStopped { get; init; } = string.Empty; + public string ConfigLabel { get; init; } = string.Empty; + public string EndpointLabel { get; init; } = string.Empty; + public string ListenerLabel { get; init; } = string.Empty; + public string ProtocolLabel { get; init; } = string.Empty; + public string TrafficLabel { get; init; } = string.Empty; + public string SpeedLabel { get; init; } = string.Empty; + public string UptimeLabel { get; init; } = string.Empty; + public string UnknownEndpoint { get; init; } = string.Empty; + } + + internal sealed class AndroidConnectionNotificationSession + { + public string ConfigName { get; init; } = string.Empty; + public string Endpoint { get; init; } = string.Empty; + public string Listener { get; init; } = string.Empty; + public string Protocol { get; init; } = string.Empty; + public AndroidConnectionNotificationText Text { get; init; } = new(); + } + + internal enum AndroidConnectionNotificationState + { + Starting, + Running, + Stopping, + Stopped + } + + internal static class AndroidConnectionNotificationManager + { + private const string ChannelId = "invisiblegorilla.connection.status"; + private const int NotificationId = 42042; + internal const int ForegroundNotificationId = NotificationId; + private static readonly object SyncRoot = new(); + + private static Timer? updateTimer; + private static AndroidConnectionNotificationSession? currentSession; + private static AndroidConnectionNotificationState currentState; + private static DateTime startedAtUtc; + private static DateTime lastSampleUtc; + private static long baseRxBytes; + private static long baseTxBytes; + private static long lastRxBytes; + private static long lastTxBytes; + private static bool channelCreated; + + public static void ShowStarting(AndroidConnectionNotificationSession session) + { + lock (SyncRoot) + { + currentSession = session; + currentState = AndroidConnectionNotificationState.Starting; + startedAtUtc = DateTime.UtcNow; + DiagnosticLog.Write("AndroidConnectionNotification", $"ShowStarting config={session.ConfigName}"); + + long rxBytes = ReadUidRxBytes(); + long txBytes = ReadUidTxBytes(); + + baseRxBytes = rxBytes; + baseTxBytes = txBytes; + lastRxBytes = rxBytes; + lastTxBytes = txBytes; + lastSampleUtc = startedAtUtc; + + EnsureChannelLocked(); + EnsureTimerLocked(); + PublishNotificationLocked(); + } + } + + public static void MarkRunning() + { + lock (SyncRoot) + { + if (currentSession == null) + return; + + currentState = AndroidConnectionNotificationState.Running; + DiagnosticLog.Write("AndroidConnectionNotification", $"MarkRunning config={currentSession.ConfigName}"); + PublishNotificationLocked(); + } + } + + public static void MarkStopping() + { + lock (SyncRoot) + { + if (currentSession == null) + return; + + currentState = AndroidConnectionNotificationState.Stopping; + DiagnosticLog.Write("AndroidConnectionNotification", $"MarkStopping config={currentSession.ConfigName}"); + PublishNotificationLocked(); + } + } + + public static void MarkStopped() + { + lock (SyncRoot) + { + if (currentSession == null) + return; + + updateTimer?.Dispose(); + updateTimer = null; + currentState = AndroidConnectionNotificationState.Stopped; + DiagnosticLog.Write("AndroidConnectionNotification", $"MarkStopped config={currentSession.ConfigName}"); + PublishNotificationLocked(); + } + } + + public static void Stop() + { + lock (SyncRoot) + { + updateTimer?.Dispose(); + updateTimer = null; + DiagnosticLog.Write("AndroidConnectionNotification", "Stop and clear notification state"); + currentSession = null; + CancelNotificationLocked(); + } + } + + internal static Notification BuildForegroundNotification(Context context) + { + lock (SyncRoot) + { + EnsureChannelLocked(); + return currentSession == null + ? BuildFallbackNotification(context) + : BuildNotificationLocked(context); + } + } + + private static void EnsureChannelLocked() + { + if (channelCreated || Build.VERSION.SdkInt < BuildVersionCodes.O) + return; + + Context? context = global::Android.App.Application.Context; + if (context?.GetSystemService(Context.NotificationService) is not NotificationManager manager) + return; + + string channelName = currentSession?.Text.ChannelName ?? string.Empty; + if (string.IsNullOrWhiteSpace(channelName)) + channelName = "Connection status"; + + string channelDescription = currentSession?.Text.ChannelDescription ?? string.Empty; + if (string.IsNullOrWhiteSpace(channelDescription)) + channelDescription = "Shows current proxy connection status and traffic."; + + NotificationChannel channel = new( + ChannelId, + channelName, + NotificationImportance.Low) + { + Description = channelDescription, + LockscreenVisibility = NotificationVisibility.Public + }; + + manager.CreateNotificationChannel(channel); + channelCreated = true; + } + + private static void EnsureTimerLocked() + { + updateTimer?.Dispose(); + updateTimer = new Timer( + callback: static _ => OnTimerTick(), + state: null, + dueTime: TimeSpan.FromSeconds(2), + period: TimeSpan.FromSeconds(2)); + } + + private static void OnTimerTick() + { + lock (SyncRoot) + { + if (currentSession == null) + return; + + PublishNotificationLocked(); + } + } + + private static void PublishNotificationLocked() + { + Context? context = global::Android.App.Application.Context; + if (context == null || currentSession == null) + return; + + if (!CanPostNotifications(context)) + return; + + if (context.GetSystemService(Context.NotificationService) is not NotificationManager manager) + return; + + try + { + manager.Notify(NotificationId, BuildNotificationLocked(context)); + } + catch (Exception ex) + { + DiagnosticLog.WriteException("AndroidConnectionNotification.Notify", ex); + } + } + + private static void CancelNotificationLocked() + { + Context? context = global::Android.App.Application.Context; + if (context?.GetSystemService(Context.NotificationService) is not NotificationManager manager) + return; + + try + { + manager.Cancel(NotificationId); + } + catch (Exception ex) + { + DiagnosticLog.WriteException("AndroidConnectionNotification.Cancel", ex); + } + } + + private static Notification BuildNotificationLocked(Context context) + { + AndroidConnectionNotificationSession session = currentSession ?? new AndroidConnectionNotificationSession(); + DateTime now = DateTime.UtcNow; + + long currentRxBytes = ReadUidRxBytes(); + long currentTxBytes = ReadUidTxBytes(); + + long totalRxBytes = Math.Max(0, currentRxBytes - baseRxBytes); + long totalTxBytes = Math.Max(0, currentTxBytes - baseTxBytes); + + double elapsedSampleSeconds = Math.Max(1d, (now - lastSampleUtc).TotalSeconds); + long rxSpeedBytes = currentState == AndroidConnectionNotificationState.Running + ? (long)Math.Max(0, (currentRxBytes - lastRxBytes) / elapsedSampleSeconds) + : 0; + long txSpeedBytes = currentState == AndroidConnectionNotificationState.Running + ? (long)Math.Max(0, (currentTxBytes - lastTxBytes) / elapsedSampleSeconds) + : 0; + + lastSampleUtc = now; + lastRxBytes = currentRxBytes; + lastTxBytes = currentTxBytes; + + string stateText = GetStateText(session.Text); + string contentText = currentState == AndroidConnectionNotificationState.Running + ? $"{stateText} - {session.ConfigName} - RX {FormatBytes(rxSpeedBytes)}/s TX {FormatBytes(txSpeedBytes)}/s" + : $"{stateText} - {session.ConfigName}"; + + StringBuilder expandedText = new(); + expandedText.AppendLine(stateText); + expandedText.AppendLine($"{session.Text.ConfigLabel}: {session.ConfigName}"); + expandedText.AppendLine($"{session.Text.ListenerLabel}: {session.Listener}"); + expandedText.AppendLine($"{session.Text.TrafficLabel}: RX {FormatBytes(totalRxBytes)} / TX {FormatBytes(totalTxBytes)}"); + expandedText.AppendLine($"{session.Text.SpeedLabel}: RX {FormatBytes(rxSpeedBytes)}/s / TX {FormatBytes(txSpeedBytes)}/s"); + expandedText.Append($"{session.Text.UptimeLabel}: {FormatElapsed(now - startedAtUtc)}"); + + Notification.Builder builder = Build.VERSION.SdkInt >= BuildVersionCodes.O + ? new Notification.Builder(context, ChannelId) + : new Notification.Builder(context); + + builder + .SetContentTitle(session.Text.AppName) + .SetContentText(contentText) + .SetStyle(new Notification.BigTextStyle().BigText(expandedText.ToString())) + .SetSmallIcon(Resource.Drawable.ic_notification_connection) + .SetContentIntent(CreateLaunchPendingIntent(context)) + .SetOnlyAlertOnce(true) + .SetOngoing(currentState != AndroidConnectionNotificationState.Stopped) + .SetShowWhen(false) + .SetVisibility(NotificationVisibility.Public); + + return builder.Build(); + } + + private static Notification BuildFallbackNotification(Context context) + { + Notification.Builder builder = Build.VERSION.SdkInt >= BuildVersionCodes.O + ? new Notification.Builder(context, ChannelId) + : new Notification.Builder(context); + + builder + .SetContentTitle("Invisible Gorilla XRay") + .SetContentText("Preparing Android VPN tunnel...") + .SetSmallIcon(Resource.Drawable.ic_notification_connection) + .SetContentIntent(CreateLaunchPendingIntent(context)) + .SetOnlyAlertOnce(true) + .SetOngoing(true) + .SetShowWhen(false) + .SetVisibility(NotificationVisibility.Public); + + return builder.Build(); + } + + private static PendingIntent CreateLaunchPendingIntent(Context context) + { + Intent intent = new Intent(context, typeof(MainActivity)); + intent.AddFlags(ActivityFlags.SingleTop | ActivityFlags.ClearTop | ActivityFlags.NewTask); + + return PendingIntent.GetActivity( + context, + requestCode: 0, + intent, + PendingIntentFlags.Immutable | PendingIntentFlags.UpdateCurrent)!; + } + + private static bool CanPostNotifications(Context context) + { + if (Build.VERSION.SdkInt < BuildVersionCodes.Tiramisu) + return true; + + return context.CheckSelfPermission(global::Android.Manifest.Permission.PostNotifications) == Permission.Granted; + } + + private static long ReadUidRxBytes() + { + long bytes = TrafficStats.GetUidRxBytes(global::Android.OS.Process.MyUid()); + return bytes < 0 ? 0 : bytes; + } + + private static long ReadUidTxBytes() + { + long bytes = TrafficStats.GetUidTxBytes(global::Android.OS.Process.MyUid()); + return bytes < 0 ? 0 : bytes; + } + + private static string GetStateText(AndroidConnectionNotificationText text) + { + return currentState switch + { + AndroidConnectionNotificationState.Starting => text.StateStarting, + AndroidConnectionNotificationState.Running => text.StateRunning, + AndroidConnectionNotificationState.Stopping => text.StateStopping, + AndroidConnectionNotificationState.Stopped => text.StateStopped, + _ => text.StateRunning + }; + } + + private static string FormatBytes(long bytes) + { + string[] units = { "B", "KB", "MB", "GB", "TB" }; + double value = Math.Max(0, bytes); + int unitIndex = 0; + + while (value >= 1024d && unitIndex < units.Length - 1) + { + value /= 1024d; + unitIndex++; + } + + string pattern = value >= 100d || unitIndex == 0 ? "0" : "0.0"; + return $"{value.ToString(pattern, System.Globalization.CultureInfo.InvariantCulture)} {units[unitIndex]}"; + } + + private static string FormatElapsed(TimeSpan elapsed) + { + if (elapsed.TotalHours >= 1) + return elapsed.ToString(@"hh\:mm\:ss", System.Globalization.CultureInfo.InvariantCulture); + + return elapsed.ToString(@"mm\:ss", System.Globalization.CultureInfo.InvariantCulture); + } + } +} diff --git a/InvisibleGorilla-XRay.Android/Services/AndroidInstalledAppDiscovery.cs b/InvisibleGorilla-XRay.Android/Services/AndroidInstalledAppDiscovery.cs new file mode 100644 index 0000000..253ec74 --- /dev/null +++ b/InvisibleGorilla-XRay.Android/Services/AndroidInstalledAppDiscovery.cs @@ -0,0 +1,109 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Android.Content; +using Android.Content.PM; + +namespace InvisibleGorillaXRay.Android.Services +{ + using InvisibleGorillaXRay.Core; + + internal sealed class AndroidInstalledAppInfo + { + public string PackageName { get; init; } = string.Empty; + public string DisplayName { get; init; } = string.Empty; + public string IconRef { get; init; } = string.Empty; + public bool IsSystemApp { get; init; } + } + + internal static class AndroidInstalledAppDiscovery + { + public static IReadOnlyList GetLaunchableApps() + { + try + { + Context? context = global::Android.App.Application.Context; + if (context == null) + return Array.Empty(); + + PackageManager? packageManager = context.PackageManager; + if (packageManager == null) + return Array.Empty(); + + Intent intent = new Intent(Intent.ActionMain); + intent.AddCategory(Intent.CategoryLauncher); + + IList? activities = null; + try + { + activities = packageManager.QueryIntentActivities(intent, 0); + } + catch (Exception ex) + { + DiagnosticLog.WriteException("AndroidInstalledAppDiscovery.QueryIntentActivities", ex); + return Array.Empty(); + } + + if (activities == null || activities.Count == 0) + return Array.Empty(); + + Dictionary apps = new(StringComparer.OrdinalIgnoreCase); + + foreach (ResolveInfo? activity in activities) + { + try + { + if (activity == null) + continue; + + string packageName = activity.ActivityInfo?.PackageName?.Trim() ?? string.Empty; + if (string.IsNullOrWhiteSpace(packageName)) + continue; + + if (string.Equals(packageName, context.PackageName, StringComparison.OrdinalIgnoreCase)) + continue; + + ApplicationInfo? applicationInfo = activity.ActivityInfo?.ApplicationInfo; + bool isSystemApp = applicationInfo != null + && (applicationInfo.Flags & ApplicationInfoFlags.System) == ApplicationInfoFlags.System; + + string displayName = + activity.LoadLabel(packageManager)?.ToString()?.Trim() + ?? applicationInfo?.LoadLabel(packageManager)?.ToString()?.Trim() + ?? packageName; + + if (apps.TryGetValue(packageName, out AndroidInstalledAppInfo? existing)) + { + if (!existing.IsSystemApp && isSystemApp) + continue; + } + + apps[packageName] = new AndroidInstalledAppInfo + { + PackageName = packageName, + DisplayName = string.IsNullOrWhiteSpace(displayName) ? packageName : displayName, + IconRef = packageName, + IsSystemApp = isSystemApp + }; + } + catch (Exception ex) + { + string packageName = activity?.ActivityInfo?.PackageName?.Trim() ?? ""; + DiagnosticLog.WriteException($"AndroidInstalledAppDiscovery.ResolveInfo.{packageName}", ex); + } + } + + return apps.Values + .OrderBy(app => app.IsSystemApp) + .ThenBy(app => app.DisplayName, StringComparer.CurrentCultureIgnoreCase) + .ThenBy(app => app.PackageName, StringComparer.OrdinalIgnoreCase) + .ToList(); + } + catch (Exception ex) + { + DiagnosticLog.WriteException("AndroidInstalledAppDiscovery.GetLaunchableApps", ex); + return Array.Empty(); + } + } + } +} diff --git a/InvisibleGorilla-XRay.Android/Services/AndroidLogShareService.cs b/InvisibleGorilla-XRay.Android/Services/AndroidLogShareService.cs new file mode 100644 index 0000000..acba0c9 --- /dev/null +++ b/InvisibleGorilla-XRay.Android/Services/AndroidLogShareService.cs @@ -0,0 +1,375 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Android.App; +using Android.Content; +using Android.OS; +using Android.Provider; +using AndroidX.Core.Content; +using AndroidUri = Android.Net.Uri; +using SystemEnvironment = System.Environment; + +namespace InvisibleGorillaXRay.Android.Services +{ + using InvisibleGorillaXRay.Core; + + internal static class AndroidLogShareService + { + private const string FileProviderAuthority = "io.invisiblegorilla.xray.fileprovider"; + private const string ShareMimeType = "text/plain"; + private const string ShareSnapshotFolderName = "log-share"; + private const string DownloadsSubdirectory = "InvisibleGorilla-XRay"; + + public enum SaveResultKind + { + SavedToMediaStore, + SavedToFile, + Failed + } + + public sealed class SaveResult + { + public SaveResultKind Kind { get; init; } + public string? Path { get; init; } + public string? ErrorMessage { get; init; } + + public bool Succeeded => Kind != SaveResultKind.Failed; + } + + public static async Task ShareDiagnosticLogAsync(Activity activity, string chooserTitle) + { + if (activity == null) + return false; + + try + { + FileInfo? snapshot = await Task.Run(() => CreateShareSnapshot(activity)); + if (snapshot == null || !snapshot.Exists) + { + DiagnosticLog.Write("AndroidLogShareService", "Share aborted: snapshot was not created"); + return false; + } + + global::Java.IO.File javaFile = new global::Java.IO.File(snapshot.FullName); + AndroidUri uri = FileProvider.GetUriForFile(activity, FileProviderAuthority, javaFile); + Intent sendIntent = new Intent(Intent.ActionSend) + .SetType(ShareMimeType) + .PutExtra(Intent.ExtraStream, uri) + .PutExtra(Intent.ExtraSubject, "Invisible Gorilla XRay diagnostic log") + .PutExtra(Intent.ExtraText, BuildShareBody()) + .AddFlags(ActivityFlags.GrantReadUriPermission); + + Intent chooser = Intent.CreateChooser(sendIntent, chooserTitle)! + .AddFlags(ActivityFlags.GrantReadUriPermission); + + activity.StartActivity(chooser); + DiagnosticLog.Write("AndroidLogShareService", $"Share chooser launched for {snapshot.FullName}"); + return true; + } + catch (Exception ex) + { + DiagnosticLog.WriteException("AndroidLogShareService.Share", ex); + return false; + } + } + + public static async Task SaveDiagnosticLogAsync(Context context) + { + if (context == null) + { + return new SaveResult + { + Kind = SaveResultKind.Failed, + ErrorMessage = "Android context is unavailable." + }; + } + + try + { + return await Task.Run(() => SaveDiagnosticLogCore(context)); + } + catch (Exception ex) + { + DiagnosticLog.WriteException("AndroidLogShareService.Save", ex); + return new SaveResult + { + Kind = SaveResultKind.Failed, + ErrorMessage = ex.Message + }; + } + } + + public static bool ClearDiagnosticLog() + { + try + { + DiagnosticLog.ClearAll(); + DiagnosticLog.Write("AndroidLogShareService", "Diagnostic log cleared by user request"); + return true; + } + catch (Exception ex) + { + DiagnosticLog.WriteException("AndroidLogShareService.Clear", ex); + return false; + } + } + + public static string GetDiagnosticLogPath() + { + return DiagnosticLog.ActiveLogPath; + } + + public static long GetDiagnosticLogSizeBytes() + { + long size = 0; + try + { + string active = DiagnosticLog.ActiveLogPath; + if (File.Exists(active)) + size += new FileInfo(active).Length; + + string archived = DiagnosticLog.ArchivedLogPath; + if (File.Exists(archived)) + size += new FileInfo(archived).Length; + } + catch + { + } + + return size; + } + + private static SaveResult SaveDiagnosticLogCore(Context context) + { + string snapshotName = BuildSnapshotFileName(); + byte[] payload = Encoding.UTF8.GetBytes(BuildLogBundle()); + + if (Build.VERSION.SdkInt >= BuildVersionCodes.Q) + { + SaveResult mediaStoreResult = TrySaveToMediaStoreDownloads(context, snapshotName, payload); + if (mediaStoreResult.Succeeded) + return mediaStoreResult; + } + + return SaveToAppExternalFiles(context, snapshotName, payload); + } + + private static SaveResult TrySaveToMediaStoreDownloads(Context context, string fileName, byte[] payload) + { + try + { + ContentResolver? resolver = context.ContentResolver; + if (resolver == null) + { + return new SaveResult + { + Kind = SaveResultKind.Failed, + ErrorMessage = "ContentResolver is unavailable." + }; + } + + ContentValues values = new ContentValues(); + values.Put(MediaStore.IMediaColumns.DisplayName, fileName); + values.Put(MediaStore.IMediaColumns.MimeType, ShareMimeType); + values.Put( + MediaStore.IMediaColumns.RelativePath, + System.IO.Path.Combine(global::Android.OS.Environment.DirectoryDownloads!, DownloadsSubdirectory)); + + AndroidUri? collection = MediaStore.Downloads.GetContentUri(MediaStore.VolumeExternalPrimary); + if (collection == null) + { + return new SaveResult + { + Kind = SaveResultKind.Failed, + ErrorMessage = "MediaStore downloads URI is unavailable." + }; + } + + AndroidUri? itemUri = resolver.Insert(collection, values); + if (itemUri == null) + { + return new SaveResult + { + Kind = SaveResultKind.Failed, + ErrorMessage = "MediaStore insert returned null." + }; + } + + using (Stream? output = resolver.OpenOutputStream(itemUri)) + { + if (output == null) + { + return new SaveResult + { + Kind = SaveResultKind.Failed, + ErrorMessage = "MediaStore output stream is unavailable." + }; + } + + output.Write(payload, 0, payload.Length); + output.Flush(); + } + + string displayPath = System.IO.Path.Combine( + global::Android.OS.Environment.DirectoryDownloads, + DownloadsSubdirectory, + fileName); + + DiagnosticLog.Write("AndroidLogShareService", $"Saved log snapshot to MediaStore Downloads/{DownloadsSubdirectory}/{fileName}"); + + return new SaveResult + { + Kind = SaveResultKind.SavedToMediaStore, + Path = displayPath + }; + } + catch (Exception ex) + { + DiagnosticLog.WriteException("AndroidLogShareService.SaveMediaStore", ex); + return new SaveResult + { + Kind = SaveResultKind.Failed, + ErrorMessage = ex.Message + }; + } + } + + private static SaveResult SaveToAppExternalFiles(Context context, string fileName, byte[] payload) + { + try + { + global::Java.IO.File? baseDir = context.GetExternalFilesDir(global::Android.OS.Environment.DirectoryDownloads); + string directoryPath = baseDir?.AbsolutePath + ?? System.IO.Path.Combine(SystemEnvironment.GetFolderPath(SystemEnvironment.SpecialFolder.LocalApplicationData), "Logs"); + + System.IO.Directory.CreateDirectory(directoryPath); + string fullPath = System.IO.Path.Combine(directoryPath, fileName); + File.WriteAllBytes(fullPath, payload); + + DiagnosticLog.Write("AndroidLogShareService", $"Saved log snapshot to {fullPath}"); + + return new SaveResult + { + Kind = SaveResultKind.SavedToFile, + Path = fullPath + }; + } + catch (Exception ex) + { + DiagnosticLog.WriteException("AndroidLogShareService.SaveExternalFiles", ex); + return new SaveResult + { + Kind = SaveResultKind.Failed, + ErrorMessage = ex.Message + }; + } + } + + private static FileInfo? CreateShareSnapshot(Context context) + { + try + { + string? cacheRoot = context.CacheDir?.AbsolutePath; + if (string.IsNullOrWhiteSpace(cacheRoot)) + return null; + + string snapshotDir = System.IO.Path.Combine(cacheRoot, ShareSnapshotFolderName); + System.IO.Directory.CreateDirectory(snapshotDir); + + foreach (string stale in System.IO.Directory.GetFiles(snapshotDir)) + { + try { File.Delete(stale); } catch { } + } + + string snapshotPath = System.IO.Path.Combine(snapshotDir, BuildSnapshotFileName()); + File.WriteAllText(snapshotPath, BuildLogBundle(), Encoding.UTF8); + return new FileInfo(snapshotPath); + } + catch (Exception ex) + { + DiagnosticLog.WriteException("AndroidLogShareService.CreateShareSnapshot", ex); + return null; + } + } + + private static string BuildSnapshotFileName() + { + return $"igxray-log-{DateTime.Now:yyyyMMdd-HHmmss}.txt"; + } + + private static string BuildLogBundle() + { + StringBuilder builder = new StringBuilder(); + builder.AppendLine("=== Invisible Gorilla XRay Android diagnostic snapshot ==="); + builder.Append(BuildSystemInfoBlock()); + builder.AppendLine("=== Diagnostic log (oldest -> newest) ==="); + builder.AppendLine(); + builder.Append(DiagnosticLog.ReadAll()); + return builder.ToString(); + } + + private static string BuildSystemInfoBlock() + { + StringBuilder builder = new StringBuilder(); + try + { + builder.AppendLine($"Generated at: {DateTime.Now:yyyy-MM-dd HH:mm:ss zzz}"); + builder.AppendLine($"Android SDK: {(int)Build.VERSION.SdkInt} ({Build.VERSION.Release})"); + builder.AppendLine($"Device: {Build.Manufacturer} {Build.Model} ({Build.Device})"); + builder.AppendLine($"Brand: {Build.Brand}"); + builder.AppendLine($"Hardware: {Build.Hardware}"); + builder.AppendLine($"Build fingerprint: {Build.Fingerprint}"); + builder.AppendLine($"Supported ABIs: {string.Join(", ", Build.SupportedAbis ?? Array.Empty())}"); + builder.AppendLine($"Active log: {DiagnosticLog.ActiveLogPath}"); + builder.AppendLine($"Archived log: {DiagnosticLog.ArchivedLogPath}"); + builder.AppendLine($"Active log size: {GetDiagnosticLogSizeBytes()} bytes total"); + + Context? context = global::Android.App.Application.Context; + if (context != null) + { + builder.AppendLine($"Package: {context.PackageName}"); + try + { + global::Android.Content.PM.PackageInfo? info = context.PackageManager?.GetPackageInfo(context.PackageName!, 0); + if (info != null) + { + builder.AppendLine($"App version: {info.VersionName} (code {info.LongVersionCode})"); + } + } + catch + { + } + } + + try + { + builder.AppendLine($"VPN running: {AndroidVpnServiceController.IsRunning}"); + builder.AppendLine($"VPN stopping: {AndroidVpnServiceController.IsStopping}"); + if (!string.IsNullOrWhiteSpace(AndroidVpnServiceController.LastError)) + builder.AppendLine($"Last VPN error: {AndroidVpnServiceController.LastError}"); + } + catch + { + } + } + catch + { + builder.AppendLine("Failed to collect full system info block."); + } + + builder.AppendLine(); + return builder.ToString(); + } + + private static string BuildShareBody() + { + return new StringBuilder() + .AppendLine("Invisible Gorilla XRay diagnostic log.") + .AppendLine("Attached file contains device info and the recent diagnostic events.") + .ToString(); + } + } +} diff --git a/InvisibleGorilla-XRay.Android/Services/AndroidVpnService.cs b/InvisibleGorilla-XRay.Android/Services/AndroidVpnService.cs new file mode 100644 index 0000000..50ebd4d --- /dev/null +++ b/InvisibleGorilla-XRay.Android/Services/AndroidVpnService.cs @@ -0,0 +1,455 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Android.App; +using Android.Content; +using Android.Content.PM; +using Android.Net; +using Android.OS; +using InvisibleGorillaXRay.Core; +using InvisibleGorillaXRay.Models; + +namespace InvisibleGorillaXRay.Android.Services +{ + [Service( + Name = "io.invisiblegorilla.xray.AndroidVpnService", + Enabled = true, + Exported = true, + Permission = "android.permission.BIND_VPN_SERVICE", + ForegroundServiceType = ForegroundService.TypeSpecialUse)] + [IntentFilter(new[] { "android.net.VpnService" })] + [MetaData("android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE", Value = "device_wide_vpn_tunnel_routing")] + public class AndroidVpnService : VpnService + { + private const string ActionStart = "io.invisiblegorilla.xray.action.START_VPN"; + private const string ActionStop = "io.invisiblegorilla.xray.action.STOP_VPN"; + private const string ExtraProxyPort = "proxy_port"; + private const string ExtraProxyUsername = "proxy_username"; + private const string ExtraProxyPassword = "proxy_password"; + private const string ExtraUdpEnabled = "udp_enabled"; + private const string ExtraTunAddress = "tun_address"; + private const string ExtraDns = "dns"; + private const string ExtraSessionName = "session_name"; + private const string ExtraAppRulesMode = "app_rules_mode"; + private const string ExtraAppPackages = "app_packages"; + private const int DefaultMtu = 1500; + private const string DefaultIpv6Address = "fdfe:dcba:9876::1"; + private const int DefaultIpv6PrefixLength = 126; + private static readonly object SyncRoot = new(); + + private Timer? healthTimer; + + internal static Intent CreateStartIntent(Context context, AndroidVpnStartOptions options) + { + Intent intent = new Intent(context, typeof(AndroidVpnService)); + intent.SetAction(ActionStart); + intent.PutExtra(ExtraProxyPort, options.ProxyPort); + intent.PutExtra(ExtraProxyUsername, options.ProxyUsername); + intent.PutExtra(ExtraProxyPassword, options.ProxyPassword); + intent.PutExtra(ExtraUdpEnabled, options.UdpEnabled); + intent.PutExtra(ExtraTunAddress, options.TunAddress); + intent.PutExtra(ExtraDns, options.Dns); + intent.PutExtra(ExtraSessionName, options.SessionName); + intent.PutExtra(ExtraAppRulesMode, (int)options.AppRulesMode); + if (options.AppPackages.Count > 0) + intent.PutStringArrayListExtra(ExtraAppPackages, new List(options.AppPackages)); + return intent; + } + + internal static Intent CreateStopIntent(Context context) + { + Intent intent = new Intent(context, typeof(AndroidVpnService)); + intent.SetAction(ActionStop); + return intent; + } + + public override StartCommandResult OnStartCommand(Intent? intent, StartCommandFlags flags, int startId) + { + string? action = intent?.Action; + DiagnosticLog.Write("AndroidVpnService", $"OnStartCommand action={action ?? ""}"); + + if (string.Equals(action, ActionStop, StringComparison.Ordinal)) + { + AndroidConnectionNotificationManager.MarkStopping(); + _ = StopVpnAsync("Stop requested", startId); + return StartCommandResult.NotSticky; + } + + if (!string.Equals(action, ActionStart, StringComparison.Ordinal)) + return StartCommandResult.NotSticky; + + try + { + StartForegroundCompat(); + _ = StartVpnAsync(intent!, startId); + return StartCommandResult.Sticky; + } + catch (Exception ex) + { + DiagnosticLog.WriteException("AndroidVpnService.Start", ex); + AndroidVpnServiceController.NotifyStartFailed(ex.Message); + StopVpn(ex.Message); + StopSelfResult(startId); + return StartCommandResult.NotSticky; + } + } + + public override void OnDestroy() + { + DiagnosticLog.Write("AndroidVpnService", "Foreground VPN service destroyed"); + StopVpn("Android VPN service destroyed"); + base.OnDestroy(); + } + + private async Task StartVpnAsync(Intent intent, int startId) + { + try + { + await Task.Run(() => StartVpn(intent)); + AndroidVpnServiceController.NotifyStarted(); + } + catch (Exception ex) + { + DiagnosticLog.WriteException("AndroidVpnService.StartAsync", ex); + AndroidVpnServiceController.NotifyStartFailed(ex.Message); + StopVpn(ex.Message); + StopSelfResult(startId); + } + } + + private async Task StopVpnAsync(string reason, int startId) + { + try + { + await Task.Run(() => StopVpn(reason)); + } + catch (Exception ex) + { + DiagnosticLog.WriteException("AndroidVpnService.StopAsync", ex); + } + finally + { + StopSelfResult(startId); + } + } + + private void StartVpn(Intent intent) + { + if (Prepare(this) != null) + throw new InvalidOperationException("Android VPN permission has not been granted."); + + int proxyPort = intent.GetIntExtra(ExtraProxyPort, 0); + if (proxyPort <= 0) + throw new InvalidOperationException("Android VPN proxy port is missing."); + + LocalProxyCredentials localProxyCredentials = new( + username: intent.GetStringExtra(ExtraProxyUsername) ?? string.Empty, + password: intent.GetStringExtra(ExtraProxyPassword) ?? string.Empty); + if (!localProxyCredentials.HasValue) + throw new InvalidOperationException("Android VPN local proxy credentials are missing."); + + bool udpEnabled = intent.GetBooleanExtra(ExtraUdpEnabled, true); + string tunAddress = intent.GetStringExtra(ExtraTunAddress)?.Trim() ?? "10.0.236.10"; + string dns = intent.GetStringExtra(ExtraDns)?.Trim() ?? "8.8.8.8"; + string sessionName = intent.GetStringExtra(ExtraSessionName)?.Trim() ?? "Invisible Gorilla XRay"; + AppRulesMode appRulesMode = NormalizeAppRulesMode(intent.GetIntExtra(ExtraAppRulesMode, (int)AppRulesMode.ALL_APPS)); + string[] appPackages = intent.GetStringArrayListExtra(ExtraAppPackages)? + .Where(packageName => !string.IsNullOrWhiteSpace(packageName)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray() + ?? Array.Empty(); + + lock (SyncRoot) + { + ResetVpnCore("Restarting Android VPN"); + + Builder builder = new Builder(this) + .SetSession(sessionName) + .SetMtu(DefaultMtu) + .AddAddress(tunAddress, 32) + .AddRoute("0.0.0.0", 0); + + if (Build.VERSION.SdkInt >= BuildVersionCodes.Q) + builder.SetBlocking(true); + + foreach (string dnsServer in SplitDnsServers(dns)) + builder.AddDnsServer(dnsServer); + + TryEnableIpv6(builder); + ApplyApplicationRules(builder, appRulesMode, appPackages); + + DiagnosticLog.Write( + "AndroidVpnService", + $"Calling Builder.Establish() (mtu={DefaultMtu}, address={tunAddress}, mode={appRulesMode}, packages={appPackages.Length})..."); + ParcelFileDescriptor? tunInterface = builder.Establish(); + if (tunInterface == null) + throw new InvalidOperationException("Android VPN interface could not be established (Builder.Establish returned null). " + + "Verify the VPN consent dialog was accepted and that no other always-on VPN is owning the tunnel."); + DiagnosticLog.Write("AndroidVpnService", "Builder.Establish() returned a TUN file descriptor"); + + int tunFd = tunInterface.DetachFd(); + tunInterface.Dispose(); + + string? bridgeError = XRayCoreWrapper.StartAndroidTunnel( + tunFd, + proxyPort, + udpEnabled, + localProxyCredentials); + if (!string.IsNullOrWhiteSpace(bridgeError)) + { + XRayCoreWrapper.StopAndroidTunnel(); + throw new InvalidOperationException(bridgeError); + } + + EnsureHealthTimer(); + } + + DiagnosticLog.Write( + "AndroidVpnService", + $"Android VPN established with proxyPort={proxyPort}, tunAddress={tunAddress}, dns={dns}, udpEnabled={udpEnabled}, authEnabled={localProxyCredentials.HasValue}, appRulesMode={appRulesMode}, appPackages={string.Join(",", appPackages)}"); + } + + private void StartForegroundCompat() + { + Notification notification = AndroidConnectionNotificationManager.BuildForegroundNotification(this); + + if (Build.VERSION.SdkInt >= BuildVersionCodes.Q) + { + StartForeground( + AndroidConnectionNotificationManager.ForegroundNotificationId, + notification, + ForegroundService.TypeSpecialUse); + } + else + { + StartForeground(AndroidConnectionNotificationManager.ForegroundNotificationId, notification); + } + } + + private void EnsureHealthTimer() + { + healthTimer?.Dispose(); + healthTimer = new Timer( + callback: static state => + { + if (state is not AndroidVpnService service) + return; + + if (XRayCoreWrapper.IsAndroidTunnelRunning()) + return; + + string message = XRayCoreWrapper.GetAndroidTunnelLastError() + ?? "Android tunnel bridge stopped unexpectedly."; + DiagnosticLog.Write("AndroidVpnService", message); + service.StopVpn(message); + service.StopSelf(); + }, + state: this, + dueTime: TimeSpan.FromSeconds(2), + period: TimeSpan.FromSeconds(2)); + } + + private void StopVpn(string reason) + { + lock (SyncRoot) + { + StopVpnCore(reason); + } + } + + private void ResetVpnCore(string reason) + { + healthTimer?.Dispose(); + healthTimer = null; + + try + { + XRayCoreWrapper.StopAndroidTunnel(); + } + catch (Exception ex) + { + DiagnosticLog.WriteException("AndroidVpnService.ResetTunnel", ex); + } + } + + private void StopVpnCore(string reason) + { + healthTimer?.Dispose(); + healthTimer = null; + + try + { + XRayCoreWrapper.StopAndroidTunnel(); + } + catch (Exception ex) + { + DiagnosticLog.WriteException("AndroidVpnService.StopTunnel", ex); + } + + AndroidVpnServiceController.NotifyStopped(reason); + + try + { + StopForeground(StopForegroundFlags.Remove); + } + catch (Exception ex) + { + DiagnosticLog.WriteException("AndroidVpnService.StopForeground", ex); + } + + AndroidConnectionNotificationManager.MarkStopped(); + } + + private static string[] SplitDnsServers(string dns) + { + string[] servers = dns + .Split(new[] { ',', ';', ' ' }, StringSplitOptions.RemoveEmptyEntries) + .Select(server => server.Trim()) + .Where(server => !string.IsNullOrWhiteSpace(server)) + .ToArray(); + + return servers.Length == 0 ? new[] { "8.8.8.8" } : servers; + } + + private void ApplyApplicationRules(Builder builder, AppRulesMode mode, IEnumerable packages) + { + string[] packageArray = packages as string[] ?? packages.ToArray(); + DiagnosticLog.Write($"[AppRules] ApplyApplicationRules: mode={mode}, packageCount={packageArray.Length}"); + + if (mode == AppRulesMode.ONLY_SELECTED_APPS) + { + if (packageArray.Length == 0) + { + // Whitelist with zero entries would silently route ALL apps through the VPN + // because Android falls back to "no allow-list" when no allowed package is + // registered. The user explicitly opted for "only selected apps", so refuse + // to start instead of producing surprising routing. + throw new InvalidOperationException( + "Whitelist mode is enabled but no apps are selected. " + + "Open Settings → App rules → Manage and choose at least one application, " + + "or switch the mode back to \"All apps\"."); + } + + DiagnosticLog.Write("[AppRules] → TryAllowUserSelectedApplications (AddAllowedApplication)"); + int allowed = TryAllowUserSelectedApplications(builder, packageArray); + + if (allowed == 0) + { + // Every requested package failed to register (e.g. all of them were uninstalled + // after the template was saved). Without at least one allowed app the VPN would + // route everything; fail closed with a clear message instead. + throw new InvalidOperationException( + "Whitelist mode is enabled but none of the selected apps are installed on this device. " + + "Open Settings → App rules → Manage and re-pick the applications you want to route through the VPN."); + } + + return; + } + + DiagnosticLog.Write("[AppRules] → TryExcludeOwnProcess + TryExcludeUserSelectedApplications (AddDisallowedApplication)"); + TryExcludeOwnProcess(builder); + TryExcludeUserSelectedApplications(builder, packageArray); + } + + private void TryExcludeOwnProcess(Builder builder) + { + try + { + builder.AddDisallowedApplication(PackageName!); + } + catch (Exception ex) + { + DiagnosticLog.WriteException("AndroidVpnService.AddDisallowedApplication", ex); + } + } + + private void TryExcludeUserSelectedApplications(Builder builder, IEnumerable packages) + { + int added = 0; + foreach (string packageName in packages) + { + try + { + if (string.Equals(packageName, PackageName, StringComparison.OrdinalIgnoreCase)) + continue; + + builder.AddDisallowedApplication(packageName); + added++; + DiagnosticLog.Write($"[AppRules] Disallowed: {packageName}"); + } + catch (PackageManager.NameNotFoundException ex) + { + DiagnosticLog.WriteException($"AndroidVpnService.AddDisallowedApplication.{packageName}", ex); + } + catch (Exception ex) + { + DiagnosticLog.WriteException($"AndroidVpnService.AddDisallowedApplication.{packageName}", ex); + } + } + + DiagnosticLog.Write($"[AppRules] Total disallowed: {added}"); + } + + private int TryAllowUserSelectedApplications(Builder builder, IEnumerable packages) + { + int added = 0; + int skippedSelf = 0; + int skippedMissing = 0; + + foreach (string packageName in packages) + { + try + { + if (string.Equals(packageName, PackageName, StringComparison.OrdinalIgnoreCase)) + { + skippedSelf++; + DiagnosticLog.Write($"[AppRules] Skipped own package in whitelist: {packageName}"); + continue; + } + + builder.AddAllowedApplication(packageName); + added++; + DiagnosticLog.Write($"[AppRules] Allowed: {packageName}"); + } + catch (PackageManager.NameNotFoundException ex) + { + skippedMissing++; + DiagnosticLog.WriteException($"AndroidVpnService.AddAllowedApplication.{packageName}", ex); + } + catch (Exception ex) + { + skippedMissing++; + DiagnosticLog.WriteException($"AndroidVpnService.AddAllowedApplication.{packageName}", ex); + } + } + + DiagnosticLog.Write($"[AppRules] Total allowed: {added}, skippedSelf={skippedSelf}, skippedMissing={skippedMissing}"); + return added; + } + + private void TryEnableIpv6(Builder builder) + { + try + { + builder.AddAddress(DefaultIpv6Address, DefaultIpv6PrefixLength); + builder.AddRoute("::", 0); + } + catch (Exception ex) + { + DiagnosticLog.WriteException("AndroidVpnService.EnableIpv6", ex); + } + } + + private static AppRulesMode NormalizeAppRulesMode(int rawValue) + { + return rawValue switch + { + (int)AppRulesMode.BYPASS_SELECTED_APPS => AppRulesMode.BYPASS_SELECTED_APPS, + (int)AppRulesMode.ONLY_SELECTED_APPS => AppRulesMode.ONLY_SELECTED_APPS, + _ => AppRulesMode.ALL_APPS + }; + } + } +} diff --git a/InvisibleGorilla-XRay.Android/Services/AndroidVpnServiceController.cs b/InvisibleGorilla-XRay.Android/Services/AndroidVpnServiceController.cs new file mode 100644 index 0000000..c949a73 --- /dev/null +++ b/InvisibleGorilla-XRay.Android/Services/AndroidVpnServiceController.cs @@ -0,0 +1,185 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Android.Content; +using Android.OS; +using InvisibleGorillaXRay.Models; + +namespace InvisibleGorillaXRay.Android.Services +{ + internal sealed class AndroidVpnStartOptions + { + public int ProxyPort { get; init; } + public string ProxyUsername { get; init; } = string.Empty; + public string ProxyPassword { get; init; } = string.Empty; + public bool UdpEnabled { get; init; } + public string TunAddress { get; init; } = string.Empty; + public string Dns { get; init; } = string.Empty; + public string SessionName { get; init; } = "Invisible Gorilla XRay"; + public AppRulesMode AppRulesMode { get; init; } = AppRulesMode.ALL_APPS; + public IReadOnlyList AppPackages { get; init; } = Array.Empty(); + } + + internal static class AndroidVpnServiceController + { + private static readonly object SyncRoot = new(); + private static TaskCompletionSource? pendingStart; + private static bool isRunning; + private static bool isStopping; + private static string lastError = string.Empty; + + public static Status Start(AndroidVpnStartOptions options) + { + Context? context = global::Android.App.Application.Context; + if (context == null) + return CreateError("Android application context is unavailable."); + + if (global::Android.Net.VpnService.Prepare(context) != null) + return CreateError("Android VPN permission has not been granted."); + + TaskCompletionSource completion = new(TaskCreationOptions.RunContinuationsAsynchronously); + lock (SyncRoot) + { + pendingStart = completion; + lastError = string.Empty; + isStopping = false; + } + + try + { + Intent intent = AndroidVpnService.CreateStartIntent(context, options); + if (Build.VERSION.SdkInt >= BuildVersionCodes.O) + context.StartForegroundService(intent); + else + context.StartService(intent); + } + catch (Exception ex) + { + lock (SyncRoot) + { + if (pendingStart == completion) + pendingStart = null; + + lastError = ex.Message; + } + + return CreateError(ex.Message); + } + + if (!completion.Task.Wait(TimeSpan.FromSeconds(15))) + { + lock (SyncRoot) + { + if (pendingStart == completion) + pendingStart = null; + + lastError = "Timed out while waiting for the Android VPN service to start."; + } + + return CreateError(lastError); + } + + return completion.Task.Result; + } + + public static void Stop() + { + Context? context = global::Android.App.Application.Context; + if (context == null) + return; + + lock (SyncRoot) + { + if (!isRunning || isStopping) + return; + + isStopping = true; + } + + try + { + context.StartService(AndroidVpnService.CreateStopIntent(context)); + } + catch (Exception ex) + { + lock (SyncRoot) + { + lastError = ex.Message; + isRunning = false; + } + } + } + + public static bool IsRunning + { + get + { + lock (SyncRoot) + return isRunning; + } + } + + public static bool IsStopping + { + get + { + lock (SyncRoot) + return isStopping; + } + } + + public static string LastError + { + get + { + lock (SyncRoot) + return lastError; + } + } + + internal static void NotifyStarted() + { + lock (SyncRoot) + { + isRunning = true; + isStopping = false; + lastError = string.Empty; + pendingStart?.TrySetResult(new Status(Code.SUCCESS, SubCode.SUCCESS, string.Empty)); + pendingStart = null; + } + } + + internal static void NotifyStartFailed(string message) + { + lock (SyncRoot) + { + isRunning = false; + isStopping = false; + lastError = string.IsNullOrWhiteSpace(message) + ? "Android VPN service failed to start." + : message; + pendingStart?.TrySetResult(CreateError(lastError)); + pendingStart = null; + } + } + + internal static void NotifyStopped(string? message = null) + { + lock (SyncRoot) + { + isRunning = false; + isStopping = false; + if (!string.IsNullOrWhiteSpace(message)) + lastError = message; + } + } + + private static Status CreateError(string message) + { + return new Status( + code: Code.ERROR, + subCode: SubCode.CANT_TUNNEL, + content: message); + } + } +} diff --git a/InvisibleGorilla-XRay.Android/Views/MainView.axaml b/InvisibleGorilla-XRay.Android/Views/MainView.axaml new file mode 100644 index 0000000..ad6c0d4 --- /dev/null +++ b/InvisibleGorilla-XRay.Android/Views/MainView.axaml @@ -0,0 +1,1579 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/InvisibleGorilla-XRay.Android/Views/MainView.axaml.cs b/InvisibleGorilla-XRay.Android/Views/MainView.axaml.cs new file mode 100644 index 0000000..e385d1b --- /dev/null +++ b/InvisibleGorilla-XRay.Android/Views/MainView.axaml.cs @@ -0,0 +1,3303 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; +using Android.App; +using Android.Content; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Shapes; +using Avalonia.Interactivity; +using Avalonia.Markup.Xaml; +using Avalonia.Media; +using Avalonia.Platform.Storage; +using Avalonia.Input; +using Avalonia.Layout; +using Avalonia.Media.Imaging; +using Avalonia.Styling; +using Avalonia.Threading; +using Avalonia.VisualTree; + +namespace InvisibleGorillaXRay.Android.Views +{ + using InvisibleGorillaXRay.Android.Handlers.DeepLinks; + using InvisibleGorillaXRay.Android.Managers; + using InvisibleGorillaXRay.Android.Services; + using InvisibleGorillaXRay.Core; + using InvisibleGorillaXRay.Handlers; + using InvisibleGorillaXRay.Models; + using InvisibleGorillaXRay.Utilities; + + public partial class MainView : UserControl + { + private enum NavigationSection { Home, Servers, Settings } + private enum ServerTab { Configurations, Subscriptions } + private enum ConnectionState { Stopped, Starting, Running } + private enum ServersViewMode { Browse, AddConfig, AddSubscription } + private enum ConfigImportMode { File, Link } + + private sealed class TemplateComboItem + { + public string Id { get; init; } = string.Empty; + public string Name { get; init; } = string.Empty; + + public override string ToString() => Name; + } + + private static readonly IBrush StoppedBrush = new SolidColorBrush(Color.Parse("#D66A75")); + private static readonly IBrush StartingBrush = new SolidColorBrush(Color.Parse("#C9A227")); + private static readonly IBrush RunningBrush = new SolidColorBrush(Color.Parse("#56B870")); + private static readonly IBrush SelectedConfigBrush = new SolidColorBrush(Color.Parse("#343434")); + private static readonly IBrush IdleConfigBrush = new SolidColorBrush(Color.Parse("#252526")); + private static readonly IBrush SelectedMarkerBrush = new SolidColorBrush(Color.Parse("#6DCC8E")); + private static readonly IBrush IdleMarkerBrush = new SolidColorBrush(Color.Parse("#5A5A5A")); + private static readonly IBrush AvailabilityPendingBrush = new SolidColorBrush(Color.Parse("#8C8C8C")); + private static readonly IBrush AvailabilityErrorBrush = new SolidColorBrush(Color.Parse("#D95F5F")); + private static readonly IBrush AvailabilitySuccessBrush = new SolidColorBrush(Color.Parse("#6DCC8E")); + + private InvisibleGorillaXRay.Core.InvisibleGorillaXRayCore core = null!; + private SettingsHandler settingsHandler = null!; + private ConfigHandler configHandler = null!; + private TemplateHandler templateHandler = null!; + private UpdateHandler updateHandler = null!; + private BroadcastHandler broadcastHandler = null!; + private Android.Handlers.AndroidLocalizationHandler localizationHandler = null!; + + private List generalConfigs = new(); + private List subscriptionConfigs = new(); + private List subscriptionGroups = new(); + private List discoveredAndroidApps = new(); + private Subscription? selectedSubscription; + private readonly Dictionary configAvailability = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary appRuleToggles = new(StringComparer.OrdinalIgnoreCase); + private readonly List workingAppRuleTemplates = new(); + private readonly List workingAppRuleBindings = new(); + private AppRuleTemplate workingDefaultAppRuleTemplate = new(); + private string activeAppRulesTemplateId = AppRuleTemplate.DefaultTemplateId; + private bool isApplyingAppRulesEditor; + private bool isCheckWorkerBusy; + private bool isRunWorkerBusy; + private bool isStopWorkerBusy; + private bool isInitialized; + private bool isShowingAdvancedImport; + private bool isServersSectionInitialized; + private bool isSettingsSectionInitialized; + private bool suppressSubscriptionSelectionChanged; + private bool updateAvailable; + private string? broadcastMessage; + private Config? pendingConfigShare; + private ServersViewMode currentServersViewMode; + private ConfigImportMode currentConfigImportMode; + private IStorageFile? pendingConfigImportFile; + + public MainView() + { + DiagnosticLog.Write("MainView", "Constructor start"); + InitializeComponent(); + DiagnosticLog.Write("MainView", "XAML loaded"); + } + + public MainView(AndroidAppManager appManager) : this() + { + Setup(appManager); + } + + public void Setup(AndroidAppManager appManager) + { + if (isInitialized) + { + DiagnosticLog.Write("MainView", "Setup skipped because view is already initialized"); + return; + } + + DiagnosticLog.Write("MainView", "Setup start"); + + core = appManager.Core; + settingsHandler = appManager.HandlersManager.GetHandler(); + configHandler = appManager.HandlersManager.GetHandler(); + templateHandler = appManager.HandlersManager.GetHandler(); + updateHandler = appManager.HandlersManager.GetHandler(); + broadcastHandler = appManager.HandlersManager.GetHandler(); + localizationHandler = appManager.HandlersManager.GetHandler(); + DiagnosticLog.Write("MainView", "Handlers resolved"); + + InitializeControls(); + ApplyLocalizedText(); + AndroidDeepLinkDispatcher.Register(HandlePendingImport); + DiagnosticLog.Write("MainView", "Controls initialized"); + UpdateCurrentConfigSummary(); + DiagnosticLog.Write("MainView", "Current config summary updated"); + SetConnectionState(ConnectionState.Stopped); + SetStatus(string.Empty); + isInitialized = true; + DiagnosticLog.Write("MainView", "Setup completed"); + _ = LoadRemoteInfoAsync(); + DiagnosticLog.Write("MainView", "Remote info background load started"); + } + + private StackPanel HomeSectionScroll => GetRequiredControl("HomeSectionPanel"); + private StackPanel ServersSectionScroll => GetRequiredControl("ServersSectionPanel"); + private StackPanel SettingsSectionScroll => GetRequiredControl("SettingsSectionPanel"); + private Button HomeNavButton => GetRequiredControl + + + + + + + + + + + + + + + + diff --git a/InvisibleGorilla-XRay.Mac/Views/MainWindow.axaml.cs b/InvisibleGorilla-XRay.Mac/Views/MainWindow.axaml.cs new file mode 100644 index 0000000..ad26cc7 --- /dev/null +++ b/InvisibleGorilla-XRay.Mac/Views/MainWindow.axaml.cs @@ -0,0 +1,415 @@ +using System; +using System.Threading.Tasks; +using Avalonia.Controls; +using Avalonia.Interactivity; +using Avalonia.Input; +using Avalonia.Threading; + +namespace InvisibleGorillaXRay.Mac.Views +{ + using Core; + using Models; + using Values; + using InvisibleGorillaXRay.Services; + using InvisibleGorillaXRay.Services.Analytics.General; + using InvisibleGorillaXRay.Services.Analytics.MainWindow; + + public partial class MainWindow : Window + { + private bool isRerunRequest; + private bool isRunWorkerBusy; + private bool isDialogOpen; + + private Func isNeedToShowPolicyWindow; + private Func shouldStartHidden; + private Func isNeedToAutoConnect; + private Func getConfig; + private Func loadConfig; + private Func enableMode; + private Func checkForUpdate; + private Func openServerWindow; + private Func openSettingsWindow; + private Func openUpdateWindow; + private Func openAboutWindow; + private Func openPolicyWindow; + private Action onRunServer; + private Action onCancelServer; + private Action onStopServer; + private Action onDisableMode; + private Action onGenerateClientId; + private Action onGitHubClick; + private Action onBugReportingClick; + private Action onCustomLinkClick; + + private LocalizationService LocalizationService => ServiceLocator.Get(); + private AnalyticsService AnalyticsService => ServiceLocator.Get(); + + public MainWindow() + { + InitializeComponent(); + + Opened += OnWindowOpened; + } + + public void Setup( + Func isNeedToShowPolicyWindow, + Func shouldStartHidden, + Func isNeedToAutoConnect, + Func getConfig, + Func loadConfig, + Func enableMode, + Func checkForUpdate, + Func openServerWindow, + Func openSettingsWindow, + Func openUpdateWindow, + Func openAboutWindow, + Func openPolicyWindow, + Action onRunServer, + Action onStopServer, + Action onCancelServer, + Action onDisableMode, + Action onGenerateClientId, + Action onGitHubClick, + Action onBugReportingClick, + Action onCustomLinkClick) + { + this.isNeedToShowPolicyWindow = isNeedToShowPolicyWindow; + this.shouldStartHidden = shouldStartHidden; + this.isNeedToAutoConnect = isNeedToAutoConnect; + this.getConfig = getConfig; + this.loadConfig = loadConfig; + this.checkForUpdate = checkForUpdate; + this.openServerWindow = openServerWindow; + this.openSettingsWindow = openSettingsWindow; + this.openUpdateWindow = openUpdateWindow; + this.openAboutWindow = openAboutWindow; + this.openPolicyWindow = openPolicyWindow; + this.onRunServer = onRunServer; + this.onCancelServer = onCancelServer; + this.onStopServer = onStopServer; + this.enableMode = enableMode; + this.onDisableMode = onDisableMode; + this.onGenerateClientId = onGenerateClientId; + this.onGitHubClick = onGitHubClick; + this.onBugReportingClick = onBugReportingClick; + this.onCustomLinkClick = onCustomLinkClick; + + UpdateUI(); + } + + private void OnWindowOpened(object sender, EventArgs e) + { + try + { + TryOpenPolicyWindow(); + TryStartHidden(); + TryAutoConnect(); + RunUpdateCheck(); + + AnalyticsService.SendEvent(new AppOpenedEvent()); + } + catch (Exception ex) + { + DiagnosticLog.WriteException("MacMainWindow.OnWindowOpened", ex); + } + } + + public void UpdateUI() + { + Config config = getConfig?.Invoke(); + + if (config == null) + { + textServerConfig.Text = LocalizationService.GetTerm(Localization.NO_SERVER_CONFIGURATION); + return; + } + + textServerConfig.Text = config.Name; + } + + public void TryRerun() + { + if (!isRunWorkerBusy) + return; + + onStopServer.Invoke(); + isRerunRequest = true; + } + + public void TryDisableModeAndRerun() + { + if (!isRunWorkerBusy) + return; + + Task.Run(() => onDisableMode.Invoke()); + onStopServer.Invoke(); + isRerunRequest = true; + } + + private void RunWorkerAsync() + { + if (isRunWorkerBusy) + return; + + isRunWorkerBusy = true; + + Task.Run(() => + { + try + { + Dispatcher.UIThread.InvokeAsync(ShowWaitForRunStatus); + + Status configStatus = loadConfig.Invoke(); + + if (configStatus.Code == Code.ERROR) + { + Dispatcher.UIThread.InvokeAsync(() => + { + HandleConfigError(configStatus); + ShowStopStatus(); + }); + return; + } + + Status modeStatus = enableMode.Invoke(); + + if (modeStatus.Code == Code.ERROR) + { + Dispatcher.UIThread.InvokeAsync(() => + { + System.Diagnostics.Debug.WriteLine( + $"Mode error: {modeStatus.Content}"); + ShowStopStatus(); + }); + return; + } + else if (modeStatus.Code == Code.INFO && modeStatus.SubCode == SubCode.CANCELED) + { + Dispatcher.UIThread.InvokeAsync(ShowStopStatus); + return; + } + + Dispatcher.UIThread.InvokeAsync(ShowRunStatus); + + onRunServer.Invoke(configStatus.Content.ToString()); + + Dispatcher.UIThread.InvokeAsync(ShowStopStatus); + } + finally + { + isRunWorkerBusy = false; + + if (isRerunRequest) + { + isRerunRequest = false; + RunWorkerAsync(); + } + } + }); + } + + private void HandleConfigError(Status configStatus) + { + switch (configStatus.SubCode) + { + case SubCode.NO_CONFIG: + OpenServerWindow(); + break; + case SubCode.INVALID_CONFIG: + System.Diagnostics.Debug.WriteLine( + $"Invalid config: {configStatus.Content}"); + break; + } + } + + private void RunUpdateCheck() + { + Task.Run(() => + { + try + { + Status updateStatus = checkForUpdate.Invoke(); + if (updateStatus.SubCode == SubCode.UPDATE_AVAILABLE) + { + Dispatcher.UIThread.InvokeAsync(() => + { + notificationUpdate.IsVisible = true; + }); + } + } + catch { } + }); + } + + private void OnManageServersClick(object sender, TappedEventArgs e) + { + e.Handled = true; + OpenServerWindow(); + AnalyticsService.SendEvent(new ManageServersButtonClickedEvent()); + } + + private void OnRunButtonClick(object sender, RoutedEventArgs e) + { + RunWorkerAsync(); + AnalyticsService.SendEvent(new RunButtonClickedEvent()); + } + + private void OnStopButtonClick(object sender, RoutedEventArgs e) + { + onStopServer.Invoke(); + Task.Run(() => onDisableMode.Invoke()); + isRerunRequest = false; + AnalyticsService.SendEvent(new StopButtonClickedEvent()); + } + + private void OnCancelButtonClick(object sender, RoutedEventArgs e) + { + if (!isRunWorkerBusy) + return; + + onCancelServer.Invoke(); + } + + private void OnGitHubButtonClick(object sender, RoutedEventArgs e) + { + onGitHubClick.Invoke(); + AnalyticsService.SendEvent(new GitHubButtonClickedEvent()); + } + + private void OnBugReportingButtonClick(object sender, RoutedEventArgs e) + { + onBugReportingClick.Invoke(); + AnalyticsService.SendEvent(new BugReportingButtonClickedEvent()); + } + + private void OnSettingsButtonClick(object sender, RoutedEventArgs e) + { + OpenSettingsWindow(); + AnalyticsService.SendEvent(new SettingsButtonClickedEvent()); + } + + private void OnUpdateButtonClick(object sender, RoutedEventArgs e) + { + OpenUpdateWindow(); + AnalyticsService.SendEvent(new UpdateButtonClickedEvent()); + } + + private void OnAboutButtonClick(object sender, RoutedEventArgs e) + { + OpenAboutWindow(); + AnalyticsService.SendEvent(new AboutButtonClickedEvent()); + } + + private void TryStartHidden() + { + if (!shouldStartHidden.Invoke()) + return; + + Hide(); + } + + private void TryAutoConnect() + { + if (!isNeedToAutoConnect.Invoke()) + return; + + OnRunButtonClick(null, null); + } + + private void TryOpenPolicyWindow() + { + if (!isNeedToShowPolicyWindow.Invoke()) + return; + + onGenerateClientId.Invoke(); + AnalyticsService.SendEvent(new NewUserEvent()); + + PolicyWindow policyWindow = openPolicyWindow.Invoke(); + policyWindow.ShowDialog(this); + } + + private async void OpenServerWindow() + { + if (isDialogOpen) return; + isDialogOpen = true; + try + { + ServerWindow serverWindow = openServerWindow.Invoke(); + await serverWindow.ShowDialog(this); + UpdateUI(); + } + finally { isDialogOpen = false; } + } + + private async void OpenSettingsWindow() + { + if (isDialogOpen) return; + isDialogOpen = true; + try + { + SettingsWindow settingsWindow = openSettingsWindow.Invoke(); + await settingsWindow.ShowDialog(this); + UpdateUI(); + } + finally { isDialogOpen = false; } + } + + private void OpenUpdateWindow() + { + UpdateWindow updateWindow = openUpdateWindow.Invoke(); + updateWindow.ShowDialog(this); + } + + private void OpenAboutWindow() + { + AboutWindow aboutWindow = openAboutWindow.Invoke(); + aboutWindow.ShowDialog(this); + } + + private void ShowRunStatus() + { + statusRun.IsVisible = true; + statusStop.IsVisible = false; + statusWaitForRun.IsVisible = false; + + buttonStop.IsVisible = true; + buttonCancel.IsVisible = false; + buttonRun.IsVisible = false; + } + + private void ShowStopStatus() + { + statusStop.IsVisible = true; + statusRun.IsVisible = false; + statusWaitForRun.IsVisible = false; + + buttonRun.IsVisible = true; + buttonCancel.IsVisible = false; + buttonStop.IsVisible = false; + } + + private void ShowWaitForRunStatus() + { + statusWaitForRun.IsVisible = true; + statusStop.IsVisible = false; + statusRun.IsVisible = false; + + buttonCancel.IsVisible = true; + buttonRun.IsVisible = false; + buttonStop.IsVisible = false; + } + + public void ShowAndActivate() + { + WindowState = WindowState.Normal; + Show(); + Activate(); + } + + protected override void OnClosing(WindowClosingEventArgs e) + { + e.Cancel = true; + Hide(); + } + } +} diff --git a/InvisibleGorilla-XRay.Mac/Views/PolicyWindow.axaml b/InvisibleGorilla-XRay.Mac/Views/PolicyWindow.axaml new file mode 100644 index 0000000..de488e0 --- /dev/null +++ b/InvisibleGorilla-XRay.Mac/Views/PolicyWindow.axaml @@ -0,0 +1,95 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/InvisibleGorilla-XRay.Mac/Views/PolicyWindow.axaml.cs b/InvisibleGorilla-XRay.Mac/Views/PolicyWindow.axaml.cs new file mode 100644 index 0000000..c075a08 --- /dev/null +++ b/InvisibleGorilla-XRay.Mac/Views/PolicyWindow.axaml.cs @@ -0,0 +1,30 @@ +using System; +using Avalonia.Controls; +using Avalonia.Input; + +namespace InvisibleGorillaXRay.Mac.Views +{ + using InvisibleGorillaXRay.Services; + using InvisibleGorillaXRay.Services.Analytics.PolicyWindow; + + public partial class PolicyWindow : Window + { + private Action onEmailClick; + + public PolicyWindow() + { + InitializeComponent(); + } + + public void Setup(Action onEmailClick) + { + this.onEmailClick = onEmailClick; + } + + private void OnEmailClick(object sender, PointerPressedEventArgs e) + { + onEmailClick?.Invoke(); + ServiceLocator.Get().SendEvent(new EmailClickedEvent()); + } + } +} diff --git a/InvisibleGorilla-XRay.Mac/Views/ServerWindow.axaml b/InvisibleGorilla-XRay.Mac/Views/ServerWindow.axaml new file mode 100644 index 0000000..e31c2bb --- /dev/null +++ b/InvisibleGorilla-XRay.Mac/Views/ServerWindow.axaml @@ -0,0 +1,316 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +