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