Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 15 additions & 4 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -119,14 +119,14 @@ jobs:
uses: actions/checkout@v6

- name: Import Apple Codesign Certificates
if: ${{ inputs.upload-artifact && startsWith(matrix.os,'macos') }}
if: ${{ inputs.upload-artifact && startsWith(matrix.os,'macos') && secrets.APPLE_CERTIFICATE_P12 != '' }}
uses: apple-actions/import-codesign-certs@v6
with:
p12-file-base64: "${{ secrets.APPLE_CERTIFICATE_P12 }}"
p12-password: "${{ secrets.APPLE_CERTIFICATE_P12_PASSWORD }}"

- name: Import Apple Mobile Provisioning Profile
if: ${{ inputs.upload-artifact && startsWith(matrix.os,'macos') }}
if: ${{ inputs.upload-artifact && startsWith(matrix.os,'macos') && secrets.APPLE_MOBILE_PROVISIONING_PROFILES_TARGZ_BASE64 != '' }}
run: |
mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
echo "${{secrets.APPLE_MOBILE_PROVISIONING_PROFILES_TARGZ_BASE64}}"|base64 --decode | tar xz -C ~/Library/MobileDevice/Provisioning\ Profiles
Expand Down Expand Up @@ -309,6 +309,10 @@ jobs:
cp ./build/app/outputs/flutter-apk/*armeabi-v7a*.apk out/${TARGET_NAME_apk}-arm7.apk || echo "no arm7 apk"
cp ./build/app/outputs/flutter-apk/*x86_64*.apk out/${TARGET_NAME_apk}-x86_64.apk || echo "no x64 apk"
cp ./build/app/outputs/flutter-apk/app-release.apk out/${TARGET_NAME_apk}-universal.apk || echo "no universal apk"
cp ./build/app/outputs/apk/release/*arm64-v8a*.apk out/${TARGET_NAME_apk}-arm64.apk || echo "no arm64 apk in release"
cp ./build/app/outputs/apk/release/*armeabi-v7a*.apk out/${TARGET_NAME_apk}-arm7.apk || echo "no arm7 apk in release"
cp ./build/app/outputs/apk/release/*x86_64*.apk out/${TARGET_NAME_apk}-x86_64.apk || echo "no x64 apk in release"
cp ./build/app/outputs/apk/release/app-release.apk out/${TARGET_NAME_apk}-universal.apk || echo "no universal apk in release"

- name: Copy to out Android AAB
if: matrix.platform == 'android-aab'
Expand Down Expand Up @@ -383,8 +387,10 @@ jobs:


- name: Display Files Structure
run: ls -R
working-directory: ./out
run: |
mkdir -p out
ls -R
working-directory: ./

- name: Delete Current Release Assets
uses: 8Mi-Tech/delete-release-assets-action@main
Expand Down Expand Up @@ -495,17 +501,20 @@ jobs:
path: ./out/

- uses: Apple-Actions/import-codesign-certs@v6
if: ${{ secrets.APPLE_UPLOAD_CERTIFICATE_P12 != '' }}
with:
p12-file-base64: ${{ secrets.APPLE_UPLOAD_CERTIFICATE_P12 }}
p12-password: ${{ secrets.APPLE_CERTIFICATE_P12_PASSWORD }}

- uses: Apple-Actions/download-provisioning-profiles@v1
if: ${{ secrets.APPSTORE_ISSUER_ID != '' }}
with:
bundle-id: app.hiddify.com
issuer-id: ${{ secrets.APPSTORE_ISSUER_ID }}
api-key-id: ${{ secrets.APPSTORE_API_KEY_ID }}
api-private-key: ${{ secrets.APPSTORE_API_PRIVATE_KEY }}
- uses: Apple-Actions/download-provisioning-profiles@v1
if: ${{ secrets.APPSTORE_ISSUER_ID != '' }}
with:
bundle-id: app.hiddify.com.SingBoxPacketTunnel
issuer-id: ${{ secrets.APPSTORE_ISSUER_ID }}
Expand All @@ -515,12 +524,14 @@ jobs:


- name: Import Apple Mobile Provisioning Profile
if: ${{ secrets.APPLE_DIST_PROVISIONING_PROFILES_TARGZ_BASE64 != '' }}
run: |
mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
echo "${{secrets.APPLE_DIST_PROVISIONING_PROFILES_TARGZ_BASE64}}"|base64 --decode | tar xz -C ~/Library/MobileDevice/Provisioning\ Profiles
#echo "${{secrets.NEW_APPLE_STORE_PROVISIONING_PROFILES_TARXZ_BASE64}}"|base64 --decode | tar xJ -C ~/Library/MobileDevice/Provisioning\ Profiles

- uses: Apple-Actions/upload-testflight-build@v4
if: ${{ secrets.APPSTORE_ISSUER_ID != '' }}
with:
app-path: 'out/Hiddify-iOS.ipa'
issuer-id: ${{ secrets.APPSTORE_ISSUER_ID }}
Expand Down
6 changes: 3 additions & 3 deletions .gitmodules
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
[submodule "hiddify-core"]
path = hiddify-core
url = ssh://git@github.com/hiddify/hiddify-core
branch = v3
path = hiddify-core
url = https://github.com/hiddify/hiddify-core.git
branch = v3
30 changes: 30 additions & 0 deletions lib/core/battery_saver/battery_saver_service.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import 'package:battery_plus/battery_plus.dart';

class BatterySaverService {
final Battery _battery = Battery();

Future<bool> isBatteryLow() async {
try {
final level = await _battery.batteryLevel;
final state = await _battery.batteryState;
return level < 20 && state != BatteryState.charging && state != BatteryState.full;
} catch (e) {
// In case of error (e.g. running on unsupported platform), assume false
return false;
}
}

Future<bool> isInPowerSaveMode() async {
try {
return await _battery.isInBatterySaveMode;
} catch (e) {
return false;
}
}

Future<bool> shouldPauseHeavyTasks() async {
final isLow = await isBatteryLow();
final isSaving = await isInPowerSaveMode();
return isLow || isSaving;
}
}
64 changes: 64 additions & 0 deletions lib/core/caching/benchmark_cache_manager.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import 'package:hiddify/core/battery_saver/battery_saver_service.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';

part 'benchmark_cache_manager.g.dart';

@Riverpod(keepAlive: true)
BenchmarkCacheManager benchmarkCacheManager(BenchmarkCacheManagerRef ref) {
return BenchmarkCacheManager();
}

class BenchmarkCacheManager {
final Map<String, _BenchmarkResult> _cache = {};
final BatterySaverService _batterySaver = BatterySaverService();

Future<bool> shouldBenchmark(String tag, {bool force = false}) async {
if (force) return true;

if (await _batterySaver.shouldPauseHeavyTasks()) {
return false; // Skip entirely if battery is low or saving power
}

final entry = _cache[tag];
if (entry == null) return true; // New node

if (entry.hasFailed) return true; // Failed node

if (entry.isDegraded) return true; // Degraded performance

return entry.isExpired;
}

void updateCache(String tag, int delay, {bool isDegraded = false, bool hasFailed = false}) {
_cache[tag] = _BenchmarkResult(
timestamp: DateTime.now(),
delay: delay,
isDegraded: isDegraded,
hasFailed: hasFailed,
);
}

int? getCachedDelay(String tag) => _cache[tag]?.delay;

void clearCache() {
_cache.clear();
}
}

class _BenchmarkResult {
final DateTime timestamp;
final int delay;
final bool isDegraded;
final bool hasFailed;

_BenchmarkResult({
required this.timestamp,
required this.delay,
this.isDegraded = false,
this.hasFailed = false,
});

bool get isExpired {
return DateTime.now().difference(timestamp) > const Duration(minutes: 10);
}
}
76 changes: 76 additions & 0 deletions lib/core/diagnostics/smart_diagnostics_service.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import 'dart:async';
import 'package:hiddify/utils/custom_loggers.dart';

enum DiagnosticIssueType { dns, mtu, ipv6, routing, general }

class DiagnosticResult {
final DiagnosticIssueType type;
final String description;
final String suggestion;

DiagnosticResult({
required this.type,
required this.description,
required this.suggestion,
});
}

class SmartDiagnosticsService with AppLogger {
Future<List<DiagnosticResult>> analyzeConnectionError(dynamic error, String? stackTrace) async {
loggy.info('Analyzing connection error: $error');
final results = <DiagnosticResult>[];
final errorString = error.toString().toLowerCase();

if (errorString.contains('dns') || errorString.contains('resolve')) {
results.add(
DiagnosticResult(
type: DiagnosticIssueType.dns,
description: 'DNS resolution failed. The application could not resolve the server address.',
suggestion: 'Try changing your DNS server settings (e.g., to 1.1.1.1 or 8.8.8.8) or disable IPv6 if your network does not support it.',
),
);
}

if (errorString.contains('mtu') || errorString.contains('fragment')) {
results.add(
DiagnosticResult(
type: DiagnosticIssueType.mtu,
description: 'MTU size issue detected. Packets may be dropping because they are too large.',
suggestion: 'Lower the MTU size in the connection settings. A value of 1280 to 1420 often helps.',
),
);
}

if (errorString.contains('ipv6') || errorString.contains('network is unreachable')) {
results.add(
DiagnosticResult(
type: DiagnosticIssueType.ipv6,
description: 'IPv6 connectivity issue detected.',
suggestion: 'Try forcing IPv4 in the settings if your ISP does not fully support IPv6.',
),
);
}

if (errorString.contains('route') || errorString.contains('no route to host')) {
results.add(
DiagnosticResult(
type: DiagnosticIssueType.routing,
description: 'Routing problem detected. The connection cannot reach the server.',
suggestion: 'Check if you are on a restricted network. You might need to change the node or enable a proxy.',
),
);
}

if (results.isEmpty) {
results.add(
DiagnosticResult(
type: DiagnosticIssueType.general,
description: 'A general connection error occurred.',
suggestion: 'Check your internet connection, try a different node, or update the app.',
),
);
}

return results;
}
}
81 changes: 81 additions & 0 deletions lib/features/cloudflare_scanner/cloudflare_scanner_service.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import 'dart:async';
import 'package:dart_ping/dart_ping.dart';
import 'package:hiddify/core/battery_saver/battery_saver_service.dart';
import 'package:hiddify/features/cloudflare_scanner/model/cloudflare_ip_info.dart';
import 'package:hiddify/utils/custom_loggers.dart';

class CloudflareScannerService with AppLogger {
List<String> candidateIps = [
'1.1.1.1',
'1.0.0.1',
'104.16.132.229',
'104.16.133.229',
'104.16.134.229',
];

final BatterySaverService _batterySaver = BatterySaverService();
final Map<String, CloudflareIpInfo> _scanCache = {};

Future<CloudflareIpInfo?> getBestIp({bool forceScan = false}) async {
if (!forceScan && await _batterySaver.shouldPauseHeavyTasks()) {
loggy.info('Skipping Cloudflare IP scan due to battery constraints');
return _getBestCachedIp();
}

final bestCached = _getBestCachedIp();
if (!forceScan && bestCached != null && bestCached.isReachable && bestCached.latency < 200) {
loggy.debug('Reusing cached Cloudflare IP: ${bestCached.ip}');
return bestCached;
}

loggy.info('Starting Cloudflare IP scan');
final results = <CloudflareIpInfo>[];

for (final ip in candidateIps) {
final ping = Ping(ip, count: 3, timeout: 2);
int totalTime = 0;
int receivedCount = 0;

await for (final data in ping.stream) {
if (data.response != null && data.response!.time != null) {
totalTime += data.response!.time!.inMilliseconds;
receivedCount++;
}
}

int packetLoss = receivedCount > 0 ? ((3 - receivedCount) / 3 * 100).toInt() : 100;
int latency = receivedCount > 0 ? (totalTime / receivedCount).toInt() : -1;

final info = CloudflareIpInfo(ip: ip, latency: latency, packetLoss: packetLoss);
_scanCache[ip] = info;

if (info.isReachable) {
results.add(info);
}
}

if (results.isEmpty) {
loggy.warning('No reachable Cloudflare IP found');
return null;
}

results.sort((a, b) {
if (a.packetLoss != b.packetLoss) return a.packetLoss.compareTo(b.packetLoss);
return a.latency.compareTo(b.latency);
});

return results.first;
}

CloudflareIpInfo? _getBestCachedIp() {
if (_scanCache.isEmpty) return null;
final cachedReachable = _scanCache.values.where((info) => info.isReachable).toList();
if (cachedReachable.isEmpty) return null;

cachedReachable.sort((a, b) {
if (a.packetLoss != b.packetLoss) return a.packetLoss.compareTo(b.packetLoss);
return a.latency.compareTo(b.latency);
});
return cachedReachable.first;
}
}
13 changes: 13 additions & 0 deletions lib/features/cloudflare_scanner/model/cloudflare_ip_info.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
class CloudflareIpInfo {
final String ip;
final int latency;
final int packetLoss;

CloudflareIpInfo({
required this.ip,
required this.latency,
required this.packetLoss,
});

bool get isReachable => packetLoss < 100 && latency > 0;
}
Loading