diff --git a/.github/workflows/android-auto-apk.yml b/.github/workflows/android-auto-apk.yml new file mode 100644 index 0000000000..2a5f6ad6ec --- /dev/null +++ b/.github/workflows/android-auto-apk.yml @@ -0,0 +1,131 @@ +name: Android Auto test APK + +on: + workflow_dispatch: + push: + branches: + - android-auto + paths: + - app/** + - build.gradle.kts + - settings.gradle.kts + - gradle/** + - gradle.properties + - .github/workflows/android-auto-apk.yml + +permissions: read-all + +concurrency: + group: android-auto-test-apk-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Set up JDK 21 + uses: actions/setup-java@v6 + with: + distribution: temurin + java-version: '21' + + - name: Validate Gradle wrapper + uses: gradle/actions/wrapper-validation@v5 + + - name: Assemble standard GPlay debug APK + run: ./gradlew --no-daemon :app:assembleGplayDebug --stacktrace + + - name: Stage standard APK + shell: bash + run: | + set -euo pipefail + python3 - <<'PY' + import json + from pathlib import Path + import shutil + + output_dir = Path('app/build/outputs/apk/gplay/debug') + metadata = output_dir / 'output-metadata.json' + if not metadata.exists(): + raise SystemExit('Standard output-metadata.json not found') + + data = json.loads(metadata.read_text()) + actual = data.get('applicationId') + expected = 'com.nextcloud.talk2' + print(f'Standard APK applicationId: {actual}') + if actual != expected: + raise SystemExit(f'Expected {expected}, got {actual}') + + apks = list(output_dir.glob('*.apk')) + if len(apks) != 1: + raise SystemExit(f'Expected exactly one standard APK, found {len(apks)}') + + destination = Path('dist/standard') + destination.mkdir(parents=True, exist_ok=True) + shutil.copy2(apks[0], destination / 'Nextcloud-Talk-Android-Auto-Gplay-Debug.apk') + (destination / 'package-id.txt').write_text(expected + '\n') + PY + ( + cd dist/standard + sha256sum Nextcloud-Talk-Android-Auto-Gplay-Debug.apk > Nextcloud-Talk-Android-Auto-Gplay-Debug.apk.sha256 + ) + + - name: Upload standard Android Auto APK + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: Nextcloud-Talk-Android-Auto-Gplay-Debug + path: dist/standard/* + if-no-files-found: error + retention-days: 14 + + - name: Clean before side-by-side build + run: ./gradlew --no-daemon :app:clean + + - name: Assemble side-by-side GPlay debug APK + run: ./gradlew --no-daemon -PandroidAutoSideBySide=true :app:assembleGplayDebug --stacktrace + + - name: Stage side-by-side APK + shell: bash + run: | + set -euo pipefail + python3 - <<'PY' + import json + from pathlib import Path + import shutil + + output_dir = Path('app/build/outputs/apk/gplay/debug') + metadata = output_dir / 'output-metadata.json' + if not metadata.exists(): + raise SystemExit('Side-by-side output-metadata.json not found') + + data = json.loads(metadata.read_text()) + actual = data.get('applicationId') + expected = 'com.nextcloud.talk2.auto' + print(f'Side-by-side APK applicationId: {actual}') + if actual != expected: + raise SystemExit(f'Expected {expected}, got {actual}') + + apks = list(output_dir.glob('*.apk')) + if len(apks) != 1: + raise SystemExit(f'Expected exactly one side-by-side APK, found {len(apks)}') + + destination = Path('dist/side-by-side') + destination.mkdir(parents=True, exist_ok=True) + shutil.copy2(apks[0], destination / 'Nextcloud-Talk-Android-Auto-Gplay-Debug-SideBySide.apk') + (destination / 'package-id.txt').write_text(expected + '\n') + PY + ( + cd dist/side-by-side + sha256sum Nextcloud-Talk-Android-Auto-Gplay-Debug-SideBySide.apk > Nextcloud-Talk-Android-Auto-Gplay-Debug-SideBySide.apk.sha256 + ) + + - name: Upload side-by-side Android Auto APK + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: Nextcloud-Talk-Android-Auto-Gplay-Debug-SideBySide + path: dist/side-by-side/* + if-no-files-found: error + retention-days: 14 diff --git a/.github/workflows/android-auto-build.yml b/.github/workflows/android-auto-build.yml new file mode 100644 index 0000000000..ce9c68bf8d --- /dev/null +++ b/.github/workflows/android-auto-build.yml @@ -0,0 +1,34 @@ +name: Android Auto build + +on: + push: + branches: + - android-auto + pull_request: + branches: + - master + +permissions: + contents: read + +concurrency: + group: android-auto-build-${{ github.ref }} + cancel-in-progress: true + +jobs: + compile: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Set up JDK 21 + uses: actions/setup-java@v6 + with: + distribution: temurin + java-version: '21' + + - name: Validate Gradle wrapper + uses: gradle/actions/wrapper-validation@v5 + + - name: Compile Google Play and generic flavors + run: ./gradlew :app:compileGplayDebugKotlin :app:compileGenericDebugKotlin --stacktrace diff --git a/SETUP.md b/SETUP.md index 540fca71af..14aab913bd 100644 --- a/SETUP.md +++ b/SETUP.md @@ -106,6 +106,19 @@ This requires a working Internet connection. The generated APK file is saved in ```app/build/outputs/apk``` as ```app-generic-debug.apk```. +### Working with Android Auto + +To test [notification extension to Android Auto](https://developer.android.com/training/cars/communication/notification-messaging), Developer settings and Unknown sources need to be enabled in +the Android Auto settings: + +1. Open the Settings app on your device +2. Search for Android Auto, or click on Connected Devices > Android Auto +3. Scroll all the way down to Version, and click it 10 times to enable Developer settings +4. Click the 3 dots in the top right and select Developer settings +5. Enable Unknown sources + +You can now receive notifications on Android Auto from a Nextcloud Talk development build. + ### App flavours The app is currently equipped to be built with three flavours: diff --git a/app/build.gradle.kts b/app/build.gradle.kts index db8f8e673c..d4e78c672d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -24,6 +24,9 @@ plugins { val kotlinVersion: String by rootProject.extra +val androidAutoSideBySide = + providers.gradleProperty("androidAutoSideBySide").orNull == "true" + val coilKtVersion = "2.7.0" val daggerVersion = "2.60.1" val emojiVersion = "1.6.0" @@ -110,6 +113,13 @@ android { } buildTypes { + getByName("debug") { + if (androidAutoSideBySide) { + applicationIdSuffix = ".auto" + versionNameSuffix = "-auto" + } + } + release { isMinifyEnabled = false proguardFiles( @@ -194,6 +204,10 @@ configurations.configureEach { } dependencies { + // Android Auto communication UI is isolated to the Google Play flavor. + "gplayImplementation"("androidx.car.app:app:1.7.0") + "gplayImplementation"("androidx.car.app:app-projected:1.7.0") + "gplayImplementation"("androidx.core:core-telecom:1.1.0-alpha06") implementation("androidx.media3:media3-session:1.11.0") kapt("org.jetbrains.kotlin:kotlin-metadata-jvm:$kotlinVersion") implementation("androidx.room:room-testing-android:$roomVersion") diff --git a/app/src/gplay/AndroidManifest.xml b/app/src/gplay/AndroidManifest.xml index ee962f64db..bda3387275 100644 --- a/app/src/gplay/AndroidManifest.xml +++ b/app/src/gplay/AndroidManifest.xml @@ -9,6 +9,8 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/gplay/java/com/nextcloud/talk/auto/TalkCallsScreen.kt b/app/src/gplay/java/com/nextcloud/talk/auto/TalkCallsScreen.kt new file mode 100644 index 0000000000..ad9cdcbb60 --- /dev/null +++ b/app/src/gplay/java/com/nextcloud/talk/auto/TalkCallsScreen.kt @@ -0,0 +1,185 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Michael Avery + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.auto + +import android.Manifest +import android.content.ActivityNotFoundException +import android.content.Intent +import android.content.pm.PackageManager +import androidx.car.app.CarContext +import androidx.car.app.CarToast +import androidx.car.app.Screen +import androidx.car.app.model.Action +import androidx.car.app.model.Header +import androidx.car.app.model.ItemList +import androidx.car.app.model.ListTemplate +import androidx.car.app.model.ParkedOnlyOnClickListener +import androidx.car.app.model.Row +import androidx.car.app.model.Template +import androidx.core.content.ContextCompat +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import com.nextcloud.talk.chat.ChatActivity +import com.nextcloud.talk.data.database.dao.ConversationsDao +import com.nextcloud.talk.data.database.model.ConversationEntity +import com.nextcloud.talk.models.json.conversations.ConversationEnums +import com.nextcloud.talk.utils.bundle.BundleKeys +import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_CALL_VOICE_ONLY +import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_ROOM_TOKEN +import com.nextcloud.talk.utils.database.user.CurrentUserProvider +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch + +internal class TalkCallsScreen( + carContext: CarContext, + private val currentUserProvider: CurrentUserProvider, + private val conversationsDao: ConversationsDao +) : Screen(carContext) { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + + private var conversations: List = emptyList() + private var loading = true + private var errorMessage: String? = null + + init { + lifecycle.addObserver( + object : DefaultLifecycleObserver { + override fun onDestroy(owner: LifecycleOwner) { + scope.cancel() + } + } + ) + observeConversations() + } + + override fun onGetTemplate(): Template { + val itemList = ItemList.Builder() + + when { + loading -> itemList.addItem(Row.Builder().setTitle("Loading calls…").build()) + errorMessage != null -> itemList.addItem(Row.Builder().setTitle(errorMessage!!).build()) + conversations.isEmpty() -> { + itemList.addItem( + Row.Builder() + .setTitle("No conversations available for calls") + .build() + ) + } + + else -> conversations.forEach { conversation -> + val row = Row.Builder() + .setTitle(conversation.displayName) + .addText(if (conversation.hasCall) "Join ongoing Talk call" else "Start voice call") + + if (hasMicrophonePermission()) { + row.setOnClickListener { startVoiceCall(conversation) } + } else { + row.setOnClickListener( + ParkedOnlyOnClickListener.create { + requestMicrophonePermissionAndStart(conversation) + } + ) + } + + itemList.addItem(row.build()) + } + } + + return ListTemplate.Builder() + .setHeader( + Header.Builder() + .setStartHeaderAction(Action.BACK) + .setTitle("Calls") + .build() + ) + .setSingleList(itemList.build()) + .build() + } + + private fun observeConversations() { + scope.launch { + try { + val activeUser = currentUserProvider.getCurrentUser().getOrElse { throw it } + val accountId = activeUser.id ?: error("Current Talk account has no local ID") + + conversationsDao.getConversationsForUser(accountId).collectLatest { conversationList -> + conversations = conversationList + .asSequence() + .filter(::isCallableConversation) + .sortedByDescending(ConversationEntity::lastActivity) + .take(MAX_CONVERSATIONS) + .toList() + loading = false + errorMessage = null + invalidate() + } + } catch (e: CancellationException) { + throw e + } catch (_: Exception) { + loading = false + errorMessage = "Talk calls are unavailable" + invalidate() + } + } + } + + private fun hasMicrophonePermission(): Boolean = + ContextCompat.checkSelfPermission( + carContext, + Manifest.permission.RECORD_AUDIO + ) == PackageManager.PERMISSION_GRANTED + + private fun requestMicrophonePermissionAndStart(conversation: ConversationEntity) { + carContext.requestPermissions(listOf(Manifest.permission.RECORD_AUDIO)) { grantedPermissions, _ -> + if (Manifest.permission.RECORD_AUDIO in grantedPermissions) { + startVoiceCall(conversation) + } else { + CarToast.makeText( + carContext, + "Microphone permission is required for Talk calls", + CarToast.LENGTH_LONG + ).show() + } + invalidate() + } + + CarToast.makeText( + carContext, + "Grant microphone access on your phone", + CarToast.LENGTH_LONG + ).show() + } + + private fun startVoiceCall(conversation: ConversationEntity) { + try { + carContext.startActivity( + Intent(carContext, ChatActivity::class.java).apply { + putExtra(KEY_ROOM_TOKEN, conversation.token) + putExtra(BundleKeys.KEY_FROM_NOTIFICATION_START_CALL, true) + putExtra(KEY_CALL_VOICE_ONLY, true) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP) + } + ) + } catch (_: ActivityNotFoundException) { + CarToast.makeText(carContext, "Unable to start Talk call", CarToast.LENGTH_SHORT).show() + } + } + + private fun isCallableConversation(conversation: ConversationEntity): Boolean = + conversation.type != ConversationEnums.ConversationType.DUMMY && + conversation.type != ConversationEnums.ConversationType.ROOM_SYSTEM && + (conversation.canStartCall || conversation.hasCall) + + companion object { + private const val MAX_CONVERSATIONS = 10 + } +} diff --git a/app/src/gplay/java/com/nextcloud/talk/auto/TalkCarAppService.kt b/app/src/gplay/java/com/nextcloud/talk/auto/TalkCarAppService.kt new file mode 100644 index 0000000000..ea87dbeb0b --- /dev/null +++ b/app/src/gplay/java/com/nextcloud/talk/auto/TalkCarAppService.kt @@ -0,0 +1,120 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Michael Avery + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.auto + +import android.content.Intent +import android.content.pm.ApplicationInfo +import androidx.car.app.CarAppService +import androidx.car.app.CarContext +import androidx.car.app.Screen +import androidx.car.app.Session +import androidx.car.app.model.Action +import androidx.car.app.model.Header +import androidx.car.app.model.ItemList +import androidx.car.app.model.ListTemplate +import androidx.car.app.model.Row +import androidx.car.app.model.Template +import androidx.car.app.validation.HostValidator +import autodagger.AutoInjector +import com.nextcloud.talk.application.NextcloudTalkApplication +import com.nextcloud.talk.auto.call.TalkTelecomManager +import com.nextcloud.talk.data.database.dao.ChatMessagesDao +import com.nextcloud.talk.data.database.dao.ConversationsDao +import com.nextcloud.talk.utils.database.user.CurrentUserProvider +import javax.inject.Inject + +/** Android Auto entry point for Talk messaging and calling. */ +@AutoInjector(NextcloudTalkApplication::class) +class TalkCarAppService : CarAppService() { + @Inject + lateinit var currentUserProvider: CurrentUserProvider + + @Inject + lateinit var conversationsDao: ConversationsDao + + @Inject + lateinit var chatMessagesDao: ChatMessagesDao + + override fun onCreate() { + super.onCreate() + NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) + TalkTelecomManager.get(applicationContext).registerWithTelecom() + } + + override fun createHostValidator(): HostValidator = + if ((applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) != 0) { + HostValidator.ALLOW_ALL_HOSTS_VALIDATOR + } else { + HostValidator.Builder(this) + .addAllowedHosts(androidx.car.app.R.array.hosts_allowlist_sample) + .build() + } + + override fun onCreateSession(): Session = TalkCarSession(currentUserProvider, conversationsDao, chatMessagesDao) +} + +private class TalkCarSession( + private val currentUserProvider: CurrentUserProvider, + private val conversationsDao: ConversationsDao, + private val chatMessagesDao: ChatMessagesDao +) : Session() { + override fun onCreateScreen(intent: Intent): Screen = + TalkCarHomeScreen(carContext, currentUserProvider, conversationsDao, chatMessagesDao) +} + +private class TalkCarHomeScreen( + carContext: CarContext, + private val currentUserProvider: CurrentUserProvider, + private val conversationsDao: ConversationsDao, + private val chatMessagesDao: ChatMessagesDao +) : Screen(carContext) { + override fun onGetTemplate(): Template { + val items = ItemList.Builder() + .addItem( + Row.Builder() + .setTitle("Messages") + .addText("Read and reply to recent Talk conversations") + .setOnClickListener { + screenManager.push( + TalkConversationsScreen( + carContext, + currentUserProvider, + conversationsDao, + chatMessagesDao + ) + ) + } + .build() + ) + .addItem( + Row.Builder() + .setTitle("Calls") + .addText("Start or join Talk voice calls") + .setOnClickListener { + screenManager.push( + TalkCallsScreen( + carContext, + currentUserProvider, + conversationsDao + ) + ) + } + .build() + ) + .build() + + return ListTemplate.Builder() + .setHeader( + Header.Builder() + .setStartHeaderAction(Action.APP_ICON) + .setTitle("Nextcloud Talk") + .build() + ) + .setSingleList(items) + .build() + } +} diff --git a/app/src/gplay/java/com/nextcloud/talk/auto/TalkConversationsScreen.kt b/app/src/gplay/java/com/nextcloud/talk/auto/TalkConversationsScreen.kt new file mode 100644 index 0000000000..6ccc8180ed --- /dev/null +++ b/app/src/gplay/java/com/nextcloud/talk/auto/TalkConversationsScreen.kt @@ -0,0 +1,255 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Michael Avery + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.auto + +import android.content.Intent +import android.os.Bundle +import androidx.car.app.CarContext +import androidx.car.app.Screen +import androidx.car.app.messaging.model.CarMessage +import androidx.car.app.messaging.model.ConversationCallback +import androidx.car.app.messaging.model.ConversationItem +import androidx.car.app.model.Action +import androidx.car.app.model.CarText +import androidx.car.app.model.Header +import androidx.car.app.model.ItemList +import androidx.car.app.model.ListTemplate +import androidx.car.app.model.Row +import androidx.car.app.model.Template +import androidx.core.app.Person +import androidx.core.app.RemoteInput +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import com.nextcloud.talk.data.database.dao.ChatMessagesDao +import com.nextcloud.talk.data.database.dao.ConversationsDao +import com.nextcloud.talk.data.database.model.ChatMessageEntity +import com.nextcloud.talk.data.database.model.ConversationEntity +import com.nextcloud.talk.data.user.model.User +import com.nextcloud.talk.models.json.conversations.ConversationEnums +import com.nextcloud.talk.receivers.DirectReplyReceiver +import com.nextcloud.talk.receivers.MarkAsReadReceiver +import com.nextcloud.talk.utils.NotificationUtils +import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_INTERNAL_USER_ID +import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_MESSAGE_ID +import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_ROOM_TOKEN +import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_SYSTEM_NOTIFICATION_ID +import com.nextcloud.talk.utils.database.user.CurrentUserProvider +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch + +internal class TalkConversationsScreen( + carContext: CarContext, + private val currentUserProvider: CurrentUserProvider, + private val conversationsDao: ConversationsDao, + private val chatMessagesDao: ChatMessagesDao +) : Screen(carContext) { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + + private var user: User? = null + private var snapshots: List = emptyList() + private var loading = true + private var errorMessage: String? = null + + init { + lifecycle.addObserver( + object : DefaultLifecycleObserver { + override fun onDestroy(owner: LifecycleOwner) { + scope.cancel() + } + } + ) + observeConversations() + } + + override fun onGetTemplate(): Template { + val itemList = ItemList.Builder() + + when { + loading -> itemList.addItem(Row.Builder().setTitle("Loading conversations…").build()) + errorMessage != null -> itemList.addItem(Row.Builder().setTitle(errorMessage!!).build()) + snapshots.isEmpty() -> itemList.addItem(Row.Builder().setTitle("No recent conversations").build()) + else -> { + val activeUser = user ?: return buildListTemplate(itemList.build()) + snapshots.forEach { snapshot -> + itemList.addItem(buildConversationItem(activeUser, snapshot)) + } + } + } + + return buildListTemplate(itemList.build()) + } + + private fun observeConversations() { + scope.launch { + try { + val activeUser = currentUserProvider.getCurrentUser().getOrElse { throw it } + val accountId = activeUser.id ?: error("Current Talk account has no local ID") + user = activeUser + + conversationsDao.getConversationsForUser(accountId).collectLatest { conversations -> + val recentConversations = conversations + .asSequence() + .filter(::isVisibleConversation) + .sortedByDescending(ConversationEntity::lastActivity) + .take(MAX_CONVERSATIONS) + .toList() + + val updatedSnapshots = mutableListOf() + for (conversation in recentConversations) { + val messages = chatMessagesDao + .getMessagesForConversation(conversation.internalId, null) + .first() + .asSequence() + .filter { !it.deleted && it.message.isNotBlank() } + .take(MAX_MESSAGES_PER_CONVERSATION) + .toList() + .asReversed() + + updatedSnapshots.add(ConversationSnapshot(conversation, messages)) + } + snapshots = updatedSnapshots + + loading = false + errorMessage = null + invalidate() + } + } catch (e: CancellationException) { + throw e + } catch (_: Exception) { + loading = false + errorMessage = "Talk conversations are unavailable" + invalidate() + } + } + } + + private fun buildConversationItem(activeUser: User, snapshot: ConversationSnapshot): ConversationItem { + val conversation = snapshot.conversation + val self = Person.Builder() + .setName(activeUser.displayName ?: activeUser.userId ?: activeUser.username ?: "You") + .setKey(activeUser.userId ?: activeUser.username ?: activeUser.id?.toString() ?: "self") + .build() + + val messages = snapshot.messages.map { message -> + buildCarMessage(activeUser, conversation, message) + } + + val callback = object : ConversationCallback { + override fun onMarkAsRead() { + val newestMessageId = snapshot.messages.lastOrNull()?.id ?: return + sendMarkAsRead(conversation, newestMessageId) + } + + override fun onTextReply(replyText: String) { + if (replyText.isNotBlank() && + conversation.conversationReadOnlyState == + ConversationEnums.ConversationReadOnlyState.CONVERSATION_READ_WRITE + ) { + sendDirectReply(conversation, replyText) + } + } + } + + return ConversationItem.Builder( + conversation.internalId, + CarText.create(conversation.displayName), + self, + messages, + callback + ) + .setGroupConversation(isGroupConversation(conversation)) + .build() + } + + private fun buildCarMessage( + activeUser: User, + conversation: ConversationEntity, + message: ChatMessageEntity + ): CarMessage { + val sentBySelf = message.actorId == activeUser.userId + val sender = if (sentBySelf) { + null + } else { + Person.Builder() + .setName(message.actorDisplayName.ifBlank { "Talk user" }) + .setKey("${conversation.internalId}:${message.actorType}:${message.actorId}") + .build() + } + + val timestampMillis = if (message.timestamp < TIMESTAMP_MILLISECONDS_THRESHOLD) { + message.timestamp * MILLISECONDS_PER_SECOND + } else { + message.timestamp + } + + return CarMessage.Builder() + .setBody(CarText.create(message.message)) + .setRead(sentBySelf || message.id <= conversation.lastReadMessage.toLong()) + .setReceivedTimeEpochMillis(timestampMillis) + .apply { sender?.let(::setSender) } + .build() + } + + private fun sendDirectReply(conversation: ConversationEntity, replyText: String) { + val intent = Intent(carContext, DirectReplyReceiver::class.java) + .putExtra(KEY_SYSTEM_NOTIFICATION_ID, NO_SYSTEM_NOTIFICATION_ID) + .putExtra(KEY_ROOM_TOKEN, conversation.token) + .putExtra(KEY_INTERNAL_USER_ID, conversation.accountId) + + val remoteInput = RemoteInput.Builder(NotificationUtils.KEY_DIRECT_REPLY).build() + val results = Bundle().apply { + putCharSequence(NotificationUtils.KEY_DIRECT_REPLY, replyText) + } + RemoteInput.addResultsToIntent(arrayOf(remoteInput), intent, results) + carContext.sendBroadcast(intent) + } + + private fun sendMarkAsRead(conversation: ConversationEntity, messageId: Long) { + carContext.sendBroadcast( + Intent(carContext, MarkAsReadReceiver::class.java) + .putExtra(KEY_SYSTEM_NOTIFICATION_ID, NO_SYSTEM_NOTIFICATION_ID) + .putExtra(KEY_ROOM_TOKEN, conversation.token) + .putExtra(KEY_INTERNAL_USER_ID, conversation.accountId) + .putExtra(KEY_MESSAGE_ID, messageId.toInt()) + ) + } + + private fun buildListTemplate(itemList: ItemList): Template = + ListTemplate.Builder() + .setHeader( + Header.Builder() + .setStartHeaderAction(Action.BACK) + .setTitle("Messages") + .build() + ) + .setSingleList(itemList) + .build() + + private fun isVisibleConversation(conversation: ConversationEntity): Boolean = + conversation.type != ConversationEnums.ConversationType.DUMMY && + conversation.type != ConversationEnums.ConversationType.ROOM_SYSTEM + + private fun isGroupConversation(conversation: ConversationEntity): Boolean = + conversation.type == ConversationEnums.ConversationType.ROOM_GROUP_CALL || + conversation.type == ConversationEnums.ConversationType.ROOM_PUBLIC_CALL + + private data class ConversationSnapshot(val conversation: ConversationEntity, val messages: List) + + companion object { + private const val MAX_CONVERSATIONS = 10 + private const val MAX_MESSAGES_PER_CONVERSATION = 5 + private const val MILLISECONDS_PER_SECOND = 1000L + private const val NO_SYSTEM_NOTIFICATION_ID = 0 + private const val TIMESTAMP_MILLISECONDS_THRESHOLD = 10_000_000_000L + } +} diff --git a/app/src/gplay/java/com/nextcloud/talk/auto/call/TalkTelecomInteropReceiver.kt b/app/src/gplay/java/com/nextcloud/talk/auto/call/TalkTelecomInteropReceiver.kt new file mode 100644 index 0000000000..848d441571 --- /dev/null +++ b/app/src/gplay/java/com/nextcloud/talk/auto/call/TalkTelecomInteropReceiver.kt @@ -0,0 +1,62 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Michael Avery + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.auto.call + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.os.Bundle +import com.nextcloud.talk.call.TalkCallInterop + +/** Receives package-local Talk call lifecycle events and mirrors them into Core-Telecom. */ +class TalkTelecomInteropReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + val manager = TalkTelecomManager.get(context) + val callKey = intent.getStringExtra(TalkCallInterop.EXTRA_CALL_KEY).orEmpty() + + when (intent.action) { + TalkCallInterop.ACTION_INCOMING_CALL -> { + TalkCallInterop.beginTelecomAudioManagement(context, callKey) + manager.onIncomingCall( + callKey = callKey, + callExtras = intent.getBundleExtra(TalkCallInterop.EXTRA_CALL_EXTRAS) ?: Bundle(), + displayName = intent.getStringExtra(TalkCallInterop.EXTRA_DISPLAY_NAME).orEmpty(), + video = intent.getBooleanExtra(TalkCallInterop.EXTRA_VIDEO, false) + ) + } + + TalkCallInterop.ACTION_CALL_STARTED -> { + TalkCallInterop.beginTelecomAudioManagement(context, callKey) + manager.onCallStarted( + callKey = callKey, + callExtras = intent.getBundleExtra(TalkCallInterop.EXTRA_CALL_EXTRAS) ?: Bundle(), + displayName = intent.getStringExtra(TalkCallInterop.EXTRA_DISPLAY_NAME).orEmpty(), + incoming = intent.getBooleanExtra(TalkCallInterop.EXTRA_INCOMING, false), + video = intent.getBooleanExtra(TalkCallInterop.EXTRA_VIDEO, false) + ) + } + + TalkCallInterop.ACTION_CALL_ACTIVE -> manager.onCallActive(callKey) + TalkCallInterop.ACTION_CALL_ENDED -> manager.onCallEnded(callKey) + TalkCallInterop.ACTION_CALL_PARTICIPANTS_CHANGED -> { + manager.onParticipantsChanged( + callKey = callKey, + participantIds = intent.getStringArrayExtra(TalkCallInterop.EXTRA_PARTICIPANT_IDS) ?: emptyArray(), + participantNames = + intent.getStringArrayExtra(TalkCallInterop.EXTRA_PARTICIPANT_NAMES) ?: emptyArray(), + activeParticipantId = intent.getStringExtra(TalkCallInterop.EXTRA_ACTIVE_PARTICIPANT_ID) + ) + } + TalkCallInterop.ACTION_CONTROL_AUDIO_ENDPOINT -> { + manager.requestAudioEndpoint( + callKey, + intent.getStringExtra(TalkCallInterop.EXTRA_AUDIO_ROUTE).orEmpty() + ) + } + } + } +} diff --git a/app/src/gplay/java/com/nextcloud/talk/auto/call/TalkTelecomManager.kt b/app/src/gplay/java/com/nextcloud/talk/auto/call/TalkTelecomManager.kt new file mode 100644 index 0000000000..0b15291a66 --- /dev/null +++ b/app/src/gplay/java/com/nextcloud/talk/auto/call/TalkTelecomManager.kt @@ -0,0 +1,392 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Michael Avery + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.auto.call + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.telecom.DisconnectCause +import android.util.Log +import androidx.core.app.NotificationManagerCompat +import androidx.core.telecom.CallAttributesCompat +import androidx.core.telecom.CallControlScope +import androidx.core.telecom.CallEndpointCompat +import androidx.core.telecom.CallsManager +import androidx.core.telecom.extensions.Participant as TelecomParticipant +import androidx.core.telecom.extensions.ParticipantExtension +import com.nextcloud.talk.activities.CallActivity +import com.nextcloud.talk.call.TalkCallInterop +import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_CALL_VOICE_ONLY +import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_NOTIFICATION_TIMESTAMP +import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_ROOM_TOKEN +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.launch +import java.util.concurrent.ConcurrentHashMap + +/** + * Bridges Talk's existing WebRTC calls into Core-Telecom so Android Auto and + * other system call surfaces can control them. + * + * This class does not own media/signaling. It only mirrors call state and + * translates Telecom requests into package-local Talk call controls. + */ +class TalkTelecomManager private constructor(context: Context) { + private val appContext = context.applicationContext + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + private val callsManager = CallsManager(appContext) + private val calls = ConcurrentHashMap() + + @Volatile + private var registered = false + + @Synchronized + fun registerWithTelecom() { + if (registered) return + val capabilities = + CallsManager.CAPABILITY_BASELINE or CallsManager.CAPABILITY_SUPPORTS_VIDEO_CALLING + callsManager.registerAppWithTelecom(capabilities) + registered = true + } + + fun onIncomingCall(callKey: String, callExtras: Bundle, displayName: String, video: Boolean) { + addCallIfNeeded( + callKey = callKey, + callExtras = callExtras, + displayName = displayName, + incoming = true, + video = video, + activityStarted = false + ) + } + + fun onCallStarted(callKey: String, callExtras: Bundle, displayName: String, incoming: Boolean, video: Boolean) { + val existing = calls[callKey] + if (existing != null) { + existing.activityStarted = true + existing.callExtras = Bundle(callExtras) + existing.control?.let { control -> + scope.launch { activateStartedCall(existing, control) } + } + return + } + + addCallIfNeeded( + callKey = callKey, + callExtras = callExtras, + displayName = displayName, + incoming = incoming, + video = video, + activityStarted = true + ) + } + + fun onCallActive(callKey: String) { + val managed = calls[callKey] ?: return + managed.control?.let { control -> + scope.launch { + if (!managed.incoming) { + control.setActive() + } + } + } + } + + fun onCallEnded(callKey: String) { + TalkCallInterop.clearTelecomAudioState(appContext, callKey) + val managed = calls.remove(callKey) ?: return + managed.control?.let { control -> + scope.launch { + runCatching { + control.disconnect(DisconnectCause(DisconnectCause.LOCAL)) + }.onFailure { Log.w(TAG, "Telecom disconnect failed for $callKey", it) } + } + } + } + + fun onParticipantsChanged( + callKey: String, + participantIds: Array, + participantNames: Array, + activeParticipantId: String? + ) { + if (callKey.isBlank() || participantIds.size != participantNames.size) return + val managed = calls[callKey] ?: return + managed.participants = participantIds.zip(participantNames) + .mapNotNull { (id, name) -> + id.takeIf { it.isNotBlank() }?.let { TelecomParticipant(it, name.ifBlank { "Guest" }) } + } + .distinctBy(TelecomParticipant::id) + managed.activeParticipantId = activeParticipantId + if (managed.participantExtension != null) { + scope.launch { publishParticipantState(managed) } + } + } + + fun requestAudioEndpoint(callKey: String, route: String) { + if (callKey.isBlank() || route.isBlank()) return + val managed = calls[callKey] ?: return + val control = managed.control ?: return + val endpoint = managed.availableEndpoints.firstOrNull { endpoint -> + routeForEndpoint(endpoint) == route + } ?: if (route == TalkCallInterop.AUDIO_ROUTE_BLUETOOTH) { + managed.availableEndpoints.firstOrNull { endpoint -> + routeForEndpoint(endpoint) == TalkCallInterop.AUDIO_ROUTE_EXTERNAL + } + } else { + null + } + + if (endpoint == null) { + Log.w(TAG, "No Telecom endpoint for requested route $route on $callKey") + return + } + + scope.launch { + runCatching { + control.requestEndpointChange(endpoint) + }.onFailure { + Log.w(TAG, "Unable to request Telecom audio route $route for $callKey", it) + } + } + } + + private suspend fun activateStartedCall(managed: ManagedCall, control: CallControlScope) { + runCatching { + when { + managed.incoming && !managed.answeredByTelecom -> { + control.answer( + if (managed.video) { + CallAttributesCompat.CALL_TYPE_VIDEO_CALL + } else { + CallAttributesCompat.CALL_TYPE_AUDIO_CALL + } + ) + } + + !managed.incoming -> control.setActive() + } + }.onFailure { + Log.w(TAG, "Unable to activate Talk call in Telecom: ${managed.callKey}", it) + } + } + + private fun addCallIfNeeded( + callKey: String, + callExtras: Bundle, + displayName: String, + incoming: Boolean, + video: Boolean, + activityStarted: Boolean + ) { + if (callKey.isBlank() || calls.containsKey(callKey)) return + registerWithTelecom() + + val managed = ManagedCall( + callKey = callKey, + callExtras = Bundle(callExtras), + displayName = displayName.ifBlank { "Nextcloud Talk" }, + incoming = incoming, + video = video, + activityStarted = activityStarted + ) + if (calls.putIfAbsent(callKey, managed) != null) return + + scope.launch { + try { + val roomToken = managed.callExtras.getString(KEY_ROOM_TOKEN).orEmpty() + val attributes = CallAttributesCompat( + displayName = managed.displayName, + address = Uri.parse("sip:${Uri.encode(roomToken)}@nextcloud-talk"), + direction = if (managed.incoming) { + CallAttributesCompat.DIRECTION_INCOMING + } else { + CallAttributesCompat.DIRECTION_OUTGOING + }, + callType = if (managed.video) { + CallAttributesCompat.CALL_TYPE_VIDEO_CALL + } else { + CallAttributesCompat.CALL_TYPE_AUDIO_CALL + }, + // Talk does not currently expose a true hold operation that stops + // both microphone and incoming media, so don't advertise hold yet. + callCapabilities = 0 + ) + + callsManager.addCallWithExtensions( + callAttributes = attributes, + onAnswer = { requestedCallType -> + managed.answeredByTelecom = true + launchTalkCall( + managed, + voiceOnly = requestedCallType != CallAttributesCompat.CALL_TYPE_VIDEO_CALL + ) + }, + onDisconnect = { + cancelIncomingNotification(managed) + if (managed.activityStarted) { + TalkCallInterop.requestDisconnect(appContext, managed.callKey) + } else { + calls.remove(managed.callKey) + TalkCallInterop.clearTelecomAudioState(appContext, managed.callKey) + } + }, + onSetActive = { + if (!managed.activityStarted) { + managed.answeredByTelecom = managed.incoming + launchTalkCall(managed, voiceOnly = !managed.video) + } + }, + onSetInactive = { + // We intentionally do not advertise hold. If Telecom must make + // the call inactive (for example for a cellular call), ending + // Talk is safer than leaving WebRTC media active in the car. + if (managed.activityStarted) { + TalkCallInterop.requestDisconnect(appContext, managed.callKey) + } else { + calls.remove(managed.callKey) + TalkCallInterop.clearTelecomAudioState(appContext, managed.callKey) + } + } + ) { + val participantExtension = addParticipantExtension( + initialParticipants = managed.participants, + initialActiveParticipant = managed.activeParticipant() + ) + onCall { + val callControl = this + managed.control = callControl + managed.participantExtension = participantExtension + publishParticipantState(managed) + + scope.launch { + currentCallEndpoint + .distinctUntilChanged() + .collect { endpoint -> + managed.currentEndpoint = endpoint + publishAudioState(managed) + } + } + + scope.launch { + availableEndpoints + .distinctUntilChanged() + .collect { endpoints -> + managed.availableEndpoints = endpoints + publishAudioState(managed) + } + } + + scope.launch { + isMuted + .distinctUntilChanged() + .collect { muted -> + TalkCallInterop.requestMute(appContext, managed.callKey, muted) + } + } + + if (managed.activityStarted) { + scope.launch { activateStartedCall(managed, callControl) } + } + } + } + } catch (t: Throwable) { + calls.remove(callKey) + TalkCallInterop.clearTelecomAudioState(appContext, callKey) + Log.e(TAG, "Unable to add Talk call to Telecom: $callKey", t) + } + } + } + + private suspend fun publishParticipantState(managed: ManagedCall) { + val participantExtension = managed.participantExtension ?: return + participantExtension.updateParticipants(managed.participants) + participantExtension.updateActiveParticipant(managed.activeParticipant()) + } + + private fun publishAudioState(managed: ManagedCall) { + val currentRoute = managed.currentEndpoint?.let(::routeForEndpoint) + val availableRoutes = managed.availableEndpoints + .mapNotNull(::routeForEndpoint) + .distinct() + .toTypedArray() + + TalkCallInterop.updateTelecomAudioState( + appContext, + managed.callKey, + currentRoute, + availableRoutes + ) + } + + private fun routeForEndpoint(endpoint: CallEndpointCompat): String? = + when (endpoint.type) { + CallEndpointCompat.TYPE_EARPIECE -> TalkCallInterop.AUDIO_ROUTE_EARPIECE + CallEndpointCompat.TYPE_BLUETOOTH -> TalkCallInterop.AUDIO_ROUTE_BLUETOOTH + CallEndpointCompat.TYPE_WIRED_HEADSET -> TalkCallInterop.AUDIO_ROUTE_WIRED_HEADSET + CallEndpointCompat.TYPE_SPEAKER -> TalkCallInterop.AUDIO_ROUTE_SPEAKER + CallEndpointCompat.TYPE_STREAMING -> TalkCallInterop.AUDIO_ROUTE_EXTERNAL + else -> null + } + + private fun launchTalkCall(managed: ManagedCall, voiceOnly: Boolean) { + managed.activityStarted = true + cancelIncomingNotification(managed) + + val extras = Bundle(managed.callExtras).apply { + putBoolean(KEY_CALL_VOICE_ONLY, voiceOnly) + } + appContext.startActivity( + Intent(appContext, CallActivity::class.java).apply { + putExtras(extras) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP) + } + ) + } + + private fun cancelIncomingNotification(managed: ManagedCall) { + val notificationId = managed.callExtras.getInt(KEY_NOTIFICATION_TIMESTAMP, 0) + if (notificationId != 0) { + NotificationManagerCompat.from(appContext).cancel(notificationId) + } + } + + private data class ManagedCall( + val callKey: String, + var callExtras: Bundle, + val displayName: String, + val incoming: Boolean, + val video: Boolean, + @Volatile var activityStarted: Boolean, + @Volatile var answeredByTelecom: Boolean = false, + @Volatile var control: CallControlScope? = null, + @Volatile var participantExtension: ParticipantExtension? = null, + @Volatile var participants: List = emptyList(), + @Volatile var activeParticipantId: String? = null, + @Volatile var currentEndpoint: CallEndpointCompat? = null, + @Volatile var availableEndpoints: List = emptyList() + ) + + private fun ManagedCall.activeParticipant(): TelecomParticipant? = + participants.firstOrNull { it.id == activeParticipantId } + + companion object { + private const val TAG = "TalkTelecomManager" + + @Volatile + private var instance: TalkTelecomManager? = null + + fun get(context: Context): TalkTelecomManager = + instance ?: synchronized(this) { + instance ?: TalkTelecomManager(context).also { instance = it } + } + } +} diff --git a/app/src/gplay/res/xml/automotive_app_desc.xml b/app/src/gplay/res/xml/automotive_app_desc.xml new file mode 100644 index 0000000000..1d01264c41 --- /dev/null +++ b/app/src/gplay/res/xml/automotive_app_desc.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 54182da11b..5260266902 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -100,7 +100,11 @@ + android:value="10"/> + + - - - - - + + + + + + hangup(shutDownView = true, endCallForAll = false) + + TalkCallInterop.ACTION_CONTROL_MUTE -> { + val shouldMute = intent.getBooleanExtra(TalkCallInterop.EXTRA_MUTED, false) + if (microphoneOn == shouldMute) { + onMicrophoneClick() + } + } + } + } + } private val callControlHandler = Handler() private val callInfosHandler = Handler() private val cameraSwitchHandler = Handler() @@ -453,6 +478,28 @@ class CallActivity : CallBaseActivity() { processExtras(intent.extras!!) conversationUser = currentUserProviderOld.currentUser.blockingGet() + val telecomAccountId = conversationUser.id + val telecomRoomToken = roomToken + if (telecomAccountId != null && !telecomRoomToken.isNullOrBlank()) { + activeTelecomCallKey = TalkCallInterop.callKey(telecomAccountId, telecomRoomToken) + registerTelecomControlReceiver() + TalkCallInterop.notifyCallStarted( + context = this, + accountId = telecomAccountId, + roomToken = telecomRoomToken, + displayName = conversationName.orEmpty(), + incoming = isIncomingCallFromNotification, + video = !isVoiceOnlyCall, + callExtras = Bundle(extras) + ) + } + + lifecycleScope.launch { + callViewModel.participants.collectLatest { participants -> + publishTelecomParticipants(participants) + } + } + credentials = ApiUtils.getCredentials(conversationUser!!.username, conversationUser!!.token) if (TextUtils.isEmpty(baseUrl)) { baseUrl = conversationUser!!.baseUrl @@ -475,6 +522,52 @@ class CallActivity : CallBaseActivity() { checkInitialDevicePermissions() } + private fun publishTelecomParticipants(participants: List) { + if (!::conversationUser.isInitialized) return + val accountId = conversationUser.id ?: return + val token = roomToken?.takeIf { it.isNotBlank() } ?: return + val connectedParticipants = participants + .filter { it.isConnected && !it.sessionKey.isNullOrBlank() } + .sortedBy { it.sessionKey } + + val participantIds = mutableListOf("self:$accountId") + val participantNames = mutableListOf( + conversationUser.displayName?.takeIf { it.isNotBlank() } + ?: conversationUser.username?.takeIf { it.isNotBlank() } + ?: "You" + ) + + connectedParticipants.forEach { participant -> + val sessionId = participant.sessionKey ?: return@forEach + participantIds.add("session:$sessionId") + participantNames.add(participant.nick?.takeIf { it.isNotBlank() } ?: "Guest") + } + + val activeParticipantId = connectedParticipants + .firstOrNull { it.isSpeaking } + ?.sessionKey + ?.let { "session:$it" } + + TalkCallInterop.notifyCallParticipants( + context = this, + accountId = accountId, + roomToken = token, + participantIds = participantIds.toTypedArray(), + participantNames = participantNames.toTypedArray(), + activeParticipantId = activeParticipantId + ) + } + + private fun registerTelecomControlReceiver() { + if (telecomControlReceiverRegistered) return + val filter = IntentFilter().apply { + addAction(TalkCallInterop.ACTION_CONTROL_DISCONNECT) + addAction(TalkCallInterop.ACTION_CONTROL_MUTE) + } + registerBroadcastReceiver(telecomControlReceiver, filter, ReceiverFlag.NotExported) + telecomControlReceiverRegistered = true + } + private fun initCallRecordingViewModel(recordingState: Int) { callRecordingViewModel = ViewModelProvider(this, viewModelFactory).get( CallRecordingViewModel::class.java @@ -1391,6 +1484,10 @@ class CallActivity : CallBaseActivity() { } CallForegroundService.stop(applicationContext) powerManagerUtils!!.updatePhoneState(PowerManagerUtils.PhoneState.IDLE) + if (telecomControlReceiverRegistered) { + unregisterReceiver(telecomControlReceiver) + telecomControlReceiverRegistered = false + } super.onDestroy() } @@ -1966,6 +2063,13 @@ class CallActivity : CallBaseActivity() { private fun hangup(shutDownView: Boolean, endCallForAll: Boolean) { Log.d(TAG, "hangup! shutDownView=$shutDownView") + if (shutDownView && ::conversationUser.isInitialized) { + val accountId = conversationUser.id + val token = roomToken + if (accountId != null && !token.isNullOrBlank()) { + TalkCallInterop.notifyCallEnded(this, accountId, token) + } + } joinRoomInitiated = false if (shutDownView) { setCallState(CallStatus.LEAVING) @@ -2639,6 +2743,16 @@ class CallActivity : CallBaseActivity() { private fun setCallState(callState: CallStatus) { if (currentCallStatus == null || currentCallStatus !== callState) { currentCallStatus = callState + if ( + ::conversationUser.isInitialized && + (callState === CallStatus.JOINED || callState === CallStatus.IN_CONVERSATION) + ) { + val accountId = conversationUser.id + val token = roomToken + if (accountId != null && !token.isNullOrBlank()) { + TalkCallInterop.notifyCallActive(this, accountId, token) + } + } if (handler == null) { handler = Handler(Looper.getMainLooper()) } else { diff --git a/app/src/main/java/com/nextcloud/talk/activities/ParticipantHandler.kt b/app/src/main/java/com/nextcloud/talk/activities/ParticipantHandler.kt index ce7ad3dde7..27af911059 100644 --- a/app/src/main/java/com/nextcloud/talk/activities/ParticipantHandler.kt +++ b/app/src/main/java/com/nextcloud/talk/activities/ParticipantHandler.kt @@ -145,7 +145,15 @@ class ParticipantHandler( override fun onAudioOff() { Log.d(TAG, "onAudioOff: ${_uiState.value.nick} (sessionId=${_uiState.value.sessionKey})") - _uiState.update { it.copy(isAudioEnabled = false) } + _uiState.update { it.copy(isAudioEnabled = false, isSpeaking = false) } + } + + override fun onSpeaking() { + _uiState.update { it.copy(isSpeaking = true) } + } + + override fun onStoppedSpeaking() { + _uiState.update { it.copy(isSpeaking = false) } } override fun onVideoOn() { diff --git a/app/src/main/java/com/nextcloud/talk/activities/ParticipantUiState.kt b/app/src/main/java/com/nextcloud/talk/activities/ParticipantUiState.kt index eed0e0e812..8e41d0a886 100644 --- a/app/src/main/java/com/nextcloud/talk/activities/ParticipantUiState.kt +++ b/app/src/main/java/com/nextcloud/talk/activities/ParticipantUiState.kt @@ -24,5 +24,6 @@ data class ParticipantUiState( val raisedHand: Boolean, val actorType: Participant.ActorType? = null, val actorId: String? = null, - val isInternal: Boolean + val isInternal: Boolean, + val isSpeaking: Boolean = false ) diff --git a/app/src/main/java/com/nextcloud/talk/call/TalkCallInterop.kt b/app/src/main/java/com/nextcloud/talk/call/TalkCallInterop.kt new file mode 100644 index 0000000000..d26c40da30 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/call/TalkCallInterop.kt @@ -0,0 +1,211 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Michael Avery + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.call + +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.os.Bundle +import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_INTERNAL_USER_ID +import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_ROOM_TOKEN + +/** + * Flavor-neutral bridge between Talk's WebRTC call lifecycle and optional + * platform call integrations such as Core-Telecom in the Google Play flavor. + * + * The broadcasts are package-restricted, so they never leave this app. On a + * flavor without a platform integration receiver they are harmless no-ops. + */ +object TalkCallInterop { + const val ACTION_INCOMING_CALL = "com.nextcloud.talk.call.action.INCOMING" + const val ACTION_CALL_STARTED = "com.nextcloud.talk.call.action.STARTED" + const val ACTION_CALL_ACTIVE = "com.nextcloud.talk.call.action.ACTIVE" + const val ACTION_CALL_ENDED = "com.nextcloud.talk.call.action.ENDED" + const val ACTION_CALL_PARTICIPANTS_CHANGED = "com.nextcloud.talk.call.action.PARTICIPANTS_CHANGED" + + const val ACTION_CONTROL_DISCONNECT = "com.nextcloud.talk.call.action.CONTROL_DISCONNECT" + const val ACTION_CONTROL_MUTE = "com.nextcloud.talk.call.action.CONTROL_MUTE" + const val ACTION_CONTROL_AUDIO_ENDPOINT = "com.nextcloud.talk.call.action.CONTROL_AUDIO_ENDPOINT" + const val ACTION_TELECOM_AUDIO_STATE_CHANGED = "com.nextcloud.talk.call.action.TELECOM_AUDIO_STATE_CHANGED" + + const val AUDIO_ROUTE_EARPIECE = "earpiece" + const val AUDIO_ROUTE_BLUETOOTH = "bluetooth" + const val AUDIO_ROUTE_WIRED_HEADSET = "wired_headset" + const val AUDIO_ROUTE_SPEAKER = "speaker" + const val AUDIO_ROUTE_EXTERNAL = "external" + + const val EXTRA_CALL_KEY = "talk_call_key" + const val EXTRA_CALL_EXTRAS = "talk_call_extras" + const val EXTRA_DISPLAY_NAME = "talk_display_name" + const val EXTRA_INCOMING = "talk_incoming" + const val EXTRA_VIDEO = "talk_video" + const val EXTRA_MUTED = "talk_muted" + const val EXTRA_AUDIO_ROUTE = "talk_audio_route" + const val EXTRA_PARTICIPANT_IDS = "talk_participant_ids" + const val EXTRA_PARTICIPANT_NAMES = "talk_participant_names" + const val EXTRA_ACTIVE_PARTICIPANT_ID = "talk_active_participant_id" + + @Volatile + private var activeTelecomCallKey: String? = null + + @Volatile + private var telecomCurrentAudioRoute: String? = null + + @Volatile + private var telecomAvailableAudioRoutes: Array = emptyArray() + + fun callKey(accountId: Long, roomToken: String): String = "$accountId@$roomToken" + + fun notifyIncomingCall(context: Context, callExtras: Bundle, displayName: String, video: Boolean) { + val accountId = callExtras.getLong(KEY_INTERNAL_USER_ID, -1L) + val roomToken = callExtras.getString(KEY_ROOM_TOKEN).orEmpty() + if (accountId < 0L || roomToken.isBlank()) return + + val key = callKey(accountId, roomToken) + prepareTelecomAudioManagementIfAvailable(context, key) + send( + context, + Intent(ACTION_INCOMING_CALL) + .putExtra(EXTRA_CALL_KEY, key) + .putExtra(EXTRA_CALL_EXTRAS, Bundle(callExtras)) + .putExtra(EXTRA_DISPLAY_NAME, displayName) + .putExtra(EXTRA_INCOMING, true) + .putExtra(EXTRA_VIDEO, video) + ) + } + + fun notifyCallStarted( + context: Context, + accountId: Long, + roomToken: String, + displayName: String, + incoming: Boolean, + video: Boolean, + callExtras: Bundle + ) { + if (accountId < 0L || roomToken.isBlank()) return + val key = callKey(accountId, roomToken) + prepareTelecomAudioManagementIfAvailable(context, key) + send( + context, + Intent(ACTION_CALL_STARTED) + .putExtra(EXTRA_CALL_KEY, key) + .putExtra(EXTRA_CALL_EXTRAS, Bundle(callExtras)) + .putExtra(EXTRA_DISPLAY_NAME, displayName) + .putExtra(EXTRA_INCOMING, incoming) + .putExtra(EXTRA_VIDEO, video) + ) + } + + fun notifyCallActive(context: Context, accountId: Long, roomToken: String) { + notifySimple(context, ACTION_CALL_ACTIVE, accountId, roomToken) + } + + fun notifyCallEnded(context: Context, accountId: Long, roomToken: String) { + notifySimple(context, ACTION_CALL_ENDED, accountId, roomToken) + } + + fun notifyCallParticipants( + context: Context, + accountId: Long, + roomToken: String, + participantIds: Array, + participantNames: Array, + activeParticipantId: String? + ) { + if (accountId < 0L || roomToken.isBlank() || participantIds.size != participantNames.size) return + val intent = Intent(ACTION_CALL_PARTICIPANTS_CHANGED) + .putExtra(EXTRA_CALL_KEY, callKey(accountId, roomToken)) + .putExtra(EXTRA_PARTICIPANT_IDS, participantIds) + .putExtra(EXTRA_PARTICIPANT_NAMES, participantNames) + if (!activeParticipantId.isNullOrBlank()) { + intent.putExtra(EXTRA_ACTIVE_PARTICIPANT_ID, activeParticipantId) + } + send(context, intent) + } + + fun requestDisconnect(context: Context, callKey: String) { + send(context, Intent(ACTION_CONTROL_DISCONNECT).putExtra(EXTRA_CALL_KEY, callKey)) + } + + fun requestMute(context: Context, callKey: String, muted: Boolean) { + send( + context, + Intent(ACTION_CONTROL_MUTE) + .putExtra(EXTRA_CALL_KEY, callKey) + .putExtra(EXTRA_MUTED, muted) + ) + } + + @JvmStatic + fun requestTelecomAudioRoute(context: Context, route: String) { + val callKey = activeTelecomCallKey ?: return + send( + context, + Intent(ACTION_CONTROL_AUDIO_ENDPOINT) + .putExtra(EXTRA_CALL_KEY, callKey) + .putExtra(EXTRA_AUDIO_ROUTE, route) + ) + } + + fun beginTelecomAudioManagement(context: Context, callKey: String) { + if (callKey.isBlank() || activeTelecomCallKey == callKey) return + activeTelecomCallKey = callKey + telecomCurrentAudioRoute = null + telecomAvailableAudioRoutes = emptyArray() + send(context, Intent(ACTION_TELECOM_AUDIO_STATE_CHANGED).putExtra(EXTRA_CALL_KEY, callKey)) + } + + fun updateTelecomAudioState( + context: Context, + callKey: String, + currentRoute: String?, + availableRoutes: Array + ) { + if (callKey.isBlank()) return + activeTelecomCallKey = callKey + telecomCurrentAudioRoute = currentRoute + telecomAvailableAudioRoutes = availableRoutes.copyOf() + send(context, Intent(ACTION_TELECOM_AUDIO_STATE_CHANGED).putExtra(EXTRA_CALL_KEY, callKey)) + } + + fun clearTelecomAudioState(context: Context, callKey: String) { + if (callKey.isBlank() || activeTelecomCallKey != callKey) return + activeTelecomCallKey = null + telecomCurrentAudioRoute = null + telecomAvailableAudioRoutes = emptyArray() + send(context, Intent(ACTION_TELECOM_AUDIO_STATE_CHANGED).putExtra(EXTRA_CALL_KEY, callKey)) + } + + @JvmStatic + fun isTelecomAudioManaged(): Boolean = activeTelecomCallKey != null + + @JvmStatic + fun getTelecomCurrentAudioRoute(): String? = telecomCurrentAudioRoute + + @JvmStatic + fun getTelecomAvailableAudioRoutes(): Array = telecomAvailableAudioRoutes.copyOf() + + private fun notifySimple(context: Context, action: String, accountId: Long, roomToken: String) { + if (accountId < 0L || roomToken.isBlank()) return + send(context, Intent(action).putExtra(EXTRA_CALL_KEY, callKey(accountId, roomToken))) + } + + @Suppress("DEPRECATION") + private fun prepareTelecomAudioManagementIfAvailable(context: Context, callKey: String) { + val probe = Intent(ACTION_CALL_STARTED).setPackage(context.packageName) + val receivers = context.packageManager.queryBroadcastReceivers(probe, PackageManager.MATCH_ALL) + if (receivers.isNotEmpty()) { + beginTelecomAudioManagement(context, callKey) + } + } + + private fun send(context: Context, intent: Intent) { + intent.setPackage(context.packageName) + context.sendBroadcast(intent) + } +} diff --git a/app/src/main/java/com/nextcloud/talk/jobs/NotificationWorker.kt b/app/src/main/java/com/nextcloud/talk/jobs/NotificationWorker.kt index 6ea912878c..ae5abbbf7b 100644 --- a/app/src/main/java/com/nextcloud/talk/jobs/NotificationWorker.kt +++ b/app/src/main/java/com/nextcloud/talk/jobs/NotificationWorker.kt @@ -16,6 +16,7 @@ import android.content.Intent import android.content.pm.PackageManager import android.graphics.Bitmap import android.graphics.drawable.BitmapDrawable +import android.net.Uri import android.os.Build import android.os.Bundle import android.os.Handler @@ -32,6 +33,9 @@ import androidx.core.app.NotificationManagerCompat import androidx.core.app.Person import androidx.core.app.RemoteInput import androidx.core.content.ContextCompat +import androidx.core.content.pm.ShortcutInfoCompat +import androidx.core.content.pm.ShortcutManagerCompat +import androidx.core.graphics.drawable.IconCompat import androidx.core.graphics.drawable.toBitmap import androidx.core.net.toUri import androidx.emoji2.text.EmojiCompat @@ -51,6 +55,7 @@ import com.nextcloud.talk.api.NcApi import com.nextcloud.talk.application.NextcloudTalkApplication import com.nextcloud.talk.application.NextcloudTalkApplication.Companion.sharedApplication import com.nextcloud.talk.arbitrarystorage.ArbitraryStorageManager +import com.nextcloud.talk.call.TalkCallInterop import com.nextcloud.talk.callnotification.CallNotificationActivity import com.nextcloud.talk.chat.data.network.ChatNetworkDataSource import com.nextcloud.talk.conversationlist.DirectShareHelper @@ -71,8 +76,8 @@ import com.nextcloud.talk.receivers.MarkAsReadReceiver import com.nextcloud.talk.receivers.ShareRecordingToChatReceiver import com.nextcloud.talk.users.UserManager import com.nextcloud.talk.utils.ApiUtils -import com.nextcloud.talk.utils.DisplayUtils import com.nextcloud.talk.utils.ConversationUtils +import com.nextcloud.talk.utils.DisplayUtils import com.nextcloud.talk.utils.NotificationUtils import com.nextcloud.talk.utils.NotificationUtils.cancelAllNotificationsForAccount import com.nextcloud.talk.utils.NotificationUtils.cancelNotification @@ -110,13 +115,13 @@ import java.security.InvalidKeyException import java.security.NoSuchAlgorithmException import java.security.PrivateKey import java.util.concurrent.TimeUnit -import java.util.function.Consumer import java.util.zip.CRC32 import javax.crypto.BadPaddingException import javax.crypto.Cipher import javax.crypto.NoSuchPaddingException import javax.inject.Inject +@Suppress("TooManyFunctions", "LargeClass", "CyclomaticComplexMethod") @AutoInjector(NextcloudTalkApplication::class) class NotificationWorker(context: Context, workerParams: WorkerParameters) : Worker(context, workerParams) { @@ -354,6 +359,14 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor val callerPerson = callerPersonBuilder.build() val isVideoCall = (conversation.callFlag and Participant.InCallFlags.WITH_VIDEO) > 0 + + TalkCallInterop.notifyIncomingCall( + applicationContext, + bundle, + conversation.displayName, + isVideoCall + ) + val primaryAnswerIntent = if (isVideoCall) answerVideoPendingIntent else answerVoicePendingIntent val notification = @@ -700,17 +713,28 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor val systemNotificationId: Int = activeStatusBarNotification?.id ?: calculateCRC32(System.currentTimeMillis().toString()).toInt() - if (TYPE_CHAT == pushMessage.type || TYPE_REMINDER == pushMessage.type) { + if ((TYPE_CHAT == pushMessage.type || TYPE_REMINDER == pushMessage.type) && + pushMessage.notificationUser != null + ) { notificationBuilder.setOnlyAlertOnce(false) - if (pushMessage.notificationUser != null) { - if (imagePreviewUrl != null) { - styleImageNotification(notificationBuilder) - } else { - styleChatNotification(notificationBuilder, activeStatusBarNotification) - } - addReplyAction(notificationBuilder, systemNotificationId) - addMarkAsReadAction(notificationBuilder, systemNotificationId) + val senderAvatar = loadSenderAvatar(pushMessage.notificationUser) + val imageUri = imagePreviewUrl?.let { loadImageBitmapSync(it) }?.let { + NotificationUtils.saveBitmapToCache( + context!!, + it, + "notification_preview_${pushMessage.id}.png" + ) } + + styleConversationNotification( + notificationBuilder, + activeStatusBarNotification, + senderAvatar, + imageUri + ) + addReplyAction(notificationBuilder, systemNotificationId) + addMarkAsReadAction(notificationBuilder, systemNotificationId) + pushConversationShortcut(notificationBuilder, senderAvatar) } if (TYPE_RECORDING == pushMessage.type && ncNotification != null) { @@ -811,17 +835,78 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor return crc32.value } - private fun styleImageNotification(notificationBuilder: NotificationCompat.Builder) { - val bitmap = loadImageBitmapSync(imagePreviewUrl!!) - if (bitmap != null) { - notificationBuilder - .setLargeIcon(bitmap) - .setStyle( - NotificationCompat.BigPictureStyle() - .bigPicture(bitmap) - .bigLargeIcon(null as Bitmap?) + private fun styleConversationNotification( + notificationBuilder: NotificationCompat.Builder, + activeStatusBarNotification: StatusBarNotification?, + senderAvatar: Bitmap?, + imageUri: Uri? + ) { + val notificationUser = pushMessage.notificationUser ?: return + + val userType = notificationUser.type + var style: NotificationCompat.MessagingStyle? = null + if (activeStatusBarNotification != null) { + style = NotificationCompat.MessagingStyle.extractMessagingStyleFromNotification( + activeStatusBarNotification.notification + ) + } + val personBuilder = Person.Builder() + .setKey(user.id.toString() + "@" + notificationUser.id) + .setName(EmojiCompat.get().process(notificationUser.name!!)) + .setBot("bot" == userType) + + if (senderAvatar != null) { + personBuilder.setIcon(IconCompat.createWithBitmap(senderAvatar)) + notificationBuilder.setLargeIcon(senderAvatar) + } + + val deviceUser = Person.Builder() + .setKey(user.id.toString() + "@" + user.userId) + .setName(user.displayName ?: user.userId ?: "You") + .build() + + val sender = personBuilder.build() + val newStyle = NotificationCompat.MessagingStyle(deviceUser) + newStyle.conversationTitle = pushMessage.subject.ifEmpty { sender.name } + newStyle.isGroupConversation = "one2one" != conversationType + style?.messages?.forEach { message -> + newStyle.addMessage( + NotificationCompat.MessagingStyle.Message( + message.text, + message.timestamp, + message.person ) + ) } + + val message = NotificationCompat.MessagingStyle.Message( + pushMessage.text, + pushMessage.timestamp, + sender + ) + if (imageUri != null) { + message.setData(imageMimeType ?: "image/*", imageUri) + } + newStyle.addMessage(message) + notificationBuilder.setStyle(newStyle) + } + + private fun loadSenderAvatar(notificationUser: NotificationUser?): Bitmap? { + val userType = notificationUser?.type + if (userType != "user" && userType != "guest") return null + + val baseUrl = user.baseUrl + val avatarUrl = if ("user" == userType) { + ApiUtils.getUrlForAvatar( + baseUrl!!, + notificationUser.id, + false, + darkMode = DisplayUtils.isDarkModeOn(context!!) + ) + } else { + ApiUtils.getUrlForGuestAvatar(baseUrl!!, notificationUser.name, false) + } + return NotificationUtils.loadAvatarBitmapSync(avatarUrl, context!!) } private fun loadImageBitmapSync(imageUrl: String): Bitmap? { @@ -839,42 +924,43 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor return bitmap } - private fun styleChatNotification( - notificationBuilder: NotificationCompat.Builder, - activeStatusBarNotification: StatusBarNotification? - ) { + private fun pushConversationShortcut(notificationBuilder: NotificationCompat.Builder, avatarBitmap: Bitmap?) { val notificationUser = pushMessage.notificationUser ?: return + val roomToken = pushMessage.id ?: return - val userType = notificationUser.type - var style: NotificationCompat.MessagingStyle? = null - if (activeStatusBarNotification != null) { - style = NotificationCompat.MessagingStyle.extractMessagingStyleFromNotification( - activeStatusBarNotification.notification - ) - } - val person = Person.Builder() + val shortcutId = "conversation_${user.id}_$roomToken" + + val personBuilder = Person.Builder() .setKey(user.id.toString() + "@" + notificationUser.id) .setName(EmojiCompat.get().process(notificationUser.name!!)) - .setBot("bot" == userType) - if ("user" == userType || "guest" == userType) { - val baseUrl = user.baseUrl - val avatarUrl = if ("user" == userType) { - ApiUtils.getUrlForAvatar( - baseUrl!!, - notificationUser.id, - false, - darkMode = DisplayUtils.isDarkModeOn(context!!) - ) - } else { - ApiUtils.getUrlForGuestAvatar(baseUrl!!, notificationUser.name, false) - } - person.setIcon(loadAvatarSync(avatarUrl, context!!)) + if (avatarBitmap != null) { + personBuilder.setIcon(IconCompat.createWithBitmap(avatarBitmap)) } - notificationBuilder.setStyle(getStyle(person.build(), style)) + + val intent = Intent(context, MainActivity::class.java).apply { + action = Intent.ACTION_VIEW + putExtra(KEY_ROOM_TOKEN, roomToken) + putExtra(KEY_INTERNAL_USER_ID, user.id) + } + + val shortcut = ShortcutInfoCompat.Builder(context!!, shortcutId) + .setShortLabel(pushMessage.subject.ifEmpty { notificationUser.name ?: "Chat" }) + .setLongLived(true) + .setIntent(intent) + .setPerson(personBuilder.build()) + .build() + + ShortcutManagerCompat.pushDynamicShortcut(context!!, shortcut) + notificationBuilder.setShortcutId(shortcutId) } - private fun buildIntentForAction(cls: Class<*>, systemNotificationId: Int, messageId: Int): PendingIntent { + private fun buildIntentForAction( + cls: Class<*>, + systemNotificationId: Int, + messageId: Int, + mutable: Boolean = false + ): PendingIntent { val actualIntent = Intent(context, cls) // NOTE - systemNotificationId is an internal ID used on the device only. @@ -885,7 +971,8 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor actualIntent.putExtra(KEY_MESSAGE_ID, messageId) val intentFlag: Int = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + val mutabilityFlag = if (mutable) PendingIntent.FLAG_MUTABLE else PendingIntent.FLAG_IMMUTABLE + mutabilityFlag or PendingIntent.FLAG_UPDATE_CURRENT } else { PendingIntent.FLAG_UPDATE_CURRENT } @@ -904,7 +991,8 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor val pendingIntent = buildIntentForAction( MarkAsReadReceiver::class.java, systemNotificationId, - messageId + messageId, + mutable = false ) val markAsReadAction = NotificationCompat.Action.Builder( R.drawable.ic_mark_chat_read_24px, @@ -927,7 +1015,8 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor val replyPendingIntent = buildIntentForAction( DirectReplyReceiver::class.java, systemNotificationId, - 0 + 0, + mutable = true ) val replyAction = NotificationCompat.Action.Builder(R.drawable.ic_reply, replyLabel, replyPendingIntent) .setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_REPLY) @@ -958,7 +1047,7 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor dismissIntent.putExtra(KEY_DISMISS_RECORDING_URL, dismissRecordingUrl) val intentFlag: Int = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT } else { PendingIntent.FLAG_UPDATE_CURRENT } @@ -992,7 +1081,7 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor shareRecordingIntent.putExtra(KEY_ROOM_TOKEN, pushMessage.id) val intentFlag: Int = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT } else { PendingIntent.FLAG_UPDATE_CURRENT } @@ -1014,25 +1103,6 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor notificationBuilder.addAction(shareRecordingAction) } - private fun getStyle(person: Person, style: NotificationCompat.MessagingStyle?): NotificationCompat.MessagingStyle { - val newStyle = NotificationCompat.MessagingStyle(person) - newStyle.conversationTitle = pushMessage.subject - newStyle.isGroupConversation = "one2one" != conversationType - style?.messages?.forEach( - Consumer { message: NotificationCompat.MessagingStyle.Message -> - newStyle.addMessage( - NotificationCompat.MessagingStyle.Message( - message.text, - message.timestamp, - message.person - ) - ) - } - ) - newStyle.addMessage(pushMessage.text, pushMessage.timestamp, person) - return newStyle - } - @Throws(NumberFormatException::class) private fun parseMessageId(objectId: String): Int { val objectIdParts = objectId.split("/".toRegex()).toTypedArray() @@ -1210,7 +1280,7 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor // See https://github.com/nextcloud/talk-android/issues/2111 val requestCode = System.currentTimeMillis().toInt() val intentFlag: Int = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT } else { PendingIntent.FLAG_UPDATE_CURRENT } diff --git a/app/src/main/java/com/nextcloud/talk/receivers/DirectReplyReceiver.kt b/app/src/main/java/com/nextcloud/talk/receivers/DirectReplyReceiver.kt index 0620602e3a..309710f8fa 100644 --- a/app/src/main/java/com/nextcloud/talk/receivers/DirectReplyReceiver.kt +++ b/app/src/main/java/com/nextcloud/talk/receivers/DirectReplyReceiver.kt @@ -26,6 +26,7 @@ import com.nextcloud.talk.R import com.nextcloud.talk.api.NcApi import com.nextcloud.talk.application.NextcloudTalkApplication import com.nextcloud.talk.data.user.model.User +import com.nextcloud.talk.jobs.ChatMessageCatchUpWorker import com.nextcloud.talk.models.json.chat.ChatOverallSingleMessage import com.nextcloud.talk.users.UserManager import com.nextcloud.talk.utils.ApiUtils @@ -121,6 +122,7 @@ class DirectReplyReceiver : BroadcastReceiver() { private fun confirmReplySent() { appendMessageToNotification(replyMessage!!) + ChatMessageCatchUpWorker.enqueue(context, currentUser.id!!, roomToken!!, null) } private fun informReplyFailed() { diff --git a/app/src/main/java/com/nextcloud/talk/utils/NotificationUtils.kt b/app/src/main/java/com/nextcloud/talk/utils/NotificationUtils.kt index 4023b8083e..a5a19fa41f 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/NotificationUtils.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/NotificationUtils.kt @@ -11,12 +11,14 @@ import android.app.Notification import android.app.NotificationChannel import android.app.NotificationManager import android.content.Context +import android.graphics.Bitmap import android.graphics.drawable.BitmapDrawable import android.media.AudioAttributes import android.net.Uri import android.service.notification.StatusBarNotification import android.text.TextUtils import android.util.Log +import androidx.core.content.FileProvider import androidx.core.graphics.drawable.IconCompat import androidx.core.net.toUri import coil.executeBlocking @@ -30,6 +32,8 @@ import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.models.RingtoneSettings import com.nextcloud.talk.utils.bundle.BundleKeys import com.nextcloud.talk.utils.preferences.AppPreferences +import java.io.File +import java.io.FileOutputStream import java.io.IOException @Suppress("TooManyFunctions") @@ -55,6 +59,8 @@ object NotificationUtils { const val KEY_UPLOAD_GROUP = "com.nextcloud.talk.utils.KEY_UPLOAD_GROUP" const val GROUP_SUMMARY_NOTIFICATION_ID = -1 + private const val BITMAP_COMPRESSION_QUALITY = 100 + private fun createNotificationChannel( context: Context, notificationChannel: Channel, @@ -316,7 +322,12 @@ object NotificationUtils { ) fun loadAvatarSync(avatarUrl: String, context: Context): IconCompat? { - var avatarIcon: IconCompat? = null + val bitmap = loadAvatarBitmapSync(avatarUrl, context) + return bitmap?.let { IconCompat.createWithBitmap(it) } + } + + fun loadAvatarBitmapSync(avatarUrl: String, context: Context): Bitmap? { + var avatarBitmap: Bitmap? = null val request = ImageRequest.Builder(context) .data(avatarUrl) @@ -324,13 +335,11 @@ object NotificationUtils { .placeholder(R.drawable.account_circle_96dp) .target( onSuccess = { result -> - val bitmap = (result as BitmapDrawable).bitmap - avatarIcon = IconCompat.createWithBitmap(bitmap) + avatarBitmap = (result as BitmapDrawable).bitmap }, onError = { error -> error?.let { - val bitmap = (error as BitmapDrawable).bitmap - avatarIcon = IconCompat.createWithBitmap(bitmap) + avatarBitmap = (error as BitmapDrawable).bitmap } Log.w(TAG, "Can't load avatar for URL: $avatarUrl") } @@ -339,7 +348,20 @@ object NotificationUtils { context.imageLoader.executeBlocking(request) - return avatarIcon + return avatarBitmap + } + + fun saveBitmapToCache(context: Context, bitmap: Bitmap, fileName: String): Uri? { + val cacheFile = File(context.cacheDir, fileName) + return try { + FileOutputStream(cacheFile).use { out -> + bitmap.compress(Bitmap.CompressFormat.PNG, BITMAP_COMPRESSION_QUALITY, out) + } + FileProvider.getUriForFile(context, BuildConfig.APPLICATION_ID, cacheFile) + } catch (e: IOException) { + Log.e(TAG, "Failed to save bitmap to cache", e) + null + } } private data class Channel(val id: String, val name: String, val description: String, val isImportant: Boolean) diff --git a/app/src/main/java/com/nextcloud/talk/webrtc/DataChannelMessageNotifier.java b/app/src/main/java/com/nextcloud/talk/webrtc/DataChannelMessageNotifier.java index 2fca22ac8f..83fd74b64a 100644 --- a/app/src/main/java/com/nextcloud/talk/webrtc/DataChannelMessageNotifier.java +++ b/app/src/main/java/com/nextcloud/talk/webrtc/DataChannelMessageNotifier.java @@ -45,6 +45,18 @@ public synchronized void notifyAudioOff() { } } + public synchronized void notifySpeaking() { + for (PeerConnectionWrapper.DataChannelMessageListener listener : new ArrayList<>(dataChannelMessageListeners)) { + listener.onSpeaking(); + } + } + + public synchronized void notifyStoppedSpeaking() { + for (PeerConnectionWrapper.DataChannelMessageListener listener : new ArrayList<>(dataChannelMessageListeners)) { + listener.onStoppedSpeaking(); + } + } + public synchronized void notifyVideoOn() { for (PeerConnectionWrapper.DataChannelMessageListener listener : new ArrayList<>(dataChannelMessageListeners)) { listener.onVideoOn(); diff --git a/app/src/main/java/com/nextcloud/talk/webrtc/PeerConnectionWrapper.java b/app/src/main/java/com/nextcloud/talk/webrtc/PeerConnectionWrapper.java index 484ad34460..7436cd1da6 100644 --- a/app/src/main/java/com/nextcloud/talk/webrtc/PeerConnectionWrapper.java +++ b/app/src/main/java/com/nextcloud/talk/webrtc/PeerConnectionWrapper.java @@ -83,6 +83,8 @@ public class PeerConnectionWrapper { public interface DataChannelMessageListener { void onAudioOn(); void onAudioOff(); + void onSpeaking(); + void onStoppedSpeaking(); void onVideoOn(); void onVideoOff(); void onNickChanged(String nick); @@ -517,6 +519,18 @@ public void onMessage(DataChannel.Buffer buffer) { return; } + if ("speaking".equals(dataChannelMessage.getType())) { + dataChannelMessageNotifier.notifySpeaking(); + + return; + } + + if ("stoppedSpeaking".equals(dataChannelMessage.getType())) { + dataChannelMessageNotifier.notifyStoppedSpeaking(); + + return; + } + if ("videoOn".equals(dataChannelMessage.getType())) { dataChannelMessageNotifier.notifyVideoOn(); diff --git a/app/src/main/java/com/nextcloud/talk/webrtc/WebRtcAudioManager.java b/app/src/main/java/com/nextcloud/talk/webrtc/WebRtcAudioManager.java index aec2561da7..948a3ef1cc 100644 --- a/app/src/main/java/com/nextcloud/talk/webrtc/WebRtcAudioManager.java +++ b/app/src/main/java/com/nextcloud/talk/webrtc/WebRtcAudioManager.java @@ -30,6 +30,7 @@ import android.media.AudioManager; import android.util.Log; +import com.nextcloud.talk.call.TalkCallInterop; import com.nextcloud.talk.events.ProximitySensorEvent; import com.nextcloud.talk.utils.ContextExtensionsKt; import com.nextcloud.talk.utils.ReceiverFlag; @@ -54,6 +55,9 @@ public class WebRtcAudioManager { private boolean savedIsSpeakerPhoneOn = false; private boolean savedIsMicrophoneMute = false; private boolean hasWiredHeadset = false; + private boolean telecomManagedAudioSession = false; + private boolean wiredHeadsetReceiverRegistered = false; + private boolean telecomAudioStateReceiverRegistered = false; private AudioDevice userSelectedAudioDevice; private AudioDevice currentAudioDevice; @@ -66,6 +70,7 @@ public class WebRtcAudioManager { private Set internalAudioDevices = new HashSet<>(); private final BroadcastReceiver wiredHeadsetReceiver; + private final BroadcastReceiver telecomAudioStateReceiver; private AudioManager.OnAudioFocusChangeListener audioFocusChangeListener; private AudioFocusRequest audioFocusRequest; private final AudioFocusState audioFocusState = new AudioFocusState(); @@ -79,6 +84,14 @@ private WebRtcAudioManager(Context context, boolean useProximitySensor) { audioManager = ((AudioManager) context.getSystemService(Context.AUDIO_SERVICE)); bluetoothManager = WebRtcBluetoothManager.create(context, this); wiredHeadsetReceiver = new WiredHeadsetReceiver(); + telecomAudioStateReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + if (TalkCallInterop.ACTION_TELECOM_AUDIO_STATE_CHANGED.equals(intent.getAction())) { + updateAudioDeviceState(); + } + } + }; amState = AudioManagerState.UNINITIALIZED; powerManagerUtils = new PowerManagerUtils(); @@ -108,6 +121,10 @@ public static WebRtcAudioManager create(Context context, boolean useProximitySen } public void startBluetoothManager() { + if (isTelecomAudioManaged()) { + Log.d(TAG, "Telecom owns audio routing; not starting the legacy Bluetooth SCO manager"); + return; + } // Initialize and start Bluetooth if a BT device is available or initiate // detection of new (enabled) BT devices. bluetoothManager.start(); @@ -118,7 +135,7 @@ public void startBluetoothManager() { * NEAR". */ private void onProximitySensorChangedState() { - if (!useProximitySensor) { + if (!useProximitySensor || isTelecomAudioManaged()) { return; } @@ -154,6 +171,7 @@ public void start(AudioManagerListener audioManagerListener) { Log.d(TAG, "AudioManager starts..."); this.audioManagerListener = audioManagerListener; amState = AudioManagerState.RUNNING; + telecomManagedAudioSession = TalkCallInterop.isTelecomAudioManaged(); // Store current audio state so we can restore it when stop() is called. savedAudioMode = audioManager.getMode(); @@ -174,11 +192,14 @@ public void start(AudioManagerListener audioManagerListener) { // Start by setting MODE_IN_COMMUNICATION as default audio mode. It is // required to be in this mode when playout and/or recording starts for - // best possible VoIP performance. + // best possible VoIP performance. Telecom owns the endpoint, not this mode. audioManager.setMode(AudioManager.MODE_IN_COMMUNICATION); - // Always disable microphone mute during a WebRTC call. - setMicrophoneMute(false); + // Legacy calls use AudioManager mute state. Telecom-managed calls mirror + // mute through the WebRTC track instead, so don't override Telecom here. + if (!telecomManagedAudioSession) { + setMicrophoneMute(false); + } // Set initial device states. userSelectedAudioDevice = AudioDevice.NONE; @@ -187,17 +208,22 @@ public void start(AudioManagerListener audioManagerListener) { audioDevices.clear(); internalAudioDevices.clear(); - startBluetoothManager(); + registerReceiver( + telecomAudioStateReceiver, + new IntentFilter(TalkCallInterop.ACTION_TELECOM_AUDIO_STATE_CHANGED) + ); + telecomAudioStateReceiverRegistered = true; + + if (!telecomManagedAudioSession) { + startBluetoothManager(); + proximitySensor.start(); + registerReceiver(wiredHeadsetReceiver, new IntentFilter(Intent.ACTION_HEADSET_PLUG)); + wiredHeadsetReceiverRegistered = true; + } - // Do initial selection of audio device. This setting can later be changed - // either by adding/removing a BT or wired headset or by covering/uncovering - // the proximity sensor. + // Do initial selection of audio device. In a Telecom-managed call this + // consumes Telecom's endpoint flows; otherwise it uses Talk's legacy route logic. updateAudioDeviceState(); - - proximitySensor.start(); - // Register receiver for broadcast intents related to adding/removing a - // wired headset. - registerReceiver(wiredHeadsetReceiver, new IntentFilter(Intent.ACTION_HEADSET_PLUG)); Log.d(TAG, "AudioManager started"); } @@ -263,15 +289,25 @@ public void stop() { } amState = AudioManagerState.UNINITIALIZED; - unregisterReceiver(wiredHeadsetReceiver); + if (wiredHeadsetReceiverRegistered) { + unregisterReceiver(wiredHeadsetReceiver); + wiredHeadsetReceiverRegistered = false; + } + if (telecomAudioStateReceiverRegistered) { + unregisterReceiver(telecomAudioStateReceiver); + telecomAudioStateReceiverRegistered = false; + } if(bluetoothManager.started()) { bluetoothManager.stop(); } - // Restore previously stored audio states. - setSpeakerphoneOn(savedIsSpeakerPhoneOn); - setMicrophoneMute(savedIsMicrophoneMute); + // Don't alter endpoint or global microphone state for a Telecom-managed + // session. Telecom restores the route after the call leaves its scope. + if (!telecomManagedAudioSession) { + setSpeakerphoneOn(savedIsSpeakerPhoneOn); + setMicrophoneMute(savedIsMicrophoneMute); + } audioManager.setMode(savedAudioMode); // Abandon audio focus. Gives the previous focus owner, if any, focus. @@ -290,6 +326,7 @@ public void stop() { powerManagerUtils.updatePhoneState(PowerManagerUtils.PhoneState.IDLE); audioManagerListener = null; + telecomManagedAudioSession = false; Log.d(TAG, "AudioManager stopped"); } @@ -301,6 +338,11 @@ public void stop() { private void setAudioDeviceInternal(AudioDevice audioDevice) { Log.d(TAG, "setAudioDeviceInternal(device=" + audioDevice + ")"); + if (isTelecomAudioManaged()) { + // Telecom endpoint changes arrive asynchronously through currentCallEndpoint. + return; + } + if (audioDevices.contains(audioDevice)) { switch (audioDevice) { case SPEAKER_PHONE: @@ -324,10 +366,13 @@ private void setAudioDeviceInternal(AudioDevice audioDevice) { */ public void setDefaultAudioDevice(AudioDevice device) { ThreadUtils.checkIsOnMainThread(); + defaultAudioDevice = device; + if (isTelecomAudioManaged()) { + return; + } if (!audioDevices.contains(device)) { Log.e(TAG, "Can not select default " + device + " from available " + audioDevices); } - defaultAudioDevice = device; updateAudioDeviceState(); } @@ -336,6 +381,14 @@ public void setDefaultAudioDevice(AudioDevice device) { */ public void selectAudioDevice(AudioDevice device) { ThreadUtils.checkIsOnMainThread(); + if (isTelecomAudioManaged()) { + String route = toTelecomAudioRoute(device); + if (route != null) { + userSelectedAudioDevice = device; + TalkCallInterop.requestTelecomAudioRoute(context, route); + } + return; + } if (!audioDevices.contains(device)) { Log.e(TAG, "Can not select " + device + " from available " + audioDevices); } @@ -377,6 +430,10 @@ private void unregisterReceiver(BroadcastReceiver receiver) { * Sets the speaker phone mode. */ private void setSpeakerphoneOn(boolean on) { + if (isTelecomAudioManaged()) { + Log.d(TAG, "Telecom owns speaker routing; ignoring setSpeakerphoneOn(" + on + ")"); + return; + } boolean wasOn = audioManager.isSpeakerphoneOn(); if (wasOn == on) { return; @@ -388,6 +445,9 @@ private void setSpeakerphoneOn(boolean on) { * Sets the microphone mute state. */ private void setMicrophoneMute(boolean on) { + if (isTelecomAudioManaged()) { + return; + } boolean wasMuted = audioManager.isMicrophoneMute(); if (wasMuted == on) { return; @@ -425,6 +485,15 @@ private boolean hasWiredHeadset() { public final void updateAudioDeviceState() { ThreadUtils.checkIsOnMainThread(); + + if (TalkCallInterop.isTelecomAudioManaged()) { + telecomManagedAudioSession = true; + } + if (telecomManagedAudioSession) { + updateTelecomAudioDeviceState(); + return; + } + Log.d(TAG, "--- updateAudioDeviceState: " + "wired headset=" + hasWiredHeadset + ", " + "BT state=" + bluetoothManager.getState()); @@ -557,6 +626,92 @@ public final void updateAudioDeviceState() { Log.d(TAG, "--- updateAudioDeviceState done"); } + private void updateTelecomAudioDeviceState() { + String currentRoute = TalkCallInterop.getTelecomCurrentAudioRoute(); + String[] availableRoutes = TalkCallInterop.getTelecomAvailableAudioRoutes(); + Set newAudioDevices = new HashSet<>(); + + for (String route : availableRoutes) { + AudioDevice device = fromTelecomAudioRoute(route); + if (device != AudioDevice.NONE) { + newAudioDevices.add(device); + } + } + + AudioDevice newCurrentAudioDevice = fromTelecomAudioRoute(currentRoute); + if (newCurrentAudioDevice != AudioDevice.NONE) { + newAudioDevices.add(newCurrentAudioDevice); + } + + // beginTelecomAudioManagement() deliberately publishes an empty snapshot + // before Telecom's endpoint flows emit. Keep the last visible state until + // the real snapshot arrives instead of flashing the picker to an empty list. + if (newAudioDevices.isEmpty() && newCurrentAudioDevice == AudioDevice.NONE) { + Log.d(TAG, "Waiting for initial Telecom audio endpoint snapshot"); + return; + } + + boolean audioDeviceSetUpdated = !audioDevices.equals(newAudioDevices); + boolean currentAudioDeviceUpdated = + newCurrentAudioDevice != AudioDevice.NONE && newCurrentAudioDevice != currentAudioDevice; + + internalAudioDevices = new HashSet<>(newAudioDevices); + audioDevices = new HashSet<>(newAudioDevices); + if (newCurrentAudioDevice != AudioDevice.NONE) { + currentAudioDevice = newCurrentAudioDevice; + } + + Log.d(TAG, "Telecom audio state: available=" + audioDevices + ", current=" + currentAudioDevice); + if ((audioDeviceSetUpdated || currentAudioDeviceUpdated) && audioManagerListener != null) { + audioManagerListener.onAudioDeviceChanged(currentAudioDevice, audioDevices); + } + } + + private boolean isTelecomAudioManaged() { + return telecomManagedAudioSession || TalkCallInterop.isTelecomAudioManaged(); + } + + private static String toTelecomAudioRoute(AudioDevice device) { + if (device == null) { + return null; + } + switch (device) { + case EARPIECE: + return TalkCallInterop.AUDIO_ROUTE_EARPIECE; + case BLUETOOTH: + case BLUETOOTH_SCO: + return TalkCallInterop.AUDIO_ROUTE_BLUETOOTH; + case WIRED_HEADSET: + return TalkCallInterop.AUDIO_ROUTE_WIRED_HEADSET; + case SPEAKER_PHONE: + return TalkCallInterop.AUDIO_ROUTE_SPEAKER; + default: + return null; + } + } + + private static AudioDevice fromTelecomAudioRoute(String route) { + if (route == null) { + return AudioDevice.NONE; + } + switch (route) { + case TalkCallInterop.AUDIO_ROUTE_EARPIECE: + return AudioDevice.EARPIECE; + case TalkCallInterop.AUDIO_ROUTE_BLUETOOTH: + case TalkCallInterop.AUDIO_ROUTE_EXTERNAL: + // The current Talk picker has one external wireless output row. + // Treat Telecom's streaming endpoint as that row until the UI + // grows named endpoint support. + return AudioDevice.BLUETOOTH; + case TalkCallInterop.AUDIO_ROUTE_WIRED_HEADSET: + return AudioDevice.WIRED_HEADSET; + case TalkCallInterop.AUDIO_ROUTE_SPEAKER: + return AudioDevice.SPEAKER_PHONE; + default: + return AudioDevice.NONE; + } + } + /** * AudioDevice is the names of possible audio devices that we currently support. */ diff --git a/app/src/main/res/xml/automotive_app_desc.xml b/app/src/main/res/xml/automotive_app_desc.xml new file mode 100644 index 0000000000..558e61c01b --- /dev/null +++ b/app/src/main/res/xml/automotive_app_desc.xml @@ -0,0 +1,12 @@ + + + + + + diff --git a/app/src/test/java/com/nextcloud/talk/webrtc/DataChannelMessageNotifierTest.kt b/app/src/test/java/com/nextcloud/talk/webrtc/DataChannelMessageNotifierTest.kt index ead358b6ec..90f23948f9 100644 --- a/app/src/test/java/com/nextcloud/talk/webrtc/DataChannelMessageNotifierTest.kt +++ b/app/src/test/java/com/nextcloud/talk/webrtc/DataChannelMessageNotifierTest.kt @@ -51,6 +51,20 @@ class DataChannelMessageNotifierTest { verify(listener).onAudioOff() } + @Test + fun testNotifySpeaking() { + notifier.addListener(listener) + notifier.notifySpeaking() + verify(listener).onSpeaking() + } + + @Test + fun testNotifyStoppedSpeaking() { + notifier.addListener(listener) + notifier.notifyStoppedSpeaking() + verify(listener).onStoppedSpeaking() + } + @Test fun testNotifyVideoOn() { notifier.addListener(listener) diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index 2ebe711fe9..f2db971406 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -64,6 +64,7 @@ + @@ -570,6 +571,11 @@ + + + + + @@ -818,6 +824,11 @@ + + + + + @@ -945,6 +956,14 @@ + + + + + + + + @@ -993,6 +1012,14 @@ + + + + + + + + @@ -1417,6 +1444,22 @@ + + + + + + + + + + + + + + + + @@ -1706,6 +1749,11 @@ + + + + + @@ -1716,6 +1764,11 @@ + + + + + @@ -1749,6 +1802,11 @@ + + + + + @@ -1842,6 +1900,14 @@ + + + + + + + + @@ -1920,6 +1986,11 @@ + + + + + @@ -2018,6 +2089,14 @@ + + + + + + + + @@ -2096,6 +2175,11 @@ + + + + + @@ -2192,6 +2276,14 @@ + + + + + + + + @@ -2273,6 +2365,11 @@ + + + + + @@ -2369,6 +2466,14 @@ + + + + + + + + @@ -2654,6 +2759,11 @@ + + + + + @@ -2742,6 +2852,14 @@ + + + + + + + + @@ -2881,6 +2999,11 @@ + + + + + @@ -2977,6 +3100,14 @@ + + + + + + + + @@ -3058,6 +3189,11 @@ + + + + + @@ -3122,6 +3258,14 @@ + + + + + + + + @@ -3171,6 +3315,11 @@ + + + + + @@ -3219,6 +3368,14 @@ + + + + + + + + @@ -3257,6 +3414,11 @@ + + + + + @@ -3363,6 +3525,14 @@ + + + + + + + + @@ -3452,6 +3622,11 @@ + + + + + @@ -3553,6 +3728,14 @@ + + + + + + + + @@ -3639,6 +3822,11 @@ + + + + + @@ -3735,6 +3923,14 @@ + + + + + + + + @@ -3816,6 +4012,11 @@ + + + + + @@ -3917,6 +4118,14 @@ + + + + + + + + @@ -4003,6 +4212,11 @@ + + + + + @@ -4099,6 +4313,14 @@ + + + + + + + + @@ -4180,6 +4402,11 @@ + + + + + @@ -4276,6 +4503,14 @@ + + + + + + + + @@ -4372,6 +4607,14 @@ + + + + + + + + @@ -4458,6 +4701,11 @@ + + + + + @@ -4559,6 +4807,14 @@ + + + + + + + + @@ -4642,6 +4898,11 @@ + + + + + @@ -4725,6 +4986,14 @@ + + + + + + + + @@ -4808,6 +5077,11 @@ + + + + + @@ -4891,6 +5165,14 @@ + + + + + + + + @@ -4977,6 +5259,11 @@ + + + + + @@ -5078,6 +5365,14 @@ + + + + + + + + @@ -5164,6 +5459,11 @@ + + + + + @@ -5260,6 +5560,14 @@ + + + + + + + + @@ -5341,6 +5649,11 @@ + + + + + @@ -5437,6 +5750,14 @@ + + + + + + + + @@ -5559,6 +5880,14 @@ + + + + + + + + @@ -5583,6 +5912,14 @@ + + + + + + + + @@ -5692,6 +6029,11 @@ + + + + + @@ -5774,6 +6116,14 @@ + + + + + + + + @@ -5966,6 +6316,14 @@ + + + + + + + + @@ -6110,6 +6468,14 @@ + + + + + + + + @@ -6254,6 +6620,14 @@ + + + + + + + + @@ -7323,6 +7697,11 @@ + + + + + @@ -8088,6 +8467,16 @@ + + + + + + + + + + @@ -8155,6 +8544,11 @@ + + + + + @@ -8579,6 +8973,14 @@ + + + + + + + + @@ -8675,6 +9077,14 @@ + + + + + + + + @@ -8771,6 +9181,14 @@ + + + + + + + + @@ -8867,6 +9285,14 @@ + + + + + + + + @@ -8963,6 +9389,14 @@ + + + + + + + + @@ -9043,6 +9477,14 @@ + + + + + + + + @@ -9059,6 +9501,14 @@ + + + + + + + + @@ -9155,6 +9605,14 @@ + + + + + + + + @@ -9251,11 +9709,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + @@ -9272,6 +9754,14 @@ + + + + + + + + @@ -10334,6 +10824,11 @@ + + + + + @@ -10366,6 +10861,14 @@ + + + + + + + + @@ -10411,6 +10914,11 @@ + + + + + @@ -10443,6 +10951,14 @@ + + + + + + + + @@ -11562,6 +12078,14 @@ + + + + + + + + @@ -11706,6 +12230,14 @@ + + + + + + + + @@ -11850,6 +12382,14 @@ + + + + + + + + @@ -11860,6 +12400,11 @@ + + + + + @@ -12100,6 +12645,14 @@ + + + + + + + + @@ -12244,6 +12797,14 @@ + + + + + + + + @@ -12388,6 +12949,14 @@ + + + + + + + + @@ -12436,6 +13005,14 @@ + + + + + + + + @@ -12580,6 +13157,14 @@ + + + + + + + + @@ -12724,6 +13309,14 @@ + + + + + + + + @@ -12868,6 +13461,14 @@ + + + + + + + + @@ -13012,6 +13613,14 @@ + + + + + + + + @@ -13156,6 +13765,14 @@ + + + + + + + + @@ -13300,6 +13917,14 @@ + + + + + + + + @@ -13444,6 +14069,14 @@ + + + + + + + + @@ -13700,6 +14333,14 @@ + + + + + + + + @@ -13844,6 +14485,14 @@ + + + + + + + + @@ -13988,6 +14637,14 @@ + + + + + + + + @@ -14132,6 +14789,14 @@ + + + + + + + + @@ -14276,6 +14941,14 @@ + + + + + + + + @@ -14420,6 +15093,14 @@ + + + + + + + + @@ -14564,6 +15245,14 @@ + + + + + + + + @@ -14788,6 +15477,14 @@ + + + + + + + + @@ -14948,6 +15645,14 @@ + + + + + + + + @@ -14980,6 +15685,14 @@ + + + + + + + + @@ -14996,6 +15709,14 @@ + + + + + + + + @@ -15156,6 +15877,14 @@ + + + + + + + + @@ -15316,6 +16045,14 @@ + + + + + + + + @@ -15484,6 +16221,14 @@ + + + + + + + + @@ -15628,6 +16373,14 @@ + + + + + + + + @@ -15676,6 +16429,14 @@ + + + + + + + + @@ -15724,6 +16485,14 @@ + + + + + + + + @@ -15772,6 +16541,14 @@ + + + + + + + + @@ -15916,6 +16693,14 @@ + + + + + + + + @@ -15964,6 +16749,14 @@ + + + + + + + + @@ -16012,6 +16805,14 @@ + + + + + + + + @@ -16060,6 +16861,14 @@ + + + + + + + + @@ -16108,6 +16917,14 @@ + + + + + + + + @@ -16252,6 +17069,14 @@ + + + + + + + + @@ -16396,6 +17221,14 @@ + + + + + + + + @@ -16540,6 +17373,14 @@ + + + + + + + + @@ -16684,6 +17525,14 @@ + + + + + + + + @@ -17244,6 +18093,14 @@ + + + + + + + + @@ -17388,6 +18245,14 @@ + + + + + + + + @@ -17532,6 +18397,14 @@ + + + + + + + + @@ -17676,6 +18549,14 @@ + + + + + + + + @@ -17820,6 +18701,14 @@ + + + + + + + + @@ -17964,6 +18853,14 @@ + + + + + + + + @@ -18108,6 +19005,14 @@ + + + + + + + + @@ -18252,6 +19157,14 @@ + + + + + + + + @@ -18396,6 +19309,14 @@ + + + + + + + + @@ -18540,6 +19461,14 @@ + + + + + + + + @@ -18684,6 +19613,14 @@ + + + + + + + + @@ -18828,6 +19765,14 @@ + + + + + + + + @@ -19116,6 +20061,14 @@ + + + + + + + + @@ -19260,6 +20213,14 @@ + + + + + + + + @@ -19276,6 +20237,14 @@ + + + + + + + + @@ -19292,6 +20261,14 @@ + + + + + + + + @@ -19436,6 +20413,14 @@ + + + + + + + + @@ -19539,6 +20524,11 @@ + + + + + @@ -20387,6 +21377,14 @@ + + + + + + + + @@ -20419,6 +21417,14 @@ + + + + + + + + @@ -20555,6 +21561,14 @@ + + + + + + + + @@ -20717,6 +21731,14 @@ + + + + + + + + @@ -20781,6 +21803,14 @@ + + + + + + + + @@ -21306,6 +22336,11 @@ + + + + + @@ -21620,6 +22655,14 @@ + + + + + + + + @@ -21652,6 +22695,14 @@ + + + + + + + + @@ -21818,6 +22869,9 @@ + + + @@ -21942,6 +22996,14 @@ + + + + + + + + @@ -22420,6 +23482,14 @@ + + + + + + + + @@ -22529,6 +23599,14 @@ + + + + + + + + @@ -22613,6 +23691,11 @@ + + + + + @@ -22689,6 +23772,9 @@ + + + @@ -22704,6 +23790,14 @@ + + + + + + + + @@ -22719,6 +23813,11 @@ + + + + + @@ -22744,6 +23843,16 @@ + + + + + + + + + + @@ -22754,6 +23863,21 @@ + + + + + + + + + + + + + + + @@ -22800,6 +23924,14 @@ + + + + + + + + @@ -22885,6 +24017,11 @@ + + + + + @@ -22935,6 +24072,14 @@ + + + + + + + + @@ -23044,6 +24189,11 @@ + + + + + @@ -25494,6 +26644,14 @@ + + + + + + + + @@ -25759,6 +26917,14 @@ + + + + + + + + @@ -26631,6 +27797,14 @@ + + + + + + + + @@ -26639,6 +27813,14 @@ + + + + + + + + @@ -26858,6 +28040,7 @@ + @@ -26959,6 +28142,11 @@ + + + + + @@ -26969,6 +28157,11 @@ + + + + + @@ -26994,6 +28187,11 @@ + + + + + @@ -27004,11 +28202,21 @@ + + + + + + + + + + @@ -27022,6 +28230,14 @@ + + + + + + + + @@ -27035,6 +28251,11 @@ + + + + + @@ -27045,6 +28266,11 @@ + + + + + @@ -27075,6 +28301,11 @@ + + + + + @@ -27085,6 +28316,11 @@ + + + + + @@ -27095,6 +28331,11 @@ + + + + + @@ -27147,6 +28388,11 @@ + + + + + @@ -27157,6 +28403,11 @@ + + + + + @@ -27177,6 +28428,11 @@ + + + + + @@ -27187,6 +28443,11 @@ + + + + + @@ -27197,6 +28458,11 @@ + + + + + @@ -31349,6 +32615,11 @@ + + + + + @@ -31512,6 +32783,11 @@ + + + + + @@ -32238,6 +33514,14 @@ + + + + + + + + @@ -32342,6 +33626,14 @@ + + + + + + + + @@ -32446,6 +33738,14 @@ + + + + + + + + @@ -32486,6 +33786,14 @@ + + + + + + + + @@ -32590,6 +33898,14 @@ + + + + + + + + @@ -32622,6 +33938,11 @@ + + + + + @@ -32638,6 +33959,14 @@ + + + + + + + + @@ -32648,6 +33977,11 @@ + + + + + @@ -32664,6 +33998,14 @@ + + + + + + + + @@ -32697,6 +34039,14 @@ + + + + + + + + @@ -33916,6 +35266,14 @@ + + + + + + + + diff --git a/scripts/android-auto-update-dependencies.py b/scripts/android-auto-update-dependencies.py new file mode 100644 index 0000000000..14bb8504bc --- /dev/null +++ b/scripts/android-auto-update-dependencies.py @@ -0,0 +1,17 @@ +#!/usr/bin/env python3 +"""Keep Android Auto-specific dependencies on the latest published versions.""" + +from pathlib import Path + +path = Path("app/build.gradle.kts") +text = path.read_text() +target = '"gplayImplementation"("androidx.core:core-telecom:1.1.0-alpha06")' +unpublished = '"gplayImplementation"("androidx.core:core-telecom:1.1.0-beta01")' + +if target not in text: + count = text.count(unpublished) + if count != 1: + raise SystemExit(f"core-telecom dependency: expected one beta01 line to restore, found {count}") + text = text.replace(unpublished, target, 1) + +path.write_text(text) diff --git a/scripts/android-auto-wire-call-bridge.py b/scripts/android-auto-wire-call-bridge.py new file mode 100644 index 0000000000..5bb6231225 --- /dev/null +++ b/scripts/android-auto-wire-call-bridge.py @@ -0,0 +1,212 @@ +#!/usr/bin/env python3 +"""Wire Talk's shared WebRTC call lifecycle into the optional Android Auto Telecom bridge.""" + +from pathlib import Path + + +def replace_once(text: str, old: str, new: str, label: str) -> str: + count = text.count(old) + if count != 1: + raise SystemExit(f"{label}: expected exactly one match, found {count}") + return text.replace(old, new, 1) + + +def patch_notification_worker() -> None: + path = Path("app/src/main/java/com/nextcloud/talk/jobs/NotificationWorker.kt") + text = path.read_text() + + if "import com.nextcloud.talk.call.TalkCallInterop" not in text: + text = replace_once( + text, + "import com.nextcloud.talk.callnotification.CallNotificationActivity\n", + "import com.nextcloud.talk.call.TalkCallInterop\n" + "import com.nextcloud.talk.callnotification.CallNotificationActivity\n", + "NotificationWorker TalkCallInterop import", + ) + + marker = ( + " val isVideoCall = (conversation.callFlag and Participant.InCallFlags.WITH_VIDEO) > 0\n" + " val primaryAnswerIntent = if (isVideoCall) answerVideoPendingIntent else answerVoicePendingIntent\n" + ) + replacement = ( + " val isVideoCall = (conversation.callFlag and Participant.InCallFlags.WITH_VIDEO) > 0\n\n" + " TalkCallInterop.notifyIncomingCall(\n" + " applicationContext,\n" + " bundle,\n" + " conversation.displayName,\n" + " isVideoCall\n" + " )\n\n" + " val primaryAnswerIntent = if (isVideoCall) answerVideoPendingIntent else answerVoicePendingIntent\n" + ) + if "TalkCallInterop.notifyIncomingCall(" not in text: + text = replace_once(text, marker, replacement, "NotificationWorker incoming call hook") + + path.write_text(text) + + +def patch_call_activity() -> None: + path = Path("app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt") + text = path.read_text() + + if "import com.nextcloud.talk.call.TalkCallInterop" not in text: + text = replace_once( + text, + "import com.nextcloud.talk.call.ReactionAnimator\n", + "import com.nextcloud.talk.call.ReactionAnimator\n" + "import com.nextcloud.talk.call.TalkCallInterop\n", + "CallActivity TalkCallInterop import", + ) + + if "import com.nextcloud.talk.utils.registerBroadcastReceiver" not in text: + text = replace_once( + text, + "import com.nextcloud.talk.utils.registerPermissionHandlerBroadcastReceiver\n", + "import com.nextcloud.talk.utils.registerBroadcastReceiver\n" + "import com.nextcloud.talk.utils.registerPermissionHandlerBroadcastReceiver\n", + "CallActivity registerBroadcastReceiver import", + ) + + field_marker = ( + " private var isIncomingCallFromNotification = false\n" + " private val callControlHandler = Handler()\n" + ) + field_replacement = ( + " private var isIncomingCallFromNotification = false\n" + " private var telecomControlReceiverRegistered = false\n" + " private var activeTelecomCallKey: String? = null\n" + " private val telecomControlReceiver = object : BroadcastReceiver() {\n" + " override fun onReceive(context: Context?, intent: Intent?) {\n" + " val action = intent?.action ?: return\n" + " val callKey = intent.getStringExtra(TalkCallInterop.EXTRA_CALL_KEY)\n" + " if (callKey.isNullOrBlank() || callKey != activeTelecomCallKey) return\n\n" + " when (action) {\n" + " TalkCallInterop.ACTION_CONTROL_DISCONNECT ->\n" + " hangup(shutDownView = true, endCallForAll = false)\n\n" + " TalkCallInterop.ACTION_CONTROL_MUTE -> {\n" + " val shouldMute = intent.getBooleanExtra(TalkCallInterop.EXTRA_MUTED, false)\n" + " if (microphoneOn == shouldMute) {\n" + " onMicrophoneClick()\n" + " }\n" + " }\n" + " }\n" + " }\n" + " }\n" + " private val callControlHandler = Handler()\n" + ) + if "telecomControlReceiverRegistered" not in text: + text = replace_once(text, field_marker, field_replacement, "CallActivity Telecom receiver fields") + + oncreate_marker = ( + " processExtras(intent.extras!!)\n" + " conversationUser = currentUserProviderOld.currentUser.blockingGet()\n\n" + " credentials = ApiUtils.getCredentials(conversationUser!!.username, conversationUser!!.token)\n" + ) + oncreate_replacement = ( + " processExtras(intent.extras!!)\n" + " conversationUser = currentUserProviderOld.currentUser.blockingGet()\n\n" + " val telecomAccountId = conversationUser.id\n" + " val telecomRoomToken = roomToken\n" + " if (telecomAccountId != null && !telecomRoomToken.isNullOrBlank()) {\n" + " activeTelecomCallKey = TalkCallInterop.callKey(telecomAccountId, telecomRoomToken)\n" + " registerTelecomControlReceiver()\n" + " TalkCallInterop.notifyCallStarted(\n" + " context = this,\n" + " accountId = telecomAccountId,\n" + " roomToken = telecomRoomToken,\n" + " displayName = conversationName.orEmpty(),\n" + " incoming = isIncomingCallFromNotification,\n" + " video = !isVoiceOnlyCall,\n" + " callExtras = Bundle(extras)\n" + " )\n" + " }\n\n" + " credentials = ApiUtils.getCredentials(conversationUser!!.username, conversationUser!!.token)\n" + ) + if "TalkCallInterop.notifyCallStarted(" not in text: + text = replace_once(text, oncreate_marker, oncreate_replacement, "CallActivity call-start hook") + + helper_marker = " private fun initCallRecordingViewModel(recordingState: Int) {\n" + helper_replacement = ( + " private fun registerTelecomControlReceiver() {\n" + " if (telecomControlReceiverRegistered) return\n" + " val filter = IntentFilter().apply {\n" + " addAction(TalkCallInterop.ACTION_CONTROL_DISCONNECT)\n" + " addAction(TalkCallInterop.ACTION_CONTROL_MUTE)\n" + " }\n" + " registerBroadcastReceiver(telecomControlReceiver, filter, ReceiverFlag.NotExported)\n" + " telecomControlReceiverRegistered = true\n" + " }\n\n" + " private fun initCallRecordingViewModel(recordingState: Int) {\n" + ) + if "private fun registerTelecomControlReceiver()" not in text: + text = replace_once(text, helper_marker, helper_replacement, "CallActivity Telecom receiver helper") + + destroy_marker = ( + " CallForegroundService.stop(applicationContext)\n" + " powerManagerUtils!!.updatePhoneState(PowerManagerUtils.PhoneState.IDLE)\n" + " super.onDestroy()\n" + ) + destroy_replacement = ( + " CallForegroundService.stop(applicationContext)\n" + " powerManagerUtils!!.updatePhoneState(PowerManagerUtils.PhoneState.IDLE)\n" + " if (telecomControlReceiverRegistered) {\n" + " unregisterReceiver(telecomControlReceiver)\n" + " telecomControlReceiverRegistered = false\n" + " }\n" + " super.onDestroy()\n" + ) + if "unregisterReceiver(telecomControlReceiver)" not in text: + text = replace_once(text, destroy_marker, destroy_replacement, "CallActivity receiver cleanup") + + hangup_marker = ( + " private fun hangup(shutDownView: Boolean, endCallForAll: Boolean) {\n" + " Log.d(TAG, \"hangup! shutDownView=$shutDownView\")\n" + " joinRoomInitiated = false\n" + ) + hangup_replacement = ( + " private fun hangup(shutDownView: Boolean, endCallForAll: Boolean) {\n" + " Log.d(TAG, \"hangup! shutDownView=$shutDownView\")\n" + " if (shutDownView && ::conversationUser.isInitialized) {\n" + " val accountId = conversationUser.id\n" + " val token = roomToken\n" + " if (accountId != null && !token.isNullOrBlank()) {\n" + " TalkCallInterop.notifyCallEnded(this, accountId, token)\n" + " }\n" + " }\n" + " joinRoomInitiated = false\n" + ) + if "TalkCallInterop.notifyCallEnded(this, accountId, token)" not in text: + text = replace_once(text, hangup_marker, hangup_replacement, "CallActivity call-end hook") + + state_marker = ( + " if (currentCallStatus == null || currentCallStatus !== callState) {\n" + " currentCallStatus = callState\n" + " if (handler == null) {\n" + ) + state_replacement = ( + " if (currentCallStatus == null || currentCallStatus !== callState) {\n" + " currentCallStatus = callState\n" + " if (\n" + " ::conversationUser.isInitialized &&\n" + " (callState === CallStatus.JOINED || callState === CallStatus.IN_CONVERSATION)\n" + " ) {\n" + " val accountId = conversationUser.id\n" + " val token = roomToken\n" + " if (accountId != null && !token.isNullOrBlank()) {\n" + " TalkCallInterop.notifyCallActive(this, accountId, token)\n" + " }\n" + " }\n" + " if (handler == null) {\n" + ) + if "TalkCallInterop.notifyCallActive(this, accountId, token)" not in text: + text = replace_once(text, state_marker, state_replacement, "CallActivity call-active hook") + + path.write_text(text) + + +def main() -> None: + patch_notification_worker() + patch_call_activity() + + +if __name__ == "__main__": + main() diff --git a/tools/android_auto_bootstrap.py b/tools/android_auto_bootstrap.py new file mode 100644 index 0000000000..88859da4ae --- /dev/null +++ b/tools/android_auto_bootstrap.py @@ -0,0 +1,238 @@ +# SPDX-FileCopyrightText: 2026 Michael Avery +# SPDX-License-Identifier: GPL-3.0-or-later + +"""Idempotently add the Android Auto Car App + Core-Telecom foundation.""" + +from pathlib import Path + + +def replace_once(text: str, old: str, new: str, description: str) -> str: + if old not in text: + raise RuntimeError(f"Could not find {description}") + return text.replace(old, new, 1) + + +gradle = Path("app/build.gradle.kts") +text = gradle.read_text() +if "androidx.car.app:app:1.7.0" not in text: + anchor = 'dependencies {\n implementation("androidx.media3:media3-session:1.11.0")\n' + addition = '''dependencies { + // Android Auto communication UI is isolated to the Google Play flavor. + "gplayImplementation"("androidx.car.app:app:1.7.0") + "gplayImplementation"("androidx.car.app:app-projected:1.7.0") + "gplayImplementation"("androidx.core:core-telecom:1.1.0-alpha06") + implementation("androidx.media3:media3-session:1.11.0") +''' + text = replace_once(text, anchor, addition, "dependencies anchor") + gradle.write_text(text) + +manifest = Path("app/src/gplay/AndroidManifest.xml") +text = manifest.read_text() +if "android.permission.MANAGE_OWN_CALLS" not in text: + marker = '\n' + text = replace_once( + text, + marker, + marker + '\n \n', + "gplay manifest root", + ) + +if "androidx.car.app.minCarApiLevel" not in text: + marker = ' \n' + addition = ''' + + + +''' + text = replace_once(text, marker, marker + addition, "gplay application metadata") + +if ".auto.TalkCarAppService" not in text: + marker = ' + + + + + + + +''' + text = replace_once(text, marker, service + marker, "gplay service anchor") +manifest.write_text(text) + +descriptor = Path("app/src/gplay/res/xml/automotive_app_desc.xml") +text = descriptor.read_text() +if '' not in text: + text = replace_once( + text, + ' \n', + ' \n \n', + "Android Auto notification capability", + ) + descriptor.write_text(text) + +service_file = Path("app/src/gplay/java/com/nextcloud/talk/auto/TalkCarAppService.kt") +service_file.parent.mkdir(parents=True, exist_ok=True) +if not service_file.exists(): + service_file.write_text('''/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Michael Avery + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.auto + +import android.content.ApplicationInfo +import android.content.Intent +import androidx.car.app.CarAppService +import androidx.car.app.CarContext +import androidx.car.app.Screen +import androidx.car.app.Session +import androidx.car.app.model.Action +import androidx.car.app.model.Header +import androidx.car.app.model.ItemList +import androidx.car.app.model.ListTemplate +import androidx.car.app.model.Row +import androidx.car.app.model.Template +import androidx.car.app.validation.HostValidator +import com.nextcloud.talk.auto.call.TalkTelecomManager + +/** Android Auto entry point for Talk messaging and calling. */ +class TalkCarAppService : CarAppService() { + override fun onCreate() { + super.onCreate() + TalkTelecomManager.get(applicationContext).registerWithTelecom() + } + + override fun createHostValidator(): HostValidator = + if ((applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) != 0) { + HostValidator.ALLOW_ALL_HOSTS_VALIDATOR + } else { + HostValidator.Builder(this) + .addAllowedHosts(androidx.car.app.R.array.hosts_allowlist_sample) + .build() + } + + override fun onCreateSession(): Session = TalkCarSession() +} + +private class TalkCarSession : Session() { + override fun onCreateScreen(intent: Intent): Screen = TalkCarHomeScreen(carContext) +} + +private class TalkCarHomeScreen(carContext: CarContext) : Screen(carContext) { + override fun onGetTemplate(): Template { + val items = ItemList.Builder() + .addItem( + Row.Builder() + .setTitle("Messages") + .addText("Read, reply to, and start Talk conversations") + .setOnClickListener { + screenManager.push( + TalkCarStatusScreen( + carContext, + "Messages", + "Messaging notifications and voice replies are enabled. " + + "Conversation history and contact selection are the next layer." + ) + ) + } + .build() + ) + .addItem( + Row.Builder() + .setTitle("Calls") + .addText("Start and control Talk voice calls") + .setOnClickListener { + screenManager.push( + TalkCarStatusScreen( + carContext, + "Calls", + "Talk is registered with Android Telecom. " + + "The next layer connects Telecom callbacks to Talk WebRTC calls." + ) + ) + } + .build() + ) + .build() + + return ListTemplate.Builder() + .setHeader( + Header.Builder() + .setStartHeaderAction(Action.APP_ICON) + .setTitle("Nextcloud Talk") + .build() + ) + .setSingleList(items) + .build() + } +} + +private class TalkCarStatusScreen( + carContext: CarContext, + private val title: String, + private val status: String +) : Screen(carContext) { + override fun onGetTemplate(): Template = + ListTemplate.Builder() + .setHeader( + Header.Builder() + .setStartHeaderAction(Action.BACK) + .setTitle(title) + .build() + ) + .setSingleList( + ItemList.Builder() + .addItem(Row.Builder().setTitle(status).build()) + .build() + ) + .build() +} +''') + +telecom_file = Path("app/src/gplay/java/com/nextcloud/talk/auto/call/TalkTelecomManager.kt") +telecom_file.parent.mkdir(parents=True, exist_ok=True) +if not telecom_file.exists(): + telecom_file.write_text('''/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Michael Avery + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.auto.call + +import android.content.Context +import androidx.core.telecom.CallsManager + +/** Owns Core-Telecom registration for the Android Auto capable build. */ +class TalkTelecomManager private constructor(context: Context) { + val callsManager = CallsManager(context.applicationContext) + + @Volatile + private var registered = false + + @Synchronized + fun registerWithTelecom() { + if (registered) return + val capabilities = + CallsManager.CAPABILITY_BASELINE or CallsManager.CAPABILITY_SUPPORTS_VIDEO_CALLING + callsManager.registerAppWithTelecom(capabilities) + registered = true + } + + companion object { + @Volatile + private var instance: TalkTelecomManager? = null + + fun get(context: Context): TalkTelecomManager = + instance ?: synchronized(this) { + instance ?: TalkTelecomManager(context).also { instance = it } + } + } +} +''')