diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index ca34e3f292..9c1f74b1b0 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 59ba7d9ad6..e3746d3c12 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 504dd6aebc..d55ac7a20b 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 0000000000..0a0d0d0c7f
--- /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 0000000000..2dcd645624
--- /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 0000000000..2f391b009c
--- /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 108f7617aa..4bca5ddc39 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