feat: фул интеграция FCM с андроидом нах
This commit is contained in:
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 (_) {}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
+399
-28
@@ -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<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,
|
||||
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<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,
|
||||
_channelName,
|
||||
importance: Importance.high,
|
||||
priority: Priority.high,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,45 +2393,66 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Expanded(
|
||||
child: draft != null
|
||||
? Text.rich(
|
||||
TextSpan(
|
||||
children: [
|
||||
TextSpan(
|
||||
text: 'Черновик: ',
|
||||
style: TextStyle(color: cs.error),
|
||||
child: ValueListenableBuilder<bool>(
|
||||
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),
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
+56
-11
@@ -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(
|
||||
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<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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user