Initial import of f7cloud-mobile native Android app.

Kotlin/Compose client for F7cloud (mail, files, talk, calendar, contacts, tasks, support).
Current version: 0.5.113 (build 121).
This commit is contained in:
F7cloud Mobile
2026-07-07 12:05:18 +03:00
commit fd17df80a8
1789 changed files with 246889 additions and 0 deletions
+28
View File
@@ -0,0 +1,28 @@
plugins {
id 'com.android.library'
id 'org.jetbrains.kotlin.android'
}
android {
namespace 'ru.forbion.f7cloud.core.push'
compileSdk 36
defaultConfig {
minSdk 26
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = '17'
}
}
dependencies {
implementation project(':core:auth')
implementation 'com.google.firebase:firebase-messaging:24.0.1'
implementation 'androidx.core:core-ktx:1.15.0'
implementation 'androidx.core:core:1.15.0'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1'
}
+16
View File
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<service
android:name=".F7FirebaseMessagingService"
android:directBootAware="true"
android:exported="false">
<intent-filter android:priority="1">
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>
<receiver
android:name=".F7CallActionReceiver"
android:exported="false" />
</application>
</manifest>
@@ -0,0 +1,20 @@
package ru.forbion.f7cloud.core.push
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
/** Handles Decline on incoming Talk call notifications. */
class F7CallActionReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action != ACTION_DECLINE) return
val roomToken = intent.getStringExtra(EXTRA_ROOM_TOKEN)
F7IncomingCallQueue.dismissAndShowNext(context, roomToken)
}
companion object {
const val ACTION_DECLINE = "ru.forbion.f7cloud.action.DECLINE_CALL"
const val EXTRA_NOTIFICATION_ID = "notificationId"
const val EXTRA_ROOM_TOKEN = "roomToken"
}
}
@@ -0,0 +1,113 @@
package ru.forbion.f7cloud.core.push
import android.Manifest
import android.content.pm.PackageManager
import android.os.Build
import android.os.PowerManager
import android.util.Log
import androidx.core.content.ContextCompat
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import ru.forbion.f7cloud.core.auth.AuthStore
class F7FirebaseMessagingService : FirebaseMessagingService() {
override fun onNewToken(token: String) {
Log.i(TAG, "FCM token refreshed")
val session = AuthStore(this).load()
if (session != null) {
CoroutineScope(Dispatchers.IO).launch {
val code = F7PushRegistrar.registerBlocking(this@F7FirebaseMessagingService, session, token)
Log.i(TAG, "push register result: $code")
}
}
super.onNewToken(token)
}
override fun onMessageReceived(message: RemoteMessage) {
val data = message.data
Log.i(TAG, "FCM data keys=${data.keys} priority=${message.priority}")
val type = data["type"]
val clickUrl = data["acceptUrl"]
?: data["url"]
?: data["clickUrl"]
?: data["link"]
val priority = data["priority"]
val highPriority = priority.equals("high", ignoreCase = true)
val title = message.notification?.title
?: data["title"]
?: "F7cloud"
val body = message.notification?.body
?: data["body"]
?: ""
// Only explicit call pushes should ring — Talk recording/chat links may also contain "/call/".
val isCall = type == "call"
val pushEvent = F7PushEventParser.parse(data, title, body)
F7PushEventHub.publish(pushEvent)
if (isCall) {
if (!canPostNotifications()) {
Log.w(TAG, "POST_NOTIFICATIONS denied — incoming call UI blocked")
}
val wakeLock = (getSystemService(POWER_SERVICE) as? PowerManager)
?.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "f7cloud:incoming_call")
?.apply { acquire(30_000L) }
try {
val shown = F7IncomingCallQueue.enqueue(
context = this,
title = title,
body = body,
acceptUrl = data["acceptUrl"] ?: clickUrl,
roomToken = data["roomToken"],
roomDisplayName = data["roomDisplayName"],
)
Log.i(TAG, "Incoming call enqueued: title=$title room=${data["roomToken"]} shown=$shown")
} catch (t: Throwable) {
Log.e(TAG, "Incoming call notification failed", t)
} finally {
wakeLock?.let { if (it.isHeld) it.release() }
}
return
}
try {
if (!canPostNotifications()) {
Log.w(TAG, "POST_NOTIFICATIONS denied — message notification skipped")
return
}
F7PushNotificationHelper.show(
context = this,
title = title,
body = body,
openUrl = clickUrl,
highPriority = highPriority,
type = type,
channelHint = data["channel"],
roomToken = data["roomToken"],
messageId = data["messageId"],
)
Log.i(TAG, "Message notification shown: $title")
} catch (t: Throwable) {
Log.e(TAG, "Message notification failed", t)
}
}
private fun canPostNotifications(): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
return true
}
return ContextCompat.checkSelfPermission(
this,
Manifest.permission.POST_NOTIFICATIONS,
) == PackageManager.PERMISSION_GRANTED
}
companion object {
private const val TAG = "F7Push"
}
}
@@ -0,0 +1,448 @@
package ru.forbion.f7cloud.core.push
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Build
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.Person
import org.json.JSONArray
import org.json.JSONObject
import ru.forbion.f7cloud.core.auth.AuthStore
/**
* Shows one incoming Talk call at a time; further calls wait in a FIFO queue.
*/
object F7IncomingCallQueue {
private const val TAG = "F7IncomingCallQueue"
private const val PREFS = "f7push_call_queue"
private const val KEY_QUEUE = "queue"
private const val KEY_ACTIVE_TOKEN = "active_token"
private const val KEY_ACTIVE_AT = "active_at"
const val ACTIVE_NOTIFICATION_ID = 5000
private const val ACTIVE_RING_TTL_MS = 3 * 60 * 1000L
private val lock = Any()
fun enqueue(
context: Context,
title: String,
body: String,
acceptUrl: String?,
roomToken: String?,
roomDisplayName: String? = null,
): Boolean {
val token = normalizeToken(roomToken, acceptUrl, title)
val displayName = TalkCallPushLabels.resolveRoomDisplayName(title, body, roomDisplayName)
synchronized(lock) {
val prefs = prefs(context)
expireStaleActiveLocked(prefs)
val call = PendingCall(token, title, body, acceptUrl, displayName)
val active = prefs.getString(KEY_ACTIVE_TOKEN, "").orEmpty()
if (token == active) {
// Duplicate FCM for the same room — refresh UI only, do not re-ring.
val shown = showNotification(context, call, waiting = 0, alert = false)
if (shown) {
touchActive(prefs)
}
return shown
}
val queue = readQueue(prefs)
if (containsToken(queue, token)) {
return false
}
if (active.isEmpty()) {
setActive(prefs, token)
val shown = showNotification(context, call, waiting = 0)
if (!shown) {
prefs.edit().remove(KEY_ACTIVE_TOKEN).remove(KEY_ACTIVE_AT).apply()
}
return shown
}
queue.put(call.toJson())
prefs.edit().putString(KEY_QUEUE, queue.toString()).apply()
Log.d(TAG, "Call queued: $token, queue size=${queue.length()}")
return false
}
}
fun dismissAndShowNext(context: Context, roomToken: String?) {
synchronized(lock) {
val prefs = prefs(context)
val active = prefs.getString(KEY_ACTIVE_TOKEN, "").orEmpty()
var queue = readQueue(prefs)
if (!roomToken.isNullOrBlank()) {
val token = roomToken.trim()
if (token != active) {
queue = removeToken(queue, token)
prefs.edit().putString(KEY_QUEUE, queue.toString()).apply()
return
}
}
cancelNotification(context)
prefs.edit().remove(KEY_ACTIVE_TOKEN).remove(KEY_ACTIVE_AT).apply()
if (queue.length() == 0) {
prefs.edit().remove(KEY_QUEUE).apply()
return
}
runCatching {
val next = PendingCall.fromJson(queue.getJSONObject(0))
val rest = JSONArray()
for (i in 1 until queue.length()) {
rest.put(queue.get(i))
}
prefs.edit().putString(KEY_QUEUE, rest.toString()).apply()
if (showNotification(context, next, rest.length())) {
setActive(prefs, next.roomToken)
}
}.onFailure {
Log.w(TAG, "Failed to parse queued call", it)
prefs.edit().remove(KEY_QUEUE).apply()
}
}
}
fun clearAll(context: Context) {
synchronized(lock) {
val prefs = prefs(context)
cancelNotification(context)
prefs.edit().remove(KEY_QUEUE).remove(KEY_ACTIVE_TOKEN).apply()
}
}
private fun showNotification(
context: Context,
call: PendingCall,
waiting: Int,
alert: Boolean = true,
): Boolean {
F7NotificationChannels.ensureAll(context)
val body = if (waiting > 0) {
call.body + "\n" + context.resources.getQuantityString(
R.plurals.call_queue_waiting,
waiting,
waiting,
)
} else {
call.body
}
val joinUrl = resolveJoinUrl(context, call) ?: run {
Log.w(TAG, "Cannot resolve join URL for call ${call.roomToken}")
return false
}
val intents = buildPendingIntents(context, call, joinUrl)
if (alert) {
runCatching { F7IncomingCallRinger.start(context, call.roomToken) }
.onFailure { Log.w(TAG, "Ringtone start failed", it) }
}
val posted = runCatching {
postCallStyleNotification(context, call, body, intents, alert)
}.onFailure {
Log.w(TAG, "CallStyle notification failed, using fallback", it)
}.isSuccess || runCatching {
postFallbackNotification(context, call, body, intents, alert)
}.onFailure {
Log.e(TAG, "Fallback call notification failed", it)
}.isSuccess
if (!posted) {
F7IncomingCallRinger.stop()
}
return posted
}
private data class CallPendingIntents(
val accept: PendingIntent,
val preview: PendingIntent,
val decline: PendingIntent,
val fullScreen: PendingIntent,
)
private fun buildPendingIntents(
context: Context,
call: PendingCall,
joinUrl: String,
): CallPendingIntents {
val requestCode = ACTIVE_NOTIFICATION_ID + kotlin.math.abs(call.roomToken.hashCode() % 10000)
val accept = PendingIntent.getActivity(
context,
requestCode,
incomingCallIntent(context, call, joinUrl, autoAccept = true),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
val preview = PendingIntent.getActivity(
context,
requestCode + 50000,
incomingCallIntent(context, call, joinUrl, autoAccept = false),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
val declineIntent = Intent(context, F7CallActionReceiver::class.java).apply {
action = F7CallActionReceiver.ACTION_DECLINE
putExtra(F7CallActionReceiver.EXTRA_NOTIFICATION_ID, ACTIVE_NOTIFICATION_ID)
putExtra(F7CallActionReceiver.EXTRA_ROOM_TOKEN, call.roomToken)
}
val decline = PendingIntent.getBroadcast(
context,
ACTIVE_NOTIFICATION_ID + 1,
declineIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
val fullScreen = PendingIntent.getActivity(
context,
requestCode + 60000,
incomingCallIntent(context, call, joinUrl, autoAccept = false),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
return CallPendingIntents(accept, preview, decline, fullScreen)
}
private fun postCallStyleNotification(
context: Context,
call: PendingCall,
body: String,
intents: CallPendingIntents,
alert: Boolean,
) {
val caller = Person.Builder()
.setName(call.displayName.ifBlank { call.title.ifBlank { context.getString(R.string.incoming_call_subtitle) } })
.build()
val canFullScreen = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
context.getSystemService(NotificationManager::class.java)
?.canUseFullScreenIntent() != false
} else {
true
}
val callStyle = NotificationCompat.CallStyle.forIncomingCall(
caller,
intents.decline,
intents.accept,
)
val notification = NotificationCompat.Builder(context, F7NotificationChannels.CALLS)
.setSmallIcon(android.R.drawable.stat_sys_phone_call)
.setContentTitle(call.displayName.ifBlank { call.title })
.setContentText(body)
.setStyle(callStyle)
.setCategory(NotificationCompat.CATEGORY_CALL)
.setPriority(NotificationCompat.PRIORITY_MAX)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setOngoing(true)
.setAutoCancel(false)
.setOnlyAlertOnce(true)
.setSound(null)
.setDefaults(0)
.setVibrate(null)
.setContentIntent(intents.preview)
.apply {
if (canFullScreen) {
setFullScreenIntent(intents.fullScreen, true)
}
}
.build()
context.getSystemService(NotificationManager::class.java)
?.notify(ACTIVE_NOTIFICATION_ID, notification)
?: throw IllegalStateException("NotificationManager unavailable")
}
private fun postFallbackNotification(
context: Context,
call: PendingCall,
body: String,
intents: CallPendingIntents,
alert: Boolean,
) {
val canFullScreen = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
context.getSystemService(NotificationManager::class.java)
?.canUseFullScreenIntent() != false
} else {
true
}
val notification = NotificationCompat.Builder(context, F7NotificationChannels.CALLS)
.setSmallIcon(android.R.drawable.stat_sys_phone_call)
.setContentTitle(call.displayName.ifBlank { call.title })
.setContentText(body)
.setStyle(NotificationCompat.BigTextStyle().bigText(body))
.setCategory(NotificationCompat.CATEGORY_CALL)
.setPriority(NotificationCompat.PRIORITY_MAX)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setOngoing(true)
.setAutoCancel(false)
.setOnlyAlertOnce(true)
.setSound(null)
.setDefaults(0)
.setVibrate(null)
.setContentIntent(intents.preview)
.apply {
if (canFullScreen) {
setFullScreenIntent(intents.fullScreen, true)
}
}
.addAction(0, context.getString(R.string.call_action_accept), intents.accept)
.addAction(0, context.getString(R.string.call_action_decline), intents.decline)
.build()
context.getSystemService(NotificationManager::class.java)
?.notify(ACTIVE_NOTIFICATION_ID, notification)
?: throw IllegalStateException("NotificationManager unavailable")
}
private fun resolveJoinUrl(context: Context, call: PendingCall): String? {
val raw = call.acceptUrl?.takeIf { it.isNotBlank() }
?: AuthStore(context).load()?.let { session ->
buildCallUrl(session.serverUrl, call.roomToken)
}
?: return null
return stripDirectCallHash(raw)
}
private fun buildCallUrl(serverBase: String, roomToken: String): String {
val base = serverBase.trimEnd('/')
return "$base/call/${roomToken.trim()}"
}
private fun stripDirectCallHash(url: String): String {
val hash = url.indexOf('#')
return if (hash >= 0) url.substring(0, hash) else url
}
private fun prefs(context: Context) =
context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
private fun setActive(prefs: android.content.SharedPreferences, token: String) {
prefs.edit()
.putString(KEY_ACTIVE_TOKEN, token)
.putLong(KEY_ACTIVE_AT, System.currentTimeMillis())
.apply()
}
private fun touchActive(prefs: android.content.SharedPreferences) {
prefs.edit().putLong(KEY_ACTIVE_AT, System.currentTimeMillis()).apply()
}
private fun expireStaleActiveLocked(prefs: android.content.SharedPreferences) {
val active = prefs.getString(KEY_ACTIVE_TOKEN, "").orEmpty()
if (active.isEmpty()) return
val activeAt = prefs.getLong(KEY_ACTIVE_AT, 0L)
if (activeAt <= 0L || System.currentTimeMillis() - activeAt > ACTIVE_RING_TTL_MS) {
Log.w(TAG, "Clearing stale active call: $active")
prefs.edit().remove(KEY_ACTIVE_TOKEN).remove(KEY_ACTIVE_AT).apply()
}
}
private fun normalizeToken(roomToken: String?, acceptUrl: String?, fallback: String): String {
extractTokenFromUrl(acceptUrl)?.let { return it }
roomToken?.trim()?.takeIf { it.isNotBlank() }?.let { return it }
return "call:${kotlin.math.abs(fallback.hashCode())}"
}
private fun extractTokenFromUrl(url: String?): String? {
if (url.isNullOrBlank()) return null
val path = runCatching { android.net.Uri.parse(url).path }.getOrNull() ?: url
val marker = "/call/"
val idx = path.indexOf(marker)
if (idx < 0) return null
val rest = path.substring(idx + marker.length)
val end = rest.indexOfFirst { it == '/' || it == '?' || it == '#' }.let { if (it < 0) rest.length else it }
return rest.substring(0, end).ifBlank { null }
}
private fun cancelNotification(context: Context) {
F7IncomingCallRinger.stop()
context.getSystemService(NotificationManager::class.java)
?.cancel(ACTIVE_NOTIFICATION_ID)
}
private fun readQueue(prefs: android.content.SharedPreferences): JSONArray {
val raw = prefs.getString(KEY_QUEUE, "[]").orEmpty()
return runCatching { JSONArray(raw) }.getOrDefault(JSONArray())
}
private fun containsToken(queue: JSONArray, token: String): Boolean {
for (i in 0 until queue.length()) {
runCatching {
if (token == queue.getJSONObject(i).optString("roomToken")) return true
}
}
return false
}
private fun removeToken(queue: JSONArray, token: String): JSONArray {
val next = JSONArray()
for (i in 0 until queue.length()) {
runCatching {
val item = queue.getJSONObject(i)
if (token != item.optString("roomToken")) {
next.put(item)
}
}
}
return next
}
private data class PendingCall(
val roomToken: String,
val title: String,
val body: String,
val acceptUrl: String?,
val displayName: String,
) {
fun toJson(): JSONObject = JSONObject().apply {
put("roomToken", roomToken)
put("title", title)
put("body", body)
put("displayName", displayName)
if (acceptUrl != null) put("acceptUrl", acceptUrl)
}
companion object {
fun fromJson(o: JSONObject): PendingCall = PendingCall(
roomToken = o.getString("roomToken"),
title = o.optString("title", ""),
body = o.optString("body", ""),
acceptUrl = if (o.has("acceptUrl")) o.optString("acceptUrl") else null,
displayName = o.optString("displayName", ""),
)
}
}
private const val INCOMING_CALL_ACTIVITY = "ru.forbion.f7cloud.mobile.CallIncomingActivity"
private fun incomingCallIntent(
context: Context,
call: PendingCall,
joinUrl: String,
autoAccept: Boolean,
): Intent = Intent().apply {
setClassName(context, INCOMING_CALL_ACTIVITY)
action = PushIntents.ACTION_OPEN_CALL
addFlags(
Intent.FLAG_ACTIVITY_NEW_TASK or
Intent.FLAG_ACTIVITY_CLEAR_TOP or
Intent.FLAG_ACTIVITY_SINGLE_TOP,
)
putExtra(PushIntents.EXTRA_ACCEPT_URL, joinUrl)
putExtra(PushIntents.EXTRA_ROOM_TOKEN, call.roomToken)
putExtra(PushIntents.EXTRA_CALL_TITLE, call.title)
putExtra(PushIntents.EXTRA_CALL_BODY, call.body)
putExtra(PushIntents.EXTRA_ROOM_DISPLAY_NAME, call.displayName)
putExtra(PushIntents.EXTRA_AUTO_ACCEPT, autoAccept)
}
}
@@ -0,0 +1,119 @@
package ru.forbion.f7cloud.core.push
import android.content.Context
import android.media.AudioAttributes
import android.media.MediaPlayer
import android.media.RingtoneManager
import android.util.Log
/**
* Loops the default ringtone while an incoming call waits for Accept/Decline.
* Debounced per call token so duplicate FCM / notification updates do not restart audio.
*/
object F7IncomingCallRinger {
private const val TAG = "F7IncomingCallRinger"
private const val RING_PREFS = "f7_call_ring_guard"
private const val KEY_LAST_TOKEN = "last_token"
private const val KEY_LAST_AT = "last_at"
private const val RING_DEBOUNCE_MS = 90_000L
private val lock = Any()
private var player: MediaPlayer? = null
private var ringing = false
private var activeToken: String? = null
fun isPlaying(): Boolean = synchronized(lock) {
runCatching { player?.isPlaying == true }.getOrDefault(false)
}
fun shouldAlert(context: Context, callToken: String): Boolean {
if (callToken.isBlank()) return false
synchronized(lock) {
if (runCatching { player?.isPlaying == true }.getOrDefault(false)) {
return false
}
val prefs = context.applicationContext.getSharedPreferences(RING_PREFS, Context.MODE_PRIVATE)
val now = System.currentTimeMillis()
val lastToken = prefs.getString(KEY_LAST_TOKEN, "").orEmpty()
val lastAt = prefs.getLong(KEY_LAST_AT, 0L)
if (callToken == lastToken && now - lastAt < RING_DEBOUNCE_MS) {
return false
}
prefs.edit()
.putString(KEY_LAST_TOKEN, callToken)
.putLong(KEY_LAST_AT, now)
.apply()
return true
}
}
fun start(context: Context, callToken: String) {
if (!shouldAlert(context, callToken)) {
return
}
synchronized(lock) {
ringing = true
activeToken = callToken
stopLocked(keepFlag = true)
val uri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_RINGTONE)
if (uri == null) {
Log.w(TAG, "No default ringtone URI")
stopLocked()
return
}
val appContext = context.applicationContext
runCatching {
player = MediaPlayer().apply {
setDataSource(appContext, uri)
isLooping = true
setAudioAttributes(
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_NOTIFICATION_RINGTONE)
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
.build(),
)
setOnPreparedListener { prepared ->
synchronized(lock) {
if (!ringing) {
runCatching { prepared.release() }
return@setOnPreparedListener
}
runCatching { prepared.start() }
.onFailure {
Log.w(TAG, "Ringtone start failed", it)
stopLocked()
}
}
}
setOnErrorListener { _, what, extra ->
Log.w(TAG, "Ringtone error what=$what extra=$extra")
synchronized(lock) { stopLocked() }
true
}
prepareAsync()
}
}.onFailure {
Log.w(TAG, "Ringtone prepare failed", it)
stopLocked()
}
}
}
fun stop() {
synchronized(lock) {
stopLocked()
}
}
private fun stopLocked(keepFlag: Boolean = false) {
if (!keepFlag) {
ringing = false
activeToken = null
}
player?.runCatching {
if (isPlaying) stop()
release()
}
player = null
}
}
@@ -0,0 +1,72 @@
package ru.forbion.f7cloud.core.push
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import android.media.AudioAttributes
import android.media.RingtoneManager
import android.os.Build
object F7NotificationChannels {
const val MESSAGES = "f7_mobile_messages"
/** Silent channel: ringtone is played only by [F7IncomingCallRinger]. */
const val CALLS = "f7_mobile_calls_v3"
/** Channel IDs referenced in FCM payloads from f7push server (background tray). */
private const val SERVER_MESSAGES = "f7cloud_messages_v2"
private const val SERVER_CALLS = "f7cloud_calls_v2"
fun ensureAll(context: Context) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val manager = context.getSystemService(NotificationManager::class.java) ?: return
val audio = AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_NOTIFICATION)
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
.build()
val notificationSound = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION)
val ringtoneAudio = AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_NOTIFICATION_RINGTONE)
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
.build()
val ringtone = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_RINGTONE)
fun create(
id: String,
name: String,
importance: Int,
vibration: LongArray,
sound: android.net.Uri?,
soundAttrs: AudioAttributes,
) {
manager.createNotificationChannel(
NotificationChannel(id, name, importance).apply {
description = name
enableLights(true)
enableVibration(true)
vibrationPattern = vibration
if (sound != null) {
setSound(sound, soundAttrs)
}
setShowBadge(true)
lockscreenVisibility = android.app.Notification.VISIBILITY_PUBLIC
},
)
}
val messagesName = context.getString(R.string.notification_channel_messages)
val callsName = context.getString(R.string.notification_channel_calls)
val msgVibration = longArrayOf(0, 250, 150, 250)
val callVibration = longArrayOf(0, 500, 200, 500)
create(MESSAGES, messagesName, NotificationManager.IMPORTANCE_HIGH, msgVibration, notificationSound, audio)
create(SERVER_MESSAGES, messagesName, NotificationManager.IMPORTANCE_HIGH, msgVibration, notificationSound, audio)
create(CALLS, callsName, NotificationManager.IMPORTANCE_HIGH, longArrayOf(0), null, audio)
create(SERVER_CALLS, callsName, NotificationManager.IMPORTANCE_HIGH, longArrayOf(0), null, audio)
}
fun resolveChannel(channelHint: String?, highPriority: Boolean): String {
if (channelHint == CALLS || channelHint == SERVER_CALLS) return CALLS
if (channelHint == MESSAGES || channelHint == SERVER_MESSAGES) return MESSAGES
return if (highPriority) CALLS else MESSAGES
}
}
@@ -0,0 +1,43 @@
package ru.forbion.f7cloud.core.push
sealed class F7PushEvent {
abstract val url: String?
data class Call(
val title: String,
val body: String,
val roomToken: String?,
val acceptUrl: String?,
override val url: String?,
) : F7PushEvent()
data class Mail(
val title: String,
val body: String,
val mailboxId: Int?,
val messageId: Int?,
override val url: String?,
) : F7PushEvent()
data class Talk(
val title: String,
val body: String,
val roomToken: String?,
val messageId: Long?,
override val url: String?,
) : F7PushEvent()
data class Files(
val title: String,
val body: String,
val fileId: Long?,
override val url: String?,
) : F7PushEvent()
data class Notification(
val title: String,
val body: String,
val source: String?,
override val url: String?,
) : F7PushEvent()
}
@@ -0,0 +1,17 @@
package ru.forbion.f7cloud.core.push
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
/**
* In-app bridge for FCM payloads — lets open screens refresh without waiting for user action.
*/
object F7PushEventHub {
private val _events = MutableSharedFlow<F7PushEvent>(extraBufferCapacity = 32)
val events: SharedFlow<F7PushEvent> = _events.asSharedFlow()
fun publish(event: F7PushEvent) {
_events.tryEmit(event)
}
}
@@ -0,0 +1,82 @@
package ru.forbion.f7cloud.core.push
object F7PushEventParser {
fun parse(
data: Map<String, String>,
title: String,
body: String,
): F7PushEvent {
val type = data["type"]?.lowercase()
val source = data["source"]?.lowercase()
val url = data["acceptUrl"]
?: data["url"]
?: data["clickUrl"]
?: data["link"]
val lowerUrl = url?.lowercase().orEmpty()
if (type == "call") {
return F7PushEvent.Call(
title = title,
body = body,
roomToken = data["roomToken"],
acceptUrl = data["acceptUrl"] ?: url,
url = url,
)
}
if (type == "mail" || source == "mail" ||
lowerUrl.contains("/apps/f7mail") || lowerUrl.contains("/apps/mail")
) {
return F7PushEvent.Mail(
title = title,
body = body,
mailboxId = data["mailboxId"]?.toIntOrNull()
?: Regex("""/box/(\d+)""", RegexOption.IGNORE_CASE).find(lowerUrl)
?.groupValues?.get(1)?.toIntOrNull(),
messageId = data["messageId"]?.toIntOrNull()
?: Regex("""/thread/(\d+)""", RegexOption.IGNORE_CASE).find(lowerUrl)
?.groupValues?.get(1)?.toIntOrNull(),
url = url,
)
}
if (type == "chat" || source == "spreed" || lowerUrl.contains("/apps/spreed")) {
val roomToken = data["roomToken"]
?: extractTalkRoomToken(url)
val messageId = data["messageId"]?.toLongOrNull()
?: Regex("""#message_(\d+)""").find(url.orEmpty())?.groupValues?.get(1)?.toLongOrNull()
return F7PushEvent.Talk(
title = title,
body = body,
roomToken = roomToken,
messageId = messageId,
url = url,
)
}
if (source == "files" || lowerUrl.contains("/apps/files") || lowerUrl.contains("/remote.php/dav/files")) {
return F7PushEvent.Files(
title = title,
body = body,
fileId = data["fileId"]?.toLongOrNull()
?: Regex("""fileid=(\d+)""", RegexOption.IGNORE_CASE).find(lowerUrl)
?.groupValues?.get(1)?.toLongOrNull(),
url = url,
)
}
return F7PushEvent.Notification(
title = title,
body = body,
source = source ?: data["source"],
url = url,
)
}
private fun extractTalkRoomToken(url: String?): String? {
if (url.isNullOrBlank()) return null
Regex("""/spreed/([a-z0-9]+)""", RegexOption.IGNORE_CASE).find(url)?.groupValues?.get(1)?.let { return it }
Regex("""/call/([a-z0-9]+)""", RegexOption.IGNORE_CASE).find(url)?.groupValues?.get(1)?.let { return it }
return null
}
}
@@ -0,0 +1,49 @@
package ru.forbion.f7cloud.core.push
import android.content.Intent
import android.os.Bundle
object F7PushIntentExtras {
fun dataMap(extras: Bundle?): Map<String, String> {
if (extras == null) return emptyMap()
return buildMap {
for (key in extras.keySet()) {
if (key.startsWith("google.") || key == "from" || key == "collapse_key") continue
extras.getString(key)?.takeIf { it.isNotBlank() }?.let { put(key, it) }
}
}
}
fun resolveOpenUrl(intent: Intent?): String? {
if (intent == null) return null
intent.getStringExtra(PushIntents.EXTRA_OPEN_URL)?.takeIf { it.isNotBlank() }?.let { return it }
val data = dataMap(intent.extras)
return data["acceptUrl"]
?: data["url"]
?: data["clickUrl"]
?: data["link"]
}
fun resolveRoomToken(intent: Intent?): String? {
intent?.getStringExtra(PushIntents.EXTRA_ROOM_TOKEN)?.takeIf { it.isNotBlank() }?.let { return it }
return dataMap(intent?.extras)["roomToken"]
}
fun resolveMessageId(intent: Intent?): String? {
intent?.getStringExtra(PushIntents.EXTRA_MESSAGE_ID)?.takeIf { it.isNotBlank() }?.let { return it }
return dataMap(intent?.extras)["messageId"]
}
fun publishEventFromIntent(intent: Intent?) {
val data = dataMap(intent?.extras)
if (data.isEmpty()) return
val title = data["title"] ?: "F7cloud"
val body = data["body"] ?: ""
F7PushEventHub.publish(F7PushEventParser.parse(data, title, body))
}
fun isFcmLaunch(intent: Intent?): Boolean {
val extras = intent?.extras ?: return false
return extras.containsKey("google.message_id") || dataMap(extras).isNotEmpty()
}
}
@@ -0,0 +1,63 @@
package ru.forbion.f7cloud.core.push
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import androidx.core.app.NotificationCompat
object F7PushNotificationHelper {
fun show(
context: Context,
title: String,
body: String,
openUrl: String?,
highPriority: Boolean,
type: String?,
channelHint: String? = null,
roomToken: String? = null,
messageId: String? = null,
) {
F7NotificationChannels.ensureAll(context)
val manager = context.getSystemService(NotificationManager::class.java) ?: return
val launch = context.packageManager.getLaunchIntentForPackage(context.packageName)
val pending = if (launch != null) {
val intent = Intent(launch)
if (!openUrl.isNullOrBlank()) {
intent.putExtra(PushIntents.EXTRA_OPEN_URL, openUrl)
}
if (!roomToken.isNullOrBlank()) {
intent.putExtra(PushIntents.EXTRA_ROOM_TOKEN, roomToken)
}
if (!messageId.isNullOrBlank()) {
intent.putExtra(PushIntents.EXTRA_MESSAGE_ID, messageId)
}
PendingIntent.getActivity(
context,
1001,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
} else {
null
}
val channel = F7NotificationChannels.resolveChannel(channelHint, highPriority || type == "call")
val priority = if (channel == F7NotificationChannels.CALLS) {
NotificationCompat.PRIORITY_HIGH
} else {
NotificationCompat.PRIORITY_DEFAULT
}
val iconRes = context.applicationInfo.icon.takeIf { it != 0 }
?: android.R.drawable.stat_notify_chat
val notification = NotificationCompat.Builder(context, channel)
.setSmallIcon(iconRes)
.setContentTitle(title)
.setContentText(body)
.setStyle(NotificationCompat.BigTextStyle().bigText(body))
.setAutoCancel(true)
.setPriority(priority)
.setContentIntent(pending)
.build()
manager.notify((System.currentTimeMillis() % Int.MAX_VALUE).toInt(), notification)
}
}
@@ -0,0 +1,90 @@
package ru.forbion.f7cloud.core.push
import android.content.Context
import android.util.Log
import org.json.JSONObject
import ru.forbion.f7cloud.core.auth.AuthSession
import java.io.BufferedReader
import java.io.InputStream
import java.io.InputStreamReader
import java.io.OutputStream
import java.net.HttpURLConnection
import java.net.URL
import java.nio.charset.StandardCharsets
import java.util.UUID
object F7PushRegistrar {
private const val TAG = "F7PushRegistrar"
private const val PREFS = "f7push"
private const val KEY_DEVICE_ID = "device_id"
fun getDeviceId(context: Context): String {
val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
val existing = prefs.getString(KEY_DEVICE_ID, null)
if (!existing.isNullOrBlank()) {
return existing
}
val id = UUID.randomUUID().toString()
prefs.edit().putString(KEY_DEVICE_ID, id).apply()
return id
}
fun registerBlocking(
context: Context,
session: AuthSession,
fcmToken: String,
): Int {
val endpoint = session.serverUrl.trimEnd('/') + "/ocs/v2.php/apps/f7push/api/v1/devices"
val body = JSONObject()
.put("deviceId", getDeviceId(context))
.put("fcmToken", fcmToken)
.put("platform", "android")
.put("clientApp", "f7cloud-mobile")
.toString()
return try {
postJson(endpoint, session, body)
} catch (t: Throwable) {
Log.w(TAG, "register failed", t)
0
}
}
private fun postJson(endpoint: String, session: AuthSession, json: String): Int {
val conn = URL(endpoint).openConnection() as HttpURLConnection
conn.connectTimeout = 15000
conn.readTimeout = 15000
conn.requestMethod = "POST"
conn.doOutput = true
conn.setRequestProperty("Content-Type", "application/json; charset=utf-8")
conn.setRequestProperty("Accept", "application/json")
conn.setRequestProperty("OCS-APIRequest", "true")
val basic = android.util.Base64.encodeToString(
"${session.username}:${session.appPassword}".toByteArray(StandardCharsets.UTF_8),
android.util.Base64.NO_WRAP
)
conn.setRequestProperty("Authorization", "Basic $basic")
val payload = json.toByteArray(StandardCharsets.UTF_8)
conn.setFixedLengthStreamingMode(payload.size)
val out: OutputStream = conn.outputStream
out.write(payload)
out.close()
val code = conn.responseCode
drainQuietly(if (code >= 400) conn.errorStream else conn.inputStream)
conn.disconnect()
return code
}
private fun drainQuietly(stream: InputStream?) {
if (stream == null) return
try {
BufferedReader(InputStreamReader(stream, StandardCharsets.UTF_8)).use { reader ->
while (reader.readLine() != null) {
// no-op
}
}
} catch (_: Exception) {
}
}
}
@@ -0,0 +1,13 @@
package ru.forbion.f7cloud.core.push
object PushIntents {
const val EXTRA_OPEN_URL = "ru.forbion.f7cloud.mobile.OPEN_URL"
const val ACTION_OPEN_CALL = "ru.forbion.f7cloud.action.OPEN_CALL"
const val EXTRA_ACCEPT_URL = "ru.forbion.f7cloud.mobile.ACCEPT_URL"
const val EXTRA_ROOM_TOKEN = "ru.forbion.f7cloud.mobile.ROOM_TOKEN"
const val EXTRA_MESSAGE_ID = "ru.forbion.f7cloud.mobile.MESSAGE_ID"
const val EXTRA_AUTO_ACCEPT = "ru.forbion.f7cloud.mobile.AUTO_ACCEPT"
const val EXTRA_CALL_TITLE = "ru.forbion.f7cloud.mobile.CALL_TITLE"
const val EXTRA_CALL_BODY = "ru.forbion.f7cloud.mobile.CALL_BODY"
const val EXTRA_ROOM_DISPLAY_NAME = "ru.forbion.f7cloud.mobile.ROOM_DISPLAY_NAME"
}
@@ -0,0 +1,32 @@
package ru.forbion.f7cloud.core.push
object TalkCallPushLabels {
private val ROOM_IN_TITLE = Regex(
"""(?i)(?:incoming call in|group call (?:has )?started in|входящий звонок в|групповой звонок.*?в)\s+(.+)$""",
)
fun resolveRoomDisplayName(
title: String,
body: String,
roomDisplayName: String?,
): String {
roomDisplayName?.trim()?.takeIf { it.isNotBlank() }?.let { return it }
val trimmedTitle = title.trim()
ROOM_IN_TITLE.find(trimmedTitle)?.groupValues?.getOrNull(1)
?.trim()
?.takeIf { it.isNotBlank() }
?.let { return it }
if (trimmedTitle.isNotBlank() && !trimmedTitle.equals("call", ignoreCase = true)) {
return trimmedTitle
}
val trimmedBody = body.trim()
if (trimmedBody.isNotBlank() && !trimmedBody.equals("call", ignoreCase = true)) {
return trimmedBody
}
return "Звонок"
}
}
+14
View File
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="notification_channel_messages">F7cloud сообщения</string>
<string name="notification_channel_calls">F7cloud звонки</string>
<string name="call_action_accept">Принять</string>
<string name="call_action_decline">Отклонить</string>
<string name="incoming_call_subtitle">F7cloud звонок</string>
<plurals name="call_queue_waiting">
<item quantity="one">Ещё %d звонок в очереди</item>
<item quantity="few">Ещё %d звонка в очереди</item>
<item quantity="many">Ещё %d звонков в очереди</item>
<item quantity="other">Ещё %d звонков в очереди</item>
</plurals>
</resources>