feat: фул интеграция FCM с андроидом нах

This commit is contained in:
Jganenokk
2026-07-02 23:32:31 +07:00
parent bcef107c25
commit a904b733d2
20 changed files with 1941 additions and 90 deletions
+3
View File
@@ -90,4 +90,7 @@ dependencies {
implementation("androidx.media3:media3-transformer:1.9.3")
implementation("androidx.media3:media3-effect:1.9.3")
implementation("androidx.media3:media3-common:1.9.3")
implementation(platform("com.google.firebase:firebase-bom:33.7.0"))
implementation("com.google.firebase:firebase-messaging")
implementation("androidx.core:core-ktx:1.13.1")
}
+26 -1
View File
@@ -1,4 +1,5 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<uses-permission android:name="android.permission.CAMERA"/>
@@ -12,6 +13,10 @@
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<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.USE_FULL_SCREEN_INTENT"/>
<uses-permission android:name="android.permission.VIBRATE"/>
<uses-permission android:name="android.permission.WAKE_LOCK"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32"/>
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO"/>
@@ -94,6 +99,26 @@
android:name="android.nfc.cardemulation.host_apdu_service"
android:resource="@xml/komet_nfc_apdu"/>
</service>
<service
android:name=".KometFcmService"
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>
<service
android:name="io.flutter.plugins.firebase.messaging.FlutterFirebaseMessagingService"
tools:node="remove" />
<service
android:name=".CallForegroundService"
android:foregroundServiceType="microphone"
android:exported="false" />
<receiver
android:name=".CallActionReceiver"
android:exported="false" />
<receiver
android:name="com.dexterous.flutterlocalnotifications.ActionBroadcastReceiver"
android:exported="false" />
<meta-data
android:name="flutterEmbedding"
android:value="2" />
@@ -0,0 +1,118 @@
package ru.komet.app
import android.app.Service
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
import androidx.core.app.Person
object CallState {
@Volatile
var inCall = false
}
class CallForegroundService : Service() {
companion object {
const val ACTION_START = "ru.komet.app.CALL_ONGOING_START"
const val ACTION_STOP = "ru.komet.app.CALL_ONGOING_STOP"
const val ONGOING_ID = 424243
fun start(ctx: Context, caller: String) {
CallState.inCall = true
val intent = Intent(ctx, CallForegroundService::class.java).apply {
action = ACTION_START
putExtra(CallConst.EXTRA_CALLER, caller)
}
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
ctx.startForegroundService(intent)
} else {
ctx.startService(intent)
}
} catch (e: Exception) {
Log.w("KometFcm", "ongoing FGS start failed: ${e.message}")
}
}
fun stop(ctx: Context) {
CallState.inCall = false
try {
ctx.startService(
Intent(ctx, CallForegroundService::class.java).apply {
action = ACTION_STOP
},
)
} catch (_: Exception) {
}
}
}
override fun onBind(intent: Intent?): IBinder? = null
override fun onDestroy() {
CallState.inCall = false
super.onDestroy()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
ACTION_STOP -> {
@Suppress("DEPRECATION")
stopForeground(true)
stopSelf()
}
else -> {
val caller = intent?.getStringExtra(CallConst.EXTRA_CALLER) ?: "Звонок"
CallNotifier.ensureChannel(this)
startAsForeground(caller)
}
}
return START_NOT_STICKY
}
private fun startAsForeground(caller: String) {
val immutable = android.app.PendingIntent.FLAG_UPDATE_CURRENT or
android.app.PendingIntent.FLAG_IMMUTABLE
val open = Intent(this, MainActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP)
}
val pi = android.app.PendingIntent.getActivity(this, 4, open, immutable)
val hangup = android.app.PendingIntent.getBroadcast(
this, 5,
Intent(this, CallActionReceiver::class.java).apply {
action = CallConst.ACTION_HANGUP
},
immutable,
)
val person = Person.Builder().setName(caller).build()
val notif = NotificationCompat.Builder(this, CallConst.CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setColor(CallConst.ACCENT)
.setColorized(true)
.setCategory(NotificationCompat.CATEGORY_CALL)
.setOngoing(true)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setContentIntent(pi)
.setStyle(NotificationCompat.CallStyle.forOngoingCall(person, hangup))
.build()
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
startForeground(
ONGOING_ID, notif,
ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE,
)
} else {
startForeground(ONGOING_ID, notif)
}
} catch (e: Exception) {
Log.w("KometFcm", "startForeground(mic) failed: ${e.message}")
stopSelf()
}
}
}
@@ -0,0 +1,321 @@
package ru.komet.app
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.media.AudioAttributes
import android.media.AudioManager
import android.media.Ringtone
import android.media.RingtoneManager
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.os.PowerManager
import android.os.VibrationEffect
import android.os.Vibrator
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.Person
import androidx.core.graphics.drawable.IconCompat
import org.json.JSONObject
object AppState {
@Volatile
var resumed = false
}
object CallEvents {
@Volatile
var sink: io.flutter.plugin.common.EventChannel.EventSink? = null
private val main = Handler(Looper.getMainLooper())
fun emit(action: String) {
val s = sink ?: return
main.post {
try {
s.success(mapOf("action" to action))
} catch (_: Exception) {
}
}
}
}
object CallConst {
const val CHANNEL_ID = "komet_calls"
const val CHANNEL_NAME = "Звонки"
const val NOTIF_ID = 424242
const val ACCENT = 0xFF7C6BF0.toInt()
const val EXTRA_CALL = "komet_call"
const val EXTRA_ACTION = "komet_call_action"
const val EXTRA_DECLINE_PAYLOAD = "komet_decline_payload"
const val EXTRA_NOTIF_ID = "komet_notif_id"
const val EXTRA_CALLER = "komet_caller"
const val ACTION_RING = "ring"
const val ACTION_ANSWER = "answer"
const val ACTION_DECLINE = "ru.komet.app.CALL_DECLINE"
const val ACTION_HANGUP = "ru.komet.app.CALL_HANGUP"
const val FLN_RECEIVER =
"com.dexterous.flutterlocalnotifications.ActionBroadcastReceiver"
const val FLN_ACTION_TAPPED =
"com.dexterous.flutterlocalnotifications.ActionBroadcastReceiver.ACTION_TAPPED"
}
object CallNotifier {
fun showIncoming(ctx: Context, data: Map<String, String>) {
if (AppState.resumed) {
Log.d("KometFcm", "call push suppressed (app foreground)")
return
}
val name = data["userName"] ?: data["title"] ?: "Неизвестный"
val callerId = data["suid"] ?: data["callerId"] ?: ""
val conversationId = data["conversationId"] ?: data["vcId"] ?: ""
val vcp = data["vcp"] ?: ""
val account = data["c"] ?: ""
val callJson = JSONObject(data as Map<*, *>).toString()
Log.d("KometFcm", "showIncoming caller=$callerId conv=$conversationId keys=${data.keys}")
ensureChannel(ctx)
val avatar = NotifAvatars.load(ctx, callerId, name)
val person = Person.Builder()
.setName(name)
.setKey(callerId)
.setIcon(IconCompat.createWithBitmap(avatar))
.build()
val fullScreen = launchIntent(ctx, callJson, CallConst.ACTION_RING, name, 1)
val answer = launchIntent(ctx, callJson, CallConst.ACTION_ANSWER, name, 2)
val decline = declineIntent(ctx, vcp, conversationId, account)
val builder = NotificationCompat.Builder(ctx, CallConst.CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setColor(CallConst.ACCENT)
.setColorized(true)
.setOngoing(true)
.setCategory(NotificationCompat.CATEGORY_CALL)
.setPriority(NotificationCompat.PRIORITY_MAX)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setContentIntent(fullScreen)
.setFullScreenIntent(fullScreen, true)
.setStyle(
NotificationCompat.CallStyle.forIncomingCall(person, decline, answer)
.setIsVideo(isVideo(data)),
)
NotificationManagerCompat.from(ctx).notify(CallConst.NOTIF_ID, builder.build())
CallRinger.start(ctx, conversationId)
}
fun finishCall(ctx: Context, data: Map<String, String>) {
Log.d("KometFcm", "finishCall keys=${data.keys}")
CallRinger.stop()
NotificationManagerCompat.from(ctx).cancel(CallConst.NOTIF_ID)
CallEvents.emit("ended")
}
private fun isVideo(data: Map<String, String>): Boolean {
val t = data["type"] ?: data["callType"]
if (t == "VIDEO") return true
val iv = data["iv"]
return iv == "true" || iv == "1"
}
private fun launchIntent(
ctx: Context,
callJson: String,
action: String,
caller: String,
requestCode: Int,
): PendingIntent {
val intent = Intent(ctx, MainActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP)
putExtra(CallConst.EXTRA_CALL, callJson)
putExtra(CallConst.EXTRA_ACTION, action)
putExtra(CallConst.EXTRA_CALLER, caller)
}
var flags = PendingIntent.FLAG_UPDATE_CURRENT
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
flags = flags or PendingIntent.FLAG_IMMUTABLE
}
return PendingIntent.getActivity(ctx, requestCode, intent, flags)
}
private fun declineIntent(
ctx: Context,
vcp: String,
conversationId: String,
account: String,
): PendingIntent {
val payload = JSONObject()
.put("vcp", vcp)
.put("conversationId", conversationId)
.put("c", account.toLongOrNull() ?: account)
.toString()
val intent = Intent(ctx, CallActionReceiver::class.java).apply {
action = CallConst.ACTION_DECLINE
putExtra(CallConst.EXTRA_DECLINE_PAYLOAD, payload)
putExtra(CallConst.EXTRA_NOTIF_ID, CallConst.NOTIF_ID)
}
var flags = PendingIntent.FLAG_UPDATE_CURRENT
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
flags = flags or PendingIntent.FLAG_IMMUTABLE
}
return PendingIntent.getBroadcast(ctx, 3, intent, flags)
}
fun ensureChannel(ctx: Context) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val mgr = ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
if (mgr.getNotificationChannel(CallConst.CHANNEL_ID) != null) return
val channel = NotificationChannel(
CallConst.CHANNEL_ID,
CallConst.CHANNEL_NAME,
NotificationManager.IMPORTANCE_HIGH,
).apply {
description = "Входящие звонки"
setSound(null, null)
enableVibration(false)
setBypassDnd(true)
lockscreenVisibility = NotificationCompat.VISIBILITY_PUBLIC
}
mgr.createNotificationChannel(channel)
}
}
object CallRinger {
private val handler = Handler(Looper.getMainLooper())
private var ringtone: Ringtone? = null
private var vibrator: Vibrator? = null
private var wakeLock: PowerManager.WakeLock? = null
private var timeout: Runnable? = null
@Volatile
var activeConversationId: String? = null
private set
fun start(ctx: Context, conversationId: String) {
stop()
activeConversationId = conversationId
val power = ctx.getSystemService(Context.POWER_SERVICE) as PowerManager
wakeLock = power.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "komet:call").apply {
setReferenceCounted(false)
acquire(60_000L)
}
val audio = ctx.getSystemService(Context.AUDIO_SERVICE) as AudioManager
val mode = audio.ringerMode
if (mode == AudioManager.RINGER_MODE_NORMAL) {
playRingtone(ctx)
vibrate(ctx)
} else if (mode == AudioManager.RINGER_MODE_VIBRATE) {
vibrate(ctx)
}
val t = Runnable { onTimeout(ctx) }
timeout = t
handler.postDelayed(t, 45_000L)
}
fun stop() {
timeout?.let { handler.removeCallbacks(it) }
timeout = null
try {
ringtone?.stop()
} catch (_: Exception) {
}
ringtone = null
try {
vibrator?.cancel()
} catch (_: Exception) {
}
vibrator = null
try {
wakeLock?.let { if (it.isHeld) it.release() }
} catch (_: Exception) {
}
wakeLock = null
activeConversationId = null
}
private fun onTimeout(ctx: Context) {
stop()
NotificationManagerCompat.from(ctx).cancel(CallConst.NOTIF_ID)
}
private fun playRingtone(ctx: Context) {
try {
val uri = RingtoneManager.getActualDefaultRingtoneUri(
ctx, RingtoneManager.TYPE_RINGTONE,
) ?: RingtoneManager.getActualDefaultRingtoneUri(
ctx, RingtoneManager.TYPE_NOTIFICATION,
) ?: return
val rt = RingtoneManager.getRingtone(ctx, uri) ?: return
rt.audioAttributes = AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_NOTIFICATION_RINGTONE)
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
.build()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
rt.isLooping = true
}
rt.play()
ringtone = rt
} catch (e: Exception) {
Log.w("KometFcm", "ringtone failed: ${e.message}")
}
}
private fun vibrate(ctx: Context) {
try {
val vib = ctx.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator
if (!vib.hasVibrator()) return
val pattern = longArrayOf(0, 1000, 1000)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
vib.vibrate(VibrationEffect.createWaveform(pattern, 0))
} else {
@Suppress("DEPRECATION")
vib.vibrate(pattern, 0)
}
vibrator = vib
} catch (e: Exception) {
Log.w("KometFcm", "vibrate failed: ${e.message}")
}
}
}
class CallActionReceiver : BroadcastReceiver() {
override fun onReceive(ctx: Context, intent: Intent) {
if (intent.action == CallConst.ACTION_HANGUP) {
CallEvents.emit("hangup")
return
}
if (intent.action != CallConst.ACTION_DECLINE) return
CallRinger.stop()
val notifId = intent.getIntExtra(CallConst.EXTRA_NOTIF_ID, CallConst.NOTIF_ID)
NotificationManagerCompat.from(ctx).cancel(notifId)
val payload = intent.getStringExtra(CallConst.EXTRA_DECLINE_PAYLOAD) ?: return
val fln = Intent(CallConst.FLN_ACTION_TAPPED).apply {
setClassName(ctx, CallConst.FLN_RECEIVER)
putExtra("notificationId", notifId)
putExtra("actionId", "call_decline")
putExtra("payload", payload)
putExtra("cancelNotification", true)
}
try {
ctx.sendBroadcast(fln)
} catch (e: Exception) {
Log.w("KometFcm", "decline broadcast failed: ${e.message}")
}
}
}
@@ -0,0 +1,312 @@
package ru.komet.app
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.Typeface
import android.os.Build
import android.text.SpannableStringBuilder
import android.text.Spanned
import android.text.style.StyleSpan
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.Person
import androidx.core.app.RemoteInput
import androidx.core.content.LocusIdCompat
import androidx.core.content.pm.ShortcutInfoCompat
import androidx.core.content.pm.ShortcutManagerCompat
import androidx.core.graphics.drawable.IconCompat
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
import org.json.JSONArray
import org.json.JSONObject
class KometFcmService : FirebaseMessagingService() {
override fun onMessageReceived(message: RemoteMessage) {
val data = message.data
Log.d("KometFcm", "onMessageReceived type=${data["type"]} keys=${data.keys}")
if (data.isEmpty()) return
KometNotifier(applicationContext).handle(data)
}
}
class KometNotifier(private val ctx: Context) {
companion object {
private const val CHANNEL_ID = "komet_messages"
private const val CHANNEL_NAME = "Сообщения"
private const val GROUP_KEY = "komet_messages_group"
private const val SUMMARY_ID = 424200
private const val PREFS = "komet_push"
private const val FLN_RECEIVER =
"com.dexterous.flutterlocalnotifications.ActionBroadcastReceiver"
private const val FLN_ACTION_TAPPED =
"com.dexterous.flutterlocalnotifications.ActionBroadcastReceiver.ACTION_TAPPED"
private const val FLN_INPUT_RESULT = "FlutterLocalNotificationsPluginInputResult"
private const val HISTORY_LIMIT = 8
private const val ACCENT = 0xFF7C6BF0.toInt()
}
private data class Hist(val text: String, val key: String, val name: String, val ts: Long)
fun handle(data: Map<String, String>) {
when (data["type"]) {
"InboundCall" -> CallNotifier.showIncoming(ctx, data)
"CallFinished" -> CallNotifier.finishCall(ctx, data)
else -> showMessage(data)
}
}
private fun manager(): NotificationManager =
ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
private fun ensureChannel() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
if (manager().getNotificationChannel(CHANNEL_ID) == null) {
manager().createNotificationChannel(
NotificationChannel(CHANNEL_ID, CHANNEL_NAME, NotificationManager.IMPORTANCE_HIGH),
)
}
}
private fun showMessage(data: Map<String, String>) {
val chatId = data["mc"]?.toLongOrNull() ?: return
val senderId = data["suid"] ?: ""
val senderName = data["userName"] ?: data["title"] ?: "MAX"
val chatTitle = data["title"] ?: senderName
val text = data["msg"] ?: data["body"] ?: data["text"] ?: "Новое сообщение"
val ts = data["ctime"]?.toLongOrNull() ?: data["ttime"]?.toLongOrNull()
?: System.currentTimeMillis()
val isGroup = chatTitle != senderName
val notifId = (chatId and 0x7fffffff).toInt()
ensureChannel()
val active = activeIds()
if (!active.contains(notifId)) clearHistory(chatId)
val history = appendHistory(chatId, Hist(text, senderId, senderName, ts))
val avatarCache = HashMap<String, Bitmap>()
val personCache = HashMap<String, Person>()
fun avatarFor(key: String, name: String): Bitmap =
avatarCache.getOrPut(key) { NotifAvatars.load(ctx, key, name) }
fun personFor(key: String, name: String): Person =
personCache.getOrPut(key) {
Person.Builder()
.setName(name)
.setKey(key)
.setIcon(IconCompat.createWithBitmap(avatarFor(key, name)))
.build()
}
val senderPerson = personFor(senderId, senderName)
val shortcutId = "chat_$chatId"
publishShortcut(shortcutId, chatId, chatTitle, senderPerson)
val style = NotificationCompat.MessagingStyle(Person.Builder().setName("Вы").build())
if (isGroup) {
style.conversationTitle = chatTitle
style.isGroupConversation = true
}
for (h in history) {
style.addMessage(h.text, h.ts, personFor(h.key, h.name))
}
val builder = NotificationCompat.Builder(ctx, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setColor(ACCENT)
.setCategory(NotificationCompat.CATEGORY_MESSAGE)
.setAutoCancel(true)
.setGroup(GROUP_KEY)
.setWhen(ts)
.setShowWhen(true)
.setContentIntent(openIntent(notifId, chatId))
.setShortcutId(shortcutId)
.setLocusId(LocusIdCompat(shortcutId))
.setStyle(style)
.setLargeIcon(avatarFor(senderId, senderName))
val account = data["c"]?.toIntOrNull() ?: 0
if (account != 0) {
builder.addAction(replyAction(notifId, account, chatId, data["msgid"]?.toLongOrNull()))
}
manager().notify(notifId, builder.build())
updateSummary(notifId, chatId, senderName, text, ts, active)
}
private fun updateSummary(
notifId: Int,
chatId: Long,
senderName: String,
text: String,
ts: Long,
activeBefore: Set<Int>,
) {
val reg = loadRegistry()
val kept = JSONObject()
val keys = reg.keys()
while (keys.hasNext()) {
val k = keys.next()
val id = k.toIntOrNull() ?: continue
if (id == notifId) continue
if (activeBefore.contains(id)) kept.put(k, reg.getJSONObject(k))
}
kept.put(
notifId.toString(),
JSONObject().put("n", senderName).put("t", text).put("ts", ts),
)
saveRegistry(kept)
if (kept.length() < 2) {
manager().cancel(SUMMARY_ID)
return
}
val entries = ArrayList<Triple<String, String, Long>>()
val kk = kept.keys()
while (kk.hasNext()) {
val k = kk.next()
val o = kept.getJSONObject(k)
entries.add(Triple(o.optString("n"), o.optString("t"), o.optLong("ts")))
}
entries.sortByDescending { it.third }
val inbox = NotificationCompat.InboxStyle()
for (e in entries.take(6)) inbox.addLine(boldLine(e.first, e.second))
val summary = NotificationCompat.Builder(ctx, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setColor(ACCENT)
.setCategory(NotificationCompat.CATEGORY_MESSAGE)
.setGroup(GROUP_KEY)
.setGroupSummary(true)
.setAutoCancel(true)
.setWhen(ts)
.setShowWhen(true)
.setNumber(entries.size)
.setContentTitle("Komet")
.setContentText(boldLine(senderName, text))
.setStyle(inbox)
.build()
manager().notify(SUMMARY_ID, summary)
}
private fun boldLine(name: String, text: String): CharSequence {
val sb = SpannableStringBuilder()
sb.append(name)
sb.setSpan(StyleSpan(Typeface.BOLD), 0, name.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
sb.append(": ")
sb.append(text)
return sb
}
private fun replyAction(
notifId: Int,
account: Int,
chatId: Long,
replyTo: Long?,
): NotificationCompat.Action {
val payload = JSONObject()
.put("c", account)
.put("chat", chatId)
.apply { if (replyTo != null) put("mid", replyTo) }
.toString()
val intent = Intent(FLN_ACTION_TAPPED).apply {
setClassName(ctx, FLN_RECEIVER)
putExtra("notificationId", notifId)
putExtra("actionId", "reply")
putExtra("payload", payload)
putExtra("cancelNotification", false)
}
var flags = PendingIntent.FLAG_UPDATE_CURRENT
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
flags = flags or PendingIntent.FLAG_MUTABLE
}
val pi = PendingIntent.getBroadcast(ctx, notifId, intent, flags)
val remoteInput = RemoteInput.Builder(FLN_INPUT_RESULT)
.setLabel("Сообщение…")
.build()
return NotificationCompat.Action.Builder(R.drawable.ic_notification, "Ответить", pi)
.addRemoteInput(remoteInput)
.setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_REPLY)
.setAllowGeneratedReplies(true)
.build()
}
private fun publishShortcut(id: String, chatId: Long, title: String, person: Person) {
try {
val intent = (ctx.packageManager.getLaunchIntentForPackage(ctx.packageName)
?: Intent(Intent.ACTION_VIEW)).apply {
action = Intent.ACTION_VIEW
putExtra("komet_chat", chatId)
}
val shortcut = ShortcutInfoCompat.Builder(ctx, id)
.setShortLabel(title)
.setLongLived(true)
.setIntent(intent)
.setPerson(person)
.setIcon(person.icon)
.build()
ShortcutManagerCompat.pushDynamicShortcut(ctx, shortcut)
} catch (e: Exception) {
Log.w("KometFcm", "shortcut push failed: $e")
}
}
private fun openIntent(notifId: Int, chatId: Long): PendingIntent? {
val launch = ctx.packageManager.getLaunchIntentForPackage(ctx.packageName) ?: return null
launch.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
launch.putExtra("komet_chat", chatId)
val flags = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
return PendingIntent.getActivity(ctx, notifId, launch, flags)
}
private fun activeIds(): Set<Int> = try {
manager().activeNotifications.map { it.id }.toSet()
} catch (e: Exception) {
emptySet()
}
private fun pushPrefs() = ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
private fun appendHistory(chatId: Long, item: Hist): List<Hist> {
val prefs = pushPrefs()
val key = "hist_$chatId"
val arr = try {
JSONArray(prefs.getString(key, "[]"))
} catch (e: Exception) {
JSONArray()
}
arr.put(
JSONObject().put("t", item.text).put("k", item.key)
.put("n", item.name).put("ts", item.ts),
)
while (arr.length() > HISTORY_LIMIT) arr.remove(0)
prefs.edit().putString(key, arr.toString()).apply()
val out = ArrayList<Hist>(arr.length())
for (i in 0 until arr.length()) {
val o = arr.getJSONObject(i)
out.add(Hist(o.optString("t"), o.optString("k"), o.optString("n"), o.optLong("ts")))
}
return out
}
private fun clearHistory(chatId: Long) {
pushPrefs().edit().remove("hist_$chatId").apply()
}
private fun loadRegistry(): JSONObject = try {
JSONObject(pushPrefs().getString("registry", "{}") ?: "{}")
} catch (e: Exception) {
JSONObject()
}
private fun saveRegistry(reg: JSONObject) {
pushPrefs().edit().putString("registry", reg.toString()).apply()
}
}
@@ -1,6 +1,7 @@
package ru.komet.app
import android.Manifest
import android.app.KeyguardManager
import android.content.ComponentName
import android.content.Context
import android.content.Intent
@@ -13,13 +14,18 @@ import android.nfc.NfcAdapter
import android.nfc.Tag
import android.nfc.tech.IsoDep
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.provider.Settings
import android.util.Log
import android.view.WindowManager
import androidx.core.app.ActivityCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.embedding.engine.FlutterEngineCache
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodChannel
import android.media.MediaCodecInfo
@@ -64,9 +70,12 @@ class MainActivity : FlutterActivity() {
private var pendingPeer: NfcExchange.Peer? = null
@Volatile private var exchangingEmitted = false
private var pendingCall: Map<String, Any?>? = null
private companion object {
const val LOG_TAG = "VpnBypass"
const val NFC_TAG = "NfcExchange"
const val CALL_ENGINE_ID = "komet_call_engine"
const val NFC_PHASE_MIN_MS = 350L
const val NFC_PHASE_JITTER_MS = 400
const val BLE_PERMS_REQUEST = 7711
@@ -256,6 +265,132 @@ class MainActivity : FlutterActivity() {
else -> result.notImplemented()
}
}
MethodChannel(
flutterEngine.dartExecutor.binaryMessenger,
"ru.komet.app/calls",
).setMethodCallHandler { call, result ->
when (call.method) {
"consumeInitialCall" -> {
val p = pendingCall
pendingCall = null
result.success(p)
}
"notifyAccepted" -> {
val caller = call.argument<String>("caller") ?: "Звонок"
CallRinger.stop()
NotificationManagerCompat.from(this).cancel(CallConst.NOTIF_ID)
CallForegroundService.start(applicationContext, caller)
result.success(null)
}
"notifyEnded" -> {
CallRinger.stop()
NotificationManagerCompat.from(this).cancel(CallConst.NOTIF_ID)
CallForegroundService.stop(applicationContext)
clearCallWindowFlags()
result.success(null)
}
"cancelIncoming" -> {
CallRinger.stop()
NotificationManagerCompat.from(this).cancel(CallConst.NOTIF_ID)
result.success(null)
}
"canUseFullScreenIntent" -> {
result.success(
NotificationManagerCompat.from(this).canUseFullScreenIntent(),
)
}
"openFullScreenIntentSettings" -> {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
try {
startActivity(
Intent(
Settings.ACTION_MANAGE_APP_USE_FULL_SCREEN_INTENT,
Uri.parse("package:$packageName"),
),
)
} catch (e: Exception) {
Log.w("KometFcm", "open FSI settings failed: ${e.message}")
}
}
result.success(null)
}
else -> result.notImplemented()
}
}
EventChannel(
flutterEngine.dartExecutor.binaryMessenger,
"ru.komet.app/calls_events",
).setStreamHandler(object : EventChannel.StreamHandler {
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
CallEvents.sink = events
}
override fun onCancel(arguments: Any?) {
CallEvents.sink = null
}
})
}
override fun onCreate(savedInstanceState: Bundle?) {
if (intent?.hasExtra(CallConst.EXTRA_CALL) == true) applyCallWindowFlags()
super.onCreate(savedInstanceState)
intent?.let { if (it.hasExtra(CallConst.EXTRA_CALL)) stashCall(it, emit = false) }
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
if (intent.hasExtra(CallConst.EXTRA_CALL)) {
applyCallWindowFlags()
stashCall(intent, emit = true)
}
}
private fun stashCall(intent: Intent, emit: Boolean) {
val json = intent.getStringExtra(CallConst.EXTRA_CALL) ?: return
val action = intent.getStringExtra(CallConst.EXTRA_ACTION) ?: CallConst.ACTION_RING
if (action == CallConst.ACTION_ANSWER) CallRinger.stop()
val map = mapOf<String, Any?>("data" to json, "action" to action)
val sink = CallEvents.sink
if (emit && sink != null) {
sink.success(map)
} else {
pendingCall = map
}
}
private fun applyCallWindowFlags() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
setShowWhenLocked(true)
setTurnScreenOn(true)
} else {
@Suppress("DEPRECATION")
window.addFlags(
WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or
WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON or
WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON,
)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val km = getSystemService(Context.KEYGUARD_SERVICE) as? KeyguardManager
km?.requestDismissKeyguard(this, null)
}
}
private fun clearCallWindowFlags() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
setShowWhenLocked(false)
setTurnScreenOn(false)
} else {
@Suppress("DEPRECATION")
window.clearFlags(
WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or
WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON or
WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON,
)
}
}
// Центр-кроп видео в квадрат size×size (без искажений) через media3
@@ -502,8 +637,42 @@ class MainActivity : FlutterActivity() {
}
}
override fun provideFlutterEngine(context: Context): FlutterEngine? {
val cache = FlutterEngineCache.getInstance()
val cached = cache.get(CALL_ENGINE_ID)
if (cached != null) {
if (CallState.inCall) return cached
cache.remove(CALL_ENGINE_ID)
cached.destroy()
}
return super.provideFlutterEngine(context)
}
override fun shouldDestroyEngineWithHost(): Boolean = !CallState.inCall
override fun cleanUpFlutterEngine(flutterEngine: FlutterEngine) {
if (!CallState.inCall) {
FlutterEngineCache.getInstance().remove(CALL_ENGINE_ID)
}
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) }
}
super.onDestroy()
}
override fun onResume() {
super.onResume()
AppState.resumed = true
}
override fun onPause() {
super.onPause()
AppState.resumed = false
if (NfcExchange.active) {
stopNfcExchange()
nfcEvents?.success(mapOf("event" to "cancelled"))
@@ -0,0 +1,102 @@
package ru.komet.app
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.BitmapShader
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.Shader
import org.json.JSONObject
import java.net.HttpURLConnection
import java.net.URL
import kotlin.math.abs
import kotlin.math.min
object NotifAvatars {
private val PALETTE = intArrayOf(
0xFF5B8DEF.toInt(), 0xFFEF5B8D.toInt(), 0xFF3FB950.toInt(),
0xFFE3883A.toInt(), 0xFF9B72F0.toInt(), 0xFF2AA9B5.toInt(),
)
fun load(ctx: Context, senderId: String, name: String): Bitmap {
val url = avatarUrl(ctx, senderId)
val raw = if (url != null) downloadBitmap(url) else null
return if (raw != null) circleCrop(raw) else initialsBitmap(name)
}
private fun avatarUrl(ctx: Context, senderId: String): String? {
if (senderId.isEmpty()) return null
return try {
val prefs =
ctx.getSharedPreferences("FlutterSharedPreferences", Context.MODE_PRIVATE)
val raw = prefs.getString("flutter.contact_cache_v1", null) ?: return null
val entry = JSONObject(raw).optJSONObject(senderId) ?: return null
entry.optString("a", "").ifEmpty { null }
} catch (e: Exception) {
null
}
}
private fun downloadBitmap(url: String): Bitmap? {
return try {
val conn = URL(url).openConnection() as HttpURLConnection
conn.connectTimeout = 4000
conn.readTimeout = 5000
conn.doInput = true
conn.connect()
val bmp = BitmapFactory.decodeStream(conn.inputStream)
conn.disconnect()
bmp
} catch (e: Exception) {
null
}
}
private fun circleCrop(src: Bitmap): Bitmap {
val size = min(src.width, src.height)
val squared = Bitmap.createBitmap(
src, (src.width - size) / 2, (src.height - size) / 2, size, size,
)
val output = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
val paint = Paint().apply {
isAntiAlias = true
shader = BitmapShader(squared, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP)
}
val r = size / 2f
Canvas(output).drawCircle(r, r, r, paint)
return output
}
private fun initialsBitmap(name: String): Bitmap {
val size = 256
val output = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
val canvas = Canvas(output)
val bg = Paint().apply {
isAntiAlias = true
color = PALETTE[if (name.isEmpty()) 0 else abs(name.hashCode()) % PALETTE.size]
}
canvas.drawCircle(size / 2f, size / 2f, size / 2f, bg)
val tp = Paint().apply {
isAntiAlias = true
color = Color.WHITE
textAlign = Paint.Align.CENTER
textSize = size * 0.42f
isFakeBoldText = true
}
val fm = tp.fontMetrics
canvas.drawText(initialsOf(name), size / 2f, size / 2f - (fm.ascent + fm.descent) / 2, tp)
return output
}
private fun initialsOf(name: String): String {
val parts = name.trim().split(Regex("\\s+")).filter { it.isNotEmpty() }
return when {
parts.isEmpty() -> "?"
parts.size == 1 -> parts[0].substring(0, 1).uppercase()
else -> (parts[0].substring(0, 1) + parts[1].substring(0, 1)).uppercase()
}
}
}
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#FFFFFFFF">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M20,2H4C2.9,2 2,2.9 2,4v18l4,-4h14c1.1,0 2,-0.9 2,-2V4C22,2.9 21.1,2 20,2z" />
</vector>
+5 -1
View File
@@ -49,6 +49,8 @@ class Api {
int? get callsSeed => _callsSeed;
String? get deviceId => _deviceId;
String? spoofScope;
List<CountryName>? _registrationCountries;
List<CountryName> get registrationCountries =>
@@ -224,7 +226,9 @@ class Api {
);
}
final spoofed = await SpoofingService.getSpoofedSessionData();
final spoofed = await SpoofingService.getSpoofedSessionData(
scope: spoofScope,
);
if (spoofed != null) {
final sDeviceType = spoofed['device_type'] as String?;
if (sDeviceType != null && sDeviceType != 'IOS') deviceType = sDeviceType;
+1 -1
View File
@@ -504,7 +504,7 @@ class AccountModule {
_ensureOnline();
final packet = await _api.sendRequest(Opcode.config, <dynamic, dynamic>{
'pushToken': pushToken,
'pushOptions': 131072,
'pushOptions': 0,
});
if (packet.isError) {
final msg = messageFromErrorPayload(packet.payload).toUpperCase();
+101
View File
@@ -0,0 +1,101 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io' show Platform;
import 'package:flutter/services.dart';
import 'call_controller.dart';
class CallBridge {
CallBridge._();
static final CallBridge instance = CallBridge._();
static const _method = MethodChannel('ru.komet.app/calls');
static const _events = EventChannel('ru.komet.app/calls_events');
bool _started = false;
bool get _android {
try {
return Platform.isAndroid;
} catch (_) {
return false;
}
}
void init() {
if (_started || !_android) return;
_started = true;
_events.receiveBroadcastStream().listen(_handle, onError: (_) {});
}
Future<void> checkInitialCall() async {
if (!_android) return;
try {
_handle(await _method.invokeMethod<dynamic>('consumeInitialCall'));
} catch (_) {}
}
void _handle(Object? event) {
if (event is! Map) return;
final action = event['action']?.toString();
if (action == 'hangup') {
unawaited(CallController.instance.endActive());
return;
}
if (action == 'ended') {
CallController.instance.dismissIncoming();
return;
}
final dataStr = event['data'];
if (dataStr is! String) return;
Object? decoded;
try {
decoded = jsonDecode(dataStr);
} catch (_) {
return;
}
if (decoded is! Map) return;
CallController.instance.injectFromNative(
decoded,
autoAccept: action == 'answer',
);
}
Future<void> notifyAccepted({String? caller}) async {
if (!_android) return;
try {
await _method.invokeMethod<void>('notifyAccepted', {'caller': caller});
} catch (_) {}
}
Future<void> notifyEnded() async {
if (!_android) return;
try {
await _method.invokeMethod<void>('notifyEnded');
} catch (_) {}
}
Future<void> cancelIncoming() async {
if (!_android) return;
try {
await _method.invokeMethod<void>('cancelIncoming');
} catch (_) {}
}
Future<bool> canUseFullScreenIntent() async {
if (!_android) return true;
try {
return await _method.invokeMethod<bool>('canUseFullScreenIntent') ?? true;
} catch (_) {
return true;
}
}
Future<void> openFullScreenIntentSettings() async {
if (!_android) return;
try {
await _method.invokeMethod<void>('openFullScreenIntentSettings');
} catch (_) {}
}
}
+72 -19
View File
@@ -4,21 +4,23 @@ import '../../backend/api.dart';
import '../../backend/modules/calls.dart';
import '../protocol/opcode_map.dart';
import '../protocol/packet.dart';
import 'call_bridge.dart';
import 'call_session.dart';
import 'conversation_params.dart';
import 'ws2_signaling.dart';
/// Данные входящего звонка (из пуша opcode 137).
class IncomingCall {
final String conversationId;
/// ONE_ME id звонящего.
final int callerId;
final bool isVideo;
final ConversationParams params;
final String? country;
final bool? isContact;
final String? callerName;
final bool autoAccept;
const IncomingCall({
required this.conversationId,
@@ -27,11 +29,11 @@ class IncomingCall {
required this.params,
this.country,
this.isContact,
this.callerName,
this.autoAccept = false,
});
}
/// Глобальный оркестратор звонков: слушает входящие (opcode 137),
/// инициирует исходящие (opcode 78) и держит активный [CallSession].
class CallController {
CallController._();
static final CallController instance = CallController._();
@@ -42,13 +44,16 @@ class CallController {
final _incoming = StreamController<IncomingCall>.broadcast();
final _ended = StreamController<void>.broadcast();
final _canceled = StreamController<void>.broadcast();
bool appResumed = false;
/// Новый входящий звонок — UI показывает экран/оверлей.
Stream<IncomingCall> get incomingCalls => _incoming.stream;
/// Активный звонок завершился (любой стороной).
Stream<void> get callEnded => _ended.stream;
Stream<void> get incomingCanceled => _canceled.stream;
CallSession? _active;
CallSession? get activeSession => _active;
@@ -66,6 +71,7 @@ class CallController {
void _onPush(Packet packet) {
if (packet.opcode != Opcode.notifCallStart) return;
if (!appResumed) return;
final payload = packet.payload;
if (payload is! Map) return;
@@ -77,22 +83,69 @@ class CallController {
final params = ConversationParams.decode(vcp);
if (params == null) return;
// Уже идёт звонок — новый игнорируем (сервер сам отметит как пропущенный).
if (_active != null) return;
final incoming = IncomingCall(
_emitIncoming(IncomingCall(
conversationId: conversationId,
callerId: callerId,
isVideo: payload['type'] == 'VIDEO',
isVideo: payload['type'] == 'VIDEO' || params.isVideo,
params: params,
country: payload['country'] as String?,
isContact: payload['isContact'] as bool?,
));
}
void injectFromNative(Map<dynamic, dynamic> data, {bool autoAccept = false}) {
final vcp = data['vcp']?.toString();
if (vcp == null || vcp.isEmpty) return;
final params = ConversationParams.decode(vcp);
if (params == null) return;
final conversationId =
(data['conversationId'] ?? data['vcId'])?.toString();
if (conversationId == null || conversationId.isEmpty) return;
final callerId = _asInt(data['callerId'] ?? data['suid']);
if (callerId == null) return;
final type = (data['type'] ?? data['callType'])?.toString();
final iv = data['iv'];
final isVideo =
params.isVideo || type == 'VIDEO' || iv == true || iv == 'true';
_emitIncoming(
IncomingCall(
conversationId: conversationId,
callerId: callerId,
isVideo: isVideo,
params: params,
country: data['country']?.toString(),
isContact: data['isContact'] is bool ? data['isContact'] as bool : null,
callerName: data['userName']?.toString(),
autoAccept: autoAccept,
),
);
}
void _emitIncoming(IncomingCall incoming) {
if (_active != null) return;
if (_pending?.conversationId == incoming.conversationId) return;
_pending = incoming;
_incoming.add(incoming);
}
/// Начать исходящий 1:1 звонок.
void dismissIncoming() {
if (_pending == null) return;
_pending = null;
_canceled.add(null);
}
static int? _asInt(Object? v) {
if (v is int) return v;
if (v is num) return v.toInt();
if (v is String) return int.tryParse(v);
return null;
}
Future<CallSession> startOutgoing(int calleeId, {bool isVideo = false}) async {
if (_active != null) throw StateError('уже идёт звонок');
final out = await _calls!.initiateCall(calleeId, isVideo: isVideo);
@@ -100,6 +153,7 @@ class CallController {
final session = CallSession(ws2Config: config, role: CallRole.caller);
_bind(session);
await session.start();
CallBridge.instance.notifyAccepted();
return session;
}
@@ -114,12 +168,13 @@ class CallController {
final session = CallSession(ws2Config: config, role: CallRole.joiner);
_bind(session);
await session.start();
CallBridge.instance.notifyAccepted();
return session;
}
/// Принять входящий звонок.
Future<CallSession> acceptIncoming(IncomingCall call) async {
_pending = null;
CallBridge.instance.cancelIncoming();
final config = Ws2Config.fromVcp(
call.params,
conversationId: call.conversationId,
@@ -132,13 +187,13 @@ class CallController {
_bind(session);
await session.start();
await session.accept();
CallBridge.instance.notifyAccepted(caller: call.callerName);
return session;
}
/// Отклонить входящий звонок (подключаемся к ws2 только чтобы отправить
/// `hangup reason=REJECTED`, без медиа).
Future<void> rejectIncoming(IncomingCall call) async {
_pending = null;
CallBridge.instance.notifyEnded();
final config = Ws2Config.fromVcp(
call.params,
conversationId: call.conversationId,
@@ -153,12 +208,8 @@ class CallController {
}
}
/// Завершить активный звонок.
Future<void> endActive() => _active?.hangup() ?? Future.value();
/// DEBUG: послать в активный звонок сигнал состояния микрофона
/// (`change-media-settings`), не трогая реальный микрофон.
/// Возвращает `false`, если активного звонка нет.
Future<bool> sendMicSignal(bool enabled) async {
final session = _active;
if (session == null) return false;
@@ -171,6 +222,7 @@ class CallController {
session.stateStream.listen((state) {
if (state == CallSessionState.ended && _active == session) {
_active = null;
CallBridge.instance.notifyEnded();
_ended.add(null);
}
});
@@ -180,5 +232,6 @@ class CallController {
_pushSub?.cancel();
_incoming.close();
_ended.close();
_canceled.close();
}
}
+398 -27
View File
@@ -1,50 +1,416 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:ui' as ui;
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../../backend/api.dart';
import '../../backend/modules/account.dart';
import '../../backend/modules/messages.dart';
import '../calls/conversation_params.dart';
import '../calls/ws2_signaling.dart';
import '../protocol/opcode_map.dart';
import '../storage/app_instance.dart';
import '../storage/token_storage.dart';
import '../utils/logger.dart';
const _channelId = 'komet_messages';
const _channelName = 'Сообщения';
const _prefsTokenKey = 'fcm_push_token';
const _groupKey = 'komet_messages_group';
const _callNotifId = 424242;
const _historyLimit = 6;
@pragma('vm:entry-point')
Future<void> _backgroundHandler(RemoteMessage message) async {
await Firebase.initializeApp();
if (message.notification != null) return;
final plugin = FlutterLocalNotificationsPlugin();
await plugin.initialize(
settings: const InitializationSettings(
android: AndroidInitializationSettings('@mipmap/ic_launcher'),
),
);
await _display(plugin, message);
Future<void> _backgroundHandler(RemoteMessage message) async {}
class _NotifMessage {
_NotifMessage(this.text, this.senderKey, this.senderName, this.ts);
final String text;
final String senderKey;
final String senderName;
final int ts;
}
Future<void> _display(
Future<void> _showMessageNotification(
FlutterLocalNotificationsPlugin plugin,
RemoteMessage message,
Map<String, dynamic> data,
) async {
final data = message.data;
final title = message.notification?.title ??
data['title']?.toString() ??
data['sender']?.toString() ??
'MAX';
final body = message.notification?.body ??
final chatId = int.tryParse(data['mc']?.toString() ?? '') ?? 0;
final senderKey = data['suid']?.toString() ?? '';
final senderName =
data['userName']?.toString() ?? data['title']?.toString() ?? 'MAX';
final chatTitle = data['title']?.toString() ?? senderName;
final text = data['msg']?.toString() ??
data['body']?.toString() ??
data['text']?.toString() ??
data['message']?.toString() ??
'Новое сообщение';
final ts = int.tryParse(data['ctime']?.toString() ?? '') ??
int.tryParse(data['ttime']?.toString() ?? '') ??
DateTime.now().millisecondsSinceEpoch;
final isGroup = chatTitle != senderName;
final account = int.tryParse(data['c']?.toString() ?? '') ?? 0;
final replyTo = int.tryParse(data['msgid']?.toString() ?? '');
final notifId = (chatId != 0 ? chatId : senderKey.hashCode) & 0x7fffffff;
if (!await _isActive(plugin, notifId)) {
await _clearHistory(chatId);
}
final photo = await _avatarBytes(senderKey);
final avatar = photo ?? await _initialsAvatar(senderName);
print('PUSHDBG avatar sender=$senderKey photo=${photo?.length} '
'final=${avatar?.length}');
final history = await _appendHistory(chatId, senderKey, senderName, text, ts);
final persons = <String, Person>{};
Person personFor(String key, String name) => persons.putIfAbsent(
key,
() => Person(
key: key,
name: name,
icon: (key == senderKey && avatar != null)
? ByteArrayAndroidIcon(avatar)
: null,
),
);
final messages = [
for (final h in history)
Message(
h.text,
DateTime.fromMillisecondsSinceEpoch(h.ts),
personFor(h.senderKey, h.senderName),
),
];
final style = MessagingStyleInformation(
const Person(name: 'Вы'),
conversationTitle: isGroup ? chatTitle : null,
groupConversation: isGroup,
messages: messages,
);
await plugin.show(
id: message.messageId?.hashCode ??
DateTime.now().millisecondsSinceEpoch ~/ 1000,
title: title,
body: body,
id: notifId,
title: chatTitle,
body: text,
notificationDetails: NotificationDetails(
android: AndroidNotificationDetails(
_channelId,
_channelName,
importance: Importance.high,
priority: Priority.high,
category: AndroidNotificationCategory.message,
styleInformation: style,
groupKey: _groupKey,
largeIcon: avatar != null ? ByteArrayAndroidBitmap(avatar) : null,
ticker: text,
actions: account != 0
? const [
AndroidNotificationAction(
'reply',
'Ответить',
inputs: [
AndroidNotificationActionInput(label: 'Сообщение…'),
],
semanticAction: SemanticAction.reply,
),
]
: null,
),
),
payload: jsonEncode({'c': account, 'chat': chatId, 'mid': replyTo}),
);
}
Future<void> _showCallNotification(
FlutterLocalNotificationsPlugin plugin,
Map<String, dynamic> data,
) async {
final name =
data['userName']?.toString() ?? data['msg']?.toString() ?? 'Неизвестный';
final avatar = await _avatarBytes(data['suid']?.toString() ?? '') ??
await _initialsAvatar(name);
await plugin.show(
id: _callNotifId,
title: 'Входящий звонок',
body: name,
notificationDetails: NotificationDetails(
android: AndroidNotificationDetails(
_channelId,
_channelName,
importance: Importance.high,
priority: Priority.high,
category: AndroidNotificationCategory.call,
largeIcon: avatar != null ? ByteArrayAndroidBitmap(avatar) : null,
ticker: 'Входящий звонок',
),
),
);
}
Future<List<_NotifMessage>> _appendHistory(
int chatId,
String senderKey,
String senderName,
String text,
int ts,
) async {
final prefs = await SharedPreferences.getInstance();
final key = 'notif_hist_$chatId';
final list = <Map<String, dynamic>>[];
final raw = prefs.getString(key);
if (raw != null) {
try {
final decoded = jsonDecode(raw);
if (decoded is List) {
for (final e in decoded) {
if (e is Map) list.add(e.cast<String, dynamic>());
}
}
} catch (_) {}
}
list.add({'t': text, 'k': senderKey, 'n': senderName, 'ts': ts});
while (list.length > _historyLimit) {
list.removeAt(0);
}
await prefs.setString(key, jsonEncode(list));
return [
for (final e in list)
_NotifMessage(
e['t']?.toString() ?? '',
e['k']?.toString() ?? '',
e['n']?.toString() ?? '',
int.tryParse(e['ts']?.toString() ?? '') ?? ts,
),
];
}
Future<bool> _isActive(FlutterLocalNotificationsPlugin plugin, int id) async {
try {
final active = await plugin.getActiveNotifications();
return active.any((n) => n.id == id);
} catch (_) {
return true;
}
}
Future<void> _clearHistory(int chatId) async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove('notif_hist_$chatId');
}
const _avatarPalette = <int>[
0xFF5B8DEF,
0xFFEF5B8D,
0xFF3FB950,
0xFFE3883A,
0xFF9B72F0,
0xFF2AA9B5,
0xFFE05252,
0xFF6A7BE0,
];
String _initialsOf(String name) {
final parts =
name.trim().split(RegExp(r'\s+')).where((p) => p.isNotEmpty).toList();
if (parts.isEmpty) return '?';
if (parts.length == 1) return parts.first.substring(0, 1).toUpperCase();
return (parts[0].substring(0, 1) + parts[1].substring(0, 1)).toUpperCase();
}
Future<Uint8List?> _initialsAvatar(String name) async {
try {
const size = 128;
final recorder = ui.PictureRecorder();
final canvas = ui.Canvas(recorder);
final paint = ui.Paint()
..isAntiAlias = true
..color = ui.Color(
_avatarPalette[name.isEmpty ? 0 : name.hashCode.abs() % _avatarPalette.length],
);
canvas.drawCircle(const ui.Offset(64, 64), 64, paint);
final builder = ui.ParagraphBuilder(
ui.ParagraphStyle(
textAlign: ui.TextAlign.center,
fontSize: 56,
fontWeight: ui.FontWeight.w600,
),
)
..pushStyle(ui.TextStyle(color: const ui.Color(0xFFFFFFFF)))
..addText(_initialsOf(name));
final paragraph = builder.build()
..layout(const ui.ParagraphConstraints(width: 128));
canvas.drawParagraph(paragraph, ui.Offset(0, (size - paragraph.height) / 2));
final image = await recorder.endRecording().toImage(size, size);
final data = await image.toByteData(format: ui.ImageByteFormat.png);
image.dispose();
if (data == null) return null;
return data.buffer.asUint8List();
} catch (_) {
return null;
}
}
Future<Uint8List?> _avatarBytes(String senderKey) async {
if (senderKey.isEmpty) return null;
try {
final prefs = await SharedPreferences.getInstance();
final raw = prefs.getString('contact_cache_v1');
if (raw == null) return null;
final map = jsonDecode(raw);
if (map is! Map) return null;
final entry = map[senderKey];
final url = entry is Map ? entry['a']?.toString() : null;
if (url == null || url.isEmpty) return null;
return await _downloadBytes(url);
} catch (_) {
return null;
}
}
Future<Uint8List?> _downloadBytes(String url) async {
HttpClient? client;
try {
client = HttpClient()..connectionTimeout = const Duration(seconds: 4);
final req = await client.getUrl(Uri.parse(url));
final resp = await req.close().timeout(const Duration(seconds: 5));
if (resp.statusCode != 200) return null;
return await consolidateHttpClientResponseBytes(resp);
} catch (_) {
return null;
} finally {
client?.close(force: true);
}
}
@pragma('vm:entry-point')
void _onNotificationResponse(NotificationResponse response) {
print('REPLYDBG cb action=${response.actionId} '
'input=${response.input} payload=${response.payload}');
if (response.actionId == 'call_decline') {
final payload = response.payload;
if (payload != null) unawaited(_handleCallDecline(payload));
return;
}
if (response.actionId != 'reply') return;
final text = response.input?.trim();
final payload = response.payload;
if (text == null || text.isEmpty || payload == null) return;
unawaited(_handleReply(payload, text));
}
Future<void> _handleCallDecline(String payloadJson) async {
String vcp;
String conversationId;
try {
final decoded = jsonDecode(payloadJson);
if (decoded is! Map) return;
vcp = decoded['vcp']?.toString() ?? '';
conversationId = decoded['conversationId']?.toString() ?? '';
} catch (_) {
return;
}
if (vcp.isEmpty || conversationId.isEmpty) return;
final params = ConversationParams.decode(vcp);
if (params == null) return;
final config = Ws2Config.fromVcp(params, conversationId: conversationId);
final signaling = Ws2Signaling(config);
try {
await signaling.connect();
await signaling.hangup(reason: 'REJECTED');
print('REPLYDBG call decline sent');
} catch (e) {
print('REPLYDBG call decline error $e');
} finally {
await signaling.close();
}
}
Future<void> _handleReply(String payloadJson, String text) async {
int account;
int chatId;
int? replyTo;
try {
final decoded = jsonDecode(payloadJson);
if (decoded is! Map) return;
account = (decoded['c'] as num?)?.toInt() ?? 0;
chatId = (decoded['chat'] as num?)?.toInt() ?? 0;
replyTo = (decoded['mid'] as num?)?.toInt();
} catch (_) {
return;
}
if (account == 0 || chatId == 0) return;
print('REPLYDBG start acc=$account chat=$chatId reply=$replyTo');
WidgetsFlutterBinding.ensureInitialized();
if (AppInstance.isNamed) {
try {
SharedPreferences.setPrefix('flutter.${AppInstance.id}.');
} catch (_) {}
}
final plugin = FlutterLocalNotificationsPlugin();
final notifId = chatId & 0x7fffffff;
Api? api;
var sent = false;
try {
final token = await TokenStorage.readToken(account);
print('REPLYDBG token=${token != null && token.isNotEmpty}');
if (token != null && token.isNotEmpty) {
api = Api()..spoofScope = '$account';
await api.connect();
if (api.state != SessionState.online) {
await api.stateStream
.firstWhere((s) => s == SessionState.online)
.timeout(const Duration(seconds: 20));
}
print('REPLYDBG online');
final login = await api.sendRequest(Opcode.login, <dynamic, dynamic>{
'token': token,
'interactive': false,
'exp': {
'chatsCountGroups': Uint8List.fromList([0x0b, 0x32]),
},
'presenceSync': 0,
});
print('REPLYDBG login ok=${login.isOk}');
if (login.isOk) {
await MessagesModule(api).sendMessage(
account,
chatId,
text,
replyToMessageId: replyTo,
);
sent = true;
print('REPLYDBG sent');
}
}
} catch (e) {
sent = false;
print('REPLYDBG error $e');
} finally {
await api?.disconnect();
}
if (sent) {
await _clearHistory(chatId);
await plugin.cancel(id: notifId);
} else {
await plugin.show(
id: notifId,
title: 'Komet',
body: 'Не удалось отправить ответ',
notificationDetails: const NotificationDetails(
android: AndroidNotificationDetails(
_channelId,
@@ -54,12 +420,19 @@ Future<void> _display(
),
),
);
}
}
class PushService {
PushService._();
static final PushService instance = PushService._();
static Future<void> clearChatNotification(int chatId) async {
final plugin = FlutterLocalNotificationsPlugin();
await plugin.cancel(id: chatId & 0x7fffffff);
await _clearHistory(chatId);
}
final FlutterLocalNotificationsPlugin _local =
FlutterLocalNotificationsPlugin();
@@ -84,8 +457,10 @@ class PushService {
await _local.initialize(
settings: const InitializationSettings(
android: AndroidInitializationSettings('@mipmap/ic_launcher'),
android: AndroidInitializationSettings('ic_notification'),
),
onDidReceiveNotificationResponse: _onNotificationResponse,
onDidReceiveBackgroundNotificationResponse: _onNotificationResponse,
);
await _local
.resolvePlatformSpecificImplementation<
@@ -101,10 +476,6 @@ class PushService {
final messaging = FirebaseMessaging.instance;
await messaging.requestPermission();
FirebaseMessaging.onBackgroundMessage(_backgroundHandler);
FirebaseMessaging.onMessage.listen((m) {
_display(_local, m);
});
messaging.onTokenRefresh.listen((t) async {
_token = t;
await _persistToken(t);
+4 -2
View File
@@ -115,9 +115,11 @@ class SpoofingService {
return profile;
}
static Future<Map<String, dynamic>?> getSpoofedSessionData() async {
static Future<Map<String, dynamic>?> getSpoofedSessionData({
String? scope,
}) async {
final prefs = await SharedPreferences.getInstance();
final profile = await _read(prefs, await activeScope());
final profile = await _read(prefs, scope ?? await activeScope());
if (profile == null || !profile.enabled) return null;
return {
+57
View File
@@ -0,0 +1,57 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
class TypingStore {
TypingStore._();
static final TypingStore instance = TypingStore._();
static const Duration _ttl = Duration(seconds: 6);
final Map<int, Set<int>> _users = {};
final Map<int, Map<int, Timer>> _timers = {};
final Map<int, ValueNotifier<bool>> _notifiers = {};
ValueListenable<bool> listenable(int chatId) => _notifiers.putIfAbsent(
chatId,
() => ValueNotifier<bool>(_users[chatId]?.isNotEmpty ?? false),
);
bool isTyping(int chatId) => _users[chatId]?.isNotEmpty ?? false;
void markTyping(int chatId, int userId) {
final timers = _timers.putIfAbsent(chatId, () => <int, Timer>{});
timers[userId]?.cancel();
timers[userId] = Timer(_ttl, () => _remove(chatId, userId));
_users.putIfAbsent(chatId, () => <int>{}).add(userId);
_sync(chatId);
}
void clearUser(int chatId, int userId) => _remove(chatId, userId);
void clearChat(int chatId) {
final timers = _timers.remove(chatId);
if (timers != null) {
for (final timer in timers.values) {
timer.cancel();
}
}
_users.remove(chatId);
_sync(chatId);
}
void _remove(int chatId, int userId) {
_timers[chatId]?.remove(userId)?.cancel();
final users = _users[chatId];
if (users != null) {
users.remove(userId);
if (users.isEmpty) _users.remove(chatId);
}
_sync(chatId);
}
void _sync(int chatId) {
_notifiers[chatId]?.value = _users[chatId]?.isNotEmpty ?? false;
}
}
@@ -33,6 +33,7 @@ class CallScreen extends StatefulWidget {
final CallSession? session;
final IncomingCall? incoming;
final bool isGroup;
final bool autoAccept;
const CallScreen({
super.key,
@@ -41,6 +42,7 @@ class CallScreen extends StatefulWidget {
this.session,
this.incoming,
this.isGroup = false,
this.autoAccept = false,
});
@override
@@ -51,6 +53,7 @@ class _CallScreenState extends State<CallScreen>
with TickerProviderStateMixin {
CallSession? _session;
StreamSubscription<CallSessionState>? _stateSub;
StreamSubscription<void>? _canceledSub;
StreamSubscription<void>? _infoSub;
StreamSubscription<void>? _kometSub;
StreamSubscription<CallChatMessage>? _chatSub;
@@ -112,6 +115,16 @@ class _CallScreenState extends State<CallScreen>
if (incoming != null && (_name.isEmpty || _avatarUrl == null)) {
_resolvePeerInfo(incoming.callerId);
}
if (incoming != null) {
_canceledSub = CallController.instance.incomingCanceled.listen((_) {
if (mounted && _incomingPending) _close();
});
}
if (widget.autoAccept && incoming != null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && _incomingPending) _accept();
});
}
}
Future<void> _resolvePeerInfo(int id) async {
@@ -364,6 +377,7 @@ class _CallScreenState extends State<CallScreen>
@override
void dispose() {
_stateSub?.cancel();
_canceledSub?.cancel();
_infoSub?.cancel();
_kometSub?.cancel();
_chatSub?.cancel();
@@ -25,7 +25,10 @@ import '../profile/settings_tab.dart';
import '../auth/login_screen.dart';
import '../digital_id/digital_id_web_screen.dart';
import '../../widgets/account_switcher_overlay.dart';
import '../../widgets/animated_text_swap.dart';
import '../../../backend/api.dart';
import '../../../core/protocol/opcode_map.dart';
import '../../../core/protocol/packet.dart';
import '../../../core/utils/haptics.dart';
import '../../../core/config/app_stories.dart';
import '../../../backend/models/chat_folder.dart';
@@ -36,6 +39,7 @@ import '../../../backend/modules/folders.dart';
import '../../../core/storage/app_database.dart';
import '../../../core/storage/draft_store.dart';
import '../../../core/storage/token_storage.dart';
import '../../../core/storage/typing_store.dart';
import '../../../main.dart'
show accountModule, api, messagesModule, appRouteObserver;
@@ -157,6 +161,8 @@ class _ChatListScreenState extends State<ChatListScreen>
StreamSubscription? _stateSub;
StreamSubscription<LoginStatus>? _loginSub;
StreamSubscription<Packet>? _typingSub;
StreamSubscription<MessageEvent>? _typingMsgSub;
Widget? _cachedChatsBody;
Object? _chatsBodyCacheKey;
@@ -500,9 +506,29 @@ class _ChatListScreenState extends State<ChatListScreen>
ChatsModule.chatsChanged.addListener(_onChatsChanged);
DraftStore.instance.revision.addListener(_onDraftsChanged);
AppStories.current.addListener(_onStoriesEnabledChanged);
_typingSub = api.pushStream
.where((p) => p.opcode == Opcode.notifTyping)
.listen(_onTypingPush);
_typingMsgSub = ChatsModule.messageEvents.listen(_onTypingMessageEvent);
unawaited(_runReload());
}
void _onTypingPush(Packet packet) {
final payload = packet.payload;
if (payload is! Map) return;
final chatId = payload['chatId'];
final userId = payload['userId'];
if (chatId is! int || userId is! int) return;
if (userId == (_profile?.id ?? 0)) return;
TypingStore.instance.markTyping(chatId, userId);
}
void _onTypingMessageEvent(MessageEvent event) {
if (event is MessageAddedEvent) {
TypingStore.instance.clearUser(event.chatId, event.message.senderId);
}
}
void _onDraftsChanged() {
if (mounted) _requestReload();
}
@@ -1065,6 +1091,8 @@ class _ChatListScreenState extends State<ChatListScreen>
AppStories.current.removeListener(_onStoriesEnabledChanged);
_loginSub?.cancel();
_stateSub?.cancel();
_typingSub?.cancel();
_typingMsgSub?.cancel();
_fabController.dispose();
_navPageAnimController.dispose();
_storiesRevealController
@@ -2171,7 +2199,6 @@ class _ChatListScreenState extends State<ChatListScreen>
String time,
String imageUrl, {
int presenceUserId = 0,
bool isTyping = false,
bool isRead = false,
int unreadCount = 0,
bool isMuted = false,
@@ -2366,6 +2393,9 @@ class _ChatListScreenState extends State<ChatListScreen>
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(
child: ValueListenableBuilder<bool>(
valueListenable: TypingStore.instance
.listenable(int.tryParse(id) ?? 0),
child: draft != null
? Text.rich(
TextSpan(
@@ -2376,7 +2406,9 @@ class _ChatListScreenState extends State<ChatListScreen>
),
TextSpan(
text: draft,
style: TextStyle(color: cs.outline),
style: TextStyle(
color: cs.outline,
),
),
],
style: const TextStyle(
@@ -2392,11 +2424,9 @@ class _ChatListScreenState extends State<ChatListScreen>
: Text(
message,
style: TextStyle(
color: isTyping ? cs.primary : cs.outline,
color: cs.outline,
fontSize: 14,
fontWeight: isTyping
? FontWeight.w500
: FontWeight.w400,
fontWeight: FontWeight.w400,
fontStyle: messageItalic
? FontStyle.italic
: FontStyle.normal,
@@ -2405,6 +2435,24 @@ class _ChatListScreenState extends State<ChatListScreen>
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
builder: (context, typing, base) {
return AnimatedTextSwap(
showAlternate: typing,
alternate: Text(
'печатает...',
style: TextStyle(
color: cs.primary,
fontSize: 14,
fontWeight: FontWeight.w500,
height: 1.2,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
child: base!,
);
},
),
),
?statusIcon,
const SizedBox(width: 8),
@@ -34,6 +34,7 @@ import '../../../core/calls/call_controller.dart';
import '../calls/call_screen.dart';
import '../../../core/protocol/opcode_map.dart';
import '../../../core/protocol/packet.dart';
import '../../../core/push/push_service.dart';
import '../../../core/storage/app_database.dart';
import '../../../core/storage/draft_store.dart';
import '../../../core/cache/info_cache.dart';
@@ -333,6 +334,7 @@ class _ChatScreenState extends State<ChatScreen>
@override
void initState() {
super.initState();
unawaited(PushService.clearChatNotification(widget.chatId));
WidgetsBinding.instance.addObserver(this);
ChatsModule.chatsChanged.addListener(_onChatsBump);
_messageController.addListener(_onTextChanged);
@@ -0,0 +1,94 @@
import 'package:flutter/material.dart';
class AnimatedTextSwap extends StatefulWidget {
const AnimatedTextSwap({
super.key,
required this.showAlternate,
required this.child,
required this.alternate,
this.duration = const Duration(milliseconds: 260),
this.curve = Curves.easeOutCubic,
this.slideExtent = 0.45,
this.alignment = AlignmentDirectional.centerStart,
});
final bool showAlternate;
final Widget child;
final Widget alternate;
final Duration duration;
final Curve curve;
final double slideExtent;
final AlignmentGeometry alignment;
@override
State<AnimatedTextSwap> createState() => _AnimatedTextSwapState();
}
class _AnimatedTextSwapState extends State<AnimatedTextSwap>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
late final Animation<double> _t;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: widget.duration,
value: widget.showAlternate ? 1 : 0,
);
_t = CurvedAnimation(
parent: _controller,
curve: widget.curve,
reverseCurve: widget.curve.flipped,
);
}
@override
void didUpdateWidget(AnimatedTextSwap oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.duration != oldWidget.duration) {
_controller.duration = widget.duration;
}
if (widget.showAlternate != oldWidget.showAlternate) {
widget.showAlternate ? _controller.forward() : _controller.reverse();
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _t,
builder: (context, _) {
final t = _t.value;
if (t <= 0) return widget.child;
if (t >= 1) return widget.alternate;
return Stack(
alignment: widget.alignment,
children: [
Opacity(
opacity: 1 - t,
child: FractionalTranslation(
translation: Offset(0, -widget.slideExtent * t),
child: widget.child,
),
),
Opacity(
opacity: t,
child: FractionalTranslation(
translation: Offset(0, widget.slideExtent * (1 - t)),
child: widget.alternate,
),
),
],
);
},
);
}
}
+50 -5
View File
@@ -46,6 +46,7 @@ import 'backend/modules/polls.dart';
import 'backend/modules/self_check.dart';
import 'backend/modules/webapp.dart';
import 'backend/modules/digital_id.dart';
import 'core/calls/call_bridge.dart';
import 'core/calls/call_controller.dart';
import 'core/links/deep_link_service.dart';
import 'frontend/screens/calls/call_screen.dart';
@@ -233,6 +234,9 @@ class KometAppState extends State<KometApp>
late Locale _locale;
late String _fontId;
bool _isLoggingOut = false;
bool _shellReady = false;
bool _incomingRouteActive = false;
IncomingCall? _pendingIncoming;
late final ValueNotifier<Color?> accentSeed = ValueNotifier(
widget.initialAccentSeed,
);
@@ -296,6 +300,7 @@ class KometAppState extends State<KometApp>
if (isOnemeFlavor) {
await PushService.instance.init(api: api, account: accountModule);
await PushService.instance.onLoginSuccess();
await _ensureFullScreenIntentPermission();
}
}
});
@@ -303,6 +308,11 @@ class KometAppState extends State<KometApp>
_callIncomingSub = CallController.instance.incomingCalls.listen(
_onIncomingCall,
);
CallController.instance.appResumed = true;
CallBridge.instance.init();
WidgetsBinding.instance.addPostFrameCallback((_) {
CallBridge.instance.checkInitialCall();
});
_sessionExpiredSub = api.sessionExpiredStream.listen((
SessionExpiredException e,
@@ -369,18 +379,49 @@ class KometAppState extends State<KometApp>
});
}
Future<void> _onIncomingCall(IncomingCall call) async {
Future<void> _ensureFullScreenIntentPermission() async {
final prefs = await SharedPreferences.getInstance();
if (prefs.getBool('fsi_prompted') ?? false) return;
if (await CallBridge.instance.canUseFullScreenIntent()) return;
await prefs.setBool('fsi_prompted', true);
await CallBridge.instance.openFullScreenIntentSettings();
}
void _onIncomingCall(IncomingCall call) {
_pendingIncoming = call;
_presentIncomingCall();
}
void markShellReady() {
if (_shellReady) return;
_shellReady = true;
_presentIncomingCall();
}
void _presentIncomingCall() {
final call = _pendingIncoming;
if (call == null || _incomingRouteActive || !_shellReady) return;
final navState = KometApp.navigatorKey.currentState;
if (navState == null) return;
navState.push(
if (navState == null) {
WidgetsBinding.instance.addPostFrameCallback((_) => _presentIncomingCall());
return;
}
_incomingRouteActive = true;
navState
.push(
MaterialPageRoute(
builder: (_) => CallScreen(
name: ContactCache.get(call.callerId) ?? '',
name: ContactCache.get(call.callerId) ?? call.callerName ?? '',
avatarUrl: ContactCache.getAvatar(call.callerId),
incoming: call,
autoAccept: call.autoAccept,
),
),
);
)
.whenComplete(() {
_incomingRouteActive = false;
if (identical(_pendingIncoming, call)) _pendingIncoming = null;
});
}
@override
@@ -407,12 +448,14 @@ class KometAppState extends State<KometApp>
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
CallController.instance.appResumed = state == AppLifecycleState.resumed;
if (state == AppLifecycleState.paused ||
state == AppLifecycleState.hidden ||
state == AppLifecycleState.detached) {
DebugSessionLog.instance.flushNow();
}
if (state != AppLifecycleState.resumed) return;
CallBridge.instance.checkInitialCall();
if (AppThemeModeConfig.current.value != AppThemeMode.schedule) return;
_rescheduleSwitch();
final next = _effectiveThemeMode;
@@ -839,6 +882,7 @@ class _StartupScreenState extends State<_StartupScreen> {
context,
MaterialPageRoute(builder: (_) => const AdaptiveShell()),
);
KometApp.stateOf(context)?.markShellReady();
}
Future<int?> _recoverActiveAccount() async {
@@ -860,6 +904,7 @@ class _StartupScreenState extends State<_StartupScreen> {
context,
MaterialPageRoute(builder: (_) => const LoginScreen()),
);
KometApp.stateOf(context)?.markShellReady();
}
}