feat: fkm существует

This commit is contained in:
Jganenokk
2026-08-21 15:17:11 +07:00
parent dbdb378137
commit 8ce5df9b7e
20 changed files with 989 additions and 70 deletions
+21
View File
@@ -16,6 +16,9 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE"/>
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS"/>
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT"/>
<uses-permission android:name="android.permission.VIBRATE"/>
<uses-permission android:name="android.permission.WAKE_LOCK"/>
@@ -126,6 +129,24 @@
android:name=".CallForegroundService"
android:foregroundServiceType="microphone|mediaProjection"
android:exported="false" />
<service
android:name=".FkmService"
android:foregroundServiceType="specialUse"
android:exported="false">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="Maintains the app's own persistent connection to the messaging server so that message notifications are delivered on builds without Google Play Services" />
</service>
<receiver
android:name=".FkmDisableReceiver"
android:exported="false" />
<receiver
android:name=".FkmBootReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED"/>
</intent-filter>
</receiver>
<receiver
android:name=".CallActionReceiver"
android:exported="false" />
@@ -0,0 +1,143 @@
package ru.komet.app
import android.Manifest
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.os.PowerManager
import android.provider.Settings
import android.util.Log
import androidx.core.app.ActivityCompat
import androidx.core.app.NotificationManagerCompat
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import java.util.concurrent.Executors
object FkmChannel {
private const val NAME = "ru.komet.app/fkm"
const val NOTIF_PERMS_REQUEST = 7713
private val main = Handler(Looper.getMainLooper())
private val worker = Executors.newSingleThreadExecutor()
private var channel: MethodChannel? = null
private var permResult: MethodChannel.Result? = null
fun attach(engine: FlutterEngine, activity: Activity) {
val ctx = activity.applicationContext
FkmState.restore(ctx)
val ch = MethodChannel(engine.dartExecutor.binaryMessenger, NAME)
channel = ch
ch.setMethodCallHandler { call, result ->
when (call.method) {
"isEnabled" -> result.success(FkmState.enabled)
"setEnabled" -> {
val enabled = call.argument<Boolean>("enabled") ?: false
FkmState.applyEnabled(ctx, enabled)
if (enabled) FkmService.start(ctx) else FkmService.stop(ctx)
result.success(null)
}
"setConnected" -> {
FkmState.connected = call.argument<Boolean>("connected") ?: false
FkmService.refresh(ctx)
result.success(null)
}
"showMessage" -> result.success(deliver(ctx, call, "showMessage"))
"showCall" -> result.success(deliver(ctx, call, "showCall"))
"hasNotificationPermission" ->
result.success(NotificationManagerCompat.from(ctx).areNotificationsEnabled())
"requestNotificationPermission" -> {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
NotificationManagerCompat.from(ctx).areNotificationsEnabled()
) {
result.success(
NotificationManagerCompat.from(ctx).areNotificationsEnabled(),
)
} else {
permResult?.success(false)
permResult = result
ActivityCompat.requestPermissions(
activity,
arrayOf(Manifest.permission.POST_NOTIFICATIONS),
NOTIF_PERMS_REQUEST,
)
}
}
"isIgnoringBatteryOptimizations" -> {
val power = ctx.getSystemService(Context.POWER_SERVICE) as PowerManager
result.success(power.isIgnoringBatteryOptimizations(ctx.packageName))
}
"requestIgnoreBatteryOptimizations" -> {
try {
activity.startActivity(
Intent(
Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS,
Uri.parse("package:${ctx.packageName}"),
),
)
} catch (e: Exception) {
Log.w("Fkm", "battery settings failed: ${e.message}")
}
result.success(null)
}
else -> result.notImplemented()
}
}
}
// Отрисовка тянет аватарку по сети — только не на главном потоке.
private fun deliver(ctx: Context, call: MethodCall, tag: String): Boolean {
val data = call.argument<Map<String, String>>("data") ?: return false
worker.execute {
try {
KometNotifier(ctx).handle(data)
FkmState.countDelivered(ctx)
FkmService.refresh(ctx)
} catch (e: Exception) {
Log.w("Fkm", "$tag failed: ${e.message}")
}
}
return true
}
fun detach() {
channel?.setMethodCallHandler(null)
channel = null
permResult?.success(false)
permResult = null
}
fun onPermissionResult(grantResults: IntArray) {
val pending = permResult ?: return
permResult = null
pending.success(
grantResults.isNotEmpty() &&
grantResults.all { it == PackageManager.PERMISSION_GRANTED },
)
}
fun notifyDisabled() {
main.post {
try {
channel?.invokeMethod("disabled", null)
} catch (e: Exception) {
Log.w("Fkm", "notifyDisabled failed: ${e.message}")
}
}
}
}
@@ -0,0 +1,254 @@
package ru.komet.app
import android.app.Notification
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.pm.ServiceInfo
import android.os.Build
import android.os.IBinder
import android.util.Log
import androidx.core.app.NotificationCompat
object FkmState {
private const val PREFS = "komet_fkm"
private const val KEY_ENABLED = "enabled"
private const val KEY_DELIVERED = "delivered"
@Volatile
var enabled = false
private set
@Volatile
var connected = false
@Volatile
var delivered = 0
private set
private fun prefs(ctx: Context) =
ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
fun restore(ctx: Context) {
val p = prefs(ctx)
enabled = p.getBoolean(KEY_ENABLED, false)
delivered = p.getInt(KEY_DELIVERED, 0)
}
fun applyEnabled(ctx: Context, value: Boolean) {
enabled = value
// Счётчик обнуляем только на выключении, чтобы перезапуск приложения
// не сбрасывал накопленное.
if (!value) {
delivered = 0
connected = false
}
prefs(ctx).edit()
.putBoolean(KEY_ENABLED, value)
.putInt(KEY_DELIVERED, delivered)
.apply()
}
fun countDelivered(ctx: Context) {
delivered += 1
prefs(ctx).edit().putInt(KEY_DELIVERED, delivered).apply()
}
}
object FkmNotification {
const val CHANNEL_ID = "komet_fkm"
const val NOTIFICATION_ID = 424244
fun build(ctx: Context): Notification {
ensureChannel(ctx)
val status = if (FkmState.connected) {
ctx.getString(R.string.fkm_status_active)
} else {
ctx.getString(R.string.fkm_status_inactive)
}
val title = ctx.getString(R.string.fkm_title)
val text = ctx.getString(R.string.fkm_status_line, status, FkmState.delivered)
val immutable = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
val open = PendingIntent.getActivity(
ctx,
0,
Intent(ctx, MainActivity::class.java)
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP),
immutable,
)
val disable = PendingIntent.getBroadcast(
ctx,
1,
Intent(ctx, FkmDisableReceiver::class.java).apply {
action = FkmDisableReceiver.ACTION_DISABLE
},
immutable,
)
return NotificationCompat.Builder(ctx, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setColor(CallConst.ACCENT)
.setContentTitle(title)
.setContentText(text)
.setStyle(
NotificationCompat.BigTextStyle()
.setBigContentTitle(title)
.bigText("$text\n\n${ctx.getString(R.string.fkm_explain)}"),
)
.setContentIntent(open)
.addAction(0, ctx.getString(R.string.fkm_disable), disable)
.setOngoing(true)
.setSilent(true)
.setOnlyAlertOnce(true)
.setShowWhen(false)
.setCategory(NotificationCompat.CATEGORY_SERVICE)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setVisibility(NotificationCompat.VISIBILITY_SECRET)
.build()
}
fun ensureChannel(ctx: Context) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val manager = manager(ctx)
if (manager.getNotificationChannel(CHANNEL_ID) != null) return
manager.createNotificationChannel(
NotificationChannel(
CHANNEL_ID,
ctx.getString(R.string.fkm_channel_name),
NotificationManager.IMPORTANCE_LOW,
).apply {
description = ctx.getString(R.string.fkm_channel_description)
setShowBadge(false)
setSound(null, null)
enableVibration(false)
enableLights(false)
},
)
}
fun manager(ctx: Context): NotificationManager =
ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
}
class FkmService : Service() {
companion object {
fun start(ctx: Context) {
val intent = Intent(ctx, FkmService::class.java)
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
ctx.startForegroundService(intent)
} else {
ctx.startService(intent)
}
} catch (e: Exception) {
Log.w("Fkm", "service start failed: ${e.message}")
}
}
// Перерисовка уже висящего уведомления, без перезапуска сервиса.
fun refresh(ctx: Context) {
if (!FkmState.enabled) return
try {
FkmNotification.manager(ctx)
.notify(FkmNotification.NOTIFICATION_ID, FkmNotification.build(ctx))
} catch (e: Exception) {
Log.w("Fkm", "notification refresh failed: ${e.message}")
}
}
fun stop(ctx: Context) {
try {
ctx.stopService(Intent(ctx, FkmService::class.java))
} catch (e: Exception) {
Log.w("Fkm", "service stop failed: ${e.message}")
}
}
}
private var inForeground = false
override fun onBind(intent: Intent?): IBinder? = null
override fun onCreate() {
super.onCreate()
FkmState.restore(applicationContext)
FkmNotification.ensureChannel(this)
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (!FkmState.enabled) {
stopForeground(STOP_FOREGROUND_REMOVE)
inForeground = false
stopSelf()
return START_NOT_STICKY
}
goForeground()
return START_STICKY
}
override fun onDestroy() {
if (inForeground) {
stopForeground(STOP_FOREGROUND_REMOVE)
inForeground = false
}
super.onDestroy()
}
private fun goForeground() {
val notification = FkmNotification.build(this)
if (inForeground) {
FkmNotification.manager(this)
.notify(FkmNotification.NOTIFICATION_ID, notification)
return
}
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
startForeground(
FkmNotification.NOTIFICATION_ID,
notification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE,
)
} else {
startForeground(FkmNotification.NOTIFICATION_ID, notification)
}
inForeground = true
} catch (e: Exception) {
Log.w("Fkm", "startForeground failed: ${e.message}")
stopSelf()
}
}
}
class FkmDisableReceiver : BroadcastReceiver() {
companion object {
const val ACTION_DISABLE = "ru.komet.app.FKM_DISABLE"
}
override fun onReceive(ctx: Context, intent: Intent) {
if (intent.action != ACTION_DISABLE) return
val app = ctx.applicationContext
FkmState.applyEnabled(app, false)
FkmService.stop(app)
FkmChannel.notifyDisabled()
}
}
class FkmBootReceiver : BroadcastReceiver() {
override fun onReceive(ctx: Context, intent: Intent) {
if (intent.action != Intent.ACTION_BOOT_COMPLETED) return
val app = ctx.applicationContext
FkmState.restore(app)
if (!FkmState.enabled) return
// Движка после ребута нет — уведомление честно скажет «не активно»,
// пока приложение не откроют.
FkmState.connected = false
FkmService.start(app)
}
}
@@ -30,6 +30,12 @@ class KometFcmService : FirebaseMessagingService() {
val data = message.data
Log.d("KometFcm", "onMessageReceived type=${data["type"]} keys=${data.keys}")
if (data.isEmpty()) return
val type = data["type"]
FkmState.restore(applicationContext)
if (FkmState.enabled && type != "InboundCall" && type != "CallFinished") {
Log.d("KometFcm", "message push dropped: FKM handles messages")
return
}
KometNotifier(applicationContext).handle(data)
}
}
@@ -81,7 +81,7 @@ class MainActivity : FlutterActivity() {
private companion object {
const val LOG_TAG = "VpnBypass"
const val NFC_TAG = "NfcExchange"
const val CALL_ENGINE_ID = "komet_call_engine"
const val KEEP_ENGINE_ID = "komet_keep_engine"
const val NFC_PHASE_MIN_MS = 350L
const val NFC_PHASE_JITTER_MS = 400
const val BLE_PERMS_REQUEST = 7711
@@ -438,6 +438,8 @@ class MainActivity : FlutterActivity() {
ChatNotifications.sink = null
}
})
FkmChannel.attach(flutterEngine, this)
}
override fun onCreate(savedInstanceState: Bundle?) {
@@ -738,6 +740,10 @@ class MainActivity : FlutterActivity() {
grantResults: IntArray,
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
if (requestCode == FkmChannel.NOTIF_PERMS_REQUEST) {
FkmChannel.onPermissionResult(grantResults)
return
}
if (requestCode == NOTE_PERMS_REQUEST) {
val pending = notePermResult
notePermResult = null
@@ -813,30 +819,35 @@ class MainActivity : FlutterActivity() {
}
}
// Движок переживает смерть активити, пока идёт звонок или включён FKM:
// в обоих случаях в фоне должно жить то же соединение, что и в UI.
private fun keepEngineAlive(): Boolean = CallState.inCall || FkmState.enabled
override fun provideFlutterEngine(context: Context): FlutterEngine? {
val cache = FlutterEngineCache.getInstance()
val cached = cache.get(CALL_ENGINE_ID)
val cached = cache.get(KEEP_ENGINE_ID)
if (cached != null) {
if (CallState.inCall) return cached
cache.remove(CALL_ENGINE_ID)
if (keepEngineAlive()) return cached
cache.remove(KEEP_ENGINE_ID)
cached.destroy()
}
return super.provideFlutterEngine(context)
}
override fun shouldDestroyEngineWithHost(): Boolean = !CallState.inCall
override fun shouldDestroyEngineWithHost(): Boolean = !keepEngineAlive()
override fun cleanUpFlutterEngine(flutterEngine: FlutterEngine) {
if (!CallState.inCall) {
FlutterEngineCache.getInstance().remove(CALL_ENGINE_ID)
if (!keepEngineAlive()) {
FlutterEngineCache.getInstance().remove(KEEP_ENGINE_ID)
FkmChannel.detach()
}
super.cleanUpFlutterEngine(flutterEngine)
}
override fun onDestroy() {
if (CallState.inCall && isFinishing) {
Log.d("KometFcm", "task removed during call, caching engine")
flutterEngine?.let { FlutterEngineCache.getInstance().put(CALL_ENGINE_ID, it) }
if (keepEngineAlive() && isFinishing) {
Log.d("KometFcm", "task removed, caching engine (call=${CallState.inCall} fkm=${FkmState.enabled})")
flutterEngine?.let { FlutterEngineCache.getInstance().put(KEEP_ENGINE_ID, it) }
}
super.onDestroy()
}
@@ -1,3 +1,11 @@
<resources>
<string name="upload_channel_name">Отправка медиа</string>
<string name="fkm_channel_name">Сервис уведомлений</string>
<string name="fkm_channel_description">Держит фоновое соединение с сервером</string>
<string name="fkm_title">Komet · сервис уведомлений</string>
<string name="fkm_status_active">Соединение активно</string>
<string name="fkm_status_inactive">Соединение не активно</string>
<string name="fkm_status_line">%1$s · принято %2$d</string>
<string name="fkm_explain">Это уведомление держит фоновое соединение с сервером, чтобы сообщения приходили без гугловых пушей. Убрать его можно, выключив FKM — кнопкой ниже или в Настройки → Уведомления → FKM.</string>
<string name="fkm_disable">Выключить</string>
</resources>
@@ -2,4 +2,12 @@
<string name="nfc_service_description">Komet contact exchange</string>
<string name="nfc_aid_group_description">Komet contact exchange</string>
<string name="upload_channel_name">Sending media</string>
<string name="fkm_channel_name">Notification service</string>
<string name="fkm_channel_description">Keeps the background connection to the server alive</string>
<string name="fkm_title">Komet · notification service</string>
<string name="fkm_status_active">Connection active</string>
<string name="fkm_status_inactive">Connection inactive</string>
<string name="fkm_status_line">%1$s · %2$d delivered</string>
<string name="fkm_explain">This notification is what keeps a background connection to the server, so messages arrive without Google push. To get rid of it, turn FKM off — with the button below, or in Settings → Notifications → FKM.</string>
<string name="fkm_disable">Turn off</string>
</resources>