From a904b733d255fc20cc0a14fc83b4b5c129e2fc47 Mon Sep 17 00:00:00 2001 From: Jganenokk Date: Thu, 2 Jul 2026 23:30:44 +0700 Subject: [PATCH] =?UTF-8?q?feat:=20=D1=84=D1=83=D0=BB=20=D0=B8=D0=BD=D1=82?= =?UTF-8?q?=D0=B5=D0=B3=D1=80=D0=B0=D1=86=D0=B8=D1=8F=20FCM=20=D1=81=20?= =?UTF-8?q?=D0=B0=D0=BD=D0=B4=D1=80=D0=BE=D0=B8=D0=B4=D0=BE=D0=BC=20=D0=BD?= =?UTF-8?q?=D0=B0=D1=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- android/app/build.gradle.kts | 3 + android/app/src/main/AndroidManifest.xml | 27 +- .../ru/komet/app/CallForegroundService.kt | 118 +++++ .../main/kotlin/ru/komet/app/CallNotifier.kt | 321 +++++++++++++ .../kotlin/ru/komet/app/KometFcmService.kt | 312 +++++++++++++ .../main/kotlin/ru/komet/app/MainActivity.kt | 169 +++++++ .../main/kotlin/ru/komet/app/NotifAvatars.kt | 102 +++++ .../src/main/res/drawable/ic_notification.xml | 10 + lib/backend/api.dart | 6 +- lib/backend/modules/account.dart | 2 +- lib/core/calls/call_bridge.dart | 101 +++++ lib/core/calls/call_controller.dart | 91 +++- lib/core/push/push_service.dart | 427 ++++++++++++++++-- lib/core/storage/spoofing_service.dart | 6 +- lib/core/storage/typing_store.dart | 57 +++ lib/frontend/screens/calls/call_screen.dart | 14 + .../screens/chats/chat_list_screen.dart | 102 +++-- lib/frontend/screens/chats/chat_screen.dart | 2 + lib/frontend/widgets/animated_text_swap.dart | 94 ++++ lib/main.dart | 67 ++- 20 files changed, 1941 insertions(+), 90 deletions(-) create mode 100644 android/app/src/main/kotlin/ru/komet/app/CallForegroundService.kt create mode 100644 android/app/src/main/kotlin/ru/komet/app/CallNotifier.kt create mode 100644 android/app/src/main/kotlin/ru/komet/app/KometFcmService.kt create mode 100644 android/app/src/main/kotlin/ru/komet/app/NotifAvatars.kt create mode 100644 android/app/src/main/res/drawable/ic_notification.xml create mode 100644 lib/core/calls/call_bridge.dart create mode 100644 lib/core/storage/typing_store.dart create mode 100644 lib/frontend/widgets/animated_text_swap.dart diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 4c9d97d..b424e3e 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -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") } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 20f7944..51e1a45 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,4 +1,5 @@ - + @@ -12,6 +13,10 @@ + + + + @@ -94,6 +99,26 @@ android:name="android.nfc.cardemulation.host_apdu_service" android:resource="@xml/komet_nfc_apdu"/> + + + + + + + + + diff --git a/android/app/src/main/kotlin/ru/komet/app/CallForegroundService.kt b/android/app/src/main/kotlin/ru/komet/app/CallForegroundService.kt new file mode 100644 index 0000000..8700101 --- /dev/null +++ b/android/app/src/main/kotlin/ru/komet/app/CallForegroundService.kt @@ -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() + } + } +} diff --git a/android/app/src/main/kotlin/ru/komet/app/CallNotifier.kt b/android/app/src/main/kotlin/ru/komet/app/CallNotifier.kt new file mode 100644 index 0000000..64db7b6 --- /dev/null +++ b/android/app/src/main/kotlin/ru/komet/app/CallNotifier.kt @@ -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) { + 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) { + Log.d("KometFcm", "finishCall keys=${data.keys}") + CallRinger.stop() + NotificationManagerCompat.from(ctx).cancel(CallConst.NOTIF_ID) + CallEvents.emit("ended") + } + + private fun isVideo(data: Map): 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}") + } + } +} diff --git a/android/app/src/main/kotlin/ru/komet/app/KometFcmService.kt b/android/app/src/main/kotlin/ru/komet/app/KometFcmService.kt new file mode 100644 index 0000000..96e54c1 --- /dev/null +++ b/android/app/src/main/kotlin/ru/komet/app/KometFcmService.kt @@ -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) { + 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) { + 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() + val personCache = HashMap() + 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, + ) { + 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>() + 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 = 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 { + 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(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() + } + +} diff --git a/android/app/src/main/kotlin/ru/komet/app/MainActivity.kt b/android/app/src/main/kotlin/ru/komet/app/MainActivity.kt index ce24545..1257145 100644 --- a/android/app/src/main/kotlin/ru/komet/app/MainActivity.kt +++ b/android/app/src/main/kotlin/ru/komet/app/MainActivity.kt @@ -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? = 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("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("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")) diff --git a/android/app/src/main/kotlin/ru/komet/app/NotifAvatars.kt b/android/app/src/main/kotlin/ru/komet/app/NotifAvatars.kt new file mode 100644 index 0000000..00326a7 --- /dev/null +++ b/android/app/src/main/kotlin/ru/komet/app/NotifAvatars.kt @@ -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() + } + } +} diff --git a/android/app/src/main/res/drawable/ic_notification.xml b/android/app/src/main/res/drawable/ic_notification.xml new file mode 100644 index 0000000..a878480 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_notification.xml @@ -0,0 +1,10 @@ + + + diff --git a/lib/backend/api.dart b/lib/backend/api.dart index dad59b8..6884616 100644 --- a/lib/backend/api.dart +++ b/lib/backend/api.dart @@ -49,6 +49,8 @@ class Api { int? get callsSeed => _callsSeed; String? get deviceId => _deviceId; + String? spoofScope; + List? _registrationCountries; List 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; diff --git a/lib/backend/modules/account.dart b/lib/backend/modules/account.dart index 533750c..9aacd04 100644 --- a/lib/backend/modules/account.dart +++ b/lib/backend/modules/account.dart @@ -504,7 +504,7 @@ class AccountModule { _ensureOnline(); final packet = await _api.sendRequest(Opcode.config, { 'pushToken': pushToken, - 'pushOptions': 131072, + 'pushOptions': 0, }); if (packet.isError) { final msg = messageFromErrorPayload(packet.payload).toUpperCase(); diff --git a/lib/core/calls/call_bridge.dart b/lib/core/calls/call_bridge.dart new file mode 100644 index 0000000..b139b3a --- /dev/null +++ b/lib/core/calls/call_bridge.dart @@ -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 checkInitialCall() async { + if (!_android) return; + try { + _handle(await _method.invokeMethod('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 notifyAccepted({String? caller}) async { + if (!_android) return; + try { + await _method.invokeMethod('notifyAccepted', {'caller': caller}); + } catch (_) {} + } + + Future notifyEnded() async { + if (!_android) return; + try { + await _method.invokeMethod('notifyEnded'); + } catch (_) {} + } + + Future cancelIncoming() async { + if (!_android) return; + try { + await _method.invokeMethod('cancelIncoming'); + } catch (_) {} + } + + Future canUseFullScreenIntent() async { + if (!_android) return true; + try { + return await _method.invokeMethod('canUseFullScreenIntent') ?? true; + } catch (_) { + return true; + } + } + + Future openFullScreenIntentSettings() async { + if (!_android) return; + try { + await _method.invokeMethod('openFullScreenIntentSettings'); + } catch (_) {} + } +} diff --git a/lib/core/calls/call_controller.dart b/lib/core/calls/call_controller.dart index e67b529..6eebfea 100644 --- a/lib/core/calls/call_controller.dart +++ b/lib/core/calls/call_controller.dart @@ -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.broadcast(); final _ended = StreamController.broadcast(); + final _canceled = StreamController.broadcast(); + + bool appResumed = false; - /// Новый входящий звонок — UI показывает экран/оверлей. Stream get incomingCalls => _incoming.stream; - /// Активный звонок завершился (любой стороной). Stream get callEnded => _ended.stream; + Stream 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 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 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 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 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 endActive() => _active?.hangup() ?? Future.value(); - /// DEBUG: послать в активный звонок сигнал состояния микрофона - /// (`change-media-settings`), не трогая реальный микрофон. - /// Возвращает `false`, если активного звонка нет. Future 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(); } } diff --git a/lib/core/push/push_service.dart b/lib/core/push/push_service.dart index 385f427..33c29da 100644 --- a/lib/core/push/push_service.dart +++ b/lib/core/push/push_service.dart @@ -1,65 +1,438 @@ 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 _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 _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 _display( +Future _showMessageNotification( FlutterLocalNotificationsPlugin plugin, - RemoteMessage message, + Map 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 = {}; + 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, - notificationDetails: const NotificationDetails( + 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 _showCallNotification( + FlutterLocalNotificationsPlugin plugin, + Map 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> _appendHistory( + int chatId, + String senderKey, + String senderName, + String text, + int ts, +) async { + final prefs = await SharedPreferences.getInstance(); + final key = 'notif_hist_$chatId'; + final list = >[]; + 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()); + } + } + } 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 _isActive(FlutterLocalNotificationsPlugin plugin, int id) async { + try { + final active = await plugin.getActiveNotifications(); + return active.any((n) => n.id == id); + } catch (_) { + return true; + } +} + +Future _clearHistory(int chatId) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove('notif_hist_$chatId'); +} + +const _avatarPalette = [ + 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 _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 _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 _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 _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 _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, { + '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, + _channelName, + importance: Importance.high, + priority: Priority.high, + ), + ), + ); + } +} + class PushService { PushService._(); static final PushService instance = PushService._(); + static Future 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); diff --git a/lib/core/storage/spoofing_service.dart b/lib/core/storage/spoofing_service.dart index 0fb6451..38031e4 100644 --- a/lib/core/storage/spoofing_service.dart +++ b/lib/core/storage/spoofing_service.dart @@ -115,9 +115,11 @@ class SpoofingService { return profile; } - static Future?> getSpoofedSessionData() async { + static Future?> 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 { diff --git a/lib/core/storage/typing_store.dart b/lib/core/storage/typing_store.dart new file mode 100644 index 0000000..1d7fd94 --- /dev/null +++ b/lib/core/storage/typing_store.dart @@ -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> _users = {}; + final Map> _timers = {}; + final Map> _notifiers = {}; + + ValueListenable listenable(int chatId) => _notifiers.putIfAbsent( + chatId, + () => ValueNotifier(_users[chatId]?.isNotEmpty ?? false), + ); + + bool isTyping(int chatId) => _users[chatId]?.isNotEmpty ?? false; + + void markTyping(int chatId, int userId) { + final timers = _timers.putIfAbsent(chatId, () => {}); + timers[userId]?.cancel(); + timers[userId] = Timer(_ttl, () => _remove(chatId, userId)); + _users.putIfAbsent(chatId, () => {}).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; + } +} diff --git a/lib/frontend/screens/calls/call_screen.dart b/lib/frontend/screens/calls/call_screen.dart index c0aed0f..74eefc0 100644 --- a/lib/frontend/screens/calls/call_screen.dart +++ b/lib/frontend/screens/calls/call_screen.dart @@ -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 with TickerProviderStateMixin { CallSession? _session; StreamSubscription? _stateSub; + StreamSubscription? _canceledSub; StreamSubscription? _infoSub; StreamSubscription? _kometSub; StreamSubscription? _chatSub; @@ -112,6 +115,16 @@ class _CallScreenState extends State 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 _resolvePeerInfo(int id) async { @@ -364,6 +377,7 @@ class _CallScreenState extends State @override void dispose() { _stateSub?.cancel(); + _canceledSub?.cancel(); _infoSub?.cancel(); _kometSub?.cancel(); _chatSub?.cancel(); diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index 19a852d..34a6b17 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -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 StreamSubscription? _stateSub; StreamSubscription? _loginSub; + StreamSubscription? _typingSub; + StreamSubscription? _typingMsgSub; Widget? _cachedChatsBody; Object? _chatsBodyCacheKey; @@ -500,9 +506,29 @@ class _ChatListScreenState extends State 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 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 String time, String imageUrl, { int presenceUserId = 0, - bool isTyping = false, bool isRead = false, int unreadCount = 0, bool isMuted = false, @@ -2366,45 +2393,66 @@ class _ChatListScreenState extends State crossAxisAlignment: CrossAxisAlignment.end, children: [ Expanded( - child: draft != null - ? Text.rich( - TextSpan( - children: [ - TextSpan( - text: 'Черновик: ', - style: TextStyle(color: cs.error), + child: ValueListenableBuilder( + valueListenable: TypingStore.instance + .listenable(int.tryParse(id) ?? 0), + child: draft != null + ? Text.rich( + TextSpan( + children: [ + TextSpan( + text: 'Черновик: ', + style: TextStyle(color: cs.error), + ), + TextSpan( + text: draft, + style: TextStyle( + color: cs.outline, + ), + ), + ], + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w400, + fontStyle: FontStyle.italic, + height: 1.2, ), - TextSpan( - text: draft, - style: TextStyle(color: cs.outline), - ), - ], - style: const TextStyle( + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ) + : Text( + message, + style: TextStyle( + color: cs.outline, fontSize: 14, fontWeight: FontWeight.w400, - fontStyle: FontStyle.italic, + fontStyle: messageItalic + ? FontStyle.italic + : FontStyle.normal, height: 1.2, ), + maxLines: 1, + overflow: TextOverflow.ellipsis, ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ) - : Text( - message, + builder: (context, typing, base) { + return AnimatedTextSwap( + showAlternate: typing, + alternate: Text( + 'печатает...', style: TextStyle( - color: isTyping ? cs.primary : cs.outline, + color: cs.primary, fontSize: 14, - fontWeight: isTyping - ? FontWeight.w500 - : FontWeight.w400, - fontStyle: messageItalic - ? FontStyle.italic - : FontStyle.normal, + fontWeight: FontWeight.w500, height: 1.2, ), maxLines: 1, overflow: TextOverflow.ellipsis, ), + child: base!, + ); + }, + ), ), ?statusIcon, const SizedBox(width: 8), diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index 89015e5..f1652b4 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -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 @override void initState() { super.initState(); + unawaited(PushService.clearChatNotification(widget.chatId)); WidgetsBinding.instance.addObserver(this); ChatsModule.chatsChanged.addListener(_onChatsBump); _messageController.addListener(_onTextChanged); diff --git a/lib/frontend/widgets/animated_text_swap.dart b/lib/frontend/widgets/animated_text_swap.dart new file mode 100644 index 0000000..00630ec --- /dev/null +++ b/lib/frontend/widgets/animated_text_swap.dart @@ -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 createState() => _AnimatedTextSwapState(); +} + +class _AnimatedTextSwapState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + late final Animation _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, + ), + ), + ], + ); + }, + ); + } +} diff --git a/lib/main.dart b/lib/main.dart index 927d613..c705efa 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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 late Locale _locale; late String _fontId; bool _isLoggingOut = false; + bool _shellReady = false; + bool _incomingRouteActive = false; + IncomingCall? _pendingIncoming; late final ValueNotifier accentSeed = ValueNotifier( widget.initialAccentSeed, ); @@ -296,6 +300,7 @@ class KometAppState extends State if (isOnemeFlavor) { await PushService.instance.init(api: api, account: accountModule); await PushService.instance.onLoginSuccess(); + await _ensureFullScreenIntentPermission(); } } }); @@ -303,6 +308,11 @@ class KometAppState extends State _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 }); } - Future _onIncomingCall(IncomingCall call) async { + Future _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( - MaterialPageRoute( - builder: (_) => CallScreen( - name: ContactCache.get(call.callerId) ?? '', - avatarUrl: ContactCache.getAvatar(call.callerId), - incoming: call, - ), - ), - ); + if (navState == null) { + WidgetsBinding.instance.addPostFrameCallback((_) => _presentIncomingCall()); + return; + } + _incomingRouteActive = true; + navState + .push( + MaterialPageRoute( + builder: (_) => CallScreen( + 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 @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 _recoverActiveAccount() async { @@ -860,6 +904,7 @@ class _StartupScreenState extends State<_StartupScreen> { context, MaterialPageRoute(builder: (_) => const LoginScreen()), ); + KometApp.stateOf(context)?.markShellReady(); } }