Add android dynamic notification

This commit is contained in:
problematicconsumer
2023-12-14 14:50:10 +03:30
parent b9f1e83473
commit af64efec00
19 changed files with 251 additions and 95 deletions

View File

@@ -37,6 +37,7 @@ class Application : Application() {
val connectivity by lazy { application.getSystemService<ConnectivityManager>()!! }
val packageManager by lazy { application.packageManager }
val powerManager by lazy { application.getSystemService<PowerManager>()!! }
val notificationManager by lazy { application.getSystemService<NotificationManager>()!! }
}
}

View File

@@ -2,12 +2,10 @@ package com.hiddify.hiddify
import android.util.Log
import com.hiddify.hiddify.bg.BoxService
import com.hiddify.hiddify.constant.Alert
import com.hiddify.hiddify.constant.Status
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.StandardMethodCodec
import io.nekohasekai.libbox.Libbox
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -92,6 +90,7 @@ class MethodHandler(private val scope: CoroutineScope) : FlutterPlugin,
result.runCatching {
val args = call.arguments as Map<*, *>
Settings.activeConfigPath = args["path"] as String? ?: ""
Settings.activeProfileName = args["name"] as String? ?: ""
val mainActivity = MainActivity.instance
val started = mainActivity.serviceStatus.value == Status.Started
if (started) {
@@ -124,6 +123,7 @@ class MethodHandler(private val scope: CoroutineScope) : FlutterPlugin,
result.runCatching {
val args = call.arguments as Map<*, *>
Settings.activeConfigPath = args["path"] as String? ?: ""
Settings.activeProfileName = args["name"] as String? ?: ""
val mainActivity = MainActivity.instance
val started = mainActivity.serviceStatus.value == Status.Started
if (!started) return@launch success(true)

View File

@@ -51,6 +51,10 @@ object Settings {
get() = preferences.getString(SettingsKey.ACTIVE_CONFIG_PATH, "")!!
set(value) = preferences.edit().putString(SettingsKey.ACTIVE_CONFIG_PATH, value).apply()
var activeProfileName: String
get() = preferences.getString(SettingsKey.ACTIVE_PROFILE_NAME, "")!!
set(value) = preferences.edit().putString(SettingsKey.ACTIVE_PROFILE_NAME, value).apply()
var serviceMode: String
get() = preferences.getString(SettingsKey.SERVICE_MODE, ServiceMode.NORMAL)!!
set(value) = preferences.edit().putString(SettingsKey.SERVICE_MODE, value).apply()
@@ -71,6 +75,11 @@ object Settings {
set(value) =
preferences.edit().putBoolean(SettingsKey.DISABLE_MEMORY_LIMIT, value).apply()
var dynamicNotification: Boolean
get() = preferences.getBoolean(SettingsKey.DYNAMIC_NOTIFICATION, true)
set(value) =
preferences.edit().putBoolean(SettingsKey.DYNAMIC_NOTIFICATION, value).apply()
var systemProxyEnabled: Boolean
get() = preferences.getBoolean(SettingsKey.SYSTEM_PROXY_ENABLED, true)
set(value) =

View File

@@ -23,7 +23,6 @@ import io.nekohasekai.libbox.BoxService
import io.nekohasekai.libbox.CommandServer
import io.nekohasekai.libbox.CommandServerHandler
import io.nekohasekai.libbox.Libbox
import io.nekohasekai.libbox.PProfServer
import io.nekohasekai.libbox.PlatformInterface
import io.nekohasekai.libbox.SystemProxyStatus
import io.nekohasekai.mobile.Mobile
@@ -72,7 +71,7 @@ class BoxService(
}
}
fun buildConfig(path: String, options: String):String {
fun buildConfig(path: String, options: String): String {
return Mobile.buildConfig(path, options)
}
@@ -106,10 +105,9 @@ class BoxService(
private val status = MutableLiveData(Status.Stopped)
private val binder = ServiceBinder(status)
private val notification = ServiceNotification(service)
private val notification = ServiceNotification(status, service)
private var boxService: BoxService? = null
private var commandServer: CommandServer? = null
private var pprofServer: PProfServer? = null
private var receiverRegistered = false
private val receiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
@@ -138,6 +136,7 @@ class BoxService(
this.commandServer = commandServer
}
private var activeProfileName = ""
private suspend fun startService(delayStart: Boolean = false) {
try {
Log.d(TAG, "starting service")
@@ -148,6 +147,8 @@ class BoxService(
return
}
activeProfileName = Settings.activeProfileName
val configOptions = Settings.configOptions
if (configOptions.isBlank()) {
stopAndAlert(Alert.EmptyConfiguration)
@@ -191,6 +192,10 @@ class BoxService(
boxService = newService
commandServer?.setService(boxService)
status.postValue(Status.Started)
withContext(Dispatchers.Main) {
notification.show(activeProfileName)
}
} catch (e: Exception) {
stopAndAlert(Alert.StartService, e.message)
return
@@ -198,8 +203,8 @@ class BoxService(
}
override fun serviceReload() {
notification.close()
status.postValue(Status.Starting)
runBlocking {
val pfd = fileDescriptor
if (pfd != null) {
pfd.close()
@@ -215,6 +220,7 @@ class BoxService(
Seq.destroyRef(refnum)
}
boxService = null
runBlocking {
startService(true)
}
}
@@ -311,7 +317,6 @@ class BoxService(
receiverRegistered = true
}
notification.show()
GlobalScope.launch(Dispatchers.IO) {
Settings.startedByUser = true
initialize()

View File

@@ -4,16 +4,28 @@ import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.app.ServiceCompat
import androidx.lifecycle.MutableLiveData
import com.hiddify.hiddify.Application
import com.hiddify.hiddify.MainActivity
import com.hiddify.hiddify.R
import com.hiddify.hiddify.Settings
import com.hiddify.hiddify.constant.Action
import com.hiddify.hiddify.constant.Status
import com.hiddify.hiddify.utils.CommandClient
import io.nekohasekai.libbox.Libbox
import io.nekohasekai.libbox.StatusMessage
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.withContext
class ServiceNotification(private val service: Service) {
class ServiceNotification(private val status: MutableLiveData<Status>, private val service: Service) : BroadcastReceiver(), CommandClient.Handler {
companion object {
private const val notificationId = 1
private const val notificationChannel = "service"
@@ -29,10 +41,17 @@ class ServiceNotification(private val service: Service) {
}
private val notification by lazy {
NotificationCompat.Builder(service, notificationChannel).setWhen(0)
private val commandClient =
CommandClient(GlobalScope, CommandClient.ConnectionType.Status, this)
private var receiverRegistered = false
private val notificationBuilder by lazy {
NotificationCompat.Builder(service, notificationChannel)
.setShowWhen(false)
.setOngoing(true)
.setContentTitle("Hiddify Next")
.setContentText("service running").setOnlyAlertOnce(true)
.setOnlyAlertOnce(true)
.setSmallIcon(R.drawable.ic_stat_logo)
.setCategory(NotificationCompat.CATEGORY_SERVICE)
.setContentIntent(
@@ -60,7 +79,7 @@ class ServiceNotification(private val service: Service) {
}
}
fun show() {
suspend fun show(profileName: String) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
Application.notification.createNotificationChannel(
NotificationChannel(
@@ -68,10 +87,56 @@ class ServiceNotification(private val service: Service) {
)
)
}
service.startForeground(notificationId, notification.build())
service.startForeground(
notificationId, notificationBuilder
.setContentTitle(profileName.takeIf { it.isNotBlank() } ?: "Hiddify Next")
.setContentText("service started").build()
)
withContext(Dispatchers.IO) {
if (Settings.dynamicNotification) {
commandClient.connect()
withContext(Dispatchers.Main) {
registerReceiver()
}
}
}
}
private fun registerReceiver() {
service.registerReceiver(this, IntentFilter().apply {
addAction(Intent.ACTION_SCREEN_ON)
addAction(Intent.ACTION_SCREEN_OFF)
})
receiverRegistered = true
}
override fun updateStatus(status: StatusMessage) {
val content =
Libbox.formatBytes(status.uplink) + "/s ↑\t" + Libbox.formatBytes(status.downlink) + "/s ↓"
Application.notificationManager.notify(
notificationId,
notificationBuilder.setContentText(content).build()
)
}
override fun onReceive(context: Context, intent: Intent) {
when (intent.action) {
Intent.ACTION_SCREEN_ON -> {
commandClient.connect()
}
Intent.ACTION_SCREEN_OFF -> {
commandClient.disconnect()
}
}
}
fun close() {
commandClient.disconnect()
ServiceCompat.stopForeground(service, ServiceCompat.STOP_FOREGROUND_REMOVE)
if (receiverRegistered) {
service.unregisterReceiver(this)
receiverRegistered = false
}
}
}

View File

@@ -4,6 +4,7 @@ object SettingsKey {
private const val KEY_PREFIX = "flutter."
const val ACTIVE_CONFIG_PATH = "${KEY_PREFIX}active_config_path"
const val ACTIVE_PROFILE_NAME = "${KEY_PREFIX}active_profile_name"
const val SERVICE_MODE = "${KEY_PREFIX}service_mode"
const val CONFIG_OPTIONS = "config_options_json"
@@ -15,6 +16,7 @@ object SettingsKey {
const val DEBUG_MODE = "${KEY_PREFIX}debug_mode"
const val ENABLE_TUN = "${KEY_PREFIX}enable-tun"
const val DISABLE_MEMORY_LIMIT = "${KEY_PREFIX}disable_memory_limit"
const val DYNAMIC_NOTIFICATION = "${KEY_PREFIX}dynamic_notification"
const val SYSTEM_PROXY_ENABLED = "${KEY_PREFIX}system_proxy_enabled"
// cache

View File

@@ -155,7 +155,8 @@
"silentStart": "Silent Start",
"openWorkingDir": "Open Working Directory",
"ignoreBatteryOptimizations": "Disable Battery Optimization",
"ignoreBatteryOptimizationsMsg": "Remove restrictions for optimal VPN performance"
"ignoreBatteryOptimizationsMsg": "Remove restrictions for optimal VPN performance",
"dynamicNotification": "Display speed in notification"
},
"advanced": {
"sectionTitle": "Advanced",

View File

@@ -155,7 +155,8 @@
"silentStart": "اجرای ساکت",
"openWorkingDir": "باز کردن دایرکتوری کاری",
"ignoreBatteryOptimizations": "غیرفعال کردن بهینه‌سازی باتری",
"ignoreBatteryOptimizationsMsg": "حذف محدودیت‌ها برای عملکرد بهتر VPN"
"ignoreBatteryOptimizationsMsg": "حذف محدودیت‌ها برای عملکرد بهتر VPN",
"dynamicNotification": "نمایش سرعت در نوتیفیکیشن"
},
"advanced": {
"sectionTitle": "پیشرفته",

View File

@@ -155,7 +155,8 @@
"silentStart": "Тихий запуск",
"openWorkingDir": "Открыть рабочую папку",
"ignoreBatteryOptimizations": "Отключить оптимизацию батареи",
"ignoreBatteryOptimizationsMsg": "Отключение ограничений для оптимальной производительности VPN."
"ignoreBatteryOptimizationsMsg": "Отключение ограничений для оптимальной производительности VPN.",
"dynamicNotification": "Отображение скорости в уведомлении"
},
"advanced": {
"sectionTitle": "Расширенные",

View File

@@ -155,7 +155,8 @@
"silentStart": "Sessiz Başlatma",
"openWorkingDir": "Çalışma Dizinini Aç",
"ignoreBatteryOptimizations": "Pil Optimizasyonunu Devre Dışı Bırak",
"ignoreBatteryOptimizationsMsg": "Optimum VPN performansı için kısıtlamaları kaldırın"
"ignoreBatteryOptimizationsMsg": "Optimum VPN performansı için kısıtlamaları kaldırın",
"dynamicNotification": "Bildirimde hızı göster"
},
"advanced": {
"sectionTitle": "Gelişmiş",

View File

@@ -155,7 +155,8 @@
"silentStart": "静默启动",
"openWorkingDir": "打开工作目录",
"ignoreBatteryOptimizations": "禁用电池优化",
"ignoreBatteryOptimizationsMsg": "消除限制以获得最佳 VPN 性能"
"ignoreBatteryOptimizationsMsg": "消除限制以获得最佳 VPN 性能",
"dynamicNotification": "在通知中显示速度"
},
"advanced": {
"sectionTitle": "高级选项",

View File

@@ -184,3 +184,20 @@ class MarkNewProfileActive extends _$MarkNewProfileActive {
return _pref.update(value);
}
}
@riverpod
class DynamicNotification extends _$DynamicNotification {
late final _pref = Pref(
ref.watch(sharedPreferencesProvider).requireValue,
"dynamic_notification",
true,
);
@override
bool build() => _pref.getValue();
Future<void> update(bool value) {
state = value;
return _pref.update(value);
}
}

View File

@@ -19,11 +19,13 @@ abstract interface class ConnectionRepository {
Stream<ConnectionStatus> watchConnectionStatus();
TaskEither<ConnectionFailure, Unit> connect(
String fileName,
String profileName,
bool disableMemoryLimit,
);
TaskEither<ConnectionFailure, Unit> disconnect();
TaskEither<ConnectionFailure, Unit> reconnect(
String fileName,
String profileName,
bool disableMemoryLimit,
);
}
@@ -144,6 +146,7 @@ class ConnectionRepositoryImpl
@override
TaskEither<ConnectionFailure, Unit> connect(
String fileName,
String profileName,
bool disableMemoryLimit,
) {
return TaskEither<ConnectionFailure, Unit>.Do(
@@ -173,6 +176,7 @@ class ConnectionRepositoryImpl
singbox
.start(
profilePathResolver.file(fileName).path,
profileName,
disableMemoryLimit,
)
.mapLeft(UnexpectedConnectionFailure.new),
@@ -192,6 +196,7 @@ class ConnectionRepositoryImpl
@override
TaskEither<ConnectionFailure, Unit> reconnect(
String fileName,
String profileName,
bool disableMemoryLimit,
) {
return exceptionHandler(
@@ -202,6 +207,7 @@ class ConnectionRepositoryImpl
() => singbox
.restart(
profilePathResolver.file(fileName).path,
profileName,
disableMemoryLimit,
)
.mapLeft(UnexpectedConnectionFailure.new),

View File

@@ -3,6 +3,7 @@ import 'package:hiddify/core/preferences/service_preferences.dart';
import 'package:hiddify/features/connection/data/connection_data_providers.dart';
import 'package:hiddify/features/connection/data/connection_repository.dart';
import 'package:hiddify/features/connection/model/connection_status.dart';
import 'package:hiddify/features/profile/model/profile_entity.dart';
import 'package:hiddify/features/profile/notifier/active_profile_notifier.dart';
import 'package:hiddify/utils/utils.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
@@ -20,7 +21,7 @@ class ConnectionNotifier extends _$ConnectionNotifier with AppLogger {
if (previous == null) return;
final shouldReconnect = next == null || previous.id != next.id;
if (shouldReconnect) {
await reconnect(next?.id);
await reconnect(next);
}
},
);
@@ -59,16 +60,20 @@ class ConnectionNotifier extends _$ConnectionNotifier with AppLogger {
}
}
Future<void> reconnect(String? profileId) async {
Future<void> reconnect(ProfileEntity? profile) async {
if (state case AsyncData(:final value) when value == const Connected()) {
if (profileId == null) {
if (profile == null) {
loggy.info("no active profile, disconnecting");
return _disconnect();
}
loggy.info("active profile changed, reconnecting");
await ref.read(startedByUserProvider.notifier).update(true);
await _connectionRepo
.reconnect(profileId, ref.read(disableMemoryLimitProvider))
.reconnect(
profile.id,
profile.name,
ref.read(disableMemoryLimitProvider),
)
.mapLeft((err) {
loggy.warning("error reconnecting", err);
state = AsyncError(err, StackTrace.current);
@@ -90,7 +95,11 @@ class ConnectionNotifier extends _$ConnectionNotifier with AppLogger {
Future<void> _connect() async {
final activeProfile = await ref.read(activeProfileProvider.future);
await _connectionRepo
.connect(activeProfile!.id, ref.read(disableMemoryLimitProvider))
.connect(
activeProfile!.id,
activeProfile.name,
ref.read(disableMemoryLimitProvider),
)
.mapLeft((err) async {
loggy.warning("error connecting", err);
await ref.read(startedByUserProvider.notifier).update(false);

View File

@@ -128,7 +128,7 @@ class UpdateProfile extends _$UpdateProfile with AppLogger {
if (active != null && active.id == profile.id) {
await ref
.read(connectionNotifierProvider.notifier)
.reconnect(profile.id);
.reconnect(profile);
}
});
return unit;

View File

@@ -1,3 +1,5 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:hiddify/core/localization/translations.dart';
@@ -73,6 +75,17 @@ class GeneralSettingTiles extends HookConsumerWidget {
}
},
),
if (Platform.isAndroid)
SwitchListTile(
title: Text(t.settings.general.dynamicNotification),
secondary: const Icon(Icons.speed),
value: ref.watch(dynamicNotificationProvider),
onChanged: (value) async {
await ref
.read(dynamicNotificationProvider.notifier)
.update(value);
},
),
if (PlatformUtils.isDesktop) ...[
SwitchListTile(
title: Text(t.settings.general.autoStart),

View File

@@ -158,7 +158,11 @@ class FFISingboxService with InfraLogger implements SingboxService {
}
@override
TaskEither<String, Unit> start(String configPath, bool disableMemoryLimit) {
TaskEither<String, Unit> start(
String configPath,
String name,
bool disableMemoryLimit,
) {
loggy.debug("starting, memory limit: [${!disableMemoryLimit}]");
return TaskEither(
() => CombineWorker().execute(
@@ -195,7 +199,11 @@ class FFISingboxService with InfraLogger implements SingboxService {
}
@override
TaskEither<String, Unit> restart(String configPath, bool disableMemoryLimit) {
TaskEither<String, Unit> restart(
String configPath,
String name,
bool disableMemoryLimit,
) {
loggy.debug("restarting, memory limit: [${!disableMemoryLimit}]");
return TaskEither(
() => CombineWorker().execute(

View File

@@ -89,13 +89,17 @@ class PlatformSingboxService with InfraLogger implements SingboxService {
}
@override
TaskEither<String, Unit> start(String path, bool disableMemoryLimit) {
TaskEither<String, Unit> start(
String path,
String name,
bool disableMemoryLimit,
) {
return TaskEither(
() async {
loggy.debug("starting");
await _methodChannel.invokeMethod(
"start",
{"path": path},
{"path": path, "name": name},
);
return right(unit);
},
@@ -114,13 +118,17 @@ class PlatformSingboxService with InfraLogger implements SingboxService {
}
@override
TaskEither<String, Unit> restart(String path, bool disableMemoryLimit) {
TaskEither<String, Unit> restart(
String path,
String name,
bool disableMemoryLimit,
) {
return TaskEither(
() async {
loggy.debug("restarting");
await _methodChannel.invokeMethod(
"restart",
{"path": path},
{"path": path, "name": name},
);
return right(unit);
},

View File

@@ -38,11 +38,19 @@ abstract interface class SingboxService {
String path,
);
TaskEither<String, Unit> start(String path, bool disableMemoryLimit);
TaskEither<String, Unit> start(
String path,
String name,
bool disableMemoryLimit,
);
TaskEither<String, Unit> stop();
TaskEither<String, Unit> restart(String path, bool disableMemoryLimit);
TaskEither<String, Unit> restart(
String path,
String name,
bool disableMemoryLimit,
);
Stream<List<SingboxOutboundGroup>> watchOutbounds();