From d4fa72b0e13244c1df53f8e3a1a39624e5935e4f Mon Sep 17 00:00:00 2001 From: angrymuesli Date: Tue, 23 Dec 2025 19:03:58 +0200 Subject: [PATCH] feat: Add deep link and launcher shortcut support for conversations - Add custom URI scheme (nctalk://conversation/{token}) for opening conversations from external launchers like KISS - Add HTTPS deep link support for /call/{token} URLs (fixes #847) - Add dynamic shortcuts for favorite/recent conversations - Add "Add to home screen" menu option in conversation long-press dialog - New DeepLinkHandler utility for parsing deep link URIs - New ShortcutManagerHelper utility for managing conversation shortcuts Signed-off-by: angrymuesli --- app/src/main/AndroidManifest.xml | 27 ++ .../nextcloud/talk/activities/MainActivity.kt | 114 +++++++++ .../ConversationsListActivity.kt | 6 + .../dialog/ConversationsListBottomDialog.kt | 15 ++ .../nextcloud/talk/utils/DeepLinkHandler.kt | 124 ++++++++++ .../talk/utils/ShortcutManagerHelper.kt | 234 ++++++++++++++++++ app/src/main/res/drawable/ic_home.xml | 15 ++ .../layout/dialog_conversation_operations.xml | 30 +++ app/src/main/res/values/strings.xml | 5 + 9 files changed, 570 insertions(+) create mode 100644 app/src/main/java/com/nextcloud/talk/utils/DeepLinkHandler.kt create mode 100644 app/src/main/java/com/nextcloud/talk/utils/ShortcutManagerHelper.kt create mode 100644 app/src/main/res/drawable/ic_home.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index ca34e3f2921..9c1f74b1b0b 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -122,6 +122,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + token=${deepLinkResult.roomToken}") + + userManager.users.subscribe(object : SingleObserver> { + override fun onSubscribe(d: Disposable) { + // unused atm + } + + override fun onSuccess(users: List) { + if (users.isEmpty()) { + runOnUiThread { + launchServerSelection() + } + return + } + + val targetUser = resolveTargetUser(users, deepLinkResult) + + if (targetUser == null) { + runOnUiThread { + Toast.makeText( + context, + context.resources.getString(R.string.nc_no_account_for_server), + Toast.LENGTH_LONG + ).show() + openConversationList() + } + return + } + + if (userManager.setUserAsActive(targetUser).blockingGet()) { + // Report shortcut usage for ranking + ShortcutManagerHelper.reportShortcutUsed( + context, + deepLinkResult.roomToken, + targetUser.id!! + ) + + runOnUiThread { + val chatIntent = Intent(context, ChatActivity::class.java) + chatIntent.putExtra(KEY_ROOM_TOKEN, deepLinkResult.roomToken) + chatIntent.putExtra(BundleKeys.KEY_INTERNAL_USER_ID, targetUser.id) + startActivity(chatIntent) + } + } + } + + override fun onError(e: Throwable) { + Log.e(TAG, "Error loading users for deep link", e) + runOnUiThread { + Toast.makeText( + context, + context.resources.getString(R.string.nc_common_error_sorry), + Toast.LENGTH_SHORT + ).show() + } + } + }) + + return true + } + + /** + * Resolves which user account to use for a deep link. + * + * Priority: + * 1. User ID specified in deep link (for nctalk:// URIs) + * 2. User matching the server URL (for https:// web links) + * 3. Current active user as fallback + */ + private fun resolveTargetUser( + users: List, + deepLinkResult: DeepLinkHandler.DeepLinkResult + ): User? { + // If user ID is specified, use that user + deepLinkResult.internalUserId?.let { userId -> + return userManager.getUserWithId(userId).blockingGet() + } + + // If server URL is specified, find matching account + deepLinkResult.serverUrl?.let { serverUrl -> + val matchingUser = users.find { user -> + user.baseUrl?.lowercase()?.contains(serverUrl.lowercase()) == true + } + if (matchingUser != null) { + return matchingUser + } + } + + // Fall back to current user + return currentUserProviderOld.currentUser.blockingGet() + } + companion object { private val TAG = MainActivity::class.java.simpleName } diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt index 59ba7d9ad69..e3746d3c123 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt @@ -131,6 +131,7 @@ import com.nextcloud.talk.utils.FileUtils import com.nextcloud.talk.utils.Mimetype import com.nextcloud.talk.utils.NotificationUtils import com.nextcloud.talk.utils.ParticipantPermissions +import com.nextcloud.talk.utils.ShortcutManagerHelper import com.nextcloud.talk.utils.SpreedFeatures import com.nextcloud.talk.utils.UserIdUtils import com.nextcloud.talk.utils.bundle.BundleKeys @@ -505,6 +506,11 @@ class ConversationsListActivity : val isNoteToSelfAvailable = noteToSelf != null handleNoteToSelfShortcut(isNoteToSelfAvailable, noteToSelf?.token ?: "") + // Update dynamic shortcuts for frequent/favorite conversations + currentUser?.let { user -> + ShortcutManagerHelper.updateDynamicShortcuts(context, list, user) + } + val pair = appPreferences.conversationListPositionAndOffset layoutManager?.scrollToPositionWithOffset(pair.first, pair.second) }.collect() diff --git a/app/src/main/java/com/nextcloud/talk/ui/dialog/ConversationsListBottomDialog.kt b/app/src/main/java/com/nextcloud/talk/ui/dialog/ConversationsListBottomDialog.kt index 504dd6aebcb..d55ac7a20b8 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/dialog/ConversationsListBottomDialog.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/dialog/ConversationsListBottomDialog.kt @@ -39,6 +39,7 @@ import com.nextcloud.talk.utils.ApiUtils import com.nextcloud.talk.utils.CapabilitiesUtil import com.nextcloud.talk.utils.ConversationUtils import com.nextcloud.talk.utils.ShareUtils +import com.nextcloud.talk.utils.ShortcutManagerHelper import com.nextcloud.talk.utils.SpreedFeatures import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_INTERNAL_USER_ID import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_ROOM_TOKEN @@ -194,6 +195,10 @@ class ConversationsListBottomDialog( dismiss() } + binding.conversationAddToHomeScreen.setOnClickListener { + addConversationToHomeScreen() + } + binding.conversationArchiveText.text = if (conversation.hasArchived) { this.activity.resources.getString(R.string.unarchive_conversation) } else { @@ -448,6 +453,16 @@ class ConversationsListBottomDialog( dismiss() } + private fun addConversationToHomeScreen() { + val success = ShortcutManagerHelper.requestPinShortcut(context, conversation, currentUser) + if (success) { + activity.showSnackbar(context.resources.getString(R.string.nc_shortcut_created)) + } else { + activity.showSnackbar(context.resources.getString(R.string.nc_common_error_sorry)) + } + dismiss() + } + private fun chatApiVersion(): Int = ApiUtils.getChatApiVersion(currentUser.capabilities!!.spreedCapability!!, intArrayOf(ApiUtils.API_V1)) diff --git a/app/src/main/java/com/nextcloud/talk/utils/DeepLinkHandler.kt b/app/src/main/java/com/nextcloud/talk/utils/DeepLinkHandler.kt new file mode 100644 index 00000000000..0a0d0d0c7f3 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/utils/DeepLinkHandler.kt @@ -0,0 +1,124 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.utils + +import android.net.Uri + +/** + * Handles parsing of deep links for opening conversations. + * + * Supported URI formats: + * - nctalk://conversation/{token} + * - nctalk://conversation/{token}?user={internalUserId} + * - https://{server}/call/{token} + * - https://{server}/index.php/call/{token} + */ +object DeepLinkHandler { + + private const val SCHEME_NCTALK = "nctalk" + private const val HOST_CONVERSATION = "conversation" + private const val QUERY_PARAM_USER = "user" + private const val PATH_CALL = "call" + private const val PATH_INDEX_PHP = "index.php" + + /** + * Result of parsing a deep link URI. + * + * @property roomToken The conversation/room token to open + * @property internalUserId Optional internal user ID for multi-account support + * @property serverUrl Optional server URL extracted from web links + */ + data class DeepLinkResult( + val roomToken: String, + val internalUserId: Long? = null, + val serverUrl: String? = null + ) + + /** + * Parses a deep link URI and extracts conversation information. + * + * @param uri The URI to parse + * @return DeepLinkResult if the URI is valid, null otherwise + */ + fun parseDeepLink(uri: Uri): DeepLinkResult? { + return when (uri.scheme?.lowercase()) { + SCHEME_NCTALK -> parseNcTalkUri(uri) + "http", "https" -> parseWebUri(uri) + else -> null + } + } + + /** + * Parses a custom scheme URI (nctalk://conversation/{token}). + */ + private fun parseNcTalkUri(uri: Uri): DeepLinkResult? { + if (uri.host?.lowercase() != HOST_CONVERSATION) { + return null + } + + val pathSegments = uri.pathSegments + if (pathSegments.isEmpty()) { + return null + } + + val token = pathSegments[0] + if (token.isBlank()) { + return null + } + + val userId = uri.getQueryParameter(QUERY_PARAM_USER)?.toLongOrNull() + + return DeepLinkResult( + roomToken = token, + internalUserId = userId + ) + } + + /** + * Parses a web URL (https://{server}/call/{token} or https://{server}/index.php/call/{token}). + */ + private fun parseWebUri(uri: Uri): DeepLinkResult? { + val path = uri.path ?: return null + val host = uri.host ?: return null + + // Match /call/{token} or /index.php/call/{token} + val tokenRegex = Regex("^(?:/$PATH_INDEX_PHP)?/$PATH_CALL/([^/]+)/?$") + val match = tokenRegex.find(path) ?: return null + val token = match.groupValues[1] + + if (token.isBlank()) { + return null + } + + val serverUrl = "${uri.scheme}://$host" + + return DeepLinkResult( + roomToken = token, + serverUrl = serverUrl + ) + } + + /** + * Creates a custom scheme URI for a conversation. + * + * @param roomToken The conversation token + * @param internalUserId Optional user ID for multi-account support + * @return URI in the format nctalk://conversation/{token}?user={userId} + */ + fun createConversationUri(roomToken: String, internalUserId: Long? = null): Uri { + val builder = Uri.Builder() + .scheme(SCHEME_NCTALK) + .authority(HOST_CONVERSATION) + .appendPath(roomToken) + + internalUserId?.let { + builder.appendQueryParameter(QUERY_PARAM_USER, it.toString()) + } + + return builder.build() + } +} diff --git a/app/src/main/java/com/nextcloud/talk/utils/ShortcutManagerHelper.kt b/app/src/main/java/com/nextcloud/talk/utils/ShortcutManagerHelper.kt new file mode 100644 index 00000000000..2dcd6456246 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/utils/ShortcutManagerHelper.kt @@ -0,0 +1,234 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.utils + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.core.content.pm.ShortcutInfoCompat +import androidx.core.content.pm.ShortcutManagerCompat +import androidx.core.graphics.drawable.IconCompat +import com.nextcloud.talk.R +import com.nextcloud.talk.activities.MainActivity +import com.nextcloud.talk.data.user.model.User +import com.nextcloud.talk.models.domain.ConversationModel +import com.nextcloud.talk.utils.bundle.BundleKeys + +/** + * Helper class for managing Android shortcuts for conversations. + * + * Provides methods to create, update, and manage dynamic shortcuts that allow + * users to quickly access conversations from their launcher. + */ +object ShortcutManagerHelper { + + private const val MAX_DYNAMIC_SHORTCUTS = 4 + private const val CONVERSATION_SHORTCUT_PREFIX = "conversation_" + + /** + * Creates a shortcut for a conversation. + * + * @param context Application context + * @param conversation The conversation to create a shortcut for + * @param user The user account associated with the conversation + * @return ShortcutInfoCompat ready to be added via ShortcutManagerCompat + */ + fun createConversationShortcut( + context: Context, + conversation: ConversationModel, + user: User + ): ShortcutInfoCompat { + val shortcutId = getShortcutId(conversation.token, user.id!!) + val displayName = conversation.displayName.ifBlank { conversation.name } + + // Use custom URI scheme for the intent + val uri = DeepLinkHandler.createConversationUri(conversation.token, user.id) + val intent = Intent(Intent.ACTION_VIEW, uri).apply { + setPackage(context.packageName) + } + + // Use the app icon as the shortcut icon - avatar loading would require async operations + val icon = IconCompat.createWithResource(context, R.drawable.baseline_chat_bubble_outline_24) + + return ShortcutInfoCompat.Builder(context, shortcutId) + .setShortLabel(displayName) + .setLongLabel(displayName) + .setIcon(icon) + .setIntent(intent) + .build() + } + + /** + * Creates a shortcut using bundle extras (alternative to URI scheme). + * This matches the existing Note To Self shortcut pattern. + * + * @param context Application context + * @param conversation The conversation to create a shortcut for + * @param user The user account associated with the conversation + * @return ShortcutInfoCompat ready to be added via ShortcutManagerCompat + */ + fun createConversationShortcutWithBundle( + context: Context, + conversation: ConversationModel, + user: User + ): ShortcutInfoCompat { + val shortcutId = getShortcutId(conversation.token, user.id!!) + val displayName = conversation.displayName.ifBlank { conversation.name } + + val bundle = Bundle().apply { + putString(BundleKeys.KEY_ROOM_TOKEN, conversation.token) + putLong(BundleKeys.KEY_INTERNAL_USER_ID, user.id!!) + } + + val intent = Intent(context, MainActivity::class.java).apply { + action = Intent.ACTION_VIEW + putExtras(bundle) + } + + val icon = IconCompat.createWithResource(context, R.drawable.baseline_chat_bubble_outline_24) + + return ShortcutInfoCompat.Builder(context, shortcutId) + .setShortLabel(displayName) + .setLongLabel(displayName) + .setIcon(icon) + .setIntent(intent) + .build() + } + + /** + * Updates dynamic shortcuts with the user's top conversations. + * Excludes Note To Self (handled separately) and archived conversations. + * + * @param context Application context + * @param conversations List of all conversations + * @param user The current user + */ + fun updateDynamicShortcuts( + context: Context, + conversations: List, + user: User + ) { + // Remove existing conversation shortcuts (keep Note To Self shortcut) + val existingShortcuts = ShortcutManagerCompat.getDynamicShortcuts(context) + val conversationShortcutIds = existingShortcuts + .filter { it.id.startsWith(CONVERSATION_SHORTCUT_PREFIX) } + .map { it.id } + + if (conversationShortcutIds.isNotEmpty()) { + ShortcutManagerCompat.removeDynamicShortcuts(context, conversationShortcutIds) + } + + // Get top conversations: favorites first, then by last activity + val topConversations = conversations + .filter { !it.hasArchived } + .filter { !ConversationUtils.isNoteToSelfConversation(it) } + .sortedWith(compareByDescending { it.favorite }.thenByDescending { it.lastActivity }) + .take(MAX_DYNAMIC_SHORTCUTS) + + // Create and push shortcuts + topConversations.forEach { conversation -> + val shortcut = createConversationShortcutWithBundle(context, conversation, user) + ShortcutManagerCompat.pushDynamicShortcut(context, shortcut) + } + } + + /** + * Requests to pin a shortcut to the home screen. + * + * @param context Application context + * @param conversation The conversation to create a pinned shortcut for + * @param user The user account associated with the conversation + * @return true if the pin request was successfully sent, false otherwise + */ + fun requestPinShortcut( + context: Context, + conversation: ConversationModel, + user: User + ): Boolean { + if (!ShortcutManagerCompat.isRequestPinShortcutSupported(context)) { + // Fall back to legacy shortcut broadcast + return createLegacyShortcut(context, conversation, user) + } + + val shortcut = createConversationShortcutWithBundle(context, conversation, user) + return ShortcutManagerCompat.requestPinShortcut(context, shortcut, null) + } + + /** + * Creates a shortcut using the legacy broadcast method for older launchers. + * + * @param context Application context + * @param conversation The conversation to create a shortcut for + * @param user The user account associated with the conversation + * @return true if the broadcast was sent successfully + */ + @Suppress("DEPRECATION") + private fun createLegacyShortcut( + context: Context, + conversation: ConversationModel, + user: User + ): Boolean { + val displayName = conversation.displayName.ifBlank { conversation.name } + + val bundle = Bundle().apply { + putString(BundleKeys.KEY_ROOM_TOKEN, conversation.token) + putLong(BundleKeys.KEY_INTERNAL_USER_ID, user.id!!) + } + + val launchIntent = Intent(context, MainActivity::class.java).apply { + action = Intent.ACTION_VIEW + putExtras(bundle) + } + + val shortcutIntent = Intent("com.android.launcher.action.INSTALL_SHORTCUT").apply { + putExtra(Intent.EXTRA_SHORTCUT_NAME, displayName) + putExtra(Intent.EXTRA_SHORTCUT_INTENT, launchIntent) + putExtra( + Intent.EXTRA_SHORTCUT_ICON_RESOURCE, + Intent.ShortcutIconResource.fromContext(context, R.mipmap.ic_launcher) + ) + } + + return try { + context.sendBroadcast(shortcutIntent) + true + } catch (e: Exception) { + false + } + } + + /** + * Reports that a shortcut has been used (helps with shortcut ranking). + * + * @param context Application context + * @param roomToken The conversation token + * @param userId The user ID + */ + fun reportShortcutUsed(context: Context, roomToken: String, userId: Long) { + val shortcutId = getShortcutId(roomToken, userId) + ShortcutManagerCompat.reportShortcutUsed(context, shortcutId) + } + + /** + * Removes a specific conversation shortcut. + * + * @param context Application context + * @param roomToken The conversation token + * @param userId The user ID + */ + fun removeConversationShortcut(context: Context, roomToken: String, userId: Long) { + val shortcutId = getShortcutId(roomToken, userId) + ShortcutManagerCompat.removeDynamicShortcuts(context, listOf(shortcutId)) + } + + /** + * Generates a unique shortcut ID for a conversation. + */ + private fun getShortcutId(roomToken: String, userId: Long): String { + return "${CONVERSATION_SHORTCUT_PREFIX}${userId}_$roomToken" + } +} diff --git a/app/src/main/res/drawable/ic_home.xml b/app/src/main/res/drawable/ic_home.xml new file mode 100644 index 00000000000..2f391b009ca --- /dev/null +++ b/app/src/main/res/drawable/ic_home.xml @@ -0,0 +1,15 @@ + + + + diff --git a/app/src/main/res/layout/dialog_conversation_operations.xml b/app/src/main/res/layout/dialog_conversation_operations.xml index 108f7617aae..4bca5ddc394 100644 --- a/app/src/main/res/layout/dialog_conversation_operations.xml +++ b/app/src/main/res/layout/dialog_conversation_operations.xml @@ -195,6 +195,36 @@ android:textSize="@dimen/bottom_sheet_text_size" /> + + + + + + + Copied to clipboard More options + + Add to home screen + Shortcut created + No account found for this server + Settings Add