diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 0e12acc93..d787b4f97 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -5,6 +5,24 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -62,6 +94,11 @@
+
+
()
private val friendListListener: FriendListListenerStub = object : FriendListListenerStub() {
@@ -60,6 +70,13 @@ class ContactsManager {
}
}
+ init {
+ contactAvatar = IconCompat.createWithResource(
+ context,
+ R.drawable.contact
+ )
+ }
+
@UiThread
fun loadContacts(activity: MainActivity) {
val manager = LoaderManager.getInstance(activity)
@@ -99,6 +116,14 @@ class ContactsManager {
return coreContext.core.defaultFriendList?.findFriendByRefKey(id)
}
+ @WorkerThread
+ fun findContactByAddress(address: Address): Friend? {
+ val friend = coreContext.core.findFriend(address)
+ if (friend != null) return friend
+
+ return null
+ }
+
@WorkerThread
fun onCoreStarted() {
val core = coreContext.core
@@ -118,6 +143,33 @@ class ContactsManager {
}
}
+@WorkerThread
+fun Friend.getPerson(): Person {
+ val personBuilder = Person.Builder().setName(name)
+
+ val bm: Bitmap? = if (!photo.isNullOrEmpty()) {
+ ImageUtils.getRoundBitmapFromUri(
+ coreContext.context,
+ Uri.parse(photo ?: "")
+ )
+ } else {
+ null
+ }
+
+ personBuilder.setIcon(
+ if (bm == null) {
+ coreContext.contactsManager.contactAvatar
+ } else {
+ IconCompat.createWithAdaptiveBitmap(bm)
+ }
+ )
+
+ personBuilder.setKey(refKey)
+ personBuilder.setUri(nativeUri)
+ personBuilder.setImportant(starred)
+ return personBuilder.build()
+}
+
interface ContactsListener {
fun onContactsLoaded()
}
diff --git a/app/src/main/java/org/linphone/core/CoreContext.kt b/app/src/main/java/org/linphone/core/CoreContext.kt
index b69b45660..a4f4a7547 100644
--- a/app/src/main/java/org/linphone/core/CoreContext.kt
+++ b/app/src/main/java/org/linphone/core/CoreContext.kt
@@ -35,6 +35,7 @@ import org.linphone.BuildConfig
import org.linphone.LinphoneApplication.Companion.corePreferences
import org.linphone.contacts.ContactsManager
import org.linphone.core.tools.Log
+import org.linphone.notifications.NotificationsManager
import org.linphone.ui.voip.VoipActivity
import org.linphone.utils.ActivityMonitor
import org.linphone.utils.LinphoneUtils
@@ -48,7 +49,9 @@ class CoreContext @UiThread constructor(val context: Context) : HandlerThread("C
val emojiCompat: EmojiCompat
- val contactsManager = ContactsManager()
+ val contactsManager = ContactsManager(context)
+
+ val notificationsManager = NotificationsManager(context)
private val activityMonitor = ActivityMonitor()
@@ -71,14 +74,13 @@ class CoreContext @UiThread constructor(val context: Context) : HandlerThread("C
message: String
) {
Log.i("$TAG Call state changed [$state]")
- if (state == Call.State.OutgoingProgress) {
- postOnMainThread {
- showCallActivity()
+ when (state) {
+ Call.State.OutgoingProgress, Call.State.Connected -> {
+ postOnMainThread {
+ showCallActivity()
+ }
}
- } else if (state == Call.State.IncomingReceived) {
- // TODO FIXME : remove when full screen intent notification
- postOnMainThread {
- showCallActivity()
+ else -> {
}
}
}
@@ -121,6 +123,7 @@ class CoreContext @UiThread constructor(val context: Context) : HandlerThread("C
core.start()
contactsManager.onCoreStarted()
+ notificationsManager.onCoreStarted()
Looper.loop()
}
@@ -128,7 +131,9 @@ class CoreContext @UiThread constructor(val context: Context) : HandlerThread("C
@WorkerThread
override fun destroy() {
core.stop()
+
contactsManager.onCoreStopped()
+ notificationsManager.onCoreStopped()
postOnMainThread {
(context as Application).unregisterActivityLifecycleCallbacks(activityMonitor)
@@ -260,6 +265,53 @@ class CoreContext @UiThread constructor(val context: Context) : HandlerThread("C
return core.videoDevicesList.size > 2 // Count StaticImage camera
}
+ @WorkerThread
+ fun answerCall(call: Call) {
+ Log.i("$TAG Answering call $call")
+ val params = core.createCallParams(call)
+ if (params == null) {
+ Log.w("$TAG Answering call without params!")
+ call.accept()
+ return
+ }
+
+ // params.recordFile = LinphoneUtils.getRecordingFilePathForAddress(call.remoteAddress)
+
+ /*if (LinphoneUtils.checkIfNetworkHasLowBandwidth(context)) {
+ Log.w("$TAG Enabling low bandwidth mode!")
+ params.isLowBandwidthEnabled = true
+ }*/
+
+ if (call.callLog.wasConference()) {
+ // Prevent incoming group call to start in audio only layout
+ // Do the same as the conference waiting room
+ params.isVideoEnabled = true
+ params.videoDirection = if (core.videoActivationPolicy.automaticallyInitiate) MediaDirection.SendRecv else MediaDirection.RecvOnly
+ Log.i(
+ "$TAG Enabling video on call params to prevent audio-only layout when answering"
+ )
+ }
+
+ call.acceptWithParams(params)
+ }
+
+ @WorkerThread
+ fun declineCall(call: Call) {
+ val reason = if (core.callsNb > 1) {
+ Reason.Busy
+ } else {
+ Reason.Declined
+ }
+ Log.i("$TAG Declining call [$call] with reason [$reason]")
+ call.decline(reason)
+ }
+
+ @WorkerThread
+ fun terminateCall(call: Call) {
+ Log.i("$TAG Terminating call $call")
+ call.terminate()
+ }
+
@UiThread
private fun showCallActivity() {
Log.i("$TAG Starting VoIP activity")
diff --git a/app/src/main/java/org/linphone/core/CoreForegroundService.kt b/app/src/main/java/org/linphone/core/CoreForegroundService.kt
new file mode 100644
index 000000000..77b47438d
--- /dev/null
+++ b/app/src/main/java/org/linphone/core/CoreForegroundService.kt
@@ -0,0 +1,69 @@
+/*
+ * Copyright (c) 2010-2023 Belledonne Communications SARL.
+ *
+ * This file is part of linphone-android
+ * (see https://www.linphone.org).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.linphone.core
+
+import android.content.Intent
+import org.linphone.LinphoneApplication.Companion.coreContext
+import org.linphone.core.tools.Log
+import org.linphone.core.tools.service.CoreService
+
+class CoreForegroundService : CoreService() {
+ companion object {
+ const val TAG = "[Core Foreground Service]"
+ }
+
+ override fun onCreate() {
+ super.onCreate()
+ Log.i("$TAG Created")
+ }
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ Log.i("$TAG onStartCommand")
+
+ coreContext.notificationsManager.onServiceStarted(this)
+
+ return super.onStartCommand(intent, flags, startId)
+ }
+
+ override fun createServiceNotificationChannel() {
+ // Done elsewhere
+ }
+
+ override fun showForegroundServiceNotification() {
+ // Done elsewhere
+ }
+
+ override fun hideForegroundServiceNotification() {
+ // Done elsewhere
+ }
+
+ override fun onTaskRemoved(rootIntent: Intent?) {
+ Log.i("$TAG Task removed, doing nothing")
+
+ super.onTaskRemoved(rootIntent)
+ }
+
+ override fun onDestroy() {
+ Log.i("$TAG onDestroy")
+ coreContext.notificationsManager.onServiceDestroyed()
+
+ super.onDestroy()
+ }
+}
diff --git a/app/src/main/java/org/linphone/notifications/NotificationBroadcastReceiver.kt b/app/src/main/java/org/linphone/notifications/NotificationBroadcastReceiver.kt
new file mode 100644
index 000000000..baf6720d8
--- /dev/null
+++ b/app/src/main/java/org/linphone/notifications/NotificationBroadcastReceiver.kt
@@ -0,0 +1,71 @@
+/*
+ * Copyright (c) 2010-2023 Belledonne Communications SARL.
+ *
+ * This file is part of linphone-android
+ * (see https://www.linphone.org).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.linphone.notifications
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import org.linphone.LinphoneApplication.Companion.coreContext
+import org.linphone.core.Call
+import org.linphone.core.tools.Log
+
+class NotificationBroadcastReceiver : BroadcastReceiver() {
+ companion object {
+ const val TAG = "[NotificationBroadcastReceiver]"
+ }
+
+ override fun onReceive(context: Context, intent: Intent) {
+ val notificationId = intent.getIntExtra(NotificationsManager.INTENT_NOTIF_ID, 0)
+ Log.i(
+ "[Notification Broadcast Receiver] Got notification broadcast for ID [$notificationId]"
+ )
+
+ if (intent.action == NotificationsManager.INTENT_ANSWER_CALL_NOTIF_ACTION || intent.action == NotificationsManager.INTENT_HANGUP_CALL_NOTIF_ACTION) {
+ handleCallIntent(intent)
+ }
+ }
+
+ private fun handleCallIntent(intent: Intent) {
+ val callId = intent.getStringExtra(NotificationsManager.INTENT_CALL_ID)
+ if (callId == null) {
+ Log.e("[Notification Broadcast Receiver] Remote SIP address is null for notification")
+ return
+ }
+
+ coreContext.postOnCoreThread { core ->
+ val call = core.getCallByCallid(callId)
+ if (call == null) {
+ Log.e("[Notification Broadcast Receiver] Couldn't find call from ID [$callId]")
+ } else {
+ if (intent.action == NotificationsManager.INTENT_ANSWER_CALL_NOTIF_ACTION) {
+ coreContext.answerCall(call)
+ } else {
+ if (call.state == Call.State.IncomingReceived ||
+ call.state == Call.State.IncomingEarlyMedia
+ ) {
+ coreContext.declineCall(call)
+ } else {
+ coreContext.terminateCall(call)
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/org/linphone/notifications/NotificationsManager.kt b/app/src/main/java/org/linphone/notifications/NotificationsManager.kt
new file mode 100644
index 000000000..4d6a0af16
--- /dev/null
+++ b/app/src/main/java/org/linphone/notifications/NotificationsManager.kt
@@ -0,0 +1,468 @@
+/*
+ * Copyright (c) 2010-2023 Belledonne Communications SARL.
+ *
+ * This file is part of linphone-android
+ * (see https://www.linphone.org).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.linphone.notifications
+
+import android.Manifest
+import android.app.Notification
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.app.Service.STOP_FOREGROUND_REMOVE
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.content.pm.ServiceInfo
+import android.graphics.Bitmap
+import android.net.Uri
+import androidx.annotation.AnyThread
+import androidx.annotation.MainThread
+import androidx.annotation.RequiresPermission
+import androidx.annotation.WorkerThread
+import androidx.core.app.ActivityCompat
+import androidx.core.app.NotificationCompat
+import androidx.core.app.NotificationManagerCompat
+import androidx.core.app.Person
+import androidx.core.content.ContextCompat
+import androidx.core.graphics.drawable.IconCompat
+import org.linphone.LinphoneApplication.Companion.coreContext
+import org.linphone.R
+import org.linphone.contacts.getPerson
+import org.linphone.core.Call
+import org.linphone.core.Core
+import org.linphone.core.CoreForegroundService
+import org.linphone.core.CoreListenerStub
+import org.linphone.core.Friend
+import org.linphone.core.tools.Log
+import org.linphone.ui.voip.VoipActivity
+import org.linphone.utils.ImageUtils
+import org.linphone.utils.LinphoneUtils
+
+class NotificationsManager @MainThread constructor(private val context: Context) {
+ companion object {
+ const val TAG = "[Notifications Manager]"
+
+ const val INTENT_HANGUP_CALL_NOTIF_ACTION = "org.linphone.HANGUP_CALL_ACTION"
+ const val INTENT_ANSWER_CALL_NOTIF_ACTION = "org.linphone.ANSWER_CALL_ACTION"
+
+ const val INTENT_CALL_ID = "CALL_ID"
+ const val INTENT_NOTIF_ID = "NOTIFICATION_ID"
+ }
+
+ private val notificationManager: NotificationManagerCompat by lazy {
+ NotificationManagerCompat.from(context)
+ }
+
+ private val coreListener = object : CoreListenerStub() {
+ @WorkerThread
+ override fun onFirstCallStarted(core: Core) {
+ startCoreForegroundService()
+ }
+
+ @WorkerThread
+ override fun onCallStateChanged(
+ core: Core,
+ call: Call,
+ state: Call.State?,
+ message: String
+ ) {
+ when (state) {
+ Call.State.IncomingReceived, Call.State.IncomingEarlyMedia -> {
+ showCallNotification(call, true)
+ }
+ Call.State.Connected -> {
+ showCallNotification(call, false)
+ }
+ Call.State.End, Call.State.Error -> {
+ dismissCallNotification(call)
+ }
+ else -> {
+ }
+ }
+ }
+
+ @WorkerThread
+ override fun onLastCallEnded(core: Core) {
+ Log.i("$TAG Last call ended, stopping foreground service")
+ stopCallForeground()
+ }
+ }
+
+ private var coreService: CoreForegroundService? = null
+
+ private val callNotificationsMap: HashMap = HashMap()
+
+ init {
+ createServiceChannel()
+ createIncomingCallNotificationChannel()
+
+ for (notification in notificationManager.activeNotifications) {
+ if (notification.tag.isNullOrEmpty()) {
+ Log.w(
+ "$TAG Found existing (call?) notification [${notification.id}] without tag, cancelling it"
+ )
+ notificationManager.cancel(notification.id)
+ }
+ }
+ }
+
+ fun onServiceStarted(service: CoreForegroundService) {
+ Log.i("$TAG Service has been started")
+ coreService = service
+ startCallForeground()
+ }
+
+ fun onServiceDestroyed() {
+ Log.i("$TAG Service has been destroyed")
+ coreService = null
+ }
+
+ @WorkerThread
+ fun onCoreStarted() {
+ coreContext.core.addListener(coreListener)
+ }
+
+ @WorkerThread
+ fun onCoreStopped() {
+ Log.i("$TAG Getting destroyed, clearing foreground Service & call notifications")
+
+ coreContext.core.removeListener(coreListener)
+ }
+
+ @WorkerThread
+ private fun showCallNotification(call: Call, isIncoming: Boolean) {
+ val notifiable = getNotifiableForCall(call)
+
+ val callNotificationIntent = Intent(context, VoipActivity::class.java)
+ callNotificationIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ val pendingIntent = PendingIntent.getActivity(
+ context,
+ 0,
+ callNotificationIntent,
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+ )
+
+ val notification = createCallNotification(
+ context,
+ call,
+ notifiable,
+ pendingIntent,
+ isIncoming
+ )
+ if (ActivityCompat.checkSelfPermission(
+ context,
+ Manifest.permission.POST_NOTIFICATIONS
+ ) == PackageManager.PERMISSION_GRANTED
+ ) {
+ notify(notifiable.notificationId, notification)
+ }
+ }
+
+ @WorkerThread
+ private fun startCallForeground() {
+ val channelId = context.getString(R.string.notification_channel_service_id)
+ val channel = notificationManager.getNotificationChannel(channelId)
+ val importance = channel?.importance ?: NotificationManagerCompat.IMPORTANCE_NONE
+ if (importance == NotificationManagerCompat.IMPORTANCE_NONE) {
+ Log.e("$TAG Service channel has been disabled, can't start foreground service!")
+ return
+ }
+
+ val notifiable = getNotifiableForCall(
+ coreContext.core.currentCall ?: coreContext.core.calls.first()
+ )
+ val notif = notificationManager.activeNotifications.find {
+ it.id == notifiable.notificationId
+ }
+ notif ?: return
+
+ val service = coreService
+ if (service != null) {
+ Log.i("$TAG Service found, starting it as foreground using notification")
+ try {
+ service.startForeground(
+ notifiable.notificationId,
+ notif.notification,
+ ServiceInfo.FOREGROUND_SERVICE_TYPE_PHONE_CALL
+ or ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE
+ or ServiceInfo.FOREGROUND_SERVICE_TYPE_CAMERA
+ )
+ } catch (e: Exception) {
+ Log.e("$TAG Can't start service as foreground! $e")
+ }
+ } else {
+ Log.w("$TAG Core Foreground Service hasn't started yet...")
+ }
+ }
+
+ @WorkerThread
+ private fun stopCallForeground() {
+ val service = coreService
+ if (service != null) {
+ Log.i("$TAG Stopping foreground service")
+ service.stopForeground(STOP_FOREGROUND_REMOVE)
+ service.stopSelf()
+ } else {
+ Log.w("$TAG Can't stop foreground service & notif, no service was found")
+ }
+ }
+
+ @WorkerThread
+ private fun startCoreForegroundService() {
+ val service = coreService
+ if (service == null) {
+ Log.i("$TAG Starting Core Foreground Service")
+ val intent = Intent()
+ intent.setClass(coreContext.context, CoreForegroundService::class.java)
+
+ try {
+ context.startForegroundService(intent)
+ } catch (e: Exception) {
+ Log.e("$TAG Failed to start Service: $e")
+ }
+ }
+ }
+
+ @RequiresPermission(Manifest.permission.POST_NOTIFICATIONS)
+ @WorkerThread
+ private fun notify(id: Int, notification: Notification, tag: String? = null) {
+ Log.i("$TAG Notifying [$id] with tag [$tag]")
+ try {
+ notificationManager.notify(tag, id, notification)
+ } catch (iae: IllegalArgumentException) {
+ if (coreService == null && tag == null) {
+ // We can't notify using CallStyle if there isn't a foreground service running
+ Log.w(
+ "$TAG Foreground service hasn't started yet, can't display a CallStyle notification until then: $iae"
+ )
+ } else {
+ Log.e("$TAG Illegal Argument Exception occurred: $iae")
+ }
+ } catch (e: Exception) {
+ Log.e("$TAG Exception occurred: $e")
+ }
+ }
+
+ @WorkerThread
+ fun cancelNotification(id: Int, tag: String? = null) {
+ Log.i("$TAG Canceling [$id] with tag [$tag]")
+ notificationManager.cancel(tag, id)
+ }
+
+ @WorkerThread
+ private fun getNotificationIdForCall(call: Call): Int {
+ return call.callLog.startDate.toInt()
+ }
+
+ @WorkerThread
+ private fun getNotifiableForCall(call: Call): Notifiable {
+ val address = call.remoteAddress.asStringUriOnly()
+ var notifiable: Notifiable? = callNotificationsMap[address]
+ if (notifiable == null) {
+ notifiable = Notifiable(getNotificationIdForCall(call))
+ notifiable.callId = call.callLog.callId
+
+ callNotificationsMap[address] = notifiable
+ }
+ return notifiable
+ }
+
+ @WorkerThread
+ private fun createCallNotification(
+ context: Context,
+ call: Call,
+ notifiable: Notifiable,
+ pendingIntent: PendingIntent?,
+ isIncoming: Boolean
+ ): Notification {
+ val declineIntent = getCallDeclinePendingIntent(notifiable)
+ val answerIntent = getCallAnswerPendingIntent(notifiable)
+
+ val contact =
+ coreContext.contactsManager.findContactByAddress(call.remoteAddress)
+ val contactPicture = contact?.photo
+ val roundPicture = if (!contactPicture.isNullOrEmpty()) {
+ ImageUtils.getRoundBitmapFromUri(context, Uri.parse(contactPicture))
+ } else {
+ null
+ }
+ val displayName = contact?.name ?: LinphoneUtils.getDisplayName(call.remoteAddress)
+
+ val person = getPerson(contact, displayName, roundPicture)
+ val caller = Person.Builder()
+ .setName(person.name)
+ .setIcon(person.icon)
+ .setUri(person.uri)
+ .setKey(person.key)
+ .setImportant(person.isImportant)
+ .build()
+
+ val isVideo = if (isIncoming) {
+ call.remoteParams?.isVideoEnabled ?: false
+ } else {
+ call.currentParams.isVideoEnabled
+ }
+
+ val smallIcon = if (isVideo) {
+ R.drawable.camera_enabled
+ } else {
+ R.drawable.calls
+ }
+
+ val style = if (isIncoming) {
+ NotificationCompat.CallStyle.forIncomingCall(
+ caller,
+ declineIntent,
+ answerIntent
+ )
+ } else {
+ NotificationCompat.CallStyle.forOngoingCall(
+ caller,
+ declineIntent
+ )
+ }
+
+ val channel = if (isIncoming) {
+ context.getString(R.string.notification_channel_incoming_call_id)
+ } else {
+ context.getString(R.string.notification_channel_service_id)
+ }
+
+ val builder = NotificationCompat.Builder(
+ context,
+ channel
+ ).apply {
+ try {
+ style.setIsVideo(isVideo)
+ style.setAnswerButtonColorHint(
+ context.resources.getColor(R.color.green_online, context.theme)
+ )
+ style.setDeclineButtonColorHint(
+ context.resources.getColor(R.color.red_danger, context.theme)
+ )
+ setStyle(style)
+ } catch (iae: IllegalArgumentException) {
+ Log.e(
+ "[Api31 Compatibility] Can't use notification call style: $iae, using API 26 notification instead"
+ )
+ }
+ setSmallIcon(smallIcon)
+ setCategory(NotificationCompat.CATEGORY_CALL)
+ setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
+ setWhen(System.currentTimeMillis())
+ setAutoCancel(false)
+ setShowWhen(true)
+ setOngoing(true)
+ color = ContextCompat.getColor(context, R.color.primary_color)
+ setFullScreenIntent(pendingIntent, true)
+ }
+
+ return builder.build()
+ }
+
+ private fun dismissCallNotification(call: Call) {
+ val address = call.remoteAddress.asStringUriOnly()
+ val notifiable: Notifiable? = callNotificationsMap[address]
+ if (notifiable != null) {
+ cancelNotification(notifiable.notificationId)
+ callNotificationsMap.remove(address)
+ } else {
+ Log.w("$TAG No notification found for call with remote address [$address]")
+ }
+ }
+
+ @AnyThread
+ fun getCallDeclinePendingIntent(notifiable: Notifiable): PendingIntent {
+ val hangupIntent = Intent(context, NotificationBroadcastReceiver::class.java)
+ hangupIntent.action = INTENT_HANGUP_CALL_NOTIF_ACTION
+ hangupIntent.putExtra(INTENT_NOTIF_ID, notifiable.notificationId)
+ hangupIntent.putExtra(INTENT_CALL_ID, notifiable.callId)
+
+ return PendingIntent.getBroadcast(
+ context,
+ notifiable.notificationId,
+ hangupIntent,
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+ )
+ }
+
+ @AnyThread
+ fun getCallAnswerPendingIntent(notifiable: Notifiable): PendingIntent {
+ val answerIntent = Intent(context, NotificationBroadcastReceiver::class.java)
+ answerIntent.action = INTENT_ANSWER_CALL_NOTIF_ACTION
+ answerIntent.putExtra(INTENT_NOTIF_ID, notifiable.notificationId)
+ answerIntent.putExtra(INTENT_CALL_ID, notifiable.callId)
+
+ return PendingIntent.getBroadcast(
+ context,
+ notifiable.notificationId,
+ answerIntent,
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+ )
+ }
+
+ @WorkerThread
+ private fun getPerson(friend: Friend?, displayName: String, picture: Bitmap?): Person {
+ return friend?.getPerson()
+ ?: Person.Builder()
+ .setName(displayName)
+ .setIcon(
+ if (picture != null) {
+ IconCompat.createWithAdaptiveBitmap(picture)
+ } else {
+ coreContext.contactsManager.contactAvatar
+ }
+ )
+ .setKey(displayName)
+ .build()
+ }
+
+ @MainThread
+ private fun createIncomingCallNotificationChannel() {
+ val id = context.getString(R.string.notification_channel_incoming_call_id)
+ val name = context.getString(R.string.notification_channel_incoming_call_name)
+
+ val channel = NotificationChannel(id, name, NotificationManager.IMPORTANCE_HIGH)
+ channel.description = name
+ channel.lightColor = context.getColor(R.color.primary_color)
+ channel.lockscreenVisibility
+ channel.enableVibration(true)
+ channel.enableLights(true)
+ channel.setShowBadge(false)
+ notificationManager.createNotificationChannel(channel)
+ }
+
+ @MainThread
+ private fun createServiceChannel() {
+ val id = context.getString(R.string.notification_channel_service_id)
+ val name = context.getString(R.string.notification_channel_service_name)
+
+ val channel = NotificationChannel(id, name, NotificationManager.IMPORTANCE_LOW)
+ channel.description = name
+ channel.enableVibration(false)
+ channel.enableLights(false)
+ channel.setShowBadge(false)
+ notificationManager.createNotificationChannel(channel)
+ }
+
+ class Notifiable(val notificationId: Int) {
+ var myself: String? = null
+ var callId: String? = null
+ }
+}
diff --git a/app/src/main/java/org/linphone/ui/main/MainActivity.kt b/app/src/main/java/org/linphone/ui/main/MainActivity.kt
index 29bfd71bf..0ea9635c0 100644
--- a/app/src/main/java/org/linphone/ui/main/MainActivity.kt
+++ b/app/src/main/java/org/linphone/ui/main/MainActivity.kt
@@ -54,6 +54,8 @@ class MainActivity : AppCompatActivity() {
private const val CONTACTS_PERMISSION_REQUEST = 0
private const val CAMERA_PERMISSION_REQUEST = 1
private const val RECORD_AUDIO_PERMISSION_REQUEST = 2
+ private const val POST_NOTIFICATIONS_PERMISSION_REQUEST = 3
+ private const val MANAGE_OWN_CALLS_PERMISSION_REQUEST = 4
}
private lateinit var binding: MainActivityBinding
@@ -140,6 +142,18 @@ class MainActivity : AppCompatActivity() {
RECORD_AUDIO_PERMISSION_REQUEST
)
}
+ if (checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
+ requestPermissions(
+ arrayOf(Manifest.permission.POST_NOTIFICATIONS),
+ POST_NOTIFICATIONS_PERMISSION_REQUEST
+ )
+ }
+ if (checkSelfPermission(Manifest.permission.MANAGE_OWN_CALLS) != PackageManager.PERMISSION_GRANTED) {
+ requestPermissions(
+ arrayOf(Manifest.permission.POST_NOTIFICATIONS),
+ MANAGE_OWN_CALLS_PERMISSION_REQUEST
+ )
+ }
}
override fun onRequestPermissionsResult(
diff --git a/app/src/main/java/org/linphone/utils/ImageUtils.kt b/app/src/main/java/org/linphone/utils/ImageUtils.kt
new file mode 100644
index 000000000..e9f817b07
--- /dev/null
+++ b/app/src/main/java/org/linphone/utils/ImageUtils.kt
@@ -0,0 +1,94 @@
+/*
+ * Copyright (c) 2010-2023 Belledonne Communications SARL.
+ *
+ * This file is part of linphone-android
+ * (see https://www.linphone.org).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.linphone.utils
+
+import android.content.Context
+import android.graphics.Bitmap
+import android.graphics.Canvas
+import android.graphics.ImageDecoder
+import android.graphics.Paint
+import android.graphics.PorterDuff
+import android.graphics.PorterDuffXfermode
+import android.graphics.Rect
+import android.net.Uri
+import androidx.annotation.AnyThread
+import java.io.FileNotFoundException
+import org.linphone.core.tools.Log
+
+class ImageUtils {
+ companion object {
+ const val TAG = "[Image Utils]"
+
+ @AnyThread
+ fun getRoundBitmapFromUri(
+ context: Context,
+ fromPictureUri: Uri?
+ ): Bitmap? {
+ var bm: Bitmap? = null
+ if (fromPictureUri != null) {
+ bm = try {
+ // We make a copy to ensure Bitmap will be Software and not Hardware, required for shortcuts
+ ImageDecoder.decodeBitmap(
+ ImageDecoder.createSource(context.contentResolver, fromPictureUri)
+ ).copy(
+ Bitmap.Config.ARGB_8888,
+ true
+ )
+ } catch (fnfe: FileNotFoundException) {
+ return null
+ } catch (e: Exception) {
+ Log.e("$TAG Failed to get bitmap from URI [$fromPictureUri]: $e")
+ return null
+ }
+ }
+ if (bm != null) {
+ val roundBm = getRoundBitmap(bm)
+ if (roundBm != null) {
+ bm.recycle()
+ return roundBm
+ }
+ }
+ return bm
+ }
+
+ @AnyThread
+ private fun getRoundBitmap(bitmap: Bitmap): Bitmap? {
+ val output =
+ Bitmap.createBitmap(bitmap.width, bitmap.height, Bitmap.Config.ARGB_8888)
+ val canvas = Canvas(output)
+ val color = -0xbdbdbe
+ val paint = Paint()
+ val rect =
+ Rect(0, 0, bitmap.width, bitmap.height)
+ paint.isAntiAlias = true
+ canvas.drawARGB(0, 0, 0, 0)
+ paint.color = color
+ canvas.drawCircle(
+ bitmap.width / 2.toFloat(),
+ bitmap.height / 2.toFloat(),
+ bitmap.width / 2.toFloat(),
+ paint
+ )
+ paint.xfermode = PorterDuffXfermode(PorterDuff.Mode.SRC_IN)
+ canvas.drawBitmap(bitmap, rect, rect, paint)
+ return output
+ }
+ }
+}
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 14c162131..7b29e82be 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -1,5 +1,19 @@
+
+
+
+]>
+
Linphone
|
+
+ linphone_notification_call_id
+ &appName; incoming calls notifications
+ linphone_notification_service_id
+ &appName; service notification
+
+ Hangup
+ Answer
\ No newline at end of file
diff --git a/build.gradle b/build.gradle
index 6efd8fde3..7a2a5267d 100644
--- a/build.gradle
+++ b/build.gradle
@@ -9,8 +9,8 @@ buildscript {
}
// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
- id 'com.android.application' version '8.1.0' apply false
- id 'com.android.library' version '8.1.0' apply false
+ id 'com.android.application' version '8.1.1' apply false
+ id 'com.android.library' version '8.1.1' apply false
id 'org.jetbrains.kotlin.android' version '1.9.0-RC' apply false
id 'com.google.gms.google-services' version '4.3.15' apply false
}
\ No newline at end of file