commit 157f60aad243d59e1493fe55dd9bf7d33b4a65f7 Author: esefxdz Date: Fri May 29 14:42:24 2026 +0300 Initial commit (Security Hardened Slate) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2851fce --- /dev/null +++ b/.gitignore @@ -0,0 +1,69 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ +.java-version + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains configure files for the Visual Studio Code +# editor. Although we may keep it, we often ignore it. +.vscode/ + +# Python cache & User files +TECHNICAL_DEBT.md +RUNNING.md +__pycache__/ +*.pyc + +# App Config (Hide real IPs/IDs from GitHub) +lib/logic/app_config.dart + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +build/ +**/build/ +local_packages/*/build/ +local_packages/*/.dart_tool/ +*.dart_tool/ + +# Android related +**/android/**/gradle-wrapper.jar +**/android/.gradle +**/android/captures/ +**/android/gradlew +**/android/gradlew.bat +**/android/local.properties +**/android/**/GeneratedPluginRegistrant.java +**/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java +**/android/.cxx/ +**/android/app/build/ + +# Keystore files +*.keystore +*.jks + +# Crashlytics cache +**/android/app/crashlytics-build.properties + +# Secrets +android/key.properties +lib/logic/backup/ +00-FULL-REPORT.md +server_hivemind_4_0.py diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..36df5ac --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 esef + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/PRIVACY_POLICY.md b/PRIVACY_POLICY.md new file mode 100644 index 0000000..07c2988 --- /dev/null +++ b/PRIVACY_POLICY.md @@ -0,0 +1,21 @@ +Privacy Policy +Last updated: May 2026 +PaladinVPN is a free VPN service built and operated by a solo developer. +What we collect +We collect the minimum required to operate the service: + +A randomly generated device ID created on your phone. This is not linked to your identity in any way. +Your active session status and data usage for the current session only. This is stored in memory and deleted when your session ends. + +What we do not collect + +We do not collect your name, email, or any personal information. +We do not log your internet traffic or browsing history. +We do not sell or share any data with third parties. + +Ads +PaladinVPN uses Google AdMob to display rewarded video ads. Google may collect data as part of ad delivery. Please refer to Google's Privacy Policy for details. +Your traffic +Your internet traffic is routed through our server located in Finland. We do not inspect, log, or store your traffic. +Contact +If you have any questions, open an issue on the GitHub repository. diff --git a/README.md b/README.md new file mode 100644 index 0000000..c63b945 --- /dev/null +++ b/README.md @@ -0,0 +1,53 @@ +--- + +# PaladinVPN +A free VPN app I built in my spare time as a student with basically no prior experience. + +There are hundreds of free VPN apps on the Play Store. Most of them are fine honestly — some are genuinely good. But a lot of them are vague about how they work, who runs them, and what happens to your traffic. I wanted to build something where the answer to all of those questions is just... public. + +So here it is. + +--- + +## How it works +You watch a short ad. You get 60 minutes and 2GB of full-speed traffic. That's it. The ad pays for the server (actually not even enough). No accounts, no emails, no subscriptions. + +When your data runs out you get throttled to 1.5 Mbps instead of getting cut off. You can watch a bonus ad to top up anytime. + +--- + +## The stack +- **App** — Flutter (Android only for now) +- **Server** — A single Debian box rented from Hetzner, located in Finland +- **VPN protocol** — AmneziaWG, which is WireGuard with obfuscation so it works on carriers that block standard WireGuard +- **Backend** — A Python script I wrote called Hivemind that manages sessions, data quotas, and throttling +- **Ads** — Google AdMob rewarded ads, verified server-side so fake callbacks don't work + +--- + +## Limitations (being honest) +- One server, one location (Finland). That's all I can afford right now +- 2 cores and 4GB RAM. It will not handle thousands of concurrent users +- iOS is not supported and probably won't be for a while +- Countries with very aggressive DPI (China, Russia, etc.) may still have issues +- I'm a student doing this in my free time. Expect rough edges + +--- + +## Why I built this +Free time project. Wanted to learn how VPNs actually work under the hood. Ended up building the whole thing from scratch — the Flutter app, the AmneziaWG setup, the session management backend, the AdMob integration. It took way longer than expected. + +--- + +## Privacy +Your traffic goes through my server in Finland. I don't log it, I don't sell it, I have no reason to. The session manager (Hivemind) only tracks whether your session is active and how much data you've used — nothing else. + +Internet privacy at the cost of watching an ad. Honest business. + +You can read the server code yourself, it's in this repo. + +--- + +*PaladinVPN is not affiliated with any VPN company.* + +--- diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000..0a741cb --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,11 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java + +# Remember to never publicly share your keystore. +# See https://flutter.dev/docs/deployment/android#reference-the-keystore-from-the-app +key.properties diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 0000000..618a377 --- /dev/null +++ b/android/app/build.gradle.kts @@ -0,0 +1,61 @@ +import java.util.Properties +import java.io.FileInputStream + +plugins { + id("com.android.application") + id("dev.flutter.flutter-gradle-plugin") +} + +android { + namespace = "com.paladinvpn.app" + compileSdk = flutter.compileSdkVersion + ndkVersion = flutter.ndkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + packaging { + resources.excludes.add("META-INF/**") + } + + defaultConfig { + applicationId = "com.paladinvpn.app" + minSdk = flutter.minSdkVersion + targetSdk = flutter.targetSdkVersion + versionCode = flutter.versionCode + versionName = flutter.versionName + } + + val keystorePropertiesFile = rootProject.file("key.properties") + val keystoreProperties = Properties() + if (keystorePropertiesFile.exists()) { + keystoreProperties.load(FileInputStream(keystorePropertiesFile)) + } + + signingConfigs { + create("release") { + keyAlias = keystoreProperties["keyAlias"] as String? + keyPassword = keystoreProperties["keyPassword"] as String? + storeFile = keystoreProperties["storeFile"]?.let { rootProject.file(it) } + storePassword = keystoreProperties["storePassword"] as String? + } + } + + buildTypes { + release { + signingConfig = signingConfigs.getByName("release") + } + } +} + +kotlin { + compilerOptions { + jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 + } +} + +flutter { + source = "../.." +} diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..f880684 --- /dev/null +++ b/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,6 @@ + + + + diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..b91cc55 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/kotlin/com/paladinvpn/app/MainActivity.kt b/android/app/src/main/kotlin/com/paladinvpn/app/MainActivity.kt new file mode 100644 index 0000000..774ec45 --- /dev/null +++ b/android/app/src/main/kotlin/com/paladinvpn/app/MainActivity.kt @@ -0,0 +1,5 @@ +package com.paladinvpn.app + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity : FlutterActivity() diff --git a/android/app/src/main/res/drawable-v21/launch_background.xml b/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..f74085f --- /dev/null +++ b/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/android/app/src/main/res/drawable/launch_background.xml b/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..19dc1a3 --- /dev/null +++ b/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..947e54f Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000..947e54f Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..947e54f Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000..947e54f Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..947e54f Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000..947e54f Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..947e54f Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..947e54f Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..947e54f Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..947e54f Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/values-night/styles.xml b/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..06952be --- /dev/null +++ b/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..1f83a33 --- /dev/null +++ b/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/android/app/src/profile/AndroidManifest.xml b/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..f880684 --- /dev/null +++ b/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,6 @@ + + + + diff --git a/android/build.gradle b/android/build.gradle new file mode 100644 index 0000000..d9af72b --- /dev/null +++ b/android/build.gradle @@ -0,0 +1,30 @@ +buildscript { + repositories { + google() + mavenCentral() + } + + dependencies { + classpath 'com.android.tools.build:gradle:8.11.1' + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:2.2.20" + } +} + +allprojects { + repositories { + google() + mavenCentral() + } +} + +rootProject.buildDir = '../build' +subprojects { + project.buildDir = "${rootProject.buildDir}/${project.name}" +} +subprojects { + project.evaluationDependsOn(':app') +} + +tasks.register("clean", Delete) { + delete rootProject.layout.buildDirectory +} diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 0000000..5c92332 --- /dev/null +++ b/android/build.gradle.kts @@ -0,0 +1,50 @@ +plugins { + id("com.android.application") + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id("dev.flutter.flutter-gradle-plugin") +} + +android { + namespace = "com.esefcloud.vpn" + compileSdk = flutter.compileSdkVersion + ndkVersion = flutter.ndkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + defaultConfig { + applicationId = "com.esefcloud.vpn" + minSdk = 21 + targetSdk = flutter.targetSdkVersion + versionCode = flutter.versionCode + versionName = flutter.versionName + } + + packaging { + resources { + excludes += setOf( + "META-INF/versions/9/OSGI-INF/MANIFEST.MF" + ) + } + } + + buildTypes { + release { + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig = signingConfigs.getByName("debug") + } + } +} + +kotlin { + compilerOptions { + jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 + } +} + +flutter { + source = "../.." +} diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..763d1a1 --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,8 @@ +org.gradle.jvmargs=-Xmx1536M +android.enableR8=true +android.useAndroidX=true +android.enableJetifier=true +# This builtInKotlin flag was added automatically by Flutter migrator +android.builtInKotlin=false +# This newDsl flag was added automatically by Flutter migrator +android.newDsl=false diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..4cc8c0b --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Fri Jun 23 08:50:38 CEST 2017 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip diff --git a/android/settings.gradle b/android/settings.gradle new file mode 100644 index 0000000..ff03f22 --- /dev/null +++ b/android/settings.gradle @@ -0,0 +1,25 @@ +pluginManagement { + def flutterSdkPath = { + def properties = new Properties() + file("local.properties").withInputStream { properties.load(it) } + def flutterSdkPath = properties.getProperty("flutter.sdk") + assert flutterSdkPath != null, "flutter.sdk not set in local.properties" + return flutterSdkPath + }() + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id "dev.flutter.flutter-plugin-loader" version "1.0.0" + id "com.android.application" version "8.11.1" apply false + id "org.jetbrains.kotlin.android" version "2.2.20" apply false +} + +include ":app" diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts new file mode 100644 index 0000000..c21f0c5 --- /dev/null +++ b/android/settings.gradle.kts @@ -0,0 +1,26 @@ +pluginManagement { + val flutterSdkPath = + run { + val properties = java.util.Properties() + file("local.properties").inputStream().use { properties.load(it) } + val flutterSdkPath = properties.getProperty("flutter.sdk") + require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } + flutterSdkPath + } + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id("dev.flutter.flutter-plugin-loader") version "1.0.0" + id("com.android.application") version "9.0.1" apply false + id("org.jetbrains.kotlin.android") version "2.3.20" apply false +} + +include(":app") diff --git a/assets/background.png b/assets/background.png new file mode 100644 index 0000000..2ac4dea Binary files /dev/null and b/assets/background.png differ diff --git a/assets/finland_flag_256.png b/assets/finland_flag_256.png new file mode 100644 index 0000000..84c5354 Binary files /dev/null and b/assets/finland_flag_256.png differ diff --git a/assets/logo.png b/assets/logo.png new file mode 100644 index 0000000..7d8b450 Binary files /dev/null and b/assets/logo.png differ diff --git a/assets/logo_black.png b/assets/logo_black.png new file mode 100644 index 0000000..947e54f Binary files /dev/null and b/assets/logo_black.png differ diff --git a/lib/components/bottom_bar.dart b/lib/components/bottom_bar.dart new file mode 100644 index 0000000..c649f81 --- /dev/null +++ b/lib/components/bottom_bar.dart @@ -0,0 +1,40 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:paladinvpn/logic/vpn_connection.dart'; +import 'package:paladinvpn/components/online_dot.dart'; + +/// BottomBar is the bottom utility panel that surfaces server connectivity status +/// and offers the user an option to support the server when the VPN tunnel is active. +class BottomBar extends StatelessWidget { + const BottomBar({super.key}); + + // Dark card background color for high contrast overlay + static const Color _cardBg = Color(0xFF1E2533); + + @override + Widget build(BuildContext context) { + return Consumer( + builder: (context, vpn, _) { + final isOnline = vpn.status == VpnStatus.connected; + + return Container( + margin: const EdgeInsets.symmetric(horizontal: 24, vertical: 8), + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14), + decoration: BoxDecoration( + color: _cardBg, + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: const Color.fromRGBO(74, 85, 104, 0.15), + ), + ), + child: Row( + children: [ + // Displays the server online/offline chip + OnlineDot(isOnline: isOnline), + ], + ), + ); + }, + ); + } +} diff --git a/lib/components/clock_display.dart b/lib/components/clock_display.dart new file mode 100644 index 0000000..4314eae --- /dev/null +++ b/lib/components/clock_display.dart @@ -0,0 +1,152 @@ +import 'dart:math'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:paladinvpn/logic/session_timer.dart'; + +/// ClockDisplay shows a large circular progress ring and the session countdown timer. +/// +/// The timer text glows electric cyan when a session is active and dims to slate gray when idle. +class ClockDisplay extends StatelessWidget { + const ClockDisplay({super.key}); + + static const Color _cyanGlow = Color(0xFF00E5FF); + static const Color _dimSlate = Color(0xFF4A5568); + + @override + Widget build(BuildContext context) { + return Consumer( + builder: (context, timer, _) { + final isLive = timer.isRunning; + final color = isLive ? _cyanGlow : _dimSlate; + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: 220, + height: 220, + child: Stack( + alignment: Alignment.center, + children: [ + // Background track ring representing the maximum capacity of the countdown + const CustomPaint( + size: Size(220, 220), + painter: _ArcPainter( + progress: 1.0, + color: Color.fromRGBO(74, 85, 104, 0.2), + strokeWidth: 4, + ), + ), + + // Live progress ring showing current remaining time relative to the session limit + if (isLive) + TweenAnimationBuilder( + tween: Tween(begin: 0, end: timer.progress), + duration: const Duration(milliseconds: 400), + builder: (context, value, _) => CustomPaint( + size: const Size(220, 220), + painter: _ArcPainter( + progress: value, + color: _cyanGlow, + strokeWidth: 4, + glow: true, + ), + ), + ), + + // Monospaced digital clock display for session countdown + AnimatedDefaultTextStyle( + duration: const Duration(milliseconds: 300), + style: TextStyle( + fontFamily: 'RobotoMono', + fontSize: 48, + fontWeight: FontWeight.w300, + letterSpacing: 4, + color: color, + shadows: isLive + ? const [ + Shadow( + color: Color.fromRGBO(0, 229, 255, 0.6), + blurRadius: 20, + ), + Shadow( + color: Color.fromRGBO(0, 229, 255, 0.3), + blurRadius: 40, + ), + ] + : const [], + ), + child: Text(timer.formatted), + ), + ], + ), + ), + const SizedBox(height: 12), + + // Subtitle state label below the timer + AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + child: Text( + isLive ? 'SESSION ACTIVE' : 'STANDBY', + key: ValueKey(isLive), + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + letterSpacing: 3, + color: isLive + ? const Color.fromRGBO(0, 229, 255, 0.7) + : const Color.fromRGBO(74, 85, 104, 0.7), + ), + ), + ), + ], + ); + }, + ); + } +} + +/// A custom painter to render a circular progress arc or background track ring. +class _ArcPainter extends CustomPainter { + final double progress; + final Color color; + final double strokeWidth; + final bool glow; + + const _ArcPainter({ + required this.progress, + required this.color, + this.strokeWidth = 4, + this.glow = false, + }); + + @override + void paint(Canvas canvas, Size size) { + final rect = Rect.fromLTWH(0, 0, size.width, size.height) + .deflate(strokeWidth / 2); + + // Optional blurred background paint to create an electric neon glow effect + if (glow) { + final glowPaint = Paint() + ..color = const Color.fromRGBO(0, 229, 255, 0.15) + ..style = PaintingStyle.stroke + ..strokeWidth = strokeWidth + 8 + ..strokeCap = StrokeCap.round + ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 8); + canvas.drawArc(rect, -pi / 2, 2 * pi * progress, false, glowPaint); + } + + // Core progress stroke paint + final paint = Paint() + ..color = color + ..style = PaintingStyle.stroke + ..strokeWidth = strokeWidth + ..strokeCap = StrokeCap.round; + + canvas.drawArc(rect, -pi / 2, 2 * pi * progress, false, paint); + } + + @override + bool shouldRepaint(covariant _ArcPainter old) => + old.progress != progress || old.color != color; +} diff --git a/lib/components/connect_button.dart b/lib/components/connect_button.dart new file mode 100644 index 0000000..82cbbe7 --- /dev/null +++ b/lib/components/connect_button.dart @@ -0,0 +1,207 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:paladinvpn/logic/vpn_connection.dart'; +import 'package:paladinvpn/logic/session_timer.dart'; + +class ConnectButton extends StatefulWidget { + const ConnectButton({super.key}); + + @override + State createState() => _ConnectButtonState(); +} + +class _ConnectButtonState extends State + with SingleTickerProviderStateMixin { + late AnimationController _pulseController; + late Animation _pulseAnim; + + static const Color _cyanGlow = Color(0xFF00E5FF); + static const Color _bgDark = Color(0xFF1A202C); + + bool _inCooldown = false; + + @override + void initState() { + super.initState(); + _pulseController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 2000), + ); + _pulseAnim = Tween(begin: 0.0, end: 1.0).animate( + CurvedAnimation(parent: _pulseController, curve: Curves.easeInOut), + ); + } + + @override + void dispose() { + _pulseController.dispose(); + super.dispose(); + } + + void _handleTap() async { + if (_inCooldown) return; + + setState(() { + _inCooldown = true; + }); + Future.delayed(const Duration(seconds: 5), () { + if (mounted) { + setState(() { + _inCooldown = false; + }); + } + }); + + final vpn = context.read(); + final timer = context.read(); + + if (vpn.status == VpnStatus.connected) { + vpn.disconnect(); + timer.stop(); + _pulseController.stop(); + _pulseController.reset(); + } else if (vpn.status == VpnStatus.disconnected || + vpn.status == VpnStatus.error) { + final success = await vpn.connect(); + if (success) { + timer.start('main_ad'); + _pulseController.repeat(reverse: true); + } + } + } + + @override + Widget build(BuildContext context) { + return Consumer2( + builder: (context, vpn, timer, _) { + final isConnected = vpn.status == VpnStatus.connected; + final isConnecting = vpn.status == VpnStatus.connecting; + + if (isConnected && !_pulseController.isAnimating) { + _pulseController.repeat(reverse: true); + } else if (!isConnected && !isConnecting && _pulseController.isAnimating) { + _pulseController.stop(); + _pulseController.reset(); + } + + return GestureDetector( + onTap: (isConnecting || _inCooldown) ? null : _handleTap, + child: AnimatedBuilder( + animation: _pulseAnim, + builder: (context, child) { + final glowRadius = isConnected ? 20.0 + (_pulseAnim.value * 15.0) : 0.0; + final glowAlpha = isConnected + ? ((0.3 + _pulseAnim.value * 0.2) * 255).round() + : 0; + + return Container( + width: 260, + height: 260, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: _bgDark, + border: Border.all( + color: _inCooldown + ? Colors.grey.withValues(alpha: 0.5) + : isConnected + ? _cyanGlow + : isConnecting + ? const Color.fromRGBO(0, 229, 255, 0.5) + : const Color.fromRGBO(74, 85, 104, 0.5), + width: isConnected && !_inCooldown ? 3 : 2, + ), + boxShadow: isConnected + ? [ + BoxShadow( + color: Color.fromARGB(glowAlpha, 0, 229, 255), + blurRadius: glowRadius, + spreadRadius: glowRadius * 0.3, + ), + ] + : const [], + ), + child: Center( + child: isConnected + ? _buildConnectedStats(timer) + : _buildDisconnectedIcon(vpn.status, _inCooldown), + ), + ); + }, + ), + ); + }, + ); + } + + Widget _buildDisconnectedIcon(VpnStatus status, bool inCooldown) { + if (status == VpnStatus.connecting || status == VpnStatus.disconnecting) { + return const SizedBox( + width: 48, + height: 48, + child: CircularProgressIndicator( + strokeWidth: 2.5, + color: Color.fromRGBO(0, 229, 255, 0.7), + ), + ); + } + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + inCooldown ? Icons.hourglass_bottom : Icons.power_settings_new, + size: 64, + color: inCooldown ? Colors.grey : const Color.fromRGBO(74, 85, 104, 0.7), + ), + const SizedBox(height: 12), + Text( + inCooldown ? 'PLEASE WAIT' : 'TAP TO CONNECT', + style: TextStyle( + color: inCooldown ? Colors.grey : const Color.fromRGBO(74, 85, 104, 0.9), + fontWeight: FontWeight.bold, + letterSpacing: 1.5, + ), + ), + ], + ); + } + + Widget _buildConnectedStats(SessionTimer timer) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Countdown Timer + Text( + timer.formatted, + style: const TextStyle( + fontSize: 48, + fontWeight: FontWeight.w800, + color: Colors.white, + letterSpacing: 2, + ), + ), + const SizedBox(height: 8), + + + + // Speed indicator + Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.speed, color: Colors.white70, size: 16), + const SizedBox(width: 4), + Text( + '${timer.currentSpeedKbps.toStringAsFixed(1)} KB/s', + style: const TextStyle( + fontSize: 14, + color: Colors.white70, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + + + ], + ); + } +} diff --git a/lib/components/header.dart b/lib/components/header.dart new file mode 100644 index 0000000..608bc68 --- /dev/null +++ b/lib/components/header.dart @@ -0,0 +1,56 @@ +import 'package:flutter/material.dart'; + +/// Header displays the Paladin brand logo, title, and subtitle. +/// +/// It represents the top branding portion of the main dashboard and is designed +/// with clean proportions, consistent typography, and a modern cyberpunk-inspired color scheme. +class Header extends StatelessWidget { + const Header({super.key}); + + // Cyber-paladin cyan glow accent color used as a fallback for the shield icon + static const Color _cyanGlow = Color(0xFF00E5FF); + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Brand logo image with a fallback Icon in case the asset is missing + Image.asset( + 'assets/logo.png', + width: 120, // Enlarged logo to stand out visually + height: 120, + errorBuilder: (context, error, stackTrace) => const Icon( + Icons.shield, + size: 120, + color: _cyanGlow, + ), + ), + const SizedBox(height: 8), // Standard padding between the logo and the title + + // Brand primary name + const Text( + 'Paladin', + style: TextStyle( + fontSize: 22, + fontWeight: FontWeight.w800, + letterSpacing: 8, + color: Colors.white, + ), + ), + const SizedBox(height: 2), // Close spacing for secondary information + + // Brand secondary label / purpose + const Text( + 'SECURE TUNNEL', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + letterSpacing: 4, + color: Color(0xAA4A5568), // Semi-transparent slate gray + ), + ), + ], + ); + } +} diff --git a/lib/components/info_button.dart b/lib/components/info_button.dart new file mode 100644 index 0000000..3e4e357 --- /dev/null +++ b/lib/components/info_button.dart @@ -0,0 +1,23 @@ +import 'package:flutter/material.dart'; +import 'package:url_launcher/url_launcher.dart'; + +class InfoButton extends StatelessWidget { + const InfoButton({Key? key}) : super(key: key); + + Future _launchGitHub() async { + final Uri url = Uri.parse('https://github.com/esefxdz/PaladinVPN'); + if (!await launchUrl(url, mode: LaunchMode.externalApplication)) { + debugPrint('Could not launch $url'); + } + } + + @override + Widget build(BuildContext context) { + return IconButton( + icon: const Icon(Icons.help_outline, color: Colors.white, size: 28), + onPressed: _launchGitHub, + tooltip: 'About PaladinVPN', + splashRadius: 24, + ); + } +} diff --git a/lib/components/online_dot.dart b/lib/components/online_dot.dart new file mode 100644 index 0000000..b0c61a0 --- /dev/null +++ b/lib/components/online_dot.dart @@ -0,0 +1,54 @@ +import 'package:flutter/material.dart'; + +/// OnlineDot displays a glowing green dot when the server is online/connected, +/// and a muted gray dot when disconnected. +class OnlineDot extends StatelessWidget { + /// Represents if the VPN tunnel session is active (online). + final bool isOnline; + + const OnlineDot({ + super.key, + required this.isOnline, + }); + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + // Glowing status dot: Green when active, muted gray when inactive. + AnimatedContainer( + duration: const Duration(milliseconds: 400), + width: 8, + height: 8, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: isOnline ? const Color(0xFF00E676) : const Color(0xFF4A5568), + boxShadow: isOnline + ? const [ + BoxShadow( + color: Color.fromRGBO(0, 230, 118, 0.5), + blurRadius: 6, + ), + ] + : const [], + ), + ), + const SizedBox(width: 10), + + // Dynamic status text label + Text( + isOnline ? 'Server Online' : 'Disconnected', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: isOnline + ? const Color.fromRGBO(255, 255, 255, 0.8) + : const Color(0xFF4A5568), + letterSpacing: 0.5, + ), + ), + ], + ); + } +} diff --git a/lib/components/privacy_policy_button.dart b/lib/components/privacy_policy_button.dart new file mode 100644 index 0000000..a98abd2 --- /dev/null +++ b/lib/components/privacy_policy_button.dart @@ -0,0 +1,23 @@ +import 'package:flutter/material.dart'; +import 'package:url_launcher/url_launcher.dart'; + +class PrivacyPolicyButton extends StatelessWidget { + const PrivacyPolicyButton({super.key}); + + Future _launchPrivacyPolicy() async { + final Uri url = Uri.parse('https://github.com/esefxdz/PaladinVPN/blob/main/PRIVACY_POLICY.md'); + if (!await launchUrl(url, mode: LaunchMode.externalApplication)) { + debugPrint('Could not launch $url'); + } + } + + @override + Widget build(BuildContext context) { + return IconButton( + icon: const Icon(Icons.privacy_tip_outlined, color: Colors.white, size: 28), + onPressed: _launchPrivacyPolicy, + tooltip: 'Privacy Policy', + splashRadius: 24, + ); + } +} diff --git a/lib/components/status_text.dart b/lib/components/status_text.dart new file mode 100644 index 0000000..ab0a12a --- /dev/null +++ b/lib/components/status_text.dart @@ -0,0 +1,83 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:paladinvpn/logic/vpn_connection.dart'; + +/// StatusText displays the status of the VPN tunnel. +/// +/// When connected, it shows the Finnish flag along with the tunnel location ("Helsinki, Finland"). +/// When connecting or disconnected, it shows status messages with clean micro-animations. +class StatusText extends StatelessWidget { + const StatusText({super.key}); + + @override + Widget build(BuildContext context) { + return Consumer( + builder: (context, vpn, _) { + final isConnected = vpn.status == VpnStatus.connected; + final isConnecting = vpn.status == VpnStatus.connecting; + + return AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + child: Container( + key: ValueKey(vpn.status), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + color: const Color(0x22FFFFFF), // Subtle glass effect background + borderRadius: BorderRadius.circular(24), + border: Border.all( + color: isConnected + ? const Color(0x8800E5FF) // Glowing cyan border when connected + : const Color(0x33FFFFFF), // Muted translucent border when disconnected/connecting + width: 1, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (isConnected) ...[ + // Circular Finnish flag icon representing the active tunnel endpoint + Image.asset( + 'assets/finland_flag_256.png', + width: 20, + height: 20, + ), + const SizedBox(width: 8), + const Text( + 'Helsinki, Finland', + style: TextStyle( + color: Color(0xFF00E5FF), + fontWeight: FontWeight.w600, + fontSize: 13, + letterSpacing: 0.5, + ), + ), + ] else ...[ + // Dynamic status icon (spinning arrows for loading, outlined shield for idle) + Icon( + isConnecting ? Icons.autorenew : Icons.shield_outlined, + size: 18, + color: isConnecting + ? const Color(0xFF00E5FF) + : const Color(0xAA4A5568), + ), + const SizedBox(width: 8), + Text( + vpn.statusMessage.toUpperCase(), + style: TextStyle( + color: isConnecting + ? const Color(0xFF00E5FF) + : const Color(0xAA4A5568), + fontWeight: FontWeight.w600, + fontSize: 13, + letterSpacing: 1, + ), + ), + ], + ], + ), + ), + ); + }, + ); + } +} diff --git a/lib/components/watch_ad_button.dart b/lib/components/watch_ad_button.dart new file mode 100644 index 0000000..77ea68d --- /dev/null +++ b/lib/components/watch_ad_button.dart @@ -0,0 +1,68 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:paladinvpn/logic/session_timer.dart'; +import 'package:paladinvpn/logic/ad_manager.dart'; + +/// WatchAdButton is a voluntary option allowing the user to watch a rewarded video ad +/// and extend their active VPN tunnel session by 30 minutes as a reward. +class WatchAdButton extends StatelessWidget { + const WatchAdButton({super.key}); + + @override + Widget build(BuildContext context) { + return Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(12), + onTap: () async { + final success = await context.read().showAd('bonus_ad'); + if (success && context.mounted) { + context.read().addBonusTime(); // Calls Hivemind API for 30m + 1GB + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text( + '+30 minutes added!', + style: TextStyle(fontWeight: FontWeight.w600), + ), + backgroundColor: const Color(0xFF006064), + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + duration: const Duration(seconds: 2), + ), + ); + } + }, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + gradient: const LinearGradient( + colors: [Color(0x1A00E5FF), Color(0x0D00E5FF)], + ), + border: Border.all( + color: const Color.fromRGBO(0, 229, 255, 0.25), + ), + ), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text('❤️', style: TextStyle(fontSize: 14)), + SizedBox(width: 6), + Text( + 'Support +30m', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: Color(0xFF00E5FF), + letterSpacing: 0.5, + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/logic/ad_manager.dart b/lib/logic/ad_manager.dart new file mode 100644 index 0000000..fd8a786 --- /dev/null +++ b/lib/logic/ad_manager.dart @@ -0,0 +1,153 @@ +import 'dart:async'; +import 'dart:convert'; +import 'package:flutter/foundation.dart'; +import 'package:google_mobile_ads/google_mobile_ads.dart'; +import 'package:paladinvpn/logic/crypto_service.dart'; +import 'package:paladinvpn/logic/hivemind_service.dart'; +import 'package:paladinvpn/logic/app_config.dart'; + +/// AdManager manages the lifecycle of rewarded video ads. +/// +/// It acts as a strict gatekeeper: users MUST watch an ad to connect. +/// If an ad is not ready when they click connect, it forces them to wait +/// while it loads. If they dismiss the ad early, they do not get the connection. +class AdManager extends ChangeNotifier { + RewardedAd? _rewardedAd; + + bool _isAdLoaded = false; + bool get isAdLoaded => _isAdLoaded; + + bool _isAdLoading = false; + bool get isAdLoading => _isAdLoading; + + Completer? _loadCompleter; + + // Google-provided test ad unit + static String get _adUnitId => AppConfig.adUnitId; + + AdManager() { + _preloadAd(); + } + + /// Internal helper to load an ad and return a Future when it's ready + Future _preloadAd() async { + // If already loaded, return instantly + if (_isAdLoaded) return true; + + // If we're already loading one right now, wait for that same request to finish + if (_isAdLoading) return _loadCompleter?.future ?? Future.value(false); + + _isAdLoading = true; + _loadCompleter = Completer(); + notifyListeners(); + + if (kIsWeb) { + await Future.delayed(const Duration(milliseconds: 500)); + _isAdLoaded = true; + _isAdLoading = false; + notifyListeners(); + _loadCompleter?.complete(true); + return true; + } + + RewardedAd.load( + adUnitId: _adUnitId, + request: const AdRequest(), + rewardedAdLoadCallback: RewardedAdLoadCallback( + onAdLoaded: (ad) { + _rewardedAd = ad; + _isAdLoaded = true; + _isAdLoading = false; + notifyListeners(); + if (!(_loadCompleter?.isCompleted ?? true)) { + _loadCompleter?.complete(true); + } + }, + onAdFailedToLoad: (error) { + debugPrint('Rewarded ad failed to load: ${error.message}'); + _isAdLoaded = false; + _isAdLoading = false; + notifyListeners(); + if (!(_loadCompleter?.isCompleted ?? true)) { + _loadCompleter?.complete(false); + } + }, + ), + ); + + return _loadCompleter!.future; + } + + /// Displays the rewarded video ad to the user. + Future showAd(String adType) async { + // 1. Ensure the ad is fully loaded before trying to show it + if (!_isAdLoaded || _rewardedAd == null) { + final loaded = await _preloadAd(); + if (!loaded || _rewardedAd == null) { + debugPrint('[AdManager] Cannot show ad, failed to load.'); + return false; + } + } + + // 2. Fetch device credentials for Server-Side Verification (SSV) + final deviceId = await CryptoService.getDeviceId(); + final keys = await CryptoService.getOrCreateKeys(); + final pubKey = keys['publicKey']!; + + // 3. Attach custom data to the ad so Google sends it back to our Hivemind server + _rewardedAd!.serverSideVerificationOptions = ServerSideVerificationOptions( + customData: jsonEncode({ + 'device_id': deviceId, + 'public_key': pubKey, + 'ad_type': adType, + }), + ); + + Completer rewardCompleter = Completer(); + + // 4. Setup full screen callbacks to handle dismissal and errors + _rewardedAd!.fullScreenContentCallback = FullScreenContentCallback( + onAdShowedFullScreenContent: (ad) => debugPrint('[AdManager] Ad showing.'), + onAdDismissedFullScreenContent: (ad) { + debugPrint('[AdManager] Ad dismissed.'); + ad.dispose(); + _isAdLoaded = false; + _rewardedAd = null; + // Start loading the next ad immediately + _preloadAd(); + if (!rewardCompleter.isCompleted) { + rewardCompleter.complete(false); // User closed before earning reward + } + }, + onAdFailedToShowFullScreenContent: (ad, error) { + debugPrint('[AdManager] Ad failed to show: $error'); + ad.dispose(); + _isAdLoaded = false; + _rewardedAd = null; + if (!rewardCompleter.isCompleted) { + rewardCompleter.complete(false); + } + }, + ); + + // 5. Show the ad + await _rewardedAd!.show( + onUserEarnedReward: (AdWithoutView ad, RewardItem reward) { + debugPrint('[AdManager] Reward earned: ${reward.amount} ${reward.type}'); + // The SSV callback will hit our server independently. We just need to signal the UI + // that the ad was successfully completed. + if (!rewardCompleter.isCompleted) { + rewardCompleter.complete(true); + } + }, + ); + + return rewardCompleter.future; + } + + @override + void dispose() { + _rewardedAd?.dispose(); + super.dispose(); + } +} diff --git a/lib/logic/app_config.example.dart b/lib/logic/app_config.example.dart new file mode 100644 index 0000000..d1e21d9 --- /dev/null +++ b/lib/logic/app_config.example.dart @@ -0,0 +1,44 @@ +// ============================================================================= +// app_config.example.dart — TEMPLATE FOR SERVER SETTINGS +// ───────────────────────────────────────────────────────────────────────────── +// Rename this file to `app_config.dart` and fill in your real values below. +// Do not commit your real `app_config.dart` to GitHub! +// ============================================================================= + +class AppConfig { + // ── STEP 1: Paste your Hetzner server IP here ───────────────────────────── + static const String serverIp = '127.0.0.1'; // REPLACE WITH REAL IP + + // ── STEP 2: WireGuard UDP traffic port (default: 51820) ─────────────────── + static const String wgPort = '4433'; + + // ── STEP 3: AmneziaWG DPI Obfuscation Settings ─────────────────────────── + // These parameters must match exactly what your amnezia-wg-easy server expects. + static const int awgJc = 4; + static const int awgJmin = 20; + static const int awgJmax = 100; + static const int awgS1 = 15; + static const int awgS2 = 23; + static const int awgH1 = 1; + static const int awgH2 = 2; + static const int awgH3 = 3; + static const int awgH4 = 4; + + // ── STEP 4: AdMob Rewarded Video ID ────────────────────────────────────── + static const String adUnitId = 'ca-app-pub-0000000000000000/0000000000'; // REPLACE WITH REAL AD UNIT + + // ── STEP 5: Network & Tunnel Settings ──────────────────────────────────── + static const String dnsServers = '1.1.1.1, 1.0.0.1'; + static const String allowedIps = '0.0.0.0/0, ::/0'; + static const int mtu = 1280; + static const int persistentKeepalive = 25; + static const int hivemindPort = 5000; + + // ───────────────────────────────────────────────────────────────────────── + // Nothing below this line needs to be changed. + // ── STEP 1: Hivemind Server API ────────────────────────────────────────── + static String get hivemindApiBase => 'https://yourdomain.duckdns.org/api'; // REPLACE WITH REAL DOMAIN + + /// The full server address passed to the WireGuard tunnel (IP:port). + static String get serverEndpoint => '$serverIp:$wgPort'; +} diff --git a/lib/logic/crypto_service.dart b/lib/logic/crypto_service.dart new file mode 100644 index 0000000..143cf18 --- /dev/null +++ b/lib/logic/crypto_service.dart @@ -0,0 +1,46 @@ +import 'dart:convert'; +import 'package:cryptography/cryptography.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:uuid/uuid.dart'; + +class CryptoService { + static const String _privKeyPref = 'wg_private_key'; + static const String _pubKeyPref = 'wg_public_key'; + static const String _deviceIdPref = 'device_uuid'; + + static const _storage = FlutterSecureStorage(); + + // ── Get or create the persistent device UUID ────────────────────────────── + static Future getDeviceId() async { + final existing = await _storage.read(key: _deviceIdPref); + if (existing != null) return existing; + + final newId = const Uuid().v4(); + await _storage.write(key: _deviceIdPref, value: newId); + return newId; + } + + // ── Get or create the X25519 keypair for WireGuard ──────────────────────── + static Future> getOrCreateKeys() async { + String? privBase64 = await _storage.read(key: _privKeyPref); + String? pubBase64 = await _storage.read(key: _pubKeyPref); + + if (privBase64 != null && pubBase64 != null) { + return {'privateKey': privBase64, 'publicKey': pubBase64}; + } + + final algorithm = X25519(); + final keyPair = await algorithm.newKeyPair(); + + final privateKeyBytes = await keyPair.extractPrivateKeyBytes(); + final publicKey = await keyPair.extractPublicKey(); + + privBase64 = base64Encode(privateKeyBytes); + pubBase64 = base64Encode(publicKey.bytes); + + await _storage.write(key: _privKeyPref, value: privBase64); + await _storage.write(key: _pubKeyPref, value: pubBase64); + + return {'privateKey': privBase64, 'publicKey': pubBase64}; + } +} diff --git a/lib/logic/hivemind_service.dart b/lib/logic/hivemind_service.dart new file mode 100644 index 0000000..803eae0 --- /dev/null +++ b/lib/logic/hivemind_service.dart @@ -0,0 +1,102 @@ +import 'dart:async'; +import 'dart:convert'; +import 'package:http/http.dart' as http; +import 'package:paladinvpn/logic/app_config.dart'; +import 'package:paladinvpn/logic/crypto_service.dart'; + +class HivemindService { + /// Polls the Hivemind server until the session is ready or timeout occurs. + /// Returns the WireGuard config string. + static Future fetchConfigWithPolling() async { + final deviceId = await CryptoService.getDeviceId(); + final keys = await CryptoService.getOrCreateKeys(); + final privKey = keys['privateKey']!; + + final url = Uri.parse('${AppConfig.hivemindApiBase}/session/status?device_id=$deviceId'); + + // ── TEMPORARY AD BYPASS HACK FOR TESTING ── + // Since you disabled the crypto signature check on the Python server, + // we can just send a fake "Ad Watched" ping from the app directly. + try { + final customData = jsonEncode({ + 'device_id': deviceId, + 'public_key': keys['publicKey']!, + 'ad_type': 'main_ad', + }); + final fakeAdmobPingUrl = Uri.parse( + '${AppConfig.hivemindApiBase}/admob/callback?signature=test&key_id=test&custom_data=${Uri.encodeComponent(customData)}'); + await http.get(fakeAdmobPingUrl).timeout(const Duration(seconds: 2)); + } catch (_) {} + // ────────────────────────────────────────── + + int attempts = 0; + while (attempts < 15) { + try { + final response = await http.get(url).timeout(const Duration(seconds: 3)); + if (response.statusCode == 200) { + final data = jsonDecode(response.body); + + // Debugging log to see exactly what the server returned + print('[HivemindService] Polling response: ${response.body}'); + + if (data['active'] == true && data['client_ip'] != null && data['server_pubkey'] != null) { + final clientIp = data['client_ip']; + final serverPubkey = data['server_pubkey']; + + // Construct the WireGuard config locally + // I CHANGED IPS MAY BREAK STUFF + final configText = ''' +[Interface] +PrivateKey = $privKey +Address = $clientIp/24 +DNS = ${AppConfig.dnsServers} +MTU = ${AppConfig.mtu} +Jc = ${AppConfig.awgJc} +Jmin = ${AppConfig.awgJmin} +Jmax = ${AppConfig.awgJmax} +S1 = ${AppConfig.awgS1} +S2 = ${AppConfig.awgS2} +H1 = ${AppConfig.awgH1} +H2 = ${AppConfig.awgH2} +H3 = ${AppConfig.awgH3} +H4 = ${AppConfig.awgH4} + +[Peer] +PublicKey = $serverPubkey +AllowedIPs = ${AppConfig.allowedIps} +Endpoint = ${AppConfig.serverEndpoint} +PersistentKeepalive = ${AppConfig.persistentKeepalive} +'''; + return configText; + } + } else { + print('[HivemindService] Non-200 status: ${response.statusCode}'); + } + } catch (e) { + print('[HivemindService] Polling exception: $e'); + // Ignore network errors and keep polling + } + + attempts++; + await Future.delayed(const Duration(seconds: 2)); + } + + throw Exception("Session not activated. S2S callback failed or timed out."); + } + + /// Disconnects the session (cleans up on server) + static Future disconnectAndCleanup() async { + final deviceId = await CryptoService.getDeviceId(); + final url = Uri.parse('${AppConfig.hivemindApiBase}/session/stop'); + try { + await http.post( + url, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode({'device_id': deviceId}), + ).timeout(const Duration(seconds: 3)); + } catch (_) { + // Best effort cleanup + } + } + +} diff --git a/lib/logic/session_timer.dart b/lib/logic/session_timer.dart new file mode 100644 index 0000000..55a92f3 --- /dev/null +++ b/lib/logic/session_timer.dart @@ -0,0 +1,170 @@ +import 'dart:async'; +import 'dart:convert'; +import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; +import 'package:paladinvpn/logic/app_config.dart'; +import 'package:paladinvpn/logic/vpn_connection.dart'; +import 'package:paladinvpn/logic/crypto_service.dart'; + +/// SessionTimer acts as the client-side session authority for the VPN tunnel. +/// +/// It connects to the Hivemind API on the server to sync the true remaining +/// time, data quotas, speed, and throttling status. +class SessionTimer extends ChangeNotifier { + Timer? _timer; + int _tickCount = 0; + + final VpnConnection vpnConnection; + + // ── Session State ── + int _remainingSeconds = 0; + int _quotaBytes = 0; + int _usedBytes = 0; + bool _isThrottled = false; + + // ── Speed State ── + int _lastUsedBytes = 0; + double _currentSpeedKbps = 0.0; // Total speed (RX+TX) + + SessionTimer({required this.vpnConnection}); + + int get remaining => _remainingSeconds; + bool get isRunning => _timer != null && _timer!.isActive; + bool get isExpired => _remainingSeconds <= 0 && !isRunning; + + int get quotaBytes => _quotaBytes; + int get usedBytes => _usedBytes; + int get remainingBytes => _quotaBytes > _usedBytes ? _quotaBytes - _usedBytes : 0; + bool get isThrottled => _isThrottled; + double get currentSpeedKbps => _currentSpeedKbps; + + String get formatted { + final h = (_remainingSeconds ~/ 3600).toString().padLeft(2, '0'); + final m = ((_remainingSeconds % 3600) ~/ 60).toString().padLeft(2, '0'); + final s = (_remainingSeconds % 60).toString().padLeft(2, '0'); + return '$h:$m:$s'; + } + + String get formattedDataRemaining { + if (_quotaBytes == 0) return '0.00 GB'; + final gb = remainingBytes / (1024 * 1024 * 1024); + return '${gb.toStringAsFixed(2)} GB'; + } + + double get progress { + // Max duration is generally 60 or 90 minutes. We'll base the ring purely on data usage or just standard time + // For UI simplicity, we'll return data progress: + if (_quotaBytes == 0) return 0.0; + return (_quotaBytes - _usedBytes) / _quotaBytes; + } + + Future start(String adType) async { + _remainingSeconds = 3600; // optimistic start + _quotaBytes = 2 * 1024 * 1024 * 1024; + _usedBytes = 0; + _lastUsedBytes = 0; + _currentSpeedKbps = 0.0; + _isThrottled = false; + _tickCount = 0; + + _timer?.cancel(); + _timer = Timer.periodic(const Duration(seconds: 1), _tick); + + // Kick off an immediate sync so the UI shows real data right away + _syncWithHivemind(); + } + + void _tick(Timer t) { + if (_remainingSeconds > 0) { + _remainingSeconds--; + } + + _tickCount++; + // Poll the server every 3 seconds to get live stats + if (_tickCount % 3 == 0) { + _syncWithHivemind(); + } + + notifyListeners(); + } + + Future _syncWithHivemind() async { + try { + final deviceId = await CryptoService.getDeviceId(); + final url = Uri.parse('${AppConfig.hivemindApiBase}/session/status?device_id=$deviceId'); + final response = await http.get(url).timeout(const Duration(seconds: 3)); + + if (response.statusCode == 200) { + final data = jsonDecode(response.body); + + final bool active = data['active'] ?? false; + if (!active) { + // Server killed the session! + stop(); + vpnConnection.disconnect(); + return; + } + + _remainingSeconds = data['expires_in_seconds'] ?? _remainingSeconds; + _quotaBytes = data['quota_bytes'] ?? _quotaBytes; + _usedBytes = data['used_bytes'] ?? _usedBytes; + _isThrottled = data['is_throttled'] ?? false; + + // Calculate speed over the polling interval + final int deltaBytes = _usedBytes - _lastUsedBytes; + if (_lastUsedBytes > 0 && deltaBytes > 0) { + _currentSpeedKbps = (deltaBytes / 3) / 1024; + } else { + _currentSpeedKbps = 0.0; + } + _lastUsedBytes = _usedBytes; + + notifyListeners(); + } else if (response.statusCode == 404) { + // Peer not found on server (expired/deleted natively) + stop(); + vpnConnection.disconnect(); + } + } catch (e) { + debugPrint('Hivemind sync error: $e'); + // If we can't reach the server, just let the local timer run + } + } + + Future addBonusTime() async { + final ip = vpnConnection.internalIp; + if (ip != null) { + try { + final deviceId = await CryptoService.getDeviceId(); + await http.post( + Uri.parse('${AppConfig.hivemindApiBase}/session/start'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode({ + 'ip': ip, + 'device_id': deviceId, + 'ad_type': 'bonus_ad' + }), + ).timeout(const Duration(seconds: 5)); + + // Immediately sync to fetch the updated limits and un-throttled status + _syncWithHivemind(); + } catch (e) { + debugPrint('Failed to add bonus time with Hivemind: $e'); + } + } + } + + void stop() { + _timer?.cancel(); + _timer = null; + _remainingSeconds = 0; + _currentSpeedKbps = 0.0; + notifyListeners(); + } + + @override + void dispose() { + _timer?.cancel(); + super.dispose(); + } +} diff --git a/lib/logic/vpn_connection.dart b/lib/logic/vpn_connection.dart new file mode 100644 index 0000000..a3210cf --- /dev/null +++ b/lib/logic/vpn_connection.dart @@ -0,0 +1,216 @@ +// ============================================================================= +// vpn_connection.dart — WireGuard Tunnel Manager +// ───────────────────────────────────────────────────────────────────────────── +// This file manages the WireGuard tunnel on the Android device. +// It is the bridge between the UI (the big connect button) and the actual +// VPN tunnel running inside the Android operating system. +// +// HOW IT WORKS: +// 1. When connect() is called, it first asks WgApiService to fetch the config +// file for this device from the server (see wg_api_service.dart). +// 2. It passes that config text to the wireguard_flutter plugin, which handles +// all the native Android VPN stuff automatically. +// 3. It listens to the native tunnel state and updates the UI accordingly. +// +// You never need to edit this file. +// Server settings → app_config.dart +// Server communication → wg_api_service.dart +// ============================================================================= + +import 'dart:async'; +import 'package:flutter/foundation.dart'; +import 'package:wireguard_flutter/wireguard_flutter.dart'; +import 'package:paladinvpn/logic/app_config.dart'; +import 'package:paladinvpn/logic/hivemind_service.dart'; + +// ── VPN States ──────────────────────────────────────────────────────────────── +// These are the possible states the tunnel can be in. +// The UI uses these to decide what to display (spinner, shield icon, etc.) +enum VpnStatus { + disconnected, // No tunnel active — idle state + connecting, // Fetching config from server + starting tunnel + connected, // Tunnel is up and routing traffic + disconnecting, // Tearing down the tunnel + error, // Something went wrong (server unreachable, etc.) +} + +// ── VpnConnection ───────────────────────────────────────────────────────────── +// This is a "ChangeNotifier" — it works like a Python observable/event emitter. +// Whenever the state changes, it automatically notifies all widgets that are +// listening, and they re-render themselves. You never call setState() manually. +class VpnConnection extends ChangeNotifier { + // Current tunnel state (starts disconnected) + VpnStatus _status = VpnStatus.disconnected; + VpnStatus get status => _status; + + // Short message shown to the user under the connect button + String _statusMessage = 'Tap to connect'; + String get statusMessage => _statusMessage; + + // Optional error message shown if something goes wrong + String? _errorMessage; + String? get errorMessage => _errorMessage; + + // The internal WireGuard IP assigned to this device (e.g. 10.8.0.2) + // Populated after a successful config fetch and used by SessionTimer. + String? internalIp; + + // The wireguard_flutter plugin instance — this is what actually talks to Android + final _wireguard = WireGuardFlutter.instance; + + // Subscription to the native tunnel state stream + StreamSubscription? _stageSub; + + // ── Initialization ──────────────────────────────────────────────────────── + // Called automatically when the app starts (via Provider in main.dart) + VpnConnection() { + _init(); + } + + Future _init() async { + // WireGuard doesn't work in a web browser — skip on web + if (kIsWeb) return; + + try { + // Register our tunnel interface with Android. + // 'Paladin0' is just the name of the network interface — like "eth0" + await _wireguard.initialize(interfaceName: 'Paladin0'); + } catch (e) { + debugPrint('WireGuard init error (expected on emulator): $e'); + } + + // Start listening to native tunnel state changes from Android. + // When Android reports the tunnel went up/down, _mapStage() is called. + try { + _stageSub = _wireguard.vpnStageSnapshot.listen(_mapStage); + } catch (_) {} + } + + // ── Native State Mapping ────────────────────────────────────────────────── + // Android sends back low-level states like "authenticating", "preparing", etc. + // This function translates those into our simpler 5-state enum above. + void _mapStage(VpnStage stage) { + switch (stage) { + case VpnStage.connected: + _setStatus(VpnStatus.connected, 'Secured'); + case VpnStage.disconnected: + _setStatus(VpnStatus.disconnected, 'Tap to connect'); + case VpnStage.connecting: + case VpnStage.waitingConnection: + case VpnStage.authenticating: + case VpnStage.preparing: + case VpnStage.reconnect: + _setStatus(VpnStatus.connecting, 'Establishing tunnel…'); + case VpnStage.disconnecting: + case VpnStage.exiting: + _setStatus(VpnStatus.disconnecting, 'Tearing down…'); + default: + _setStatus(VpnStatus.disconnected, 'Tap to connect'); + } + } + + // ── Connect ─────────────────────────────────────────────────────────────── + // This is the main function — called after the user watches the rewarded ad. + // + // Step 1: Call the WG-Easy API to get the .conf for this device + // Step 2: Pass that .conf to the WireGuard plugin to start the tunnel + // + // Returns true if the connection was successful, false if it failed. + // The connect button uses this to decide whether to start the timer. + Future connect() async { + // Don't do anything if we're already connected or currently connecting + if (_status == VpnStatus.connected || _status == VpnStatus.connecting) return false; + + _setStatus(VpnStatus.connecting, 'Fetching config…'); + _errorMessage = null; + + // ── Web / emulator fallback ──────────────────────────────────────────── + if (kIsWeb) { + await Future.delayed(const Duration(seconds: 1)); + _setStatus(VpnStatus.connected, 'Secured (dev mode)'); + return true; + } + + + + // ── Step 2: Fetch config from Hivemind ── + String configText; + try { + configText = await HivemindService.fetchConfigWithPolling(); + + // Parse the internal IP (e.g. "Address = 10.8.0.2/24" or "/32") + final regex = RegExp(r'Address\s*=\s*([0-9\.]+)/'); + final match = regex.firstMatch(configText); + if (match != null) { + internalIp = match.group(1); + } + } catch (e) { + debugPrint('Hivemind API error: $e'); + // Set the exact error as the status message so the user can see it on screen + String errorMsg = e.toString().replaceAll('Exception: ', ''); + if (errorMsg.length > 30) { + errorMsg = '${errorMsg.substring(0, 30)}...'; // keep it somewhat short for the UI + } + _errorMessage = e.toString(); + _setStatus(VpnStatus.error, errorMsg); + return false; + } + + // ── Step 2: Start the WireGuard tunnel with the fetched config ───────── + try { + // Re-initialize to ensure VPN permission is granted before starting + await _wireguard.initialize(interfaceName: 'Paladin0'); + await _wireguard.startVpn( + serverAddress: AppConfig.serverEndpoint, + wgQuickConfig: configText, + providerBundleIdentifier: 'com.paladinvpn.app.WireGuardProvider', + ); + _setStatus(VpnStatus.connected, 'Secured'); + return true; + } catch (e) { + debugPrint('WireGuard tunnel error: $e'); + _errorMessage = 'Tunnel failed to start.\nTry reconnecting.'; + _setStatus(VpnStatus.error, 'Tunnel error'); + return false; + } + } + + // ── Disconnect ──────────────────────────────────────────────────────────── + // Tears down the active tunnel and returns Android to normal networking. + Future disconnect() async { + if (_status == VpnStatus.disconnected) return; + + _setStatus(VpnStatus.disconnecting, 'Tearing down…'); + + if (kIsWeb) { + await Future.delayed(const Duration(milliseconds: 500)); + _setStatus(VpnStatus.disconnected, 'Tap to connect'); + return; + } + + try { + await _wireguard.stopVpn(); + } catch (e) { + debugPrint('WireGuard stop error: $e'); + } + + // Clean up the peer from the server so we don't leave dead connections lying around + await HivemindService.disconnectAndCleanup(); + + _setStatus(VpnStatus.disconnected, 'Tap to connect'); + } + + // ── Internal state updater ──────────────────────────────────────────────── + // Updates state and triggers a UI re-render (like calling setState() in Python/Qt) + void _setStatus(VpnStatus s, String msg) { + _status = s; + _statusMessage = msg; + notifyListeners(); // tells all listening widgets to rebuild + } + + @override + void dispose() { + _stageSub?.cancel(); + super.dispose(); + } +} diff --git a/lib/main.dart b/lib/main.dart new file mode 100644 index 0000000..7bcc1ad --- /dev/null +++ b/lib/main.dart @@ -0,0 +1,89 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:provider/provider.dart'; + +import 'package:paladinvpn/logic/vpn_connection.dart'; +import 'package:paladinvpn/logic/session_timer.dart'; +import 'package:paladinvpn/screens/main_screen.dart'; +import 'package:paladinvpn/screens/intro.dart'; + +/// The entry point for the Paladin VPN application. +void main() { + // Ensure that the Flutter engine is fully initialized before using platform channels + // (e.g., for SystemChrome or MobileAds). + WidgetsFlutterBinding.ensureInitialized(); + + // Lock the application to portrait orientation since the dashboard is a single-screen utility app + SystemChrome.setPreferredOrientations([ + DeviceOrientation.portraitUp, + ]); + + // Configure a stealth system bar layout: transparent status bar with light icons, + // blending seamlessly into the dark background gradient. + SystemChrome.setSystemUIOverlayStyle(const SystemUiOverlayStyle( + statusBarColor: Colors.transparent, + statusBarIconBrightness: Brightness.light, + systemNavigationBarColor: Color(0xFF0D1117), // Deep space black + systemNavigationBarIconBrightness: Brightness.light, + )); + + // Inflate the root widget + runApp(const PaladinApp()); +} + +/// The root application widget wrapping the app in necessary state providers and themes. +class PaladinApp extends StatelessWidget { + const PaladinApp({super.key}); + + @override + Widget build(BuildContext context) { + // MultiProvider injects global state controllers down the widget tree. + return MultiProvider( + providers: [ + // Manages the WireGuard tunnel lifecycle + ChangeNotifierProvider(create: (_) => VpnConnection()), + + // Manages the 1-hour session clock. + // Uses ProxyProvider to automatically inject the VpnConnection, + // allowing the timer to auto-disconnect the VPN when time expires. + ChangeNotifierProxyProvider( + create: (ctx) => SessionTimer( + vpnConnection: ctx.read(), + ), + update: (_, vpn, prev) => + prev ?? SessionTimer(vpnConnection: vpn), + ), + ], + child: MaterialApp( + title: 'Paladin VPN', + debugShowCheckedModeBanner: false, + theme: _buildDarkTheme(), + home: const IntroScreen(), + ), + ); + } + + /// Constructs the global dark theme used across the application. + ThemeData _buildDarkTheme() { + const cyanAccent = Color(0xFF00E5FF); + const bgDark = Color(0xFF0D1117); + + return ThemeData( + brightness: Brightness.dark, + scaffoldBackgroundColor: bgDark, + colorScheme: const ColorScheme.dark( + primary: cyanAccent, + secondary: cyanAccent, + surface: Color(0xFF151C28), + ), + fontFamily: 'Roboto', + snackBarTheme: SnackBarThemeData( + backgroundColor: const Color(0xFF1E2533), + contentTextStyle: const TextStyle(color: Colors.white), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + behavior: SnackBarBehavior.floating, + ), + useMaterial3: true, + ); + } +} diff --git a/lib/screens/intro.dart b/lib/screens/intro.dart new file mode 100644 index 0000000..0e75636 --- /dev/null +++ b/lib/screens/intro.dart @@ -0,0 +1,82 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:paladinvpn/screens/main_screen.dart'; + +class IntroScreen extends StatefulWidget { + const IntroScreen({super.key}); + + @override + State createState() => _IntroScreenState(); +} + +class _IntroScreenState extends State { + @override + void initState() { + super.initState(); + // Wait for 2.5 seconds then smoothly transition to the main screen + Timer(const Duration(milliseconds: 2500), () { + if (mounted) { + Navigator.of(context).pushReplacement( + PageRouteBuilder( + transitionDuration: const Duration(milliseconds: 800), + pageBuilder: (_, __, ___) => const MainScreen(), + transitionsBuilder: (_, animation, __, child) { + return FadeTransition(opacity: animation, child: child); + }, + ), + ); + } + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: const Color(0xFF0D1117), // Deep space black background + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // App Logo + Image.asset( + 'assets/logo.png', + width: 140, + height: 140, + errorBuilder: (context, error, stackTrace) { + // Fallback icon if logo.png is missing or misconfigured + return const Icon( + Icons.shield, + size: 140, + color: Color(0xFF00E5FF), + ); + }, + ), + const SizedBox(height: 30), + + // PaladinVPN Text + const Text( + 'PALADIN VPN', + style: TextStyle( + color: Colors.white, + fontSize: 28, + fontWeight: FontWeight.bold, + letterSpacing: 2.0, + ), + ), + const SizedBox(height: 60), + + // Loading Circle + const SizedBox( + width: 40, + height: 40, + child: CircularProgressIndicator( + color: Color(0xFF00E5FF), + strokeWidth: 3.0, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/screens/main_screen.dart b/lib/screens/main_screen.dart new file mode 100644 index 0000000..5baf356 --- /dev/null +++ b/lib/screens/main_screen.dart @@ -0,0 +1,89 @@ +import 'package:flutter/material.dart'; +import 'package:paladinvpn/components/header.dart'; +import 'package:paladinvpn/components/status_text.dart'; +import 'package:paladinvpn/components/connect_button.dart'; +import 'package:paladinvpn/components/bottom_bar.dart'; +import 'package:paladinvpn/components/info_button.dart'; +import 'package:paladinvpn/components/privacy_policy_button.dart'; + +/// MainScreen serves as the primary home screen for Paladin VPN. +/// +/// It provides a dark mode, cyberpunk-inspired visual layout displaying: +/// - Logo and Branding at the top (`Header`) +/// - Session countdown timer displaying remaining time (`ClockDisplay`) +/// - Tap-to-connect action trigger (`ConnectButton`) +/// - Connected status badge displaying tunnel information (`StatusText`) +/// - Bottom bar showing server health status and voluntary support button (`BottomBar`) +class MainScreen extends StatelessWidget { + const MainScreen({super.key}); + + // Background base color + static const Color _bgTop = Color(0xFF0D1117); + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: _bgTop, + body: Container( + width: double.infinity, + height: double.infinity, + decoration: const BoxDecoration( + color: _bgTop, + image: DecorationImage( + image: AssetImage('assets/background.png'), + fit: BoxFit.cover, + colorFilter: ColorFilter.mode( + Color(0xAA0D1117), // Deep dark tint overlay to guarantee readability of overlay elements + BlendMode.darken, + ), + ), + ), + child: SafeArea( + child: Stack( + children: [ + // ── Main content column ── + LayoutBuilder( + builder: (context, constraints) { + return SingleChildScrollView( + physics: const BouncingScrollPhysics(), + child: ConstrainedBox( + constraints: BoxConstraints(minHeight: constraints.maxHeight), + child: const IntrinsicHeight( + child: Column( + children: [ + SizedBox(height: 4), + Header(), + Spacer(flex: 1), + ConnectButton(), + SizedBox(height: 24), + StatusText(), + Spacer(flex: 3), + BottomBar(), + SizedBox(height: 16), + ], + ), + ), + ), + ); + }, + ), + // ── Info / About buttons (top-right) ── + const Positioned( + top: 4, + right: 4, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + PrivacyPolicyButton(), + InfoButton(), + ], + ), + ), + ], + ), + ), + ), + ); + } +} + diff --git a/local_packages/wireguard_flutter/LICENSE b/local_packages/wireguard_flutter/LICENSE new file mode 100644 index 0000000..919942d --- /dev/null +++ b/local_packages/wireguard_flutter/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 PT BILLION JAYA GROUP + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/local_packages/wireguard_flutter/android/build.gradle b/local_packages/wireguard_flutter/android/build.gradle new file mode 100644 index 0000000..22e8344 --- /dev/null +++ b/local_packages/wireguard_flutter/android/build.gradle @@ -0,0 +1,66 @@ +group 'billion.group.wireguard_flutter' +version '1.0-SNAPSHOT' + +buildscript { + ext.kotlin_version = '1.8.10' + repositories { + google() + mavenCentral() + } + + dependencies { + classpath 'com.android.tools.build:gradle:7.1.3' + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + } +} + +allprojects { + repositories { + google() + mavenCentral() + } +} + +apply plugin: 'com.android.library' +apply plugin: 'kotlin-android' + +android { + compileSdkVersion 31 + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + + kotlinOptions { + jvmTarget = '1.8' + } + + sourceSets { + main.java.srcDirs += 'src/main/kotlin' + } + + defaultConfig { + minSdkVersion 21 + } + + namespace 'billion.group.wireguard_flutter' + + packaging { + resources { + excludes += ['META-INF/**'] + } + } +} + +dependencies { + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" + implementation 'com.zaneschepke:amneziawg-android:2.3.7' + implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.2' + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.2" + implementation 'com.beust:klaxon:5.5' + implementation 'androidx.appcompat:appcompat:1.6.1' + + /// to run wireguard on Android 25 or lower + coreLibraryDesugaring "com.android.tools:desugar_jdk_libs:2.0.3" +} \ No newline at end of file diff --git a/local_packages/wireguard_flutter/android/gradle/wrapper/gradle-wrapper.properties b/local_packages/wireguard_flutter/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..41dfb87 --- /dev/null +++ b/local_packages/wireguard_flutter/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-7.4-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/local_packages/wireguard_flutter/android/settings.gradle b/local_packages/wireguard_flutter/android/settings.gradle new file mode 100644 index 0000000..50ac185 --- /dev/null +++ b/local_packages/wireguard_flutter/android/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'wireguard_dart' diff --git a/local_packages/wireguard_flutter/android/src/main/AndroidManifest.xml b/local_packages/wireguard_flutter/android/src/main/AndroidManifest.xml new file mode 100644 index 0000000..a2f47b6 --- /dev/null +++ b/local_packages/wireguard_flutter/android/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/local_packages/wireguard_flutter/android/src/main/kotlin/billion/group/wireguard_flutter/WireguardFlutterPlugin.kt b/local_packages/wireguard_flutter/android/src/main/kotlin/billion/group/wireguard_flutter/WireguardFlutterPlugin.kt new file mode 100644 index 0000000..5eec1e7 --- /dev/null +++ b/local_packages/wireguard_flutter/android/src/main/kotlin/billion/group/wireguard_flutter/WireguardFlutterPlugin.kt @@ -0,0 +1,320 @@ +package billion.group.wireguard_flutter + + +import io.flutter.embedding.engine.plugins.FlutterPlugin +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import io.flutter.plugin.common.MethodChannel.MethodCallHandler +import io.flutter.plugin.common.MethodChannel.Result +import io.flutter.embedding.engine.plugins.activity.ActivityAware +import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding +import io.flutter.plugin.common.PluginRegistry + +import android.app.Activity +import io.flutter.embedding.android.FlutterActivity +import android.content.Intent +import android.content.Context +import android.net.ConnectivityManager +import android.net.NetworkCapabilities +import android.os.Build +import android.util.Log +import com.beust.klaxon.Klaxon +import org.amnezia.awg.backend.* +import org.amnezia.awg.crypto.Key +import org.amnezia.awg.crypto.KeyPair +import io.flutter.plugin.common.EventChannel +import kotlinx.coroutines.* +import java.util.* + + +import kotlinx.coroutines.launch +import java.io.ByteArrayInputStream + +/** WireguardFlutterPlugin */ + +const val PERMISSIONS_REQUEST_CODE = 10014 +const val METHOD_CHANNEL_NAME = "billion.group.wireguard_flutter/wgcontrol" +const val METHOD_EVENT_NAME = "billion.group.wireguard_flutter/wgstage" + +class WireguardFlutterPlugin : FlutterPlugin, MethodCallHandler, ActivityAware, + PluginRegistry.ActivityResultListener { + private lateinit var channel: MethodChannel + private lateinit var events: EventChannel + private lateinit var tunnelName: String + private val futureBackend = CompletableDeferred() + private var vpnStageSink: EventChannel.EventSink? = null + private val scope = CoroutineScope(Job() + Dispatchers.Main.immediate) + private var backend: Backend? = null + private var havePermission = false + private lateinit var context: Context + private var activity: Activity? = null + private var config: org.amnezia.awg.config.Config? = null + private var tunnel: WireGuardTunnel? = null + private val TAG = "NVPN" + var isVpnChecked = false + companion object { + private var state: String = "no_connection" + + fun getStatus(): String { + return state + } + } + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean { + this.havePermission = + (requestCode == PERMISSIONS_REQUEST_CODE) && (resultCode == Activity.RESULT_OK) + return havePermission + } + + override fun onAttachedToActivity(activityPluginBinding: ActivityPluginBinding) { + this.activity = activityPluginBinding.activity as FlutterActivity + } + + override fun onDetachedFromActivityForConfigChanges() { + this.activity = null + } + + override fun onReattachedToActivityForConfigChanges(activityPluginBinding: ActivityPluginBinding) { + this.activity = activityPluginBinding.activity as FlutterActivity + } + + override fun onDetachedFromActivity() { + this.activity = null + } + + override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { + channel = MethodChannel(flutterPluginBinding.binaryMessenger, METHOD_CHANNEL_NAME) + events = EventChannel(flutterPluginBinding.binaryMessenger, METHOD_EVENT_NAME) + context = flutterPluginBinding.applicationContext + + scope.launch(Dispatchers.IO) { + try { + backend = createBackend() + futureBackend.complete(backend!!) + } catch (e: Throwable) { + Log.e(TAG, Log.getStackTraceString(e)) + } + } + + channel.setMethodCallHandler(this) + events.setStreamHandler(object : EventChannel.StreamHandler { + override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { + isVpnChecked = false + vpnStageSink = events + } + + override fun onCancel(arguments: Any?) { + isVpnChecked = false + vpnStageSink = null + } + }) + + } + + private fun createBackend(): Backend { + if (backend == null) { + backend = GoBackend(context, object : TunnelActionHandler { + override fun runPreUp(commands: Collection) {} + override fun runPostUp(commands: Collection) {} + override fun runPreDown(commands: Collection) {} + override fun runPostDown(commands: Collection) {} + }) + } + return backend as Backend + } + + private fun flutterSuccess(result: Result, o: Any) { + scope.launch(Dispatchers.Main) { + result.success(o) + } + } + + private fun flutterError(result: Result, error: String) { + scope.launch(Dispatchers.Main) { + result.error(error, null, null) + } + } + + private fun flutterNotImplemented(result: Result) { + scope.launch(Dispatchers.Main) { + result.notImplemented() + } + } + + override fun onMethodCall(call: MethodCall, result: Result) { + + when (call.method) { + "initialize" -> setupTunnel(call.argument("localizedDescription").toString(), result) + "start" -> { + connect(call.argument("wgQuickConfig").toString(), result) + + if (!isVpnChecked) { + if (isVpnActive()) { + state = "connected" + isVpnChecked = true + println("VPN is active") + } else { + state = "disconnected" + isVpnChecked = true + println("VPN is not active") + } + } + } + "stop" -> { + disconnect(result) + } + "stage" -> { + result.success(getStatus()) + } + "checkPermission" -> { + checkPermission() + result.success(null) + } + else -> flutterNotImplemented(result) + } + } + private fun isVpnActive(): Boolean { + try { + val connectivityManager = + context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + val activeNetwork = connectivityManager.activeNetwork + val networkCapabilities = connectivityManager.getNetworkCapabilities(activeNetwork) + return networkCapabilities?.hasTransport(NetworkCapabilities.TRANSPORT_VPN) == true + } else { + return false + } + } catch (e: Exception) { + Log.e(TAG, "isVpnActive - ERROR - ${e.message}") + return false + } + } + private fun updateStage(stage: String?) { + scope.launch(Dispatchers.Main) { + val updatedStage = stage ?: "no_connection" + state = updatedStage + vpnStageSink?.success(updatedStage.lowercase(Locale.ROOT)) + } + } + private fun updateStageFromState(state: Tunnel.State) { + scope.launch(Dispatchers.Main) { + when (state) { + Tunnel.State.UP -> updateStage("connected") + Tunnel.State.DOWN -> updateStage("disconnected") + else -> updateStage("wait_connection") + } + } + } + private fun disconnect(result: Result) { + scope.launch(Dispatchers.IO) { + try { + if (futureBackend.await().getRunningTunnelNames().isEmpty()) { + updateStage("disconnected") + throw Exception("Tunnel is not running") + } + updateStage("disconnecting") + futureBackend.await().setState( + tunnel(tunnelName) { state -> + scope.launch(Dispatchers.Main) { + Log.i(TAG, "onStateChange - $state") + updateStageFromState(state) + } + }, Tunnel.State.DOWN, config + ) + Log.i(TAG, "Disconnect - success!") + flutterSuccess(result, "") + } catch (e: BackendException) { + Log.e(TAG, "Disconnect - BackendException - ERROR - ${e.reason}", e) + flutterError(result, e.reason.toString()) + } catch (e: Throwable) { + Log.e(TAG, "Disconnect - Can't disconnect from tunnel: ${e.message}") + flutterError(result, e.message.toString()) + } + } + } + + private fun connect(wgQuickConfig: String, result: Result) { + scope.launch(Dispatchers.IO) { + try { + if (!havePermission) { + checkPermission() + throw Exception("Permissions are not given") + } + updateStage("prepare") + val inputStream = ByteArrayInputStream(wgQuickConfig.toByteArray()) + config = org.amnezia.awg.config.Config.parse(inputStream) + updateStage("connecting") + futureBackend.await().setState( + tunnel(tunnelName) { state -> + scope.launch(Dispatchers.Main) { + Log.i(TAG, "onStateChange - $state") + updateStageFromState(state) + } + }, Tunnel.State.UP, config + ) + Log.i(TAG, "Connect - success!") + flutterSuccess(result, "") + } catch (e: BackendException) { + Log.e(TAG, "Connect - BackendException - ERROR - ${e.reason}", e) + flutterError(result, e.reason.toString()) + } catch (e: Throwable) { + Log.e(TAG, "Connect - Can't connect to tunnel: $e", e) + flutterError(result, e.message.toString()) + } + } + } + + private fun setupTunnel(localizedDescription: String, result: Result) { + scope.launch(Dispatchers.IO) { + if (Tunnel.isNameInvalid(localizedDescription)) { + flutterError(result, "Invalid Name") + return@launch + } + tunnelName = localizedDescription + checkPermission() + result.success(null) + } + } + + private fun checkPermission() { + val intent = android.net.VpnService.prepare(this.activity) + if (intent != null) { + havePermission = false + this.activity?.startActivityForResult(intent, PERMISSIONS_REQUEST_CODE) + } else { + havePermission = true + } + } + + override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { + channel.setMethodCallHandler(null) + events.setStreamHandler(null) + isVpnChecked = false + } + + private fun tunnel(name: String, callback: StateChangeCallback? = null): WireGuardTunnel { + if (tunnel == null) { + tunnel = WireGuardTunnel(name, callback) + } + return tunnel as WireGuardTunnel + } +} + +typealias StateChangeCallback = (Tunnel.State) -> Unit + +class WireGuardTunnel( + private val name: String, private val onStateChanged: StateChangeCallback? = null +) : Tunnel { + + override fun getName() = name + + override fun onStateChange(newState: Tunnel.State) { + onStateChanged?.invoke(newState) + } + + override fun isIpv4ResolutionPreferred(): Boolean? = true + + override fun isMetered(): Boolean? = false + +} + diff --git a/local_packages/wireguard_flutter/lib/linux/wireguard_flutter_linux.dart b/local_packages/wireguard_flutter/lib/linux/wireguard_flutter_linux.dart new file mode 100644 index 0000000..9350350 --- /dev/null +++ b/local_packages/wireguard_flutter/lib/linux/wireguard_flutter_linux.dart @@ -0,0 +1,117 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:process_run/shell.dart'; + +import '../wireguard_flutter_platform_interface.dart'; + +class WireGuardFlutterLinux extends WireGuardFlutterInterface { + String? name; + File? configFile; + + VpnStage _stage = VpnStage.noConnection; + final _stageController = StreamController.broadcast(); + void _setStage(VpnStage stage) { + _stage = stage; + _stageController.add(stage); + } + + final shell = Shell(runInShell: true, verbose: kDebugMode); + + @override + Future initialize({required String interfaceName}) async { + name = interfaceName.replaceAll(' ', '_'); + await refreshStage(); + } + + Future get filePath async { + final tempDir = await getTemporaryDirectory(); + return '${tempDir.path}${Platform.pathSeparator}$name.conf'; + } + + @override + Future startVpn({ + required String serverAddress, + required String wgQuickConfig, + required String providerBundleIdentifier, + }) async { + final isAlreadyConnected = await isConnected(); + if (!isAlreadyConnected) { + _setStage(VpnStage.preparing); + } else { + debugPrint('Already connected'); + } + + try { + configFile = await File(await filePath).create(); + await configFile!.writeAsString(wgQuickConfig); + } on PathAccessException { + debugPrint('Denied to write file. Trying to start interface'); + if (isAlreadyConnected) { + return _setStage(VpnStage.connected); + } + + try { + await shell.run('sudo wg-quick up $name'); + } catch (_) { + } finally { + _setStage(VpnStage.denied); + } + } + + if (!isAlreadyConnected) { + _setStage(VpnStage.connecting); + await shell.run('sudo wg-quick up ${configFile?.path ?? await filePath}'); + _setStage(VpnStage.connected); + } + } + + @override + Future stopVpn() async { + assert( + (await isConnected()), + 'Bad state: vpn has not been started. Call startVpn', + ); + _setStage(VpnStage.disconnecting); + try { + await shell + .run('sudo wg-quick down ${configFile?.path ?? (await filePath)}'); + } catch (e) { + await refreshStage(); + rethrow; + } + await refreshStage(); + } + + @override + Future stage() async => _stage; + + @override + Stream get vpnStageSnapshot => _stageController.stream; + + @override + Future refreshStage() async { + if (await isConnected()) { + _setStage(VpnStage.connected); + } else if (name == null) { + _setStage(VpnStage.waitingConnection); + } else if (configFile == null) { + _setStage(VpnStage.noConnection); + } else { + _setStage(VpnStage.disconnected); + } + } + + @override + Future isConnected() async { + assert( + name != null, + 'Bad state: not initialized. Call "initialize" before calling this command', + ); + final processResultList = await shell.run('sudo wg'); + final process = processResultList.first; + return process.outLines.any((line) => line.trim() == 'interface: $name'); + } +} diff --git a/local_packages/wireguard_flutter/lib/wireguard_flutter.dart b/local_packages/wireguard_flutter/lib/wireguard_flutter.dart new file mode 100644 index 0000000..0fa8960 --- /dev/null +++ b/local_packages/wireguard_flutter/lib/wireguard_flutter.dart @@ -0,0 +1,62 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:wireguard_flutter/linux/wireguard_flutter_linux.dart'; +import 'package:wireguard_flutter/wireguard_flutter_method_channel.dart'; + +import 'wireguard_flutter_platform_interface.dart'; + +export 'wireguard_flutter_platform_interface.dart' show VpnStage; + +class WireGuardFlutter extends WireGuardFlutterInterface { + static WireGuardFlutterInterface? __instance; + static WireGuardFlutterInterface get _instance => __instance!; + static WireGuardFlutterInterface get instance { + registerWith(); + return _instance; + } + + static void registerWith() { + if (__instance == null) { + if (kIsWeb) { + throw UnsupportedError('The web platform is not supported'); + } else if (Platform.isLinux) { + __instance = WireGuardFlutterLinux(); + } else { + __instance = WireGuardFlutterMethodChannel(); + } + } + } + + WireGuardFlutter._(); + + @override + Stream get vpnStageSnapshot => _instance.vpnStageSnapshot; + + @override + Future initialize({required String interfaceName}) { + return _instance.initialize(interfaceName: interfaceName); + } + + @override + Future startVpn({ + required String serverAddress, + required String wgQuickConfig, + required String providerBundleIdentifier, + }) async { + return _instance.startVpn( + serverAddress: serverAddress, + wgQuickConfig: wgQuickConfig, + providerBundleIdentifier: providerBundleIdentifier, + ); + } + + @override + Future stopVpn() => _instance.stopVpn(); + + @override + Future refreshStage() => _instance.refreshStage(); + + @override + Future stage() => _instance.stage(); +} diff --git a/local_packages/wireguard_flutter/lib/wireguard_flutter_method_channel.dart b/local_packages/wireguard_flutter/lib/wireguard_flutter_method_channel.dart new file mode 100644 index 0000000..6388f5e --- /dev/null +++ b/local_packages/wireguard_flutter/lib/wireguard_flutter_method_channel.dart @@ -0,0 +1,60 @@ +import 'package:flutter/services.dart'; + +import 'wireguard_flutter_platform_interface.dart'; + +class WireGuardFlutterMethodChannel extends WireGuardFlutterInterface { + static const _methodChannelVpnControl = + "billion.group.wireguard_flutter/wgcontrol"; + static const _methodChannel = MethodChannel(_methodChannelVpnControl); + static const _eventChannelVpnStage = + 'billion.group.wireguard_flutter/wgstage'; + static const _eventChannel = EventChannel(_eventChannelVpnStage); + + @override + Stream get vpnStageSnapshot => + _eventChannel.receiveBroadcastStream().map( + (event) => event == VpnStage.denied.code + ? VpnStage.disconnected + : VpnStage.values.firstWhere( + (stage) => stage.code == event, + orElse: () => VpnStage.noConnection, + ), + ); + + @override + Future initialize({required String interfaceName}) { + return _methodChannel.invokeMethod("initialize", { + "localizedDescription": interfaceName, + "win32ServiceName": interfaceName, + }); + } + + @override + Future startVpn({ + required String serverAddress, + required String wgQuickConfig, + required String providerBundleIdentifier, + }) async { + return _methodChannel.invokeMethod("start", { + "serverAddress": serverAddress, + "wgQuickConfig": wgQuickConfig, + "providerBundleIdentifier": providerBundleIdentifier, + }); + } + + @override + Future stopVpn() => _methodChannel.invokeMethod('stop'); + + @override + Future refreshStage() => _methodChannel.invokeMethod("refresh"); + + @override + Future stage() => _methodChannel.invokeMethod("stage").then( + (value) => value != null + ? VpnStage.values.firstWhere( + (stage) => stage.code == value.toString(), + orElse: () => VpnStage.disconnected, + ) + : VpnStage.disconnected, + ); +} diff --git a/local_packages/wireguard_flutter/lib/wireguard_flutter_platform_interface.dart b/local_packages/wireguard_flutter/lib/wireguard_flutter_platform_interface.dart new file mode 100644 index 0000000..3264554 --- /dev/null +++ b/local_packages/wireguard_flutter/lib/wireguard_flutter_platform_interface.dart @@ -0,0 +1,36 @@ +abstract class WireGuardFlutterInterface { + Stream get vpnStageSnapshot; + + Future initialize({required String interfaceName}); + + Future startVpn({ + required String serverAddress, + required String wgQuickConfig, + required String providerBundleIdentifier, + }); + + Future stopVpn(); + + Future refreshStage(); + Future stage(); + Future isConnected() => + stage().then((stage) => stage == VpnStage.connected); +} + +enum VpnStage { + connected('connected'), + connecting('connecting'), + disconnecting('disconnecting'), + disconnected('disconnected'), + waitingConnection('wait_connection'), + authenticating('authenticating'), + reconnect('reconnect'), + noConnection('no_connection'), + preparing('prepare'), + denied('denied'), + exiting('exiting'); + + final String code; + + const VpnStage(this.code); +} diff --git a/local_packages/wireguard_flutter/pubspec.yaml b/local_packages/wireguard_flutter/pubspec.yaml new file mode 100644 index 0000000..018b7c9 --- /dev/null +++ b/local_packages/wireguard_flutter/pubspec.yaml @@ -0,0 +1,28 @@ +name: wireguard_flutter +description: Wireguard Flutter SDK +version: 0.1.3 +homepage: https://flutterflux.com +repository: https://github.com/Caqil/wireguard_flutter + +environment: + sdk: '>=3.0.0 <4.0.0' + flutter: '>=3.13.7' + +dependencies: + flutter: + sdk: flutter + path_provider: ^2.1.2 + plugin_platform_interface: ^2.0.2 + process_run: ^0.14.0+1 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^2.0.0 + +flutter: + plugin: + platforms: + android: + package: billion.group.wireguard_flutter + pluginClass: WireguardFlutterPlugin diff --git a/pubspec.lock b/pubspec.lock new file mode 100644 index 0000000..8e40e06 --- /dev/null +++ b/pubspec.lock @@ -0,0 +1,729 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.dev" + source: hosted + version: "2.13.1" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + characters: + dependency: transitive + description: + name: characters + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + url: "https://pub.dev" + source: hosted + version: "1.4.1" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: "67cf6d84013f9c601e42a6f8a6b74c4c0d9dc1a1619d775f2b28b732d3551b85" + url: "https://pub.dev" + source: hosted + version: "1.2.0" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + cryptography: + dependency: "direct main" + description: + name: cryptography + sha256: "3eda3029d34ec9095a27a198ac9785630fe525c0eb6a49f3d575272f8e792ef0" + url: "https://pub.dev" + source: hosted + version: "2.9.0" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd" + url: "https://pub.dev" + source: hosted + version: "1.0.9" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + ffi_leak_tracker: + dependency: transitive + description: + name: ffi_leak_tracker + sha256: "4093d4ef9ca06ffe2786e73bfb25e22aa92112b9bb4ec941f11e3e6b61489a97" + url: "https://pub.dev" + source: hosted + version: "0.1.2" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "9e8c3858111da373efc5aa341de011d9bd23e2c5c5e0c62bccf32438e192d7b1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + flutter_secure_storage: + dependency: "direct main" + description: + name: flutter_secure_storage + sha256: "7686b1d6a29985dcbb808c59518226e603e3bfa7c0ddfd1a0d00e4cda77c868e" + url: "https://pub.dev" + source: hosted + version: "10.3.1" + flutter_secure_storage_darwin: + dependency: transitive + description: + name: flutter_secure_storage_darwin + sha256: "82329fa5cdf343773b1b6897dea959105a29f092454259edff92f9f6637e8149" + url: "https://pub.dev" + source: hosted + version: "0.3.2" + flutter_secure_storage_linux: + dependency: transitive + description: + name: flutter_secure_storage_linux + sha256: a5f35ddab43cf5c8215d2feb4ce1957851f28c5c37e6f04335066a0602087bf5 + url: "https://pub.dev" + source: hosted + version: "3.0.1" + flutter_secure_storage_platform_interface: + dependency: transitive + description: + name: flutter_secure_storage_platform_interface + sha256: "8ceea1223bee3c6ac1a22dabd8feefc550e4729b3675de4b5900f55afcb435d6" + url: "https://pub.dev" + source: hosted + version: "2.0.1" + flutter_secure_storage_web: + dependency: transitive + description: + name: flutter_secure_storage_web + sha256: "073a62b3aeb866ab4ce795f960413948e51e5a42a9b0c8333b6daf5bb3208a1c" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + flutter_secure_storage_windows: + dependency: transitive + description: + name: flutter_secure_storage_windows + sha256: "471951813a97006d899db4948acc654a4f28c440083ea08178935ce20b173ec1" + url: "https://pub.dev" + source: hosted + version: "4.2.2" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + google_fonts: + dependency: "direct main" + description: + name: google_fonts + sha256: ba03d03bcaa2f6cb7bd920e3b5027181db75ab524f8891c8bc3aa603885b8055 + url: "https://pub.dev" + source: hosted + version: "6.3.3" + google_mobile_ads: + dependency: "direct main" + description: + name: google_mobile_ads + sha256: d2ef5ec1e1f31137fc241bdeab3037c31062d387dd221fd884fb1160444c788b + url: "https://pub.dev" + source: hosted + version: "4.0.0" + hooks: + dependency: transitive + description: + name: hooks + sha256: a41af4e8fc687cd6d33de9751eb936c8c0204ebe2bcb6c15ecf707504bf47f31 + url: "https://pub.dev" + source: hosted + version: "2.0.0" + http: + dependency: "direct main" + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + jni: + dependency: transitive + description: + name: jni + sha256: c2230682d5bc2362c1c9e8d3c7f406d9cbba23ab3f2e203a025dd47e0fb2e68f + url: "https://pub.dev" + source: hosted + version: "1.0.0" + jni_flutter: + dependency: transitive + description: + name: jni_flutter + sha256: "8b59e590786050b1cd866677dddaf76b1ade5e7bc751abe04b86e84d379d3ba6" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: cbf8d4b858bb0134ef3ef87841abdf8d63bfc255c266b7bf6b39daa1085c4290 + url: "https://pub.dev" + source: hosted + version: "3.0.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + url: "https://pub.dev" + source: hosted + version: "0.12.19" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + url: "https://pub.dev" + source: hosted + version: "0.13.0" + meta: + dependency: transitive + description: + name: meta + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + url: "https://pub.dev" + source: hosted + version: "1.18.0" + nested: + dependency: transitive + description: + name: nested + sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: "6cb691c686fa2838c6deb34980d426145c2a5d537491cb83d463c33cdbc726ed" + url: "https://pub.dev" + source: hosted + version: "9.4.1" + package_config: + dependency: transitive + description: + name: package_config + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + url: "https://pub.dev" + source: hosted + version: "2.2.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + path_provider: + dependency: transitive + description: + name: path_provider + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + url: "https://pub.dev" + source: hosted + version: "2.1.5" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd" + url: "https://pub.dev" + source: hosted + version: "2.3.1" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" + url: "https://pub.dev" + source: hosted + version: "2.6.0" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.dev" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + process_run: + dependency: transitive + description: + name: process_run + sha256: "8d9c6198b98fbbfb511edd42e7364e24d85c163e47398919871b952dc86a423e" + url: "https://pub.dev" + source: hosted + version: "0.14.2" + provider: + dependency: "direct main" + description: + name: provider + sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272" + url: "https://pub.dev" + source: hosted + version: "6.1.5+1" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + record_use: + dependency: transitive + description: + name: record_use + sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed" + url: "https://pub.dev" + source: hosted + version: "0.6.0" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf + url: "https://pub.dev" + source: hosted + version: "2.5.5" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: e8d4762b1e2e8578fc4d0fd548cebf24afd24f49719c08974df92834565e2c53 + url: "https://pub.dev" + source: hosted + version: "2.4.23" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f" + url: "https://pub.dev" + source: hosted + version: "2.5.6" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + synchronized: + dependency: transitive + description: + name: synchronized + sha256: "63896c27e81b28f8cb4e69ead0d3e8f03f1d1e5fc531a3e579cabed6a2c7c9e5" + url: "https://pub.dev" + source: hosted + version: "3.4.0+1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" + url: "https://pub.dev" + source: hosted + version: "0.7.11" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + url_launcher: + dependency: "direct main" + description: + name: url_launcher + sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 + url: "https://pub.dev" + source: hosted + version: "6.3.2" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: "17bc677f0b301615530dd1d67e0a9828cafa2d0b6b6eae4cd3679b7eac4a273c" + url: "https://pub.dev" + source: hosted + version: "6.3.30" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0" + url: "https://pub.dev" + source: hosted + version: "6.4.1" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a + url: "https://pub.dev" + source: hosted + version: "3.2.2" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18" + url: "https://pub.dev" + source: hosted + version: "3.2.5" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34" + url: "https://pub.dev" + source: hosted + version: "2.4.3" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f" + url: "https://pub.dev" + source: hosted + version: "3.1.5" + uuid: + dependency: "direct main" + description: + name: uuid + sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489" + url: "https://pub.dev" + source: hosted + version: "4.5.3" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + url: "https://pub.dev" + source: hosted + version: "2.2.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360" + url: "https://pub.dev" + source: hosted + version: "15.2.0" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + webview_flutter: + dependency: transitive + description: + name: webview_flutter + sha256: ec81f57aa1611f8ebecf1d2259da4ef052281cb5ad624131c93546c79ccc7736 + url: "https://pub.dev" + source: hosted + version: "4.9.0" + webview_flutter_android: + dependency: transitive + description: + name: webview_flutter_android + sha256: "47a8da40d02befda5b151a26dba71f47df471cddd91dfdb7802d0a87c5442558" + url: "https://pub.dev" + source: hosted + version: "3.16.9" + webview_flutter_platform_interface: + dependency: transitive + description: + name: webview_flutter_platform_interface + sha256: "1221c1b12f5278791042f2ec2841743784cf25c5a644e23d6680e5d718824f04" + url: "https://pub.dev" + source: hosted + version: "2.15.1" + webview_flutter_wkwebview: + dependency: transitive + description: + name: webview_flutter_wkwebview + sha256: "82648217f537573e1ca9ae9952d3eacedca6ab5aee69dc84445fc763766dcea2" + url: "https://pub.dev" + source: hosted + version: "3.25.1" + win32: + dependency: transitive + description: + name: win32 + sha256: ba6f4bba816c8d7e3c1580e170f3786d216951cc6b94babc3b814c08d2cb2738 + url: "https://pub.dev" + source: hosted + version: "6.3.0" + wireguard_flutter: + dependency: "direct main" + description: + path: "local_packages/wireguard_flutter" + relative: true + source: path + version: "0.1.3" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" +sdks: + dart: ">=3.11.0 <4.0.0" + flutter: ">=3.38.4" diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..d3c2148 --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,85 @@ +name: paladinvpn +description: Paladin VPN — Minimalist high-performance WireGuard tunnel + +# The following line prevents the package from being accidentally published to +# pub.dev using `pub publish`. This is preferred for private packages. +publish_to: "none" # Remove this line if you wish to publish to pub.dev + +# The following defines the version and build number for your application. +# A version number is three numbers separated by dots, like 1.2.43 +# followed by an optional build number separated by a +. +# Both the version and the builder number may be overridden in flutter +# build by specifying --build-name and --build-number, respectively. +# In Android, build-name is used as versionName while build-number used as versionCode. +# Read more about Android versioning at https://developer.android.com/studio/publish/versioning +# In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. +# Read more about iOS versioning at +# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html +version: 1.0.1+2 + +environment: + sdk: ">=3.0.0 <4.0.0" + +dependencies: + flutter: + sdk: flutter + + # The following adds the Cupertino Icons font to your application. + # Use with the CupertinoIcons class for iOS style icons. + cupertino_icons: ^1.0.5 + google_fonts: ^6.1.0 + provider: ^6.1.1 + google_mobile_ads: ^4.0.0 + wireguard_flutter: + path: local_packages/wireguard_flutter + http: ^1.6.0 + shared_preferences: ^2.5.5 + cryptography: ^2.9.0 + url_launcher: ^6.3.2 + flutter_secure_storage: ^10.3.1 + uuid: ^4.5.3 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^3.0.0 + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter. +flutter: + # The following line ensures that the Material Icons font is + # included with your application, so that you can use the icons in + # the material Icons class. + uses-material-design: true + + # To add assets to your application, add an assets section, like this: + assets: + - assets/ + + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/assets-and-images/#resolution-aware. + + # For details regarding adding assets from package dependencies, see + # https://flutter.dev/assets-and-images/#from-packages + + # To add custom fonts to your application, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts from package dependencies, + # see https://flutter.dev/custom-fonts/#from-packages