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

This commit is contained in:
Jganenokk
2026-07-02 23:32:31 +07:00
parent bcef107c25
commit a904b733d2
20 changed files with 1941 additions and 90 deletions
+3
View File
@@ -90,4 +90,7 @@ dependencies {
implementation("androidx.media3:media3-transformer:1.9.3")
implementation("androidx.media3:media3-effect:1.9.3")
implementation("androidx.media3:media3-common:1.9.3")
implementation(platform("com.google.firebase:firebase-bom:33.7.0"))
implementation("com.google.firebase:firebase-messaging")
implementation("androidx.core:core-ktx:1.13.1")
}
+26 -1
View File
@@ -1,4 +1,5 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<uses-permission android:name="android.permission.CAMERA"/>
@@ -12,6 +13,10 @@
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE"/>
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT"/>
<uses-permission android:name="android.permission.VIBRATE"/>
<uses-permission android:name="android.permission.WAKE_LOCK"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32"/>
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO"/>
@@ -94,6 +99,26 @@
android:name="android.nfc.cardemulation.host_apdu_service"
android:resource="@xml/komet_nfc_apdu"/>
</service>
<service
android:name=".KometFcmService"
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>
<service
android:name="io.flutter.plugins.firebase.messaging.FlutterFirebaseMessagingService"
tools:node="remove" />
<service
android:name=".CallForegroundService"
android:foregroundServiceType="microphone"
android:exported="false" />
<receiver
android:name=".CallActionReceiver"
android:exported="false" />
<receiver
android:name="com.dexterous.flutterlocalnotifications.ActionBroadcastReceiver"
android:exported="false" />
<meta-data
android:name="flutterEmbedding"
android:value="2" />
@@ -0,0 +1,118 @@
package ru.komet.app
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.IBinder
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.Person
object CallState {
@Volatile
var inCall = false
}
class CallForegroundService : Service() {
companion object {
const val ACTION_START = "ru.komet.app.CALL_ONGOING_START"
const val ACTION_STOP = "ru.komet.app.CALL_ONGOING_STOP"
const val ONGOING_ID = 424243
fun start(ctx: Context, caller: String) {
CallState.inCall = true
val intent = Intent(ctx, CallForegroundService::class.java).apply {
action = ACTION_START
putExtra(CallConst.EXTRA_CALLER, caller)
}
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
ctx.startForegroundService(intent)
} else {
ctx.startService(intent)
}
} catch (e: Exception) {
Log.w("KometFcm", "ongoing FGS start failed: ${e.message}")
}
}
fun stop(ctx: Context) {
CallState.inCall = false
try {
ctx.startService(
Intent(ctx, CallForegroundService::class.java).apply {
action = ACTION_STOP
},
)
} catch (_: Exception) {
}
}
}
override fun onBind(intent: Intent?): IBinder? = null
override fun onDestroy() {
CallState.inCall = false
super.onDestroy()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
ACTION_STOP -> {
@Suppress("DEPRECATION")
stopForeground(true)
stopSelf()
}
else -> {
val caller = intent?.getStringExtra(CallConst.EXTRA_CALLER) ?: "Звонок"
CallNotifier.ensureChannel(this)
startAsForeground(caller)
}
}
return START_NOT_STICKY
}
private fun startAsForeground(caller: String) {
val immutable = android.app.PendingIntent.FLAG_UPDATE_CURRENT or
android.app.PendingIntent.FLAG_IMMUTABLE
val open = Intent(this, MainActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP)
}
val pi = android.app.PendingIntent.getActivity(this, 4, open, immutable)
val hangup = android.app.PendingIntent.getBroadcast(
this, 5,
Intent(this, CallActionReceiver::class.java).apply {
action = CallConst.ACTION_HANGUP
},
immutable,
)
val person = Person.Builder().setName(caller).build()
val notif = NotificationCompat.Builder(this, CallConst.CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setColor(CallConst.ACCENT)
.setColorized(true)
.setCategory(NotificationCompat.CATEGORY_CALL)
.setOngoing(true)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setContentIntent(pi)
.setStyle(NotificationCompat.CallStyle.forOngoingCall(person, hangup))
.build()
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
startForeground(
ONGOING_ID, notif,
ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE,
)
} else {
startForeground(ONGOING_ID, notif)
}
} catch (e: Exception) {
Log.w("KometFcm", "startForeground(mic) failed: ${e.message}")
stopSelf()
}
}
}
@@ -0,0 +1,321 @@
package ru.komet.app
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.media.AudioAttributes
import android.media.AudioManager
import android.media.Ringtone
import android.media.RingtoneManager
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.os.PowerManager
import android.os.VibrationEffect
import android.os.Vibrator
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.Person
import androidx.core.graphics.drawable.IconCompat
import org.json.JSONObject
object AppState {
@Volatile
var resumed = false
}
object CallEvents {
@Volatile
var sink: io.flutter.plugin.common.EventChannel.EventSink? = null
private val main = Handler(Looper.getMainLooper())
fun emit(action: String) {
val s = sink ?: return
main.post {
try {
s.success(mapOf("action" to action))
} catch (_: Exception) {
}
}
}
}
object CallConst {
const val CHANNEL_ID = "komet_calls"
const val CHANNEL_NAME = "Звонки"
const val NOTIF_ID = 424242
const val ACCENT = 0xFF7C6BF0.toInt()
const val EXTRA_CALL = "komet_call"
const val EXTRA_ACTION = "komet_call_action"
const val EXTRA_DECLINE_PAYLOAD = "komet_decline_payload"
const val EXTRA_NOTIF_ID = "komet_notif_id"
const val EXTRA_CALLER = "komet_caller"
const val ACTION_RING = "ring"
const val ACTION_ANSWER = "answer"
const val ACTION_DECLINE = "ru.komet.app.CALL_DECLINE"
const val ACTION_HANGUP = "ru.komet.app.CALL_HANGUP"
const val FLN_RECEIVER =
"com.dexterous.flutterlocalnotifications.ActionBroadcastReceiver"
const val FLN_ACTION_TAPPED =
"com.dexterous.flutterlocalnotifications.ActionBroadcastReceiver.ACTION_TAPPED"
}
object CallNotifier {
fun showIncoming(ctx: Context, data: Map<String, String>) {
if (AppState.resumed) {
Log.d("KometFcm", "call push suppressed (app foreground)")
return
}
val name = data["userName"] ?: data["title"] ?: "Неизвестный"
val callerId = data["suid"] ?: data["callerId"] ?: ""
val conversationId = data["conversationId"] ?: data["vcId"] ?: ""
val vcp = data["vcp"] ?: ""
val account = data["c"] ?: ""
val callJson = JSONObject(data as Map<*, *>).toString()
Log.d("KometFcm", "showIncoming caller=$callerId conv=$conversationId keys=${data.keys}")
ensureChannel(ctx)
val avatar = NotifAvatars.load(ctx, callerId, name)
val person = Person.Builder()
.setName(name)
.setKey(callerId)
.setIcon(IconCompat.createWithBitmap(avatar))
.build()
val fullScreen = launchIntent(ctx, callJson, CallConst.ACTION_RING, name, 1)
val answer = launchIntent(ctx, callJson, CallConst.ACTION_ANSWER, name, 2)
val decline = declineIntent(ctx, vcp, conversationId, account)
val builder = NotificationCompat.Builder(ctx, CallConst.CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setColor(CallConst.ACCENT)
.setColorized(true)
.setOngoing(true)
.setCategory(NotificationCompat.CATEGORY_CALL)
.setPriority(NotificationCompat.PRIORITY_MAX)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setContentIntent(fullScreen)
.setFullScreenIntent(fullScreen, true)
.setStyle(
NotificationCompat.CallStyle.forIncomingCall(person, decline, answer)
.setIsVideo(isVideo(data)),
)
NotificationManagerCompat.from(ctx).notify(CallConst.NOTIF_ID, builder.build())
CallRinger.start(ctx, conversationId)
}
fun finishCall(ctx: Context, data: Map<String, String>) {
Log.d("KometFcm", "finishCall keys=${data.keys}")
CallRinger.stop()
NotificationManagerCompat.from(ctx).cancel(CallConst.NOTIF_ID)
CallEvents.emit("ended")
}
private fun isVideo(data: Map<String, String>): Boolean {
val t = data["type"] ?: data["callType"]
if (t == "VIDEO") return true
val iv = data["iv"]
return iv == "true" || iv == "1"
}
private fun launchIntent(
ctx: Context,
callJson: String,
action: String,
caller: String,
requestCode: Int,
): PendingIntent {
val intent = Intent(ctx, MainActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP)
putExtra(CallConst.EXTRA_CALL, callJson)
putExtra(CallConst.EXTRA_ACTION, action)
putExtra(CallConst.EXTRA_CALLER, caller)
}
var flags = PendingIntent.FLAG_UPDATE_CURRENT
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
flags = flags or PendingIntent.FLAG_IMMUTABLE
}
return PendingIntent.getActivity(ctx, requestCode, intent, flags)
}
private fun declineIntent(
ctx: Context,
vcp: String,
conversationId: String,
account: String,
): PendingIntent {
val payload = JSONObject()
.put("vcp", vcp)
.put("conversationId", conversationId)
.put("c", account.toLongOrNull() ?: account)
.toString()
val intent = Intent(ctx, CallActionReceiver::class.java).apply {
action = CallConst.ACTION_DECLINE
putExtra(CallConst.EXTRA_DECLINE_PAYLOAD, payload)
putExtra(CallConst.EXTRA_NOTIF_ID, CallConst.NOTIF_ID)
}
var flags = PendingIntent.FLAG_UPDATE_CURRENT
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
flags = flags or PendingIntent.FLAG_IMMUTABLE
}
return PendingIntent.getBroadcast(ctx, 3, intent, flags)
}
fun ensureChannel(ctx: Context) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val mgr = ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
if (mgr.getNotificationChannel(CallConst.CHANNEL_ID) != null) return
val channel = NotificationChannel(
CallConst.CHANNEL_ID,
CallConst.CHANNEL_NAME,
NotificationManager.IMPORTANCE_HIGH,
).apply {
description = "Входящие звонки"
setSound(null, null)
enableVibration(false)
setBypassDnd(true)
lockscreenVisibility = NotificationCompat.VISIBILITY_PUBLIC
}
mgr.createNotificationChannel(channel)
}
}
object CallRinger {
private val handler = Handler(Looper.getMainLooper())
private var ringtone: Ringtone? = null
private var vibrator: Vibrator? = null
private var wakeLock: PowerManager.WakeLock? = null
private var timeout: Runnable? = null
@Volatile
var activeConversationId: String? = null
private set
fun start(ctx: Context, conversationId: String) {
stop()
activeConversationId = conversationId
val power = ctx.getSystemService(Context.POWER_SERVICE) as PowerManager
wakeLock = power.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "komet:call").apply {
setReferenceCounted(false)
acquire(60_000L)
}
val audio = ctx.getSystemService(Context.AUDIO_SERVICE) as AudioManager
val mode = audio.ringerMode
if (mode == AudioManager.RINGER_MODE_NORMAL) {
playRingtone(ctx)
vibrate(ctx)
} else if (mode == AudioManager.RINGER_MODE_VIBRATE) {
vibrate(ctx)
}
val t = Runnable { onTimeout(ctx) }
timeout = t
handler.postDelayed(t, 45_000L)
}
fun stop() {
timeout?.let { handler.removeCallbacks(it) }
timeout = null
try {
ringtone?.stop()
} catch (_: Exception) {
}
ringtone = null
try {
vibrator?.cancel()
} catch (_: Exception) {
}
vibrator = null
try {
wakeLock?.let { if (it.isHeld) it.release() }
} catch (_: Exception) {
}
wakeLock = null
activeConversationId = null
}
private fun onTimeout(ctx: Context) {
stop()
NotificationManagerCompat.from(ctx).cancel(CallConst.NOTIF_ID)
}
private fun playRingtone(ctx: Context) {
try {
val uri = RingtoneManager.getActualDefaultRingtoneUri(
ctx, RingtoneManager.TYPE_RINGTONE,
) ?: RingtoneManager.getActualDefaultRingtoneUri(
ctx, RingtoneManager.TYPE_NOTIFICATION,
) ?: return
val rt = RingtoneManager.getRingtone(ctx, uri) ?: return
rt.audioAttributes = AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_NOTIFICATION_RINGTONE)
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
.build()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
rt.isLooping = true
}
rt.play()
ringtone = rt
} catch (e: Exception) {
Log.w("KometFcm", "ringtone failed: ${e.message}")
}
}
private fun vibrate(ctx: Context) {
try {
val vib = ctx.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator
if (!vib.hasVibrator()) return
val pattern = longArrayOf(0, 1000, 1000)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
vib.vibrate(VibrationEffect.createWaveform(pattern, 0))
} else {
@Suppress("DEPRECATION")
vib.vibrate(pattern, 0)
}
vibrator = vib
} catch (e: Exception) {
Log.w("KometFcm", "vibrate failed: ${e.message}")
}
}
}
class CallActionReceiver : BroadcastReceiver() {
override fun onReceive(ctx: Context, intent: Intent) {
if (intent.action == CallConst.ACTION_HANGUP) {
CallEvents.emit("hangup")
return
}
if (intent.action != CallConst.ACTION_DECLINE) return
CallRinger.stop()
val notifId = intent.getIntExtra(CallConst.EXTRA_NOTIF_ID, CallConst.NOTIF_ID)
NotificationManagerCompat.from(ctx).cancel(notifId)
val payload = intent.getStringExtra(CallConst.EXTRA_DECLINE_PAYLOAD) ?: return
val fln = Intent(CallConst.FLN_ACTION_TAPPED).apply {
setClassName(ctx, CallConst.FLN_RECEIVER)
putExtra("notificationId", notifId)
putExtra("actionId", "call_decline")
putExtra("payload", payload)
putExtra("cancelNotification", true)
}
try {
ctx.sendBroadcast(fln)
} catch (e: Exception) {
Log.w("KometFcm", "decline broadcast failed: ${e.message}")
}
}
}
@@ -0,0 +1,312 @@
package ru.komet.app
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.Typeface
import android.os.Build
import android.text.SpannableStringBuilder
import android.text.Spanned
import android.text.style.StyleSpan
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.Person
import androidx.core.app.RemoteInput
import androidx.core.content.LocusIdCompat
import androidx.core.content.pm.ShortcutInfoCompat
import androidx.core.content.pm.ShortcutManagerCompat
import androidx.core.graphics.drawable.IconCompat
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
import org.json.JSONArray
import org.json.JSONObject
class KometFcmService : FirebaseMessagingService() {
override fun onMessageReceived(message: RemoteMessage) {
val data = message.data
Log.d("KometFcm", "onMessageReceived type=${data["type"]} keys=${data.keys}")
if (data.isEmpty()) return
KometNotifier(applicationContext).handle(data)
}
}
class KometNotifier(private val ctx: Context) {
companion object {
private const val CHANNEL_ID = "komet_messages"
private const val CHANNEL_NAME = "Сообщения"
private const val GROUP_KEY = "komet_messages_group"
private const val SUMMARY_ID = 424200
private const val PREFS = "komet_push"
private const val FLN_RECEIVER =
"com.dexterous.flutterlocalnotifications.ActionBroadcastReceiver"
private const val FLN_ACTION_TAPPED =
"com.dexterous.flutterlocalnotifications.ActionBroadcastReceiver.ACTION_TAPPED"
private const val FLN_INPUT_RESULT = "FlutterLocalNotificationsPluginInputResult"
private const val HISTORY_LIMIT = 8
private const val ACCENT = 0xFF7C6BF0.toInt()
}
private data class Hist(val text: String, val key: String, val name: String, val ts: Long)
fun handle(data: Map<String, String>) {
when (data["type"]) {
"InboundCall" -> CallNotifier.showIncoming(ctx, data)
"CallFinished" -> CallNotifier.finishCall(ctx, data)
else -> showMessage(data)
}
}
private fun manager(): NotificationManager =
ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
private fun ensureChannel() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
if (manager().getNotificationChannel(CHANNEL_ID) == null) {
manager().createNotificationChannel(
NotificationChannel(CHANNEL_ID, CHANNEL_NAME, NotificationManager.IMPORTANCE_HIGH),
)
}
}
private fun showMessage(data: Map<String, String>) {
val chatId = data["mc"]?.toLongOrNull() ?: return
val senderId = data["suid"] ?: ""
val senderName = data["userName"] ?: data["title"] ?: "MAX"
val chatTitle = data["title"] ?: senderName
val text = data["msg"] ?: data["body"] ?: data["text"] ?: "Новое сообщение"
val ts = data["ctime"]?.toLongOrNull() ?: data["ttime"]?.toLongOrNull()
?: System.currentTimeMillis()
val isGroup = chatTitle != senderName
val notifId = (chatId and 0x7fffffff).toInt()
ensureChannel()
val active = activeIds()
if (!active.contains(notifId)) clearHistory(chatId)
val history = appendHistory(chatId, Hist(text, senderId, senderName, ts))
val avatarCache = HashMap<String, Bitmap>()
val personCache = HashMap<String, Person>()
fun avatarFor(key: String, name: String): Bitmap =
avatarCache.getOrPut(key) { NotifAvatars.load(ctx, key, name) }
fun personFor(key: String, name: String): Person =
personCache.getOrPut(key) {
Person.Builder()
.setName(name)
.setKey(key)
.setIcon(IconCompat.createWithBitmap(avatarFor(key, name)))
.build()
}
val senderPerson = personFor(senderId, senderName)
val shortcutId = "chat_$chatId"
publishShortcut(shortcutId, chatId, chatTitle, senderPerson)
val style = NotificationCompat.MessagingStyle(Person.Builder().setName("Вы").build())
if (isGroup) {
style.conversationTitle = chatTitle
style.isGroupConversation = true
}
for (h in history) {
style.addMessage(h.text, h.ts, personFor(h.key, h.name))
}
val builder = NotificationCompat.Builder(ctx, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setColor(ACCENT)
.setCategory(NotificationCompat.CATEGORY_MESSAGE)
.setAutoCancel(true)
.setGroup(GROUP_KEY)
.setWhen(ts)
.setShowWhen(true)
.setContentIntent(openIntent(notifId, chatId))
.setShortcutId(shortcutId)
.setLocusId(LocusIdCompat(shortcutId))
.setStyle(style)
.setLargeIcon(avatarFor(senderId, senderName))
val account = data["c"]?.toIntOrNull() ?: 0
if (account != 0) {
builder.addAction(replyAction(notifId, account, chatId, data["msgid"]?.toLongOrNull()))
}
manager().notify(notifId, builder.build())
updateSummary(notifId, chatId, senderName, text, ts, active)
}
private fun updateSummary(
notifId: Int,
chatId: Long,
senderName: String,
text: String,
ts: Long,
activeBefore: Set<Int>,
) {
val reg = loadRegistry()
val kept = JSONObject()
val keys = reg.keys()
while (keys.hasNext()) {
val k = keys.next()
val id = k.toIntOrNull() ?: continue
if (id == notifId) continue
if (activeBefore.contains(id)) kept.put(k, reg.getJSONObject(k))
}
kept.put(
notifId.toString(),
JSONObject().put("n", senderName).put("t", text).put("ts", ts),
)
saveRegistry(kept)
if (kept.length() < 2) {
manager().cancel(SUMMARY_ID)
return
}
val entries = ArrayList<Triple<String, String, Long>>()
val kk = kept.keys()
while (kk.hasNext()) {
val k = kk.next()
val o = kept.getJSONObject(k)
entries.add(Triple(o.optString("n"), o.optString("t"), o.optLong("ts")))
}
entries.sortByDescending { it.third }
val inbox = NotificationCompat.InboxStyle()
for (e in entries.take(6)) inbox.addLine(boldLine(e.first, e.second))
val summary = NotificationCompat.Builder(ctx, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setColor(ACCENT)
.setCategory(NotificationCompat.CATEGORY_MESSAGE)
.setGroup(GROUP_KEY)
.setGroupSummary(true)
.setAutoCancel(true)
.setWhen(ts)
.setShowWhen(true)
.setNumber(entries.size)
.setContentTitle("Komet")
.setContentText(boldLine(senderName, text))
.setStyle(inbox)
.build()
manager().notify(SUMMARY_ID, summary)
}
private fun boldLine(name: String, text: String): CharSequence {
val sb = SpannableStringBuilder()
sb.append(name)
sb.setSpan(StyleSpan(Typeface.BOLD), 0, name.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
sb.append(": ")
sb.append(text)
return sb
}
private fun replyAction(
notifId: Int,
account: Int,
chatId: Long,
replyTo: Long?,
): NotificationCompat.Action {
val payload = JSONObject()
.put("c", account)
.put("chat", chatId)
.apply { if (replyTo != null) put("mid", replyTo) }
.toString()
val intent = Intent(FLN_ACTION_TAPPED).apply {
setClassName(ctx, FLN_RECEIVER)
putExtra("notificationId", notifId)
putExtra("actionId", "reply")
putExtra("payload", payload)
putExtra("cancelNotification", false)
}
var flags = PendingIntent.FLAG_UPDATE_CURRENT
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
flags = flags or PendingIntent.FLAG_MUTABLE
}
val pi = PendingIntent.getBroadcast(ctx, notifId, intent, flags)
val remoteInput = RemoteInput.Builder(FLN_INPUT_RESULT)
.setLabel("Сообщение…")
.build()
return NotificationCompat.Action.Builder(R.drawable.ic_notification, "Ответить", pi)
.addRemoteInput(remoteInput)
.setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_REPLY)
.setAllowGeneratedReplies(true)
.build()
}
private fun publishShortcut(id: String, chatId: Long, title: String, person: Person) {
try {
val intent = (ctx.packageManager.getLaunchIntentForPackage(ctx.packageName)
?: Intent(Intent.ACTION_VIEW)).apply {
action = Intent.ACTION_VIEW
putExtra("komet_chat", chatId)
}
val shortcut = ShortcutInfoCompat.Builder(ctx, id)
.setShortLabel(title)
.setLongLived(true)
.setIntent(intent)
.setPerson(person)
.setIcon(person.icon)
.build()
ShortcutManagerCompat.pushDynamicShortcut(ctx, shortcut)
} catch (e: Exception) {
Log.w("KometFcm", "shortcut push failed: $e")
}
}
private fun openIntent(notifId: Int, chatId: Long): PendingIntent? {
val launch = ctx.packageManager.getLaunchIntentForPackage(ctx.packageName) ?: return null
launch.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
launch.putExtra("komet_chat", chatId)
val flags = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
return PendingIntent.getActivity(ctx, notifId, launch, flags)
}
private fun activeIds(): Set<Int> = try {
manager().activeNotifications.map { it.id }.toSet()
} catch (e: Exception) {
emptySet()
}
private fun pushPrefs() = ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
private fun appendHistory(chatId: Long, item: Hist): List<Hist> {
val prefs = pushPrefs()
val key = "hist_$chatId"
val arr = try {
JSONArray(prefs.getString(key, "[]"))
} catch (e: Exception) {
JSONArray()
}
arr.put(
JSONObject().put("t", item.text).put("k", item.key)
.put("n", item.name).put("ts", item.ts),
)
while (arr.length() > HISTORY_LIMIT) arr.remove(0)
prefs.edit().putString(key, arr.toString()).apply()
val out = ArrayList<Hist>(arr.length())
for (i in 0 until arr.length()) {
val o = arr.getJSONObject(i)
out.add(Hist(o.optString("t"), o.optString("k"), o.optString("n"), o.optLong("ts")))
}
return out
}
private fun clearHistory(chatId: Long) {
pushPrefs().edit().remove("hist_$chatId").apply()
}
private fun loadRegistry(): JSONObject = try {
JSONObject(pushPrefs().getString("registry", "{}") ?: "{}")
} catch (e: Exception) {
JSONObject()
}
private fun saveRegistry(reg: JSONObject) {
pushPrefs().edit().putString("registry", reg.toString()).apply()
}
}
@@ -1,6 +1,7 @@
package ru.komet.app
import android.Manifest
import android.app.KeyguardManager
import android.content.ComponentName
import android.content.Context
import android.content.Intent
@@ -13,13 +14,18 @@ import android.nfc.NfcAdapter
import android.nfc.Tag
import android.nfc.tech.IsoDep
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.provider.Settings
import android.util.Log
import android.view.WindowManager
import androidx.core.app.ActivityCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.embedding.engine.FlutterEngineCache
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodChannel
import android.media.MediaCodecInfo
@@ -64,9 +70,12 @@ class MainActivity : FlutterActivity() {
private var pendingPeer: NfcExchange.Peer? = null
@Volatile private var exchangingEmitted = false
private var pendingCall: Map<String, Any?>? = null
private companion object {
const val LOG_TAG = "VpnBypass"
const val NFC_TAG = "NfcExchange"
const val CALL_ENGINE_ID = "komet_call_engine"
const val NFC_PHASE_MIN_MS = 350L
const val NFC_PHASE_JITTER_MS = 400
const val BLE_PERMS_REQUEST = 7711
@@ -256,6 +265,132 @@ class MainActivity : FlutterActivity() {
else -> result.notImplemented()
}
}
MethodChannel(
flutterEngine.dartExecutor.binaryMessenger,
"ru.komet.app/calls",
).setMethodCallHandler { call, result ->
when (call.method) {
"consumeInitialCall" -> {
val p = pendingCall
pendingCall = null
result.success(p)
}
"notifyAccepted" -> {
val caller = call.argument<String>("caller") ?: "Звонок"
CallRinger.stop()
NotificationManagerCompat.from(this).cancel(CallConst.NOTIF_ID)
CallForegroundService.start(applicationContext, caller)
result.success(null)
}
"notifyEnded" -> {
CallRinger.stop()
NotificationManagerCompat.from(this).cancel(CallConst.NOTIF_ID)
CallForegroundService.stop(applicationContext)
clearCallWindowFlags()
result.success(null)
}
"cancelIncoming" -> {
CallRinger.stop()
NotificationManagerCompat.from(this).cancel(CallConst.NOTIF_ID)
result.success(null)
}
"canUseFullScreenIntent" -> {
result.success(
NotificationManagerCompat.from(this).canUseFullScreenIntent(),
)
}
"openFullScreenIntentSettings" -> {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
try {
startActivity(
Intent(
Settings.ACTION_MANAGE_APP_USE_FULL_SCREEN_INTENT,
Uri.parse("package:$packageName"),
),
)
} catch (e: Exception) {
Log.w("KometFcm", "open FSI settings failed: ${e.message}")
}
}
result.success(null)
}
else -> result.notImplemented()
}
}
EventChannel(
flutterEngine.dartExecutor.binaryMessenger,
"ru.komet.app/calls_events",
).setStreamHandler(object : EventChannel.StreamHandler {
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
CallEvents.sink = events
}
override fun onCancel(arguments: Any?) {
CallEvents.sink = null
}
})
}
override fun onCreate(savedInstanceState: Bundle?) {
if (intent?.hasExtra(CallConst.EXTRA_CALL) == true) applyCallWindowFlags()
super.onCreate(savedInstanceState)
intent?.let { if (it.hasExtra(CallConst.EXTRA_CALL)) stashCall(it, emit = false) }
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
if (intent.hasExtra(CallConst.EXTRA_CALL)) {
applyCallWindowFlags()
stashCall(intent, emit = true)
}
}
private fun stashCall(intent: Intent, emit: Boolean) {
val json = intent.getStringExtra(CallConst.EXTRA_CALL) ?: return
val action = intent.getStringExtra(CallConst.EXTRA_ACTION) ?: CallConst.ACTION_RING
if (action == CallConst.ACTION_ANSWER) CallRinger.stop()
val map = mapOf<String, Any?>("data" to json, "action" to action)
val sink = CallEvents.sink
if (emit && sink != null) {
sink.success(map)
} else {
pendingCall = map
}
}
private fun applyCallWindowFlags() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
setShowWhenLocked(true)
setTurnScreenOn(true)
} else {
@Suppress("DEPRECATION")
window.addFlags(
WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or
WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON or
WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON,
)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val km = getSystemService(Context.KEYGUARD_SERVICE) as? KeyguardManager
km?.requestDismissKeyguard(this, null)
}
}
private fun clearCallWindowFlags() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
setShowWhenLocked(false)
setTurnScreenOn(false)
} else {
@Suppress("DEPRECATION")
window.clearFlags(
WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or
WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON or
WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON,
)
}
}
// Центр-кроп видео в квадрат size×size (без искажений) через media3
@@ -502,8 +637,42 @@ class MainActivity : FlutterActivity() {
}
}
override fun provideFlutterEngine(context: Context): FlutterEngine? {
val cache = FlutterEngineCache.getInstance()
val cached = cache.get(CALL_ENGINE_ID)
if (cached != null) {
if (CallState.inCall) return cached
cache.remove(CALL_ENGINE_ID)
cached.destroy()
}
return super.provideFlutterEngine(context)
}
override fun shouldDestroyEngineWithHost(): Boolean = !CallState.inCall
override fun cleanUpFlutterEngine(flutterEngine: FlutterEngine) {
if (!CallState.inCall) {
FlutterEngineCache.getInstance().remove(CALL_ENGINE_ID)
}
super.cleanUpFlutterEngine(flutterEngine)
}
override fun onDestroy() {
if (CallState.inCall && isFinishing) {
Log.d("KometFcm", "task removed during call, caching engine")
flutterEngine?.let { FlutterEngineCache.getInstance().put(CALL_ENGINE_ID, it) }
}
super.onDestroy()
}
override fun onResume() {
super.onResume()
AppState.resumed = true
}
override fun onPause() {
super.onPause()
AppState.resumed = false
if (NfcExchange.active) {
stopNfcExchange()
nfcEvents?.success(mapOf("event" to "cancelled"))
@@ -0,0 +1,102 @@
package ru.komet.app
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.BitmapShader
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.Shader
import org.json.JSONObject
import java.net.HttpURLConnection
import java.net.URL
import kotlin.math.abs
import kotlin.math.min
object NotifAvatars {
private val PALETTE = intArrayOf(
0xFF5B8DEF.toInt(), 0xFFEF5B8D.toInt(), 0xFF3FB950.toInt(),
0xFFE3883A.toInt(), 0xFF9B72F0.toInt(), 0xFF2AA9B5.toInt(),
)
fun load(ctx: Context, senderId: String, name: String): Bitmap {
val url = avatarUrl(ctx, senderId)
val raw = if (url != null) downloadBitmap(url) else null
return if (raw != null) circleCrop(raw) else initialsBitmap(name)
}
private fun avatarUrl(ctx: Context, senderId: String): String? {
if (senderId.isEmpty()) return null
return try {
val prefs =
ctx.getSharedPreferences("FlutterSharedPreferences", Context.MODE_PRIVATE)
val raw = prefs.getString("flutter.contact_cache_v1", null) ?: return null
val entry = JSONObject(raw).optJSONObject(senderId) ?: return null
entry.optString("a", "").ifEmpty { null }
} catch (e: Exception) {
null
}
}
private fun downloadBitmap(url: String): Bitmap? {
return try {
val conn = URL(url).openConnection() as HttpURLConnection
conn.connectTimeout = 4000
conn.readTimeout = 5000
conn.doInput = true
conn.connect()
val bmp = BitmapFactory.decodeStream(conn.inputStream)
conn.disconnect()
bmp
} catch (e: Exception) {
null
}
}
private fun circleCrop(src: Bitmap): Bitmap {
val size = min(src.width, src.height)
val squared = Bitmap.createBitmap(
src, (src.width - size) / 2, (src.height - size) / 2, size, size,
)
val output = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
val paint = Paint().apply {
isAntiAlias = true
shader = BitmapShader(squared, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP)
}
val r = size / 2f
Canvas(output).drawCircle(r, r, r, paint)
return output
}
private fun initialsBitmap(name: String): Bitmap {
val size = 256
val output = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
val canvas = Canvas(output)
val bg = Paint().apply {
isAntiAlias = true
color = PALETTE[if (name.isEmpty()) 0 else abs(name.hashCode()) % PALETTE.size]
}
canvas.drawCircle(size / 2f, size / 2f, size / 2f, bg)
val tp = Paint().apply {
isAntiAlias = true
color = Color.WHITE
textAlign = Paint.Align.CENTER
textSize = size * 0.42f
isFakeBoldText = true
}
val fm = tp.fontMetrics
canvas.drawText(initialsOf(name), size / 2f, size / 2f - (fm.ascent + fm.descent) / 2, tp)
return output
}
private fun initialsOf(name: String): String {
val parts = name.trim().split(Regex("\\s+")).filter { it.isNotEmpty() }
return when {
parts.isEmpty() -> "?"
parts.size == 1 -> parts[0].substring(0, 1).uppercase()
else -> (parts[0].substring(0, 1) + parts[1].substring(0, 1)).uppercase()
}
}
}
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#FFFFFFFF">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M20,2H4C2.9,2 2,2.9 2,4v18l4,-4h14c1.1,0 2,-0.9 2,-2V4C22,2.9 21.1,2 20,2z" />
</vector>