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:
@@ -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 "Звонок"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user