diff --git a/.github/workflows/android-ci.yml b/.github/workflows/android-ci.yml
index 4ac7a32..b460e33 100644
--- a/.github/workflows/android-ci.yml
+++ b/.github/workflows/android-ci.yml
@@ -40,7 +40,7 @@ jobs:
uses: actions/upload-artifact@v4
with:
name: goose-relay-android-debug-universal-apk
- path: android/app/build/outputs/apk/debug/app-universal-debug.apk
+ path: android/app/build/outputs/apk/debug/GooseRelayVPN.apk
- name: Upload Go AAR artifact
uses: actions/upload-artifact@v4
diff --git a/README.md b/README.md
index d4aa3fe..c112ed1 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,9 @@
# GooseRelayVPN Android Client
🌐 **[فارسی](README_FA.md)**
+[]()
+[]()
+[]()
Android client for GooseRelayVPN that runs the GooseRelay core through a Go mobile bridge and provides a complete Android UI for VPN lifecycle, profiles, logs, and settings.
@@ -11,109 +14,494 @@ Android client for GooseRelayVPN that runs the GooseRelay core through a Go mobi
This app creates a local SOCKS5 endpoint on Android and tunnels TCP traffic through the GooseRelay architecture:
-1. Local app/browser traffic -> SOCKS5
-2. GooseRelay encrypted framing (AES key from your profile)
+1. Local app/browser traffic → SOCKS5
+2. GooseRelay encrypted framing (AES-256-GCM key from your profile)
3. HTTPS path through Google-facing endpoints (Apps Script flow)
4. Your VPS exit server handles outbound target connections
The app wraps this flow in Android `VpnService` so selected/full traffic can be routed through the tunnel.
+## App Screenshots
+
+
+
+ | Home |
+ Profiles |
+ Settings |
+ Logs |
+
+
+  |
+  |
+  |
+  |
+
+
+
+
+
+ | Profile Import |
+ Profile Sharing |
+ Settings Sharing |
+
+
+  |
+  |
+  |
+
+
+
## Key Features
-- Android VPN integration (`VpnService` + tun2socks)
-- Profile-based configuration
-- JSON import/export for profile config
-- Real-time Home status + telemetry cards
-- Logs tab for Android/core diagnostics
-- Split tunneling and internet sharing controls
+### VPN & Tunneling
+- 🔐 Android VPN integration (`VpnService` + tun2socks)
+- 🔌 SOCKS5 proxy endpoint (configurable host:port)
+- 🛡️ AES-256-GCM encryption with per-profile keys
+- 📍 Domain fronting through Google infrastructure
+- 🎯 Split tunneling with package selection
+
+### Profile Management (v1.7.1+)
+- 📋 **Remote profile subscriptions** - Sync profiles from remote HTTPS URLs
+- 🔗 **goose-relay:// protocol** - Share profiles via Base64-encoded URI scheme
+- 📤 **JSON import/export** - Full profile backup and restore
+- 📋 **Clipboard import/export** - Quick copy-paste profile sharing
+- 🔄 **Remote updates** - One-click refresh from remote source
+- ⚙️ **Auto-save** - Changes saved automatically to local database
+
+### Configuration
+- **Multiple deployment IDs** - Support for multiple Google Apps Script deployments
+- **Custom SOCKS settings** - Host, port, and optional auth (username/password)
+- **SNI list** - Customizable Server Name Indication hostnames
+- **Coalescing** - Fine-tune tunnel packet coalescing behavior
+- **Idle slots** - Control idle slot distribution per bucket
+
+### User Interface
+- 🏠 **Home tab** - Real-time connection status with telemetry cards
+- 📊 **Live logs** - Core and app-level diagnostic logs
+- ⚙️ **Global settings** - Internet sharing, DNS, connection mode
+- 🌍 **Settings tab** - Profile-specific configuration
+- 🎨 Material Design 3 with dark mode support
+
+### System Integration
+- 📱 **Internet sharing** - Share VPN via SOCKS/HTTP proxy (LAN)
+- 🔒 **Split tunneling** - Include/exclude specific apps from VPN
+- 🌐 **Fake DNS** - Local DNS interception and resolution
+- 🔐 **Allow LAN** - Route local network traffic outside VPN
+- 🔋 **Low overhead** - Efficient Go-based core
## Configuration Model (Profile)
-Profile fields are aligned with GooseRelay client config:
+Profile fields are aligned with GooseRelay client config. Create profiles manually or import JSON:
```json
{
+ "name": "My VPN Profile",
"debug_timing": false,
"socks_host": "127.0.0.1",
"socks_port": 1080,
+ "socks_user": "",
+ "socks_pass": "",
"google_host": "216.239.38.120",
- "sni": ["www.google.com", "mail.google.com", "accounts.google.com"],
+ "sni": [
+ "www.google.com",
+ "mail.google.com",
+ "accounts.google.com"
+ ],
"script_keys": [
"REPLACE_WITH_DEPLOYMENT_ID",
- "OPTIONAL_SECOND_DEPLOYMENT_ID"
+ "OPTIONAL_SECOND_DEPLOYMENT_ID|account@example.com"
],
- "tunnel_key": "REPLACE_WITH_OUTPUT_OF_scripts_gen-key.sh"
+ "tunnel_key": "REPLACE_WITH_OUTPUT_OF_scripts_gen-key.sh",
+ "coalesce_step_ms": 0,
+ "idle_slots_per_bucket": 2
}
```
-Notes:
-- `script_keys` in UI: one key per line.
-- `tunnel_key`: must match server-side key.
-- Import/export in app uses JSON.
+### Profile Fields
+
+| Field | Type | Description |
+|-------|------|-------------|
+| `name` | string | Profile display name |
+| `debug_timing` | boolean | Enable timing debug logs |
+| `socks_host` | string | SOCKS5 server host (usually `127.0.0.1`) |
+| `socks_port` | integer | SOCKS5 server port (1-65535) |
+| `socks_user` | string | SOCKS5 username (optional, paired with password) |
+| `socks_pass` | string | SOCKS5 password (optional, paired with username) |
+| `google_host` | string | Google IP for domain fronting |
+| `sni` | array | SNI hostnames for TLS |
+| `script_keys` | array/string | Google Apps Script deployment ID(s). Format: `ID` or `ID\|account` |
+| `tunnel_key` | string | Tunnel encryption key (server-side match required) |
+| `coalesce_step_ms` | integer | Packet coalescing timeout (milliseconds) |
+| `idle_slots_per_bucket` | integer | Idle slots per bucket (1-3) |
+
+### Profile Import/Export Methods (v1.7.1+)
+
+#### 1. Manual Profile
+- Tap the `+` button → Select "Add Manual"
+- Fill in fields directly in the form
+
+#### 2. JSON File
+- Tap the `+` button → Select "From JSON"
+- Choose a `.json` file from your device
+- Profile fields are auto-populated
+
+#### 3. Clipboard (Recommended) — **NEW**
+- Copy JSON or `goose-relay://` protocol to clipboard
+- Tap the `+` button → Select "From Clipboard"
+- Profile is created instantly
+
+#### 4. Profile Sharing (goose-relay:// Protocol) — **NEW**
+- Open a profile → Tap **Share** icon
+- Select "to Clipboard" to copy `goose-relay://` URI
+- Share via messaging, QR code, or link
+- Recipients paste into clipboard and tap "From Clipboard"
+
+### Remote Profile Subscriptions (v1.7.1+) — **NEW**
+
+#### Setting Up Remote Sync
+1. **Edit Profile** and add Remote URL: `https://your-server.com/profiles/my-profile.json`
+2. Profile will fetch and auto-update from this URL on each refresh
+3. **Tap Refresh** (↻) icon in Profiles toolbar to manually sync
+4. Changes save locally automatically
+
+#### Remote URL Format
+```
+https://example.com/path/to/profile.json
+```
+
+Supported endpoints:
+- Any HTTPS server hosting profile JSON
+- Optional HTTP Basic Auth: `https://user:pass@example.com/profile.json`
+- Self-signed certs: Certificate validation required (add to system trust store)
+
+#### Auto-Update Behavior
+- Profiles with remote URLs fetch on **manual refresh** only (not automatic)
+- All fields except `remoteUrl` are updated from remote
+- Local changes are preserved if remote URL is not set
+- On update failure, error message displayed in snackbar
+
+## Default Split Tunneling Apps
+
+By default, these apps bypass the VPN (included in split tunnel list):
+
+- Instagram (`com.instagram.android`)
+- Telegram (`org.telegram.messenger`)
+- WhatsApp (`com.whatsapp`)
+- YouTube (`com.google.android.youtube`)
+- Chrome (`com.android.chrome`)
+- Gmail (`com.google.android.gm`)
+- Google Play Store (`com.android.vending`)
+- Google Search (`com.google.android.googlequicksearchbox`)
+- Twitter/X (`com.twitter.android`)
+- OpenAI ChatGPT (`com.openai.chatgpt`)
+
+Modify in **Global Settings** → **Split Tunneling** → Edit package list.
+
+## Global Settings
+
+### Connection Mode
+- **VPN**: Full VPN routing (default)
+- **SOCKS**: Direct SOCKS proxy (app-by-app)
+
+### Tunneling
+- **Split Tunneling**: Enable per-app VPN selection (default: enabled)
+- **Mode**: Include selected apps or exclude selected apps
+- **Allow LAN**: Route local network traffic outside VPN
+- **Fake DNS**: Intercept DNS queries locally (default: enabled)
+
+### Internet Sharing
+- **Share VPN via Network**: Enable/disable
+- **SOCKS Port**: Proxy port for LAN devices (default: 8090)
+- **HTTP Port**: HTTP proxy port (default: 8091)
+- **Auth**: Optional username/password for proxies
+
+### Advanced
+- **Custom DNS Servers**: Comma-separated DNS IP addresses
+- **Auto-save**: Changes persist automatically (default: enabled in v1.7.1+)
+
+### Clipboard Export/Import (v1.7.1+) — **NEW**
+- Tap **⋮** (More) menu in Settings toolbar
+- **Export to Clipboard** — Copy all settings as JSON
+- **Import from Clipboard** — Paste previously exported settings
## Upstream Setup Flow (Required)
You still need upstream infrastructure prepared first:
-1. Prepare VPS and run `goose-server`
-2. Deploy `apps_script/Code.gs` and obtain deployment ID(s)
-3. Generate key via `scripts/gen-key.sh`
-4. Put deployment ID(s) and key into Android profile
-5. Connect in app and configure app/browser proxy as needed
+1. **Prepare VPS**
+ - Deploy VPS with public IP
+ - Run `goose-server` binary
+ - Note the VPS IP/domain
+
+2. **Deploy Google Apps Script**
+ - Copy `apps_script/Code.gs` from upstream repo
+ - Create Google Apps Script project
+ - Paste code and deploy as web app
+ - Note the **Deployment ID**
+
+3. **Generate Encryption Key**
+ - Run `scripts/gen-key.sh`
+ - Output is your `tunnel_key`
-For full infra details, use upstream docs:
+4. **Add Profile to App**
+ - Create new profile in app
+ - Set `script_keys` to deployment ID(s)
+ - Set `tunnel_key` to generated key
+ - Set `google_host` (usually 216.239.38.120)
+
+5. **Connect & Verify**
+ - Tap **Connect** on Home
+ - Check **Logs** for any errors
+ - Verify traffic flows through VPN
+
+For complete infrastructure details, see upstream docs:
- https://github.com/kianmhz/GooseRelayVPN
-## Android Build (Local)
+## Android Build (Local Development)
-Requirements:
-- Android Studio
-- JDK 17
-- Go 1.22+
-- Android SDK / NDK
+### Requirements
+- Android Studio 2024.1+
+- JDK 17+
+- Go 1.25.0+ (was 1.22, upgraded in v1.7.1)
+- Android SDK API 36+
+- Android NDK (latest)
-Build AAR (Go mobile bridge):
+### Build Go Mobile Bridge
```bash
-bash android/build_go_mobile.sh
+cd android
+bash ../build_go_mobile.sh
```
-Build debug APK:
+This creates AAR library with GooseRelay core compiled for all architectures.
+
+### Build Debug APK
```bash
cd android
./gradlew :app:assembleDebug
```
-## Release / CI
+Output: `app/build/outputs/apk/debug/GooseRelayVPN.apk`
+
+### Build Release APK
+
+Requires signing configuration in `local.properties`:
+
+```properties
+ANDROID_KEYSTORE_PATH=/path/to/keystore.jks
+ANDROID_KEYSTORE_PASSWORD=keystore_password
+ANDROID_KEY_ALIAS=key_alias
+ANDROID_KEY_PASSWORD=key_password
+```
+
+Then:
+
+```bash
+cd android
+./gradlew :app:bundleRelease
+```
+
+Output: `app/build/outputs/bundle/release/app-release.aab`
+
+## Release & CI/CD
+
+### GitHub Workflows
+
+**`.github/workflows/android-ci.yml`**
+- Runs on every commit
+- Builds debug APK
+- Runs unit tests
+- Checks code quality
+
+**`.github/workflows/release.yml`**
+- Triggered on version tag (v*.*.*)
+- Builds signed release APK/AAB
+- Creates GitHub Release with artifacts
+- Requires signing secrets
+
+**`.github/workflows/release-manual.yml`**
+- Manual trigger for releases
+- Same as release.yml but on-demand
+
+### Secrets Required
+
+Configure these in GitHub repository settings:
-Workflows in this repo:
-- `.github/workflows/android-ci.yml`
-- `.github/workflows/release-manual.yml`
-- `.github/workflows/release.yml`
+- `ANDROID_KEYSTORE_BASE64` — Base64-encoded keystore file
+- `ANDROID_KEYSTORE_PASSWORD` — Keystore password
+- `ANDROID_KEY_ALIAS` — Key alias in keystore
+- `ANDROID_KEY_PASSWORD` — Key password
-Manual signed release requires secrets:
-- `ANDROID_KEYSTORE_BASE64`
-- `ANDROID_KEYSTORE_PASSWORD`
-- `ANDROID_KEY_ALIAS`
-- `ANDROID_KEY_PASSWORD`
+### Build Artifacts
+
+Release produces:
+- `GooseRelayVPN.apk` — Universal APK (all architectures)
+- `GooseRelayVPN-arm64-v8a.apk` — ARM64 only
+- `GooseRelayVPN-armeabi-v7a.apk` — ARMv7 only
+- `GooseRelayVPN-x86_64.apk` — x86_64 only
+- `app-release.aab` — Google Play App Bundle
## Troubleshooting
-- If reconnect fails with SOCKS port busy:
- - Disconnect, wait a few seconds, reconnect.
- - Ensure no other app uses the same SOCKS port.
-- If connection stays in preparing state:
- - Verify `script_keys` and `tunnel_key`.
- - Check Logs tab for core errors.
-- If no traffic flows:
- - Verify VPN permission granted.
- - Verify split tunnel app selection.
+### Connection Issues
+
+**SOCKS Port Busy**
+- Disconnect VPN, wait 3 seconds, reconnect
+- Check no other app uses port 1080 (or configured port)
+- Kill app via settings and restart
+
+**Connection Stays "Preparing"**
+- Verify `script_keys` (deployment ID) is correct
+- Verify `tunnel_key` matches server-side key
+- Check **Logs** tab for core error messages
+- Ensure VPS is running and reachable
+
+**No Traffic Flows**
+- Verify VPN permission is granted
+- Check split tunneling app selection
+- Ensure profile is selected and connected
+- Review **Logs** for network errors
+
+### Performance Issues
+
+**High Battery/Data Usage**
+- Disable debug logging (set `debug_timing: false`)
+- Check split tunnel configuration
+- Monitor active connections in **Logs**
+
+**Slow Connections**
+- Try different `google_host` values
+- Adjust `coalesce_step_ms` (higher = more batch, more latency)
+- Test on different networks (WiFi vs mobile)
+
+### Build Issues
+
+**Gradle Error: "Failed to install gomobile"**
+- Check Go version: `go version` (need 1.25.0+)
+- Delete `$GOPATH/pkg/mod/golang.org/x/mobile` and retry
+
+**NDK Not Found**
+- Install via Android Studio: Settings → SDK Manager → SDK Tools
+- Set `ANDROID_NDK` environment variable
+
+## Architecture Overview
+
+```
+┌─────────────────────────────────────────────┐
+│ Android UI Layer (Compose) │
+│ ┌──────────────────────────────────────┐ │
+│ │ Home │ Profiles │ Settings │ Logs │ │
+│ └──────────────────────────────────────┘ │
+└─────────────────────────────────────────────┘
+ ↓
+┌─────────────────────────────────────────────┐
+│ ViewModels & Repository Layer │
+│ ┌──────────────────────────────────────┐ │
+│ │ Room Database │ DataStore │ Hilt DI │ │
+│ └──────────────────────────────────────┘ │
+└─────────────────────────────────────────────┘
+ ↓
+┌─────────────────────────────────────────────┐
+│ VPN Service & Go Core │
+│ ┌──────────────────────────────────────┐ │
+│ │ tun2socks │ GooseRelay Core (Go) │ │
+│ └──────────────────────────────────────┘ │
+└─────────────────────────────────────────────┘
+ ↓
+┌─────────────────────────────────────────────┐
+│ Network Layer │
+│ ┌──────────────────────────────────────┐ │
+│ │ SOCKS5 → Google Apps Script → VPS │ │
+│ └──────────────────────────────────────┘ │
+└─────────────────────────────────────────────┘
+```
+
+## Version History
+
+### v1.7.1 (Current) — July 2026
+- ✅ **Remote profile subscriptions** with manual refresh
+- ✅ **goose-relay:// protocol** for URL-based profile sharing
+- ✅ **Clipboard import/export** for profiles
+- ✅ **Global settings clipboard** export/import
+- ✅ **Auto-save** for profile and global settings changes
+- ✅ **Dropdown menus** for profile management (+ button, share)
+- ✅ **Default split tunneling apps** (Instagram, Telegram, WhatsApp, YouTube, Chrome, Gmail, Play Store, etc.)
+- ✅ Updated to **Gradle 8.13**, **Android Gradle Plugin 8.13.0**
+- ✅ Updated to **Go 1.25.0** with latest dependencies
+- ✅ JVM memory increased (Xmx4096m)
+- ✅ Fixed SOCKS field defaults in JSON export
+- ✅ Improved error handling for imports
+
+### v1.0.0+ — Earlier Releases
+- VPN service with tun2socks
+- Profile management
+- Live logs and diagnostics
+- Split tunneling
+- Internet sharing
+
+## Tech Stack
+
+### Android
+- **Language**: Kotlin 2.1.0
+- **Framework**: Jetpack Compose
+- **Architecture**: MVVM with Clean Architecture
+- **Database**: Room ORM
+- **DI**: Hilt
+- **VPN**: Android VpnService API, tun2socks
+- **Build**: Gradle 8.13, Android Gradle Plugin 8.13.0
+
+### Go Core
+- **Version**: 1.25.0 (upgraded from 1.23.1)
+- **Crypto**: AES-256-GCM (golang.org/x/crypto)
+- **Networking**: golang.org/x/net 0.52.0, tun2socks
+- **Compression**: klauspost/compress (Brotli, Gzip)
+- **Logging**: Uber zap
+
+### Key Dependencies
+
+**Go:**
+- `github.com/things-go/go-socks5` — SOCKS5 server
+- `github.com/xjasonlyu/tun2socks/v2` — TUN device handler
+- `golang.org/x/net v0.52.0` — Networking utilities
+- `golang.org/x/mobile v0.0.0-20260312152759-81488f6aeb60` — Go mobile binding
+- `golang.org/x/crypto v0.49.0` — Cryptography
+
+**Android:**
+- Jetpack Compose, Navigation, Hilt, Room
+- Datastore Proto, Gson, Coil (images)
+
+See `go.mod`, `go.sum`, and `android/build.gradle.kts` for complete lists.
## Credits
-1. Main project:
- - https://github.com/kianmhz/GooseRelayVPN
+1. **Main GooseRelay Project**
+ - https://github.com/Kianmhz/GooseRelayVPN
-2. Project that inspired the main project idea:
+2. **Inspiration**
- https://github.com/masterking32/MasterHttpRelayVPN
+
+3. **Community**
+ - Go, Kotlin, Jetpack Compose teams
+ - tun2socks, SOCKS5, and networking library authors
+
+## License
+
+See [LICENSE](LICENSE) file for details.
+
+## Support & Contributing
+
+- **Issues**: Report bugs on [GitHub Issues](https://github.com/ArashAfkandeh/GooseRelayVPN-AndroidClient/issues)
+- **Discussions**: Join [GitHub Discussions](https://github.com/ArashAfkandeh/GooseRelayVPN-AndroidClient/discussions)
+- **Contributing**: PRs welcome!
+- **Upstream**: Questions about core protocol → [GooseRelayVPN](https://github.com/kianmhz/GooseRelayVPN)
+
+## Disclaimer
+
+This tool is for educational and authorized use only. Users are responsible for:
+- Compliance with local laws and regulations
+- Proper authorization from network administrators
+- Ethical use of VPN technology
+- Respecting terms of service of infrastructure providers
+
+Developers are not liable for misuse or damages.
diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
index 78fc104..115fdac 100644
--- a/android/app/build.gradle.kts
+++ b/android/app/build.gradle.kts
@@ -11,7 +11,7 @@ android {
compileSdk = 36
val appVersionName = System.getenv("ANDROID_VERSION_NAME")
?.takeIf { it.isNotBlank() }
- ?: "1.0.0"
+ ?: "1.7.1"
val appVersionCode = System.getenv("ANDROID_VERSION_CODE")
?.toIntOrNull()
?: 1
@@ -67,6 +67,18 @@ android {
}
}
+ applicationVariants.all {
+ outputs.all {
+ val output = this as com.android.build.gradle.internal.api.ApkVariantOutputImpl
+ val abi = output.getFilter(com.android.build.OutputFile.ABI)
+ output.outputFileName = when (abi) {
+ null -> "GooseRelayVPN.apk"
+ "universal" -> "GooseRelayVPN.apk"
+ else -> "GooseRelayVPN-$abi.apk"
+ }
+ }
+ }
+
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
diff --git a/android/app/src/main/java/com/gooserelay/gooserelayvpn/data/local/ProfileEntity.kt b/android/app/src/main/java/com/gooserelay/gooserelayvpn/data/local/ProfileEntity.kt
index 9a03686..cf1c121 100644
--- a/android/app/src/main/java/com/gooserelay/gooserelayvpn/data/local/ProfileEntity.kt
+++ b/android/app/src/main/java/com/gooserelay/gooserelayvpn/data/local/ProfileEntity.kt
@@ -18,6 +18,7 @@ data class ProfileEntity(
val tunnelKey: String = "",
val coalesceStepMs: Int = 0,
val idleSlotsPerBucket: Int = 2,
+ val remoteUrl: String? = null,
val isSelected: Boolean = false,
val createdAt: Long = System.currentTimeMillis()
)
diff --git a/android/app/src/main/java/com/gooserelay/gooserelayvpn/ui/profiles/ProfilesScreen.kt b/android/app/src/main/java/com/gooserelay/gooserelayvpn/ui/profiles/ProfilesScreen.kt
index a8a2fe6..6d28fcc 100644
--- a/android/app/src/main/java/com/gooserelay/gooserelayvpn/ui/profiles/ProfilesScreen.kt
+++ b/android/app/src/main/java/com/gooserelay/gooserelayvpn/ui/profiles/ProfilesScreen.kt
@@ -2,10 +2,10 @@ package com.gooserelay.gooserelayvpn.ui.profiles
import android.content.Context
import android.net.Uri
+import android.util.Base64
import android.util.Log
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
-import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -20,19 +20,23 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
-import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.verticalScroll
-import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.PersonAdd
+import androidx.compose.material.icons.filled.Refresh
+import androidx.compose.material.icons.filled.Share
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.SnackbarHost
+import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
@@ -40,6 +44,7 @@ import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.MenuAnchorType
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
@@ -55,9 +60,10 @@ import androidx.compose.runtime.setValue
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
-import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.google.gson.Gson
@@ -67,7 +73,6 @@ import com.gooserelay.gooserelayvpn.ui.components.mdv.controls.MdvBackTopAppBar
import com.gooserelay.gooserelayvpn.ui.theme.ConnectedGreen
import com.gooserelay.gooserelayvpn.ui.theme.MdvColor
import com.gooserelay.gooserelayvpn.ui.theme.MdvSpace
-import com.gooserelay.gooserelayvpn.ui.profiles.ProfilesViewModel
import com.gooserelay.gooserelayvpn.util.ConfigGenerator
data class ScriptKeyEntry(
@@ -76,33 +81,26 @@ data class ScriptKeyEntry(
)
fun parseScriptKeysText(text: String): List {
- Log.d("ProfilesScreen", "parseScriptKeysText input: '$text'")
- val result = text.lines()
- .map { it.trim() }
- .filter { it.isNotEmpty() }
+ if (text.isBlank()) return emptyList()
+ Log.d("ProfilesScreen", "Parsing script keys text: '$text'")
+ return text.split("\n")
+ .filter { it.isNotBlank() }
.map { line ->
if (line.contains("|")) {
val parts = line.split("|")
- val entry = ScriptKeyEntry(
- id = parts.getOrElse(0) { "" }.trim(),
- account = parts.getOrElse(1) { "" }.trim()
- )
- Log.d("ProfilesScreen", "Parsed pipe: id='${entry.id}', account='${entry.account}'")
- entry
+ val id = parts[0].trim()
+ val account = parts.getOrElse(1) { "" }.trim()
+ Log.d("ProfilesScreen", " Parsed pipe: id='$id', account='$account'")
+ ScriptKeyEntry(id, account)
} else {
- Log.d("ProfilesScreen", "Parsed no-pipe: id='${line.trim()}'")
- ScriptKeyEntry(id = line.trim(), account = "")
+ val id = line.trim()
+ Log.d("ProfilesScreen", " Parsed single: id='$id'")
+ ScriptKeyEntry(id, "")
}
}
- Log.d("ProfilesScreen", "parseScriptKeysText result: $result")
- return result
}
fun scriptKeysToText(entries: List): String {
- Log.d("ProfilesScreen", "scriptKeysToText input: ${entries.size} entries")
- entries.forEachIndexed { i, entry ->
- Log.d("ProfilesScreen", " [$i] id='${entry.id}', account='${entry.account}'")
- }
val result = entries
.filter { it.id.isNotBlank() }
.joinToString("\n") { entry ->
@@ -112,16 +110,135 @@ fun scriptKeysToText(entries: List): String {
return result
}
+fun parseProfileFromJson(raw: String, defaultName: String? = null): ProfileEntity? {
+ return try {
+ val root = Gson().fromJson(raw, JsonObject::class.java)
+
+ // Check if it has at least one identifying part
+ if (!root.has("script_keys") && !root.has("tunnel_key")) return null
+
+ val name = root.get("name")?.asString ?: defaultName ?: "Imported"
+ val debugTiming = root.get("debug_timing")?.asBoolean ?: false
+ val socksHost = root.get("socks_host")?.asString ?: "127.0.0.1"
+ val socksPort = root.get("socks_port")?.asInt ?: 1080
+ val socksUser = root.get("socks_user")?.asString ?: ""
+ val socksPass = root.get("socks_pass")?.asString ?: ""
+ val googleHost = root.get("google_host")?.asString ?: "216.239.38.120"
+ val sniJson = when {
+ root.get("sni")?.isJsonArray == true -> Gson().toJson(root.getAsJsonArray("sni").mapNotNull { it.asString })
+ root.get("sni")?.isJsonPrimitive == true -> Gson().toJson(listOf(root.get("sni").asString))
+ else -> "[\"www.google.com\", \"mail.google.com\", \"accounts.google.com\"]"
+ }
+ val scriptKeysText = when {
+ root.get("script_keys")?.isJsonArray == true -> {
+ root.getAsJsonArray("script_keys").mapNotNull { element ->
+ when {
+ element.isJsonObject -> {
+ val obj = element.asJsonObject
+ val id = obj.get("id")?.asString?.trim()
+ val account = obj.get("account")?.asString?.trim()
+ if (id.isNullOrBlank()) null
+ else if (account.isNullOrBlank()) id
+ else "$id|$account"
+ }
+ element.isJsonPrimitive -> element.asString.trim()
+ else -> null
+ }
+ }.filter { it.isNotBlank() }.joinToString("\n")
+ }
+ root.get("script_keys")?.isJsonPrimitive == true -> root.get("script_keys").asString.trim()
+ else -> ""
+ }
+ val coalesceStepMs = root.get("coalesce_step_ms")?.asInt ?: 0
+ val idleSlotsPerBucket = root.get("idle_slots_per_bucket")?.asInt?.coerceIn(1, 3) ?: 2
+ val tunnelKey = root.get("tunnel_key")?.asString ?: ""
+
+ ProfileEntity(
+ name = name,
+ debugTiming = debugTiming,
+ socksHost = socksHost,
+ socksPort = socksPort,
+ socksUser = socksUser,
+ socksPass = socksPass,
+ googleHost = googleHost,
+ sniJson = sniJson,
+ scriptKeysText = scriptKeysText,
+ tunnelKey = tunnelKey,
+ coalesceStepMs = coalesceStepMs,
+ idleSlotsPerBucket = idleSlotsPerBucket,
+ remoteUrl = null
+ )
+ } catch (_: Exception) {
+ null
+ }
+}
+
+fun parseGooseRelayProtocol(raw: String): ProfileEntity? {
+ val trimmed = raw.trim()
+ if (!trimmed.startsWith("goose-relay://")) return null
+ return try {
+ val base64Content = trimmed.substring("goose-relay://".length)
+ val decodedBytes = Base64.decode(base64Content, Base64.DEFAULT)
+ val decodedString = String(decodedBytes)
+ parseProfileFromJson(decodedString)
+ } catch (_: Exception) {
+ null
+ }
+}
+
+private fun getFileNameFromUri(context: Context, uri: Uri): String? {
+ return context.contentResolver.query(uri, null, null, null, null)?.use { cursor ->
+ val nameIndex = cursor.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME)
+ if (cursor.moveToFirst()) {
+ val full = cursor.getString(nameIndex)
+ full.substringBeforeLast(".")
+ } else null
+ }
+}
+
@Composable
fun ProfilesScreen(
onBack: () -> Unit
) {
val viewModel: ProfilesViewModel = hiltViewModel()
val profiles by viewModel.profiles.collectAsState()
+ val isUpdating by viewModel.isUpdating.collectAsState()
+ val updateMessage by viewModel.updateMessage.collectAsState()
val context = LocalContext.current
+ val snackbarHostState = remember { SnackbarHostState() }
var showEditor by remember { mutableStateOf(false) }
var editing by remember { mutableStateOf(null) }
var profilePendingDelete by remember { mutableStateOf(null) }
+ var showErrorDialog by remember { mutableStateOf(null) }
+ var menuExpanded by remember { mutableStateOf(false) }
+ var profileToExport by remember { mutableStateOf(null) }
+
+ val clipboardManager = LocalClipboardManager.current
+
+ LaunchedEffect(updateMessage) {
+ updateMessage?.let {
+ snackbarHostState.showSnackbar(it)
+ viewModel.clearUpdateMessage()
+ }
+ }
+
+ val exportLauncher = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("application/json")) { uri ->
+ if (uri == null || profileToExport == null) return@rememberLauncherForActivityResult
+ writeTextToUri(context, uri, ConfigGenerator.exportProfileJson(profileToExport!!))
+ profileToExport = null
+ }
+
+ val importLauncher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
+ if (uri == null) return@rememberLauncherForActivityResult
+ val raw = readTextFromUri(context, uri)
+ val fileName = getFileNameFromUri(context, uri)
+ val profile = viewModel.parseProfileFromJson(raw, fileName)
+ if (profile != null) {
+ viewModel.addProfile(profile)
+ } else {
+ showErrorDialog = "Import failed: invalid JSON format."
+ }
+ }
Scaffold(
topBar = {
@@ -129,12 +246,70 @@ fun ProfilesScreen(
title = "Profiles",
onBack = onBack,
actions = {
- IconButton(onClick = { editing = null; showEditor = true }) {
- Icon(Icons.Filled.Add, contentDescription = "Add")
+ if (isUpdating) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(24.dp).padding(4.dp),
+ strokeWidth = 2.dp,
+ color = MdvColor.Primary
+ )
+ } else {
+ IconButton(onClick = { viewModel.updateRemoteProfile(context) }) {
+ Icon(Icons.Filled.Refresh, contentDescription = "Update Remote")
+ }
+ }
+ Box {
+ IconButton(onClick = { menuExpanded = true }) {
+ Icon(Icons.Filled.Add, contentDescription = "Add")
+ }
+ DropdownMenu(
+ expanded = menuExpanded,
+ onDismissRequest = { menuExpanded = false }
+ ) {
+ DropdownMenuItem(
+ text = { Text("From Clipboard") },
+ onClick = {
+ menuExpanded = false
+ val text = clipboardManager.getText()?.text?.trim()
+ if (!text.isNullOrBlank()) {
+ when {
+ text.startsWith("http://") || text.startsWith("https://") -> {
+ viewModel.importProfileFromUrl(text, context)
+ }
+ else -> {
+ val profile = parseGooseRelayProtocol(text) ?: parseProfileFromJson(text)
+ if (profile != null) {
+ viewModel.addProfile(profile)
+ } else {
+ showErrorDialog = "Import failed: invalid JSON, URL, or goose-relay:// format."
+ }
+ }
+ }
+ } else {
+ showErrorDialog = "Clipboard is empty"
+ }
+ }
+ )
+ DropdownMenuItem(
+ text = { Text("From JSON File") },
+ onClick = {
+ menuExpanded = false
+ importLauncher.launch(arrayOf("application/json", "text/plain", "*/*"))
+ }
+ )
+ DropdownMenuItem(
+ text = { Text("Add Manual") },
+ onClick = {
+ menuExpanded = false
+ editing = null
+ showEditor = true
+ }
+ )
+ }
}
}
)
- }
+ },
+ snackbarHost = { SnackbarHost(snackbarHostState) }
) { padding ->
if (profiles.isEmpty()) {
Box(
@@ -148,9 +323,14 @@ fun ProfilesScreen(
tint = MdvColor.OnSurfaceVariant
)
Spacer(modifier = Modifier.height(MdvSpace.S3))
- Text("No profiles yet")
- Spacer(modifier = Modifier.height(MdvSpace.S2))
- Button(onClick = { editing = null; showEditor = true }) { Text("Create Profile") }
+ Text("No profiles yet", style = MaterialTheme.typography.titleMedium)
+ Spacer(modifier = Modifier.height(MdvSpace.S1))
+ Text(
+ "Tap the + button above to add a profile\nor import from clipboard/file",
+ style = MaterialTheme.typography.bodySmall,
+ color = MdvColor.OnSurfaceVariant,
+ textAlign = androidx.compose.ui.text.style.TextAlign.Center
+ )
}
}
} else {
@@ -194,6 +374,41 @@ fun ProfilesScreen(
IconButton(onClick = { editing = profile; showEditor = true }) {
Icon(Icons.Filled.Edit, contentDescription = "Edit")
}
+
+ var shareMenuExpanded by remember { mutableStateOf(false) }
+ Box {
+ IconButton(onClick = { shareMenuExpanded = true }) {
+ Icon(Icons.Filled.Share, contentDescription = "Share")
+ }
+ DropdownMenu(
+ expanded = shareMenuExpanded,
+ onDismissRequest = { shareMenuExpanded = false }
+ ) {
+ DropdownMenuItem(
+ text = { Text("to Clipboard") },
+ onClick = {
+ shareMenuExpanded = false
+ val exportObj = JsonObject()
+ exportObj.addProperty("name", profile.name)
+ exportObj.addProperty("script_keys", profile.scriptKeysText)
+ exportObj.addProperty("tunnel_key", profile.tunnelKey)
+ val json = Gson().toJson(exportObj)
+ val base64 = Base64.encodeToString(json.toByteArray(), Base64.NO_WRAP)
+ val protocol = "goose-relay://$base64"
+ clipboardManager.setText(AnnotatedString(protocol))
+ }
+ )
+ DropdownMenuItem(
+ text = { Text("to JSON") },
+ onClick = {
+ shareMenuExpanded = false
+ profileToExport = profile
+ exportLauncher.launch("goose_profile_${profile.name}.json")
+ }
+ )
+ }
+ }
+
IconButton(onClick = { profilePendingDelete = profile }) {
Icon(Icons.Filled.Delete, contentDescription = "Delete")
}
@@ -204,9 +419,22 @@ fun ProfilesScreen(
}
}
+ val dialogError = showErrorDialog
+ if (dialogError != null) {
+ AlertDialog(
+ onDismissRequest = { showErrorDialog = null },
+ confirmButton = {
+ TextButton(onClick = { showErrorDialog = null }) {
+ Text("OK")
+ }
+ },
+ title = { Text("Notification") },
+ text = { Text(dialogError) }
+ )
+ }
+
if (showEditor) {
ProfileEditorDialog(
- context = context,
profile = editing,
onSave = {
if (editing == null) viewModel.addProfile(it) else viewModel.updateProfile(it)
@@ -251,7 +479,6 @@ fun ProfilesScreen(
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ProfileEditorDialog(
- context: Context,
profile: ProfileEntity?,
onSave: (ProfileEntity) -> Unit,
onDismiss: () -> Unit
@@ -264,107 +491,13 @@ private fun ProfileEditorDialog(
var socksPass by remember { mutableStateOf(profile?.socksPass ?: "") }
var googleHost by remember { mutableStateOf(profile?.googleHost ?: "216.239.38.120") }
var sniCsv by remember { mutableStateOf(profile?.sniJson?.replace("[", "")?.replace("]", "")?.replace("\"", "") ?: "www.google.com, mail.google.com, accounts.google.com") }
- var scriptKeysText by remember { mutableStateOf(profile?.scriptKeysText ?: "") }
var scriptKeyEntries by remember { mutableStateOf(parseScriptKeysText(profile?.scriptKeysText ?: "").ifEmpty { listOf(ScriptKeyEntry()) }) }
var tunnelKey by remember { mutableStateOf(profile?.tunnelKey ?: "") }
var coalesceStepMs by remember { mutableStateOf((profile?.coalesceStepMs ?: 0).toString()) }
var idleSlotsPerBucket by remember { mutableStateOf((profile?.idleSlotsPerBucket ?: 2).toString()) }
+ var remoteUrl by remember { mutableStateOf(profile?.remoteUrl ?: "") }
var showErrorDialog by remember { mutableStateOf(null) }
- Log.d("ProfilesScreen", "=== DIALOG INIT === profile=${profile?.name ?: "NEW"}")
- Log.d("ProfilesScreen", "Loaded scriptKeysText: '${profile?.scriptKeysText ?: "(empty)"}'")
- Log.d("ProfilesScreen", "Parsed scriptKeyEntries (${scriptKeyEntries.size}): $scriptKeyEntries")
-
- LaunchedEffect(scriptKeyEntries) {
- Log.d("ProfilesScreen", "*** scriptKeyEntries CHANGED (${scriptKeyEntries.size} items) ***")
- scriptKeyEntries.forEachIndexed { i, entry ->
- Log.d("ProfilesScreen", " [$i] id='${entry.id}', account='${entry.account}'")
- }
- }
-
- val importLauncher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
- if (uri == null) return@rememberLauncherForActivityResult
- runCatching {
- val raw = readTextFromUri(context, uri)
- val root = Gson().fromJson(raw, JsonObject::class.java)
- name = root.get("name")?.asString ?: name
- debugTiming = root.get("debug_timing")?.asBoolean ?: debugTiming
- socksHost = root.get("socks_host")?.asString ?: socksHost
- socksPort = root.get("socks_port")?.asInt?.toString() ?: socksPort
- socksUser = root.get("socks_user")?.asString ?: socksUser
- socksPass = root.get("socks_pass")?.asString ?: socksPass
- if ((socksUser.isBlank()) != (socksPass.isBlank())) {
- showErrorDialog = "Import rejected: socks_user and socks_pass must both be set or both be empty (SOCKS5 auth requires both values)."
- return@runCatching
- }
- showErrorDialog = null
- googleHost = root.get("google_host")?.asString ?: googleHost
- sniCsv = when {
- root.get("sni")?.isJsonArray == true -> root.getAsJsonArray("sni")?.mapNotNull { it.asString }?.joinToString(", ") ?: ""
- root.get("sni")?.isJsonPrimitive == true -> root.get("sni")?.asString ?: ""
- else -> ""
- }
- val keys = when {
- root.get("script_keys")?.isJsonArray == true -> {
- Log.d("ProfilesScreen", "=== IMPORTING SCRIPT KEYS FROM JSON ===")
- root.getAsJsonArray("script_keys")?.mapNotNull { element ->
- when {
- element.isJsonObject -> {
- val obj = element.asJsonObject
- val id = obj.get("id")?.asString?.trim()
- val account = obj.get("account")?.asString?.trim()
- Log.d("ProfilesScreen", "Import JSON obj: id='$id', account='$account'")
- if (id.isNullOrBlank()) null
- else if (account.isNullOrBlank()) {
- Log.d("ProfilesScreen", " -> No account, saving as: '$id'")
- id
- }
- else {
- val result = "$id|$account"
- Log.d("ProfilesScreen", " -> With account, saving as: '$result'")
- result
- }
- }
- element.isJsonPrimitive -> element.asString.trim()
- else -> null
- }
- }?.filter { it.isNotBlank() }?.joinToString("\n") ?: ""
- }
- else -> ""
- }
- Log.d("ProfilesScreen", "Final imported keys text: '$keys'")
- scriptKeysText = keys
- Log.d("ProfilesScreen", "About to parse imported keys with parseScriptKeysText()")
- scriptKeyEntries = parseScriptKeysText(keys)
- Log.d("ProfilesScreen", "After parsing, scriptKeyEntries has ${scriptKeyEntries.size} items")
- coalesceStepMs = (root.get("coalesce_step_ms")?.asInt ?: 0).toString()
- idleSlotsPerBucket = (root.get("idle_slots_per_bucket")?.asInt?.coerceIn(1, 3) ?: 2).toString()
- tunnelKey = root.get("tunnel_key")?.asString ?: tunnelKey
- }
- }
- val exportLauncher = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("application/json")) { uri ->
- if (uri == null) return@rememberLauncherForActivityResult
- val draft = ProfileEntity(
- id = profile?.id ?: 0,
- name = name,
- debugTiming = debugTiming,
- socksHost = socksHost,
- socksPort = socksPort.toIntOrNull()?.coerceIn(1, 65535) ?: 1080,
- socksUser = socksUser,
- socksPass = socksPass,
- googleHost = googleHost,
- sniJson = if (sniCsv.isBlank()) "[]" else sniCsv.split(",").map { it.trim() }.filter { it.isNotBlank() }
- .joinToString(prefix = "[\"", postfix = "\"]", separator = "\",\""),
- scriptKeysText = scriptKeysToText(scriptKeyEntries),
- tunnelKey = tunnelKey,
- coalesceStepMs = coalesceStepMs.toIntOrNull() ?: 0,
- idleSlotsPerBucket = idleSlotsPerBucket.toIntOrNull()?.coerceIn(1, 3) ?: 2,
- isSelected = profile?.isSelected ?: false,
- createdAt = profile?.createdAt ?: System.currentTimeMillis()
- )
- writeTextToUri(context, uri, ConfigGenerator.exportProfileJson(draft))
- }
-
AlertDialog(
onDismissRequest = onDismiss,
confirmButton = {
@@ -381,13 +514,8 @@ private fun ProfileEditorDialog(
}
val sniJson = sniCsv.split(",").map { it.trim() }.filter { it.isNotBlank() }
.joinToString(prefix = "[\"", postfix = "\"]", separator = "\",\"")
- Log.d("ProfilesScreen", "=== SAVING PROFILE ===")
- Log.d("ProfilesScreen", "scriptKeyEntries state (${scriptKeyEntries.size} entries):")
- scriptKeyEntries.forEachIndexed { i, entry ->
- Log.d("ProfilesScreen", " [$i] id='${entry.id}', account='${entry.account}'")
- }
+
val scriptKeysForSave = scriptKeysToText(scriptKeyEntries)
- Log.d("ProfilesScreen", "Final scriptKeysText to save: '$scriptKeysForSave'")
onSave(
ProfileEntity(
id = profile?.id ?: 0,
@@ -403,6 +531,7 @@ private fun ProfileEditorDialog(
tunnelKey = tunnelKey,
coalesceStepMs = coalesceStepMs.toIntOrNull() ?: 0,
idleSlotsPerBucket = idleSlotsPerBucket.toIntOrNull()?.coerceIn(1, 3) ?: 2,
+ remoteUrl = remoteUrl.ifBlank { null },
isSelected = profile?.isSelected ?: false,
createdAt = profile?.createdAt ?: System.currentTimeMillis()
)
@@ -416,10 +545,13 @@ private fun ProfileEditorDialog(
modifier = Modifier.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
- Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
- OutlinedButton(onClick = { importLauncher.launch(arrayOf("application/json", "text/plain", "*/*")) }, modifier = Modifier.weight(1f)) { Text("Import JSON") }
- OutlinedButton(onClick = { exportLauncher.launch("goose_profile.json") }, modifier = Modifier.weight(1f)) { Text("Export JSON") }
- }
+ OutlinedTextField(
+ value = remoteUrl,
+ onValueChange = { remoteUrl = it },
+ label = { Text("Remote URL (Subscription)") },
+ modifier = Modifier.fillMaxWidth(),
+ supportingText = { Text("If set, this profile will update from this URL") }
+ )
OutlinedTextField(value = name, onValueChange = { name = it }, label = { Text("Profile Name") })
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text("Debug Timing")
@@ -436,7 +568,7 @@ private fun ProfileEditorDialog(
readOnly = true,
label = { Text("socks_host") },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = socksHostExpanded) },
- modifier = Modifier.menuAnchor()
+ modifier = Modifier.menuAnchor(type = MenuAnchorType.PrimaryNotEditable)
)
ExposedDropdownMenu(
expanded = socksHostExpanded,
diff --git a/android/app/src/main/java/com/gooserelay/gooserelayvpn/ui/profiles/ProfilesViewModel.kt b/android/app/src/main/java/com/gooserelay/gooserelayvpn/ui/profiles/ProfilesViewModel.kt
index 29df0dd..7e57289 100644
--- a/android/app/src/main/java/com/gooserelay/gooserelayvpn/ui/profiles/ProfilesViewModel.kt
+++ b/android/app/src/main/java/com/gooserelay/gooserelayvpn/ui/profiles/ProfilesViewModel.kt
@@ -1,14 +1,25 @@
package com.gooserelay.gooserelayvpn.ui.profiles
+import android.content.Context
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
+import com.google.gson.Gson
+import com.google.gson.JsonObject
+import com.gooserelay.gooserelayvpn.R
import com.gooserelay.gooserelayvpn.data.local.ProfileEntity
import com.gooserelay.gooserelayvpn.data.repository.ProfileRepository
import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import java.net.HttpURLConnection
+import java.net.URL
import javax.inject.Inject
@HiltViewModel
@@ -20,6 +31,12 @@ class ProfilesViewModel @Inject constructor(
profileRepository.getAllProfiles()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
+ private val _isUpdating = MutableStateFlow(false)
+ val isUpdating: StateFlow = _isUpdating.asStateFlow()
+
+ private val _updateMessage = MutableStateFlow(null)
+ val updateMessage: StateFlow = _updateMessage.asStateFlow()
+
fun addProfile(profile: ProfileEntity) {
viewModelScope.launch {
val id = profileRepository.insertProfile(profile)
@@ -47,4 +64,152 @@ class ProfilesViewModel @Inject constructor(
profileRepository.setSelectedProfile(id)
}
}
+
+ fun updateRemoteProfile(context: Context) {
+ viewModelScope.launch {
+ val selectedProfile = profileRepository.getSelectedProfileFlow().first()
+ if (selectedProfile == null) {
+ _updateMessage.value = context.getString(R.string.profiles_no_selection_update)
+ return@launch
+ }
+
+ val targetUrl = selectedProfile.remoteUrl
+ if (targetUrl.isNullOrBlank()) {
+ _updateMessage.value = context.getString(R.string.profiles_no_remote_url)
+ return@launch
+ }
+
+ _isUpdating.value = true
+ try {
+ val json = withContext(Dispatchers.IO) {
+ val url = URL(targetUrl)
+ val connection = url.openConnection() as HttpURLConnection
+ connection.requestMethod = "GET"
+ connection.connectTimeout = 10000
+ connection.readTimeout = 10000
+ connection.inputStream.bufferedReader().use { it.readText() }
+ }
+
+ val remoteProfile = parseProfileFromJson(json, remoteUrl = selectedProfile.remoteUrl)
+ if (remoteProfile != null) {
+ val updated = selectedProfile.copy(
+ name = if (selectedProfile.remoteUrl != null) remoteProfile.name else selectedProfile.name,
+ debugTiming = remoteProfile.debugTiming,
+ socksHost = remoteProfile.socksHost,
+ socksPort = remoteProfile.socksPort,
+ socksUser = remoteProfile.socksUser,
+ socksPass = remoteProfile.socksPass,
+ googleHost = remoteProfile.googleHost,
+ sniJson = remoteProfile.sniJson,
+ scriptKeysText = remoteProfile.scriptKeysText,
+ tunnelKey = remoteProfile.tunnelKey,
+ coalesceStepMs = remoteProfile.coalesceStepMs,
+ idleSlotsPerBucket = remoteProfile.idleSlotsPerBucket
+ )
+ profileRepository.updateProfile(updated)
+ _updateMessage.value = context.getString(R.string.profiles_update_success)
+ } else {
+ _updateMessage.value = context.getString(R.string.profiles_update_error, "Invalid JSON format")
+ }
+ } catch (e: Exception) {
+ _updateMessage.value = context.getString(R.string.profiles_update_error, e.localizedMessage ?: "Unknown error")
+ } finally {
+ _isUpdating.value = false
+ }
+ }
+ }
+
+ fun importProfileFromUrl(url: String, context: Context) {
+ viewModelScope.launch {
+ _isUpdating.value = true
+ try {
+ val json = withContext(Dispatchers.IO) {
+ val connection = URL(url).openConnection() as HttpURLConnection
+ connection.requestMethod = "GET"
+ connection.connectTimeout = 10000
+ connection.readTimeout = 10000
+ connection.inputStream.bufferedReader().use { it.readText() }
+ }
+
+ val profile = parseProfileFromJson(json, remoteUrl = url)
+ if (profile != null) {
+ addProfile(profile)
+ _updateMessage.value = context.getString(R.string.profiles_update_success)
+ } else {
+ _updateMessage.value = "Import failed: Invalid JSON format"
+ }
+ } catch (e: Exception) {
+ _updateMessage.value = "Import failed: ${e.localizedMessage}"
+ } finally {
+ _isUpdating.value = false
+ }
+ }
+ }
+
+ fun clearUpdateMessage() {
+ _updateMessage.value = null
+ }
+
+ fun parseProfileFromJson(raw: String, defaultName: String? = null, remoteUrl: String? = null): ProfileEntity? {
+ return try {
+ val root = Gson().fromJson(raw, JsonObject::class.java)
+
+ // Check if it has at least one identifying part
+ if (!root.has("script_keys") && !root.has("tunnel_key")) return null
+
+ val name = root.get("name")?.asString ?: defaultName ?: "Imported"
+ val debugTiming = root.get("debug_timing")?.asBoolean ?: false
+ val socksHost = root.get("socks_host")?.asString ?: "127.0.0.1"
+ val socksPort = root.get("socks_port")?.asInt ?: 1080
+ val socksUser = root.get("socks_user")?.asString ?: ""
+ val socksPass = root.get("socks_pass")?.asString ?: ""
+ val googleHost = root.get("google_host")?.asString ?: "216.239.38.120"
+ val sniJson = when {
+ root.get("sni")?.isJsonArray == true -> Gson().toJson(root.getAsJsonArray("sni").mapNotNull { it.asString })
+ root.get("sni")?.isJsonPrimitive == true -> Gson().toJson(listOf(root.get("sni").asString))
+ else -> "[\"www.google.com\", \"mail.google.com\", \"accounts.google.com\"]"
+ }
+ val scriptKeysText = when {
+ root.get("script_keys")?.isJsonArray == true -> {
+ root.getAsJsonArray("script_keys").mapNotNull { element ->
+ when {
+ element.isJsonObject -> {
+ val obj = element.asJsonObject
+ val id = obj.get("id")?.asString?.trim()
+ val account = obj.get("account")?.asString?.trim()
+ if (id.isNullOrBlank()) null
+ else if (account.isNullOrBlank()) id
+ else "$id|$account"
+ }
+ element.isJsonPrimitive -> element.asString.trim()
+ else -> null
+ }
+ }.filter { it.isNotBlank() }.joinToString("\n")
+ }
+ root.get("script_keys")?.isJsonPrimitive == true -> root.get("script_keys").asString.trim()
+ else -> ""
+ }
+ val coalesceStepMs = root.get("coalesce_step_ms")?.asInt ?: 0
+ val idleSlotsPerBucket = root.get("idle_slots_per_bucket")?.asInt?.coerceIn(1, 3) ?: 2
+ val tunnelKey = root.get("tunnel_key")?.asString ?: ""
+
+ ProfileEntity(
+ name = name,
+ debugTiming = debugTiming,
+ socksHost = socksHost,
+ socksPort = socksPort,
+ socksUser = socksUser,
+ socksPass = socksPass,
+ googleHost = googleHost,
+ sniJson = sniJson,
+ scriptKeysText = scriptKeysText,
+ tunnelKey = tunnelKey,
+ coalesceStepMs = coalesceStepMs,
+ idleSlotsPerBucket = idleSlotsPerBucket,
+ remoteUrl = remoteUrl
+ )
+ } catch (_: Exception) {
+ null
+ }
+ }
}
diff --git a/android/app/src/main/java/com/gooserelay/gooserelayvpn/ui/settings/GlobalSettingsScreen.kt b/android/app/src/main/java/com/gooserelay/gooserelayvpn/ui/settings/GlobalSettingsScreen.kt
index b704b2e..4b07a09 100644
--- a/android/app/src/main/java/com/gooserelay/gooserelayvpn/ui/settings/GlobalSettingsScreen.kt
+++ b/android/app/src/main/java/com/gooserelay/gooserelayvpn/ui/settings/GlobalSettingsScreen.kt
@@ -23,7 +23,7 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowDropDown
-import androidx.compose.material.icons.filled.Save
+import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
@@ -57,7 +57,9 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
@@ -67,7 +69,6 @@ import androidx.core.graphics.drawable.toBitmap
import androidx.lifecycle.viewmodel.compose.viewModel
import com.gooserelay.gooserelayvpn.R
import com.gooserelay.gooserelayvpn.ui.components.mdv.controls.MdvFilterChip
-import com.gooserelay.gooserelayvpn.ui.components.mdv.controls.MdvPrimaryActionButton
import com.gooserelay.gooserelayvpn.ui.components.mdv.controls.MdvTopAppBar
import com.gooserelay.gooserelayvpn.ui.theme.MdvColor
import com.gooserelay.gooserelayvpn.ui.theme.MdvSpace
@@ -110,27 +111,15 @@ fun GlobalSettingsScreen(vm: GlobalSettingsViewModel = viewModel()) {
val splitPackagesCount by remember(draft.splitPackagesCsv) {
derivedStateOf { parseCsv(draft.splitPackagesCsv).size }
}
- fun saveGlobalSettings() {
- if (socksPortMissing || httpPortMissing) {
- scope.launch {
- snackbarHostState.showSnackbar(context.getString(R.string.global_settings_ports_required_msg))
- }
- return
- }
- if (socksPortValue !in 1025..65535 || httpPortValue !in 1025..65535) {
- scope.launch {
- snackbarHostState.showSnackbar(context.getString(R.string.global_settings_ports_range_msg))
- }
- return
+ val clipboardManager = LocalClipboardManager.current
+ val gson = remember { com.google.gson.Gson() }
+ var menuExpanded by remember { mutableStateOf(false) }
+
+ LaunchedEffect(draft) {
+ val normalized = normalize(draft)
+ if (normalized != current) {
+ vm.save(normalized)
}
- val safeSocksPort = socksPortValue ?: return
- val safeHttpPort = httpPortValue ?: return
- draft = draft.copy(
- internetSharingSocksPort = safeSocksPort,
- internetSharingHttpPort = safeHttpPort
- )
- vm.save(normalize(draft))
- scope.launch { snackbarHostState.showSnackbar(context.getString(R.string.global_settings_saved_msg)) }
}
Scaffold(
@@ -139,13 +128,42 @@ fun GlobalSettingsScreen(vm: GlobalSettingsViewModel = viewModel()) {
MdvTopAppBar(
title = stringResource(R.string.settings_title),
actions = {
- IconButton(
- onClick = ::saveGlobalSettings
- ) {
- Icon(
- imageVector = Icons.Filled.Save,
- contentDescription = stringResource(R.string.action_save)
- )
+ Box {
+ IconButton(onClick = { menuExpanded = true }) {
+ Icon(imageVector = Icons.Filled.MoreVert, contentDescription = "More")
+ }
+ DropdownMenu(
+ expanded = menuExpanded,
+ onDismissRequest = { menuExpanded = false }
+ ) {
+ DropdownMenuItem(
+ text = { Text("Export to Clipboard") },
+ onClick = {
+ menuExpanded = false
+ val json = gson.toJson(draft)
+ clipboardManager.setText(AnnotatedString(json))
+ scope.launch { snackbarHostState.showSnackbar("Settings copied to clipboard") }
+ }
+ )
+ DropdownMenuItem(
+ text = { Text("Import from Clipboard") },
+ onClick = {
+ menuExpanded = false
+ val text = clipboardManager.getText()?.text
+ if (!text.isNullOrBlank()) {
+ try {
+ val imported = gson.fromJson(text, GlobalSettings::class.java)
+ draft = imported
+ scope.launch { snackbarHostState.showSnackbar("Settings imported from clipboard") }
+ } catch (e: Exception) {
+ scope.launch { snackbarHostState.showSnackbar("Failed to parse settings JSON") }
+ }
+ } else {
+ scope.launch { snackbarHostState.showSnackbar("Clipboard is empty") }
+ }
+ }
+ )
+ }
}
}
)
@@ -402,13 +420,6 @@ fun GlobalSettingsScreen(vm: GlobalSettingsViewModel = viewModel()) {
}
}
}
- item {
- MdvPrimaryActionButton(
- text = stringResource(R.string.global_settings_save_button),
- onClick = ::saveGlobalSettings,
- modifier = Modifier.fillMaxWidth()
- )
- }
}
}
}
diff --git a/android/app/src/main/java/com/gooserelay/gooserelayvpn/ui/settings/SettingsScreen.kt b/android/app/src/main/java/com/gooserelay/gooserelayvpn/ui/settings/SettingsScreen.kt
index bbc874d..7388067 100644
--- a/android/app/src/main/java/com/gooserelay/gooserelayvpn/ui/settings/SettingsScreen.kt
+++ b/android/app/src/main/java/com/gooserelay/gooserelayvpn/ui/settings/SettingsScreen.kt
@@ -2,10 +2,12 @@ package com.gooserelay.gooserelayvpn.ui.settings
import android.content.Context
import android.net.Uri
+import android.util.Base64
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
@@ -25,6 +27,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.gooserelay.gooserelayvpn.ui.components.mdv.controls.MdvBackTopAppBar
@@ -56,17 +59,30 @@ fun SettingsScreen(
var scriptKeys by remember(profile.id) { mutableStateOf(profile.scriptKeysText) }
var tunnelKey by remember(profile.id) { mutableStateOf(profile.tunnelKey) }
- val importLauncher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
- if (uri == null) return@rememberLauncherForActivityResult
- val json = readTextFromUri(context, uri)
- val updated = viewModel.importJsonToProfile(profile, json)
+ val clipboardManager = LocalClipboardManager.current
+
+ fun applyImportedProfile(json: String) {
+ val raw = if (json.trim().startsWith("goose-relay://")) {
+ try {
+ val base64Content = json.trim().substring("goose-relay://".length)
+ val decodedBytes = Base64.decode(base64Content, Base64.DEFAULT)
+ String(decodedBytes)
+ } catch (_: Exception) {
+ validationMessage = "Import failed: invalid goose-relay:// format."
+ return
+ }
+ } else {
+ json
+ }
+
+ val updated = viewModel.importJsonToProfile(profile, raw)
if (updated == null) {
validationMessage = "Import failed: invalid JSON format."
- return@rememberLauncherForActivityResult
+ return
}
if ((updated.socksUser.isBlank()) != (updated.socksPass.isBlank())) {
validationMessage = "Import rejected: socks_user and socks_pass must both be set or both be empty (SOCKS5 auth requires both values)."
- return@rememberLauncherForActivityResult
+ return
}
debugTiming = updated.debugTiming
socksHost = updated.socksHost
@@ -79,6 +95,11 @@ fun SettingsScreen(
tunnelKey = updated.tunnelKey
}
+ val importLauncher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
+ if (uri == null) return@rememberLauncherForActivityResult
+ applyImportedProfile(readTextFromUri(context, uri))
+ }
+
val exportLauncher = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("application/json")) { uri ->
if (uri == null) return@rememberLauncherForActivityResult
val updated = profile.copy(
@@ -105,6 +126,25 @@ fun SettingsScreen(
validationMessage = null
}
}
+
+ LaunchedEffect(debugTiming, socksHost, socksPort, socksUser, socksPass, googleHost, sniText, scriptKeys, tunnelKey) {
+ val portInt = socksPort.toIntOrNull()?.coerceIn(1, 65535)
+ val updated = profile.copy(
+ debugTiming = debugTiming,
+ socksHost = socksHost,
+ socksPort = portInt ?: profile.socksPort,
+ socksUser = socksUser,
+ socksPass = socksPass,
+ googleHost = googleHost,
+ sniJson = if (sniText.isBlank()) "[]" else "[\"" + sniText.split(",").map { it.trim() }.filter { it.isNotBlank() }.joinToString("\",\"") + "\"]",
+ scriptKeysText = scriptKeys,
+ tunnelKey = tunnelKey
+ )
+ if (updated != profile) {
+ viewModel.saveProfile(updated)
+ }
+ }
+
Column(
modifier = Modifier.fillMaxSize().padding(padding).padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
@@ -119,27 +159,18 @@ fun SettingsScreen(
OutlinedTextField(sniText, { sniText = it }, label = { Text("sni (comma separated)") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(scriptKeys, { scriptKeys = it }, label = { Text("script_keys (one per line)") }, minLines = 3, modifier = Modifier.fillMaxWidth())
OutlinedTextField(tunnelKey, { tunnelKey = it }, label = { Text("tunnel_key") }, modifier = Modifier.fillMaxWidth())
- Button(onClick = {
- if ((socksUser.isBlank()) != (socksPass.isBlank())) {
- validationMessage = "socks_user and socks_pass must both be set or both be empty"
- return@Button
- }
- viewModel.saveProfile(
- profile.copy(
- debugTiming = debugTiming,
- socksHost = socksHost,
- socksPort = socksPort.toIntOrNull()?.coerceIn(1, 65535) ?: 1080,
- socksUser = socksUser,
- socksPass = socksPass,
- googleHost = googleHost,
- sniJson = "[\"" + sniText.split(",").map { it.trim() }.filter { it.isNotBlank() }.joinToString("\",\"") + "\"]",
- scriptKeysText = scriptKeys,
- tunnelKey = tunnelKey
- )
- )
- }) { Text("Save Settings") }
- Button(onClick = { importLauncher.launch(arrayOf("application/json", "text/plain", "*/*")) }) { Text("Import JSON") }
- Button(onClick = { exportLauncher.launch("goose_profile.json") }) { Text("Export JSON") }
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
+ Button(onClick = { importLauncher.launch(arrayOf("application/json", "text/plain", "*/*")) }, modifier = Modifier.weight(1f)) { Text("Import File") }
+ Button(onClick = {
+ val text = clipboardManager.getText()?.text
+ if (text.isNullOrBlank()) {
+ validationMessage = "Clipboard is empty"
+ } else {
+ applyImportedProfile(text)
+ }
+ }, modifier = Modifier.weight(1f)) { Text("From Clipboard") }
+ }
+ Button(onClick = { exportLauncher.launch("goose_profile.json") }, modifier = Modifier.fillMaxWidth()) { Text("Export JSON File") }
}
}
}
diff --git a/android/app/src/main/java/com/gooserelay/gooserelayvpn/util/ConfigGenerator.kt b/android/app/src/main/java/com/gooserelay/gooserelayvpn/util/ConfigGenerator.kt
index d93063c..659986a 100644
--- a/android/app/src/main/java/com/gooserelay/gooserelayvpn/util/ConfigGenerator.kt
+++ b/android/app/src/main/java/com/gooserelay/gooserelayvpn/util/ConfigGenerator.kt
@@ -14,9 +14,15 @@ object ConfigGenerator {
val root = JsonObject().apply {
addProperty("debug_timing", profile.debugTiming)
addProperty("socks_host", profile.socksHost)
- addProperty("socks_port", profile.socksPort)
- addProperty("socks_user", profile.socksUser)
- addProperty("socks_pass", profile.socksPass)
+ if (profile.socksPort != 1080) {
+ addProperty("socks_port", profile.socksPort)
+ }
+ if (profile.socksUser.isNotBlank()) {
+ addProperty("socks_user", profile.socksUser)
+ }
+ if (profile.socksPass.isNotBlank()) {
+ addProperty("socks_pass", profile.socksPass)
+ }
addProperty("google_host", profile.googleHost)
add("sni", parseSni(profile.sniJson))
add("script_keys", parseScriptKeys(profile.scriptKeysText))
diff --git a/android/app/src/main/java/com/gooserelay/gooserelayvpn/util/GlobalSettingsStore.kt b/android/app/src/main/java/com/gooserelay/gooserelayvpn/util/GlobalSettingsStore.kt
index e37b99a..4d7806c 100644
--- a/android/app/src/main/java/com/gooserelay/gooserelayvpn/util/GlobalSettingsStore.kt
+++ b/android/app/src/main/java/com/gooserelay/gooserelayvpn/util/GlobalSettingsStore.kt
@@ -25,7 +25,7 @@ data class GlobalSettings(
val internetSharingSocksPort: Int = 8090,
val internetSharingHttpPort: Int = 8091,
val internetSharingUser: String = "",
- val internetSharingPass: String = ""
+ val internetSharingPass: String = "",
)
object GlobalSettingsStore {
diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml
index 8beec50..3820cdd 100644
--- a/android/app/src/main/res/values/strings.xml
+++ b/android/app/src/main/res/values/strings.xml
@@ -144,4 +144,9 @@
https://github.com/Kianmhz/GooseRelayVPN
https://github.com/Hidden-Node/GooseRelayVPN-AndroidClient
v1.7.1
+ Profile updated from remote successfully
+ Failed to update profile: %1$s
+ Updating from remote...
+ Please select a profile to update
+ This profile is local and doesn\'t have a remote URL to update from.
diff --git a/android/build.gradle.kts b/android/build.gradle.kts
index e032c08..d51d65c 100644
--- a/android/build.gradle.kts
+++ b/android/build.gradle.kts
@@ -1,5 +1,5 @@
plugins {
- id("com.android.application") version "8.7.3" apply false
+ id("com.android.application") version "8.13.0" apply false
id("org.jetbrains.kotlin.android") version "2.1.0" apply false
id("org.jetbrains.kotlin.plugin.compose") version "2.1.0" apply false
id("com.google.dagger.hilt.android") version "2.51.1" apply false
diff --git a/android/build_go_mobile.sh b/android/build_go_mobile.sh
index 1a1ee31..b4388d6 100644
--- a/android/build_go_mobile.sh
+++ b/android/build_go_mobile.sh
@@ -7,6 +7,7 @@ cd "$ROOT_DIR"
MOBILE_TOOLS_VERSION="v0.0.0-20260312152759-81488f6aeb60"
# Always install a pinned, known-good gomobile/gobind pair.
+# go install skipped - using patched gomobile
go install "golang.org/x/mobile/cmd/gomobile@${MOBILE_TOOLS_VERSION}"
go install "golang.org/x/mobile/cmd/gobind@${MOBILE_TOOLS_VERSION}"
diff --git a/android/gradle.properties b/android/gradle.properties
index 17451ee..5bcbeed 100644
--- a/android/gradle.properties
+++ b/android/gradle.properties
@@ -1,4 +1,5 @@
android.useAndroidX=true
kotlin.code.style=official
-org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
+org.gradle.jvmargs=-Xmx4096m -Dfile.encoding=UTF-8
org.gradle.parallel=true
+kotlin.compiler.execution.strategy=in-process
diff --git a/android/gradle/gradle-daemon-jvm.properties b/android/gradle/gradle-daemon-jvm.properties
new file mode 100644
index 0000000..baa28d1
--- /dev/null
+++ b/android/gradle/gradle-daemon-jvm.properties
@@ -0,0 +1,13 @@
+#This file is generated by updateDaemonJvm
+toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/491f83666ae7f4d6ebb28fee72ebb035/redirect
+toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/0d1a1acdc708062093673f65aa9aba4b/redirect
+toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/491f83666ae7f4d6ebb28fee72ebb035/redirect
+toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/0d1a1acdc708062093673f65aa9aba4b/redirect
+toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/7083b89563e7ce20943037b8cd2b8cc2/redirect
+toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/060bbb778a1f55ea705fdebd2ccfeab9/redirect
+toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/491f83666ae7f4d6ebb28fee72ebb035/redirect
+toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/0d1a1acdc708062093673f65aa9aba4b/redirect
+toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/d09679dc60fe5aa05ef7d03efdefac20/redirect
+toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/ed4e3bf2f5e7c5d9aabc4cbd8acd555e/redirect
+toolchainVendor=JETBRAINS
+toolchainVersion=21
diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties
index dedd5d1..ed4c299 100644
--- a/android/gradle/wrapper/gradle-wrapper.properties
+++ b/android/gradle/wrapper/gradle-wrapper.properties
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-all.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-all.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts
index 012061b..01f9844 100644
--- a/android/settings.gradle.kts
+++ b/android/settings.gradle.kts
@@ -5,6 +5,9 @@ pluginManagement {
gradlePluginPortal()
}
}
+plugins {
+ id("org.gradle.toolchains.foojay-resolver-convention") version "0.10.0"
+}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
diff --git a/go.mod b/go.mod
index b7561f8..1ed59f4 100644
--- a/go.mod
+++ b/go.mod
@@ -1,13 +1,13 @@
module github.com/kianmhz/GooseRelayVPN
-go 1.23.1
+go 1.25.0
require github.com/things-go/go-socks5 v0.1.1
require (
github.com/klauspost/compress v1.17.11
github.com/xjasonlyu/tun2socks/v2 v2.6.0
- golang.org/x/net v0.40.0
+ golang.org/x/net v0.52.0
)
require (
@@ -22,16 +22,25 @@ require (
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/schema v1.4.1 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
+ github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0 // indirect
go.uber.org/atomic v1.11.0 // indirect
go.uber.org/automaxprocs v1.6.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.27.0 // indirect
- golang.org/x/crypto v0.38.0 // indirect
- golang.org/x/sys v0.33.0 // indirect
- golang.org/x/text v0.25.0 // indirect
+ golang.org/x/crypto v0.49.0 // indirect
+ golang.org/x/mobile v0.0.0-20260312152759-81488f6aeb60 // indirect
+ golang.org/x/mod v0.34.0 // indirect
+ golang.org/x/sync v0.20.0 // indirect
+ golang.org/x/sys v0.42.0 // indirect
+ golang.org/x/text v0.35.0 // indirect
golang.org/x/time v0.11.0 // indirect
+ golang.org/x/tools v0.43.0 // indirect
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
gvisor.dev/gvisor v0.0.0-20250523182742-eede7a881b20 // indirect
)
+
+replace github.com/mitchellh/osext => github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0
+
+exclude github.com/mitchellh/osext v0.0.0-20151018003038-5e2d6d41470f
diff --git a/go.sum b/go.sum
index aefc35a..eb026fd 100644
--- a/go.sum
+++ b/go.sum
@@ -1,7 +1,25 @@
+github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
+github.com/Microsoft/go-winio v0.6.0/go.mod h1:cTAf44im0RAYeL23bpB+fzCyDH2MJiz2BO69KH/soAE=
+github.com/Microsoft/hcsshim v0.9.12/go.mod h1:qAiPvMgZoM0wpkVg6qMdSEu+1VtI6/qHOOPkTGt8ftQ=
github.com/ajg/form v1.5.1 h1:t9c7v8JUKu/XxOGBU0yjNpaMloxGEJhUkqFRq0ibGeU=
github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY=
+github.com/bazelbuild/rules_go v0.44.2/go.mod h1:Dhcz716Kqg1RHNWos+N6MlXNkjNP2EwZQ0LukRKJfMs=
+github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM=
+github.com/cilium/ebpf v0.12.3/go.mod h1:TctK1ivibvI3znr66ljgi4hqOT8EYQjz1KWBfb1UVgM=
+github.com/containerd/cgroups v1.0.4/go.mod h1:nLNQtsF7Sl2HxNebu77i1R0oDlhiTG+kO4JTrUzo6IA=
+github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U=
+github.com/containerd/containerd v1.6.36/go.mod h1:gSufNaPbqri6ifEQ3eihFSXoGwqTENkqB7j//aEgE0s=
+github.com/containerd/continuity v0.3.0/go.mod h1:wJEAIwKOm/pBZuBd0JmeTvnLquTB1Ag8espWhkykbPM=
+github.com/containerd/errdefs v0.1.0/go.mod h1:YgWiiHtLmSeBrvpw+UfPijzbLaB77mEG1WwJTDETIV0=
+github.com/containerd/fifo v1.0.0/go.mod h1:ocF/ME1SX5b1AOlWi9r677YJmCPSwwWnQ9O123vzpE4=
+github.com/containerd/go-runc v1.0.0/go.mod h1:cNU0ZbCgCQVZK4lgG3P+9tn9/PaJNmoDXPpoJhDR+Ok=
+github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
+github.com/containerd/ttrpc v1.1.2/go.mod h1:XX4ZTnoOId4HklF4edwc4DcqskFZuvXB1Evzy5KFQpQ=
+github.com/containerd/typeurl v1.0.2/go.mod h1:9trJWW2sRlGub4wZJRTW83VtbOLS6hwcDZXTn6oPz9s=
+github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/docker/go-events v0.0.0-20190806004212-e31b211e4f1c/go.mod h1:Uw6UezgYA44ePAFQYUehOuCzmy5zmg/+nl2ZfMWGkpA=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8=
@@ -12,54 +30,143 @@ github.com/go-chi/render v1.0.3 h1:AsXqd2a1/INaIfUSKq3G5uA8weYx20FOsM7uSoCyyt4=
github.com/go-chi/render v1.0.3/go.mod h1:/gr3hVkmYR0YlEy3LxCuVRFzEu9Ruok+gFqbIofjao0=
github.com/go-gost/relay v0.5.0 h1:JG1tgy/KWiVXS0ukuVXvbM0kbYuJTWxYpJ5JwzsCf/c=
github.com/go-gost/relay v0.5.0/go.mod h1:lcX+23LCQ3khIeASBo+tJ/WbwXFO32/N5YN6ucuYTG8=
+github.com/go-logr/logr v1.3.0/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
+github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
+github.com/gofrs/flock v0.8.0/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU=
+github.com/gogo/googleapis v1.4.0/go.mod h1:5YRNX2z1oM5gXdAkurHa942MDgEJyk02w4OecKY87+c=
+github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
+github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
+github.com/golang/mock v1.7.0-rc.1/go.mod h1:s42URUywIqd+OcERslBJvOjepvNymP31m3q8d/GkuRs=
+github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg=
github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
+github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
+github.com/google/go-github/v56 v56.0.0/go.mod h1:D8cdcX98YWJvi7TLo7zM4/h8ZTx6u6fwGEkCdisopo0=
+github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
+github.com/google/subcommands v1.0.2-0.20190508160503-636abe8753b8/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/googleapis/gnostic v0.5.5/go.mod h1:7+EbHbldMins07ALC74bsA81Ovc97DwqyJO1AENw9kA=
github.com/gorilla/schema v1.4.1 h1:jUg5hUjCSDZpNGLuXQOgIWGdlgrIdYvgQ0wZtdK1M3E=
github.com/gorilla/schema v1.4.1/go.mod h1:Dg5SSm5PV60mhF2NFaTV1xuYYj8tV8NOPRo4FggUMnM=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
+github.com/hanwen/go-fuse/v2 v2.3.0/go.mod h1:xKwi1cF7nXAOBCXujD5ie0ZKsxc8GGSA1rlMJc+8IJs=
+github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
+github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
+github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
+github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0 h1:iQTw/8FWTuc7uiaSepXwyf3o52HaUYcV+Tu66S3F5GA=
+github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0/go.mod h1:1NbS8ALrpOvjt0rHPNLyCIeMtbizbir8U//inJ+zuB8=
github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
+github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
+github.com/kr/pty v1.1.5/go.mod h1:9r2w37qlBe7rQ6e1fg1S/9xpWHSnaqNdHD3WcMdbPDA=
+github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+github.com/mattbaird/jsonpatch v0.0.0-20171005235357-81af80346b1a/go.mod h1:M1qoD/MqPgTZIk0EWKB38wE28ACRfVcn+cU08jyArI0=
+github.com/moby/locker v1.0.1/go.mod h1:S7SDdo5zpBK84bzzVlKr2V0hz+7x9hWbYC/kq7oQppc=
+github.com/moby/sys/capability v0.4.0/go.mod h1:4g9IK291rVkms3LKCDOoYlnV8xKwoDTpIrNEE35Wq0I=
+github.com/moby/sys/mountinfo v0.6.2/go.mod h1:IJb6JQeOklcdMU9F5xQ8ZALD+CUr5VlGpwtX+VE0rpI=
+github.com/moby/sys/signal v0.6.0/go.mod h1:GQ6ObYZfqacOwTtlXvcmh9A26dVRul/hbOZn88Kg8Tg=
+github.com/moby/sys/user v0.1.0/go.mod h1:fKJhFOnsCN6xZ5gSfbM6zaHGgDJMrqt9/reuj4T7MmU=
+github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
+github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
+github.com/mohae/deepcopy v0.0.0-20170308212314-bb9b5e7adda9/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8=
+github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
+github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
+github.com/opencontainers/runtime-spec v1.1.0-rc.1/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0=
+github.com/opencontainers/selinux v1.10.1/go.mod h1:2i0OySw99QjzBBQByd1Gr9gSjvuho1lHsJxIJ3gGbJI=
+github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U=
+github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/things-go/go-socks5 v0.1.1 h1:48hy9cHEXPKeG91G/g4n8zW4uynzPUQy/FkcrJ7r5AY=
github.com/things-go/go-socks5 v0.1.1/go.mod h1:1YBHVYG7Oli5ae+Pwkp630cPAwY1pjUPmohO1n0Emg0=
+github.com/vishvananda/netlink v1.1.1-0.20211118161826-650dca95af54/go.mod h1:twkDnbuQxJYemMlGd4JFIcuhgX83tXhKS2B/PRMpOho=
+github.com/vishvananda/netns v0.0.0-20210104183010-2eb08e3e575f/go.mod h1:DD4vA1DwXk04H54A1oHXtwZmA0grkVMdPxx/VGLCah0=
github.com/xjasonlyu/tun2socks/v2 v2.6.0 h1:gI9saJT3XgH4e6v9jBuHRLwK7l3aN9YFWec/SsDTDx4=
github.com/xjasonlyu/tun2socks/v2 v2.6.0/go.mod h1:35AwqxIxnMkfBfT0UJ1Lku7PZm2ZiZJ8sxHyp0gt1yw=
+github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
+go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs=
go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8=
+go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
+golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
+golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
+golang.org/x/exp v0.0.0-20230725093048-515e97ebf090/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc=
+golang.org/x/exp/shiny v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:zxsA7NyDTOUjcveVwAMFI/YIErWwayTW/4RGqB/RzKk=
+golang.org/x/image v0.37.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY=
+golang.org/x/mobile v0.0.0-20260312152759-81488f6aeb60 h1:MOzyaj0wu2xneBkzkg9LHNYjDBB4W5vP043A2SYQRPA=
+golang.org/x/mobile v0.0.0-20260312152759-81488f6aeb60/go.mod h1:th6VJvzjMbrYF8SduQY5rpD0HG0GleGxjadkqSxFs3k=
+golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
+golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
+golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
+golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
+golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
+golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
+golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
+golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
+golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c/go.mod h1:TpUTTEp9frx7rTdLpC9gFG9kdI7zVLFTFFlqaH2Cncw=
+golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
+golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
+golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
+golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
+golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
+golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY=
+golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated/go.mod h1:RVAQXBGNv1ib0J382/DPCRS/BPnsGebyM1Gj5VSDpG8=
+golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8=
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg=
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb h1:whnFRlWMcXI9d+ZbWg+4sHnLp52d5yiIPUxMBSt4X9A=
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw=
+google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
+google.golang.org/genproto v0.0.0-20230920204549-e6e6cdab5c13/go.mod h1:CCviP9RmpZ1mxVr8MUjCnSiY09IbAXZxhLE6EhHIdPU=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20231002182017-d307bd883b97/go.mod h1:v7nGkzlmW8P3n/bKmWBn2WpBjpOEx8Q6gMueudAmKfY=
+google.golang.org/grpc v1.59.0/go.mod h1:aUPDwccQo6OTjy7Hct4AfBPD1GptF4fyUjIkQ9YtF98=
+google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.3.0/go.mod h1:Dk1tviKTvMCz5tvh7t+fh94dhmQVHuCt2OzJB3CTW9Y=
+google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
+gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
+gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gotest.tools/v3 v3.4.0/go.mod h1:CtbdzLSsqVhDgMtKsx03ird5YTGB3ar27v0u/yKBW5g=
gvisor.dev/gvisor v0.0.0-20250523182742-eede7a881b20 h1:0DxLu8hxI1OGp1qVRPqNd+2k1a7hMNUNqbZG0IrtKlM=
gvisor.dev/gvisor v0.0.0-20250523182742-eede7a881b20/go.mod h1:3r5CMtNQMKIvBlrmM9xWUNamjKBYPOWyXOjmg5Kts3g=
+honnef.co/go/tools v0.5.1/go.mod h1:e9irvo83WDG9/irijV44wr3tbhcFeRnfpVlRqVwpzMs=
+k8s.io/api v0.23.16/go.mod h1:Fk/eWEGf3ZYZTCVLbsgzlxekG6AtnT3QItT3eOSyFRE=
+k8s.io/apimachinery v0.23.16/go.mod h1:RMMUoABRwnjoljQXKJ86jT5FkTZPPnZsNv70cMsKIP0=
+k8s.io/client-go v0.23.16/go.mod h1:CUfIIQL+hpzxnD9nxiVGb99BNTp00mPFp3Pk26sTFys=
+k8s.io/klog/v2 v2.30.0/go.mod h1:y1WjHnz7Dj687irZUWR/WLkLc5N1YHtjLdmgWjndZn0=
+k8s.io/kube-openapi v0.0.0-20211115234752-e816edb12b65/go.mod h1:sX9MT8g7NVZM5lVL/j8QyCCJe8YSMW30QvGZWaCIDIk=
+k8s.io/utils v0.0.0-20211116205334-6203023598ed/go.mod h1:jPW/WVKK9YHAvNhRxK0md/EJ228hCsBRufyofKtW8HA=
+sigs.k8s.io/json v0.0.0-20211020170558-c049b76a60c6/go.mod h1:p4QtZmO4uMYipTQNzagwnNoseA6OxSUutVw05NhYDRs=
+sigs.k8s.io/structured-merge-diff/v4 v4.2.3/go.mod h1:qjx8mGObPmV2aSZepjQjbmb2ihdVs8cGKBraizNC69E=
+sigs.k8s.io/yaml v1.2.0/go.mod h1:yfXDCHCao9+ENCvLSE62v9VSji2MKu5jeNfTrofGhJc=
diff --git a/screenshots/home.png b/screenshots/home.png
new file mode 100644
index 0000000..84af74c
Binary files /dev/null and b/screenshots/home.png differ
diff --git a/screenshots/import&share-settings.png b/screenshots/import&share-settings.png
new file mode 100644
index 0000000..3ba6d11
Binary files /dev/null and b/screenshots/import&share-settings.png differ
diff --git a/screenshots/import-profile.png b/screenshots/import-profile.png
new file mode 100644
index 0000000..3078e1e
Binary files /dev/null and b/screenshots/import-profile.png differ
diff --git a/screenshots/logs.png b/screenshots/logs.png
new file mode 100644
index 0000000..32d0797
Binary files /dev/null and b/screenshots/logs.png differ
diff --git a/screenshots/profiles.png b/screenshots/profiles.png
new file mode 100644
index 0000000..d55081c
Binary files /dev/null and b/screenshots/profiles.png differ
diff --git a/screenshots/settings.png b/screenshots/settings.png
new file mode 100644
index 0000000..3298270
Binary files /dev/null and b/screenshots/settings.png differ
diff --git a/screenshots/share-profile.png b/screenshots/share-profile.png
new file mode 100644
index 0000000..c62eacc
Binary files /dev/null and b/screenshots/share-profile.png differ