From 30d91c21923c2d34ee0f15d00ae7bb0c99f49170 Mon Sep 17 00:00:00 2001 From: angrymuesli Date: Tue, 23 Dec 2025 19:03:58 +0200 Subject: [PATCH 1/2] 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 08e42088bbd..27f3707b7a0 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 122a8a2db5b..8215468e0c6 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 From e40404b514dd43be2b3cff3812857ec206af4492 Mon Sep 17 00:00:00 2001 From: angrymuesli Date: Mon, 2 Feb 2026 07:27:52 +0200 Subject: [PATCH 2/2] refactor: implement nextcloudtalk:// URI scheme per spreed#16354 Address maintainer feedback and improve code quality: - Replace nctalk:// with nextcloudtalk:// custom URI scheme - Remove HTTPS deep links (not feasible for self-hosted domains) - Add token validation (alphanumeric, 4-32 chars) - Fix server URL matching to use proper host comparison - Handle null user.id gracefully in ShortcutManagerHelper - Manage RxJava disposables to prevent memory leaks - Add lifecycle checks before UI operations - Fix generic exception handling URI format: nextcloudtalk://[user@]host/[base/]call/token --- app/src/main/AndroidManifest.xml | 21 +- .../nextcloud/talk/activities/MainActivity.kt | 211 ++++++++++-------- .../nextcloud/talk/utils/DeepLinkHandler.kt | 141 +++++++----- .../talk/utils/ShortcutManagerHelper.kt | 100 +++------ 4 files changed, 232 insertions(+), 241 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 27f3707b7a0..34134629f4e 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -128,26 +128,7 @@ - - - - - - - - - - - - - - - - - - - - + diff --git a/app/src/main/java/com/nextcloud/talk/activities/MainActivity.kt b/app/src/main/java/com/nextcloud/talk/activities/MainActivity.kt index 441b21a9261..e5208a16382 100644 --- a/app/src/main/java/com/nextcloud/talk/activities/MainActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/activities/MainActivity.kt @@ -11,6 +11,7 @@ package com.nextcloud.talk.activities import android.app.KeyguardManager import android.content.Intent +import android.net.Uri import android.os.Bundle import android.provider.ContactsContract import android.text.TextUtils @@ -33,7 +34,6 @@ import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.databinding.ActivityMainBinding import com.nextcloud.talk.invitation.InvitationsActivity import com.nextcloud.talk.lock.LockedActivity -import com.nextcloud.talk.models.json.conversations.RoomOverall import com.nextcloud.talk.users.UserManager import com.nextcloud.talk.utils.ApiUtils import com.nextcloud.talk.utils.ClosedInterfaceImpl @@ -42,10 +42,8 @@ import com.nextcloud.talk.utils.SecurityUtils import com.nextcloud.talk.utils.ShortcutManagerHelper import com.nextcloud.talk.utils.bundle.BundleKeys import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_ROOM_TOKEN -import io.reactivex.Observer -import io.reactivex.SingleObserver import io.reactivex.android.schedulers.AndroidSchedulers -import io.reactivex.disposables.Disposable +import io.reactivex.disposables.CompositeDisposable import io.reactivex.schedulers.Schedulers import javax.inject.Inject @@ -62,6 +60,8 @@ class MainActivity : @Inject lateinit var userManager: UserManager + private val disposables = CompositeDisposable() + private val onBackPressedCallback = object : OnBackPressedCallback(true) { override fun handleOnBackPressed() { finish() @@ -93,6 +93,11 @@ class MainActivity : onBackPressedDispatcher.addCallback(this, onBackPressedCallback) } + override fun onDestroy() { + super.onDestroy() + disposables.dispose() + } + fun lockScreenIfConditionsApply() { val keyguardManager = getSystemService(KEYGUARD_SERVICE) as KeyguardManager if (keyguardManager.isKeyguardSecure && appPreferences.isScreenLocked) { @@ -168,7 +173,8 @@ class MainActivity : val user = userId.substringBeforeLast("@") val baseUrl = userId.substringAfterLast("@") - if (currentUserProviderOld.currentUser.blockingGet()?.baseUrl!!.endsWith(baseUrl) == true) { + val currentUser = currentUserProviderOld.currentUser.blockingGet() + if (currentUser?.baseUrl?.endsWith(baseUrl) == true) { startConversation(user) } else { Snackbar.make( @@ -196,35 +202,28 @@ class MainActivity : invite = userId ) - ncApi.createRoom( + val disposable = ncApi.createRoom( credentials, retrofitBucket.url, retrofitBucket.queryMap ) .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) - .subscribe(object : Observer { - override fun onSubscribe(d: Disposable) { - // unused atm - } - - override fun onNext(roomOverall: RoomOverall) { + .subscribe( + { roomOverall -> + if (isFinishing || isDestroyed) return@subscribe val bundle = Bundle() bundle.putString(KEY_ROOM_TOKEN, roomOverall.ocs!!.data!!.token) val chatIntent = Intent(context, ChatActivity::class.java) chatIntent.putExtras(bundle) startActivity(chatIntent) + }, + { e -> + Log.e(TAG, "Error creating room", e) } - - override fun onError(e: Throwable) { - // unused atm - } - - override fun onComplete() { - // unused atm - } - }) + ) + disposables.add(disposable) } override fun onNewIntent(intent: Intent) { @@ -234,7 +233,7 @@ class MainActivity : } private fun handleIntent(intent: Intent) { - // Handle deep links first (nctalk:// scheme and https:// web links) + // Handle deep links first (nextcloudtalk:// scheme) if (handleDeepLink(intent)) { return } @@ -244,7 +243,7 @@ class MainActivity : val internalUserId = intent.extras?.getLong(BundleKeys.KEY_INTERNAL_USER_ID) var user: User? = null - if (internalUserId != null) { + if (internalUserId != null && internalUserId != 0L) { user = userManager.getUserWithId(internalUserId).blockingGet() } @@ -260,33 +259,30 @@ class MainActivity : startActivity(chatIntent) } } else { - userManager.users.subscribe(object : SingleObserver> { - override fun onSubscribe(d: Disposable) { - // unused atm - } - - override fun onSuccess(users: List) { - if (users.isNotEmpty()) { - ClosedInterfaceImpl().setUpPushTokenRegistration() - runOnUiThread { + val disposable = userManager.users + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { users -> + if (isFinishing || isDestroyed) return@subscribe + if (users.isNotEmpty()) { + ClosedInterfaceImpl().setUpPushTokenRegistration() openConversationList() - } - } else { - runOnUiThread { + } else { launchServerSelection() } + }, + { e -> + Log.e(TAG, "Error loading existing users", e) + if (isFinishing || isDestroyed) return@subscribe + Toast.makeText( + context, + context.resources.getString(R.string.nc_common_error_sorry), + Toast.LENGTH_SHORT + ).show() } - } - - override fun onError(e: Throwable) { - Log.e(TAG, "Error loading existing users", e) - Toast.makeText( - context, - context.resources.getString(R.string.nc_common_error_sorry), - Toast.LENGTH_SHORT - ).show() - } - }) + ) + disposables.add(disposable) } } @@ -294,9 +290,7 @@ class MainActivity : * Handles deep link URIs for opening conversations. * * Supports: - * - nctalk://conversation/{token}?user={userId} - * - https://{server}/call/{token} - * - https://{server}/index.php/call/{token} + * - nextcloudtalk://[user@]server/call/token * * @param intent The intent to process * @return true if the intent was handled as a deep link, false otherwise @@ -305,63 +299,67 @@ class MainActivity : val uri = intent.data ?: return false val deepLinkResult = DeepLinkHandler.parseDeepLink(uri) ?: return false - Log.d(TAG, "Handling deep link: $uri -> token=${deepLinkResult.roomToken}") + Log.d(TAG, "Handling deep link: token=${deepLinkResult.roomToken}, server=${deepLinkResult.serverUrl}") - userManager.users.subscribe(object : SingleObserver> { - override fun onSubscribe(d: Disposable) { - // unused atm - } + val disposable = userManager.users + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { users -> + if (isFinishing || isDestroyed) return@subscribe - override fun onSuccess(users: List) { - if (users.isEmpty()) { - runOnUiThread { + if (users.isEmpty()) { launchServerSelection() + return@subscribe } - return - } - val targetUser = resolveTargetUser(users, deepLinkResult) + val targetUser = resolveTargetUser(users, deepLinkResult) - if (targetUser == null) { - runOnUiThread { + if (targetUser == null) { Toast.makeText( context, context.resources.getString(R.string.nc_no_account_for_server), Toast.LENGTH_LONG ).show() openConversationList() + return@subscribe } - return - } - if (userManager.setUserAsActive(targetUser).blockingGet()) { - // Report shortcut usage for ranking - ShortcutManagerHelper.reportShortcutUsed( - context, - deepLinkResult.roomToken, - targetUser.id!! - ) + if (userManager.setUserAsActive(targetUser).blockingGet()) { + // Report shortcut usage for ranking + targetUser.id?.let { userId -> + ShortcutManagerHelper.reportShortcutUsed( + context, + deepLinkResult.roomToken, + userId + ) + } + + if (isFinishing || isDestroyed) return@subscribe - 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) + } else { + Toast.makeText( + context, + context.resources.getString(R.string.nc_common_error_sorry), + Toast.LENGTH_SHORT + ).show() } - } - } - - override fun onError(e: Throwable) { - Log.e(TAG, "Error loading users for deep link", e) - runOnUiThread { + }, + { e -> + Log.e(TAG, "Error loading users for deep link", e) + if (isFinishing || isDestroyed) return@subscribe Toast.makeText( context, context.resources.getString(R.string.nc_common_error_sorry), Toast.LENGTH_SHORT ).show() } - } - }) + ) + disposables.add(disposable) return true } @@ -370,31 +368,48 @@ class MainActivity : * 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 + * 1. User matching both username and server URL + * 2. User matching the server URL only + * 3. Current active user as fallback (if server matches) */ - 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() + private fun resolveTargetUser(users: List, deepLinkResult: DeepLinkHandler.DeepLinkResult): User? { + val serverUrl = deepLinkResult.serverUrl + val username = deepLinkResult.username + + // Extract host from the deep link server URL for comparison + val deepLinkHost = Uri.parse(serverUrl).host?.lowercase() + if (deepLinkHost.isNullOrBlank()) { + return currentUserProviderOld.currentUser.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 username is specified, try to find exact match (username + server) + if (username != null) { + val exactMatch = users.find { user -> + val userHost = user.baseUrl?.let { Uri.parse(it).host?.lowercase() } + userHost == deepLinkHost && user.username?.lowercase() == username.lowercase() } - if (matchingUser != null) { - return matchingUser + if (exactMatch != null) { + return exactMatch } } - // Fall back to current user - return currentUserProviderOld.currentUser.blockingGet() + // Find user matching the server URL (host match) + val matchingUser = users.find { user -> + val userHost = user.baseUrl?.let { Uri.parse(it).host?.lowercase() } + userHost == deepLinkHost + } + if (matchingUser != null) { + return matchingUser + } + + // Fall back to current user only if their server matches + val currentUser = currentUserProviderOld.currentUser.blockingGet() + val currentUserHost = currentUser?.baseUrl?.let { Uri.parse(it).host?.lowercase() } + if (currentUserHost == deepLinkHost) { + return currentUser + } + + return null } companion object { diff --git a/app/src/main/java/com/nextcloud/talk/utils/DeepLinkHandler.kt b/app/src/main/java/com/nextcloud/talk/utils/DeepLinkHandler.kt index 0a0d0d0c7f3..f9b3b9b8592 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/DeepLinkHandler.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/DeepLinkHandler.kt @@ -11,32 +11,32 @@ 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} + * Supported URI format (per https://github.com/nextcloud/spreed/issues/16354): + * - nextcloudtalk://[userid@]server_host/[server_base/]call/token + * + * Examples: + * - nextcloudtalk://cloud.example.com/call/abc123 + * - nextcloudtalk://user1@cloud.example.com/call/abc123 + * - nextcloudtalk://cloud.example.com/nextcloud/call/abc123 + * - nextcloudtalk://user1@cloud.example.com/index.php/call/abc123 */ object DeepLinkHandler { - private const val SCHEME_NCTALK = "nctalk" - private const val HOST_CONVERSATION = "conversation" - private const val QUERY_PARAM_USER = "user" + private const val SCHEME_NEXTCLOUD_TALK = "nextcloudtalk" private const val PATH_CALL = "call" private const val PATH_INDEX_PHP = "index.php" + // Token validation: alphanumeric characters, reasonable length + private val TOKEN_PATTERN = Regex("^[a-zA-Z0-9]{4,32}$") + /** * 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 + * @property serverUrl The server URL extracted from the deep link + * @property username Optional username from the URI authority (user@host format) */ - data class DeepLinkResult( - val roomToken: String, - val internalUserId: Long? = null, - val serverUrl: String? = null - ) + data class DeepLinkResult(val roomToken: String, val serverUrl: String, val username: String? = null) /** * Parses a deep link URI and extracts conversation information. @@ -45,80 +45,103 @@ object DeepLinkHandler { * @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 + if (uri.scheme?.lowercase() != SCHEME_NEXTCLOUD_TALK) { + return null } + return parseNextcloudTalkUri(uri) } /** - * Parses a custom scheme URI (nctalk://conversation/{token}). + * Parses a nextcloudtalk:// URI. + * Format: nextcloudtalk://[userid@]server_host/[server_base/]call/token */ - private fun parseNcTalkUri(uri: Uri): DeepLinkResult? { - if (uri.host?.lowercase() != HOST_CONVERSATION) { - return null - } + private fun parseNextcloudTalkUri(uri: Uri): DeepLinkResult? { + val authority = uri.authority ?: return null + val path = uri.path ?: return null + + // Extract optional username and server host from authority + val (username, serverHost) = parseAuthority(authority) - val pathSegments = uri.pathSegments - if (pathSegments.isEmpty()) { + if (serverHost.isBlank()) { return null } - val token = pathSegments[0] - if (token.isBlank()) { + // Extract room token from path + val token = extractTokenFromPath(path) ?: return null + + // Validate token format + if (!isValidToken(token)) { return null } - val userId = uri.getQueryParameter(QUERY_PARAM_USER)?.toLongOrNull() + // Build server URL (always use https for security) + val serverUrl = "https://$serverHost" return DeepLinkResult( roomToken = token, - internalUserId = userId + serverUrl = serverUrl, + username = username ) } /** - * Parses a web URL (https://{server}/call/{token} or https://{server}/index.php/call/{token}). + * Parses the authority part to extract optional username and server host. + * Format: [userid@]server_host + * + * @return Pair of (username or null, serverHost) */ - 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 + private fun parseAuthority(authority: String): Pair = + if (authority.contains("@")) { + val parts = authority.split("@", limit = 2) + val username = parts[0].takeIf { it.isNotBlank() } + val host = parts.getOrElse(1) { "" } + Pair(username, host) + } else { + Pair(null, authority) } - val serverUrl = "${uri.scheme}://$host" - - return DeepLinkResult( - roomToken = token, - serverUrl = serverUrl - ) + /** + * Extracts the room token from the path. + * Matches /call/{token} or /[anything]/call/{token} patterns. + */ + private fun extractTokenFromPath(path: String): String? { + // Match patterns like /call/token or /base/call/token or /index.php/call/token + val tokenRegex = Regex("/$PATH_CALL/([^/]+)/?$") + val match = tokenRegex.find(path) ?: return null + return match.groupValues[1].takeIf { it.isNotBlank() } } + /** + * Validates that a token matches the expected format. + * Tokens should be alphanumeric and between 4-32 characters. + */ + private fun isValidToken(token: String): Boolean = TOKEN_PATTERN.matches(token) + /** * 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} + * @param serverUrl The server base URL (e.g., "https://cloud.example.com") + * @param username Optional username for multi-account support + * @return URI in the format nextcloudtalk://[user@]host/call/token */ - 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()) + fun createConversationUri(roomToken: String, serverUrl: String, username: String? = null): Uri { + // Extract host from server URL + val serverUri = Uri.parse(serverUrl) + val host = serverUri.host ?: return Uri.EMPTY + + // Build authority with optional username + val authority = if (username != null) { + "$username@$host" + } else { + host } - return builder.build() + return Uri.Builder() + .scheme(SCHEME_NEXTCLOUD_TALK) + .authority(authority) + .appendPath(PATH_CALL) + .appendPath(roomToken) + .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 index 2dcd6456246..8e8ee8fa9c8 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/ShortcutManagerHelper.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/ShortcutManagerHelper.kt @@ -9,6 +9,7 @@ package com.nextcloud.talk.utils import android.content.Context import android.content.Intent import android.os.Bundle +import android.util.Log import androidx.core.content.pm.ShortcutInfoCompat import androidx.core.content.pm.ShortcutManagerCompat import androidx.core.graphics.drawable.IconCompat @@ -26,62 +27,31 @@ import com.nextcloud.talk.utils.bundle.BundleKeys */ object ShortcutManagerHelper { + private const val TAG = "ShortcutManagerHelper" private const val MAX_DYNAMIC_SHORTCUTS = 4 private const val CONVERSATION_SHORTCUT_PREFIX = "conversation_" /** - * Creates a shortcut for a conversation. + * Creates a shortcut for a conversation using bundle extras. + * 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 + * @return ShortcutInfoCompat ready to be added, or null if user ID is invalid */ - 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) + fun createConversationShortcut(context: Context, conversation: ConversationModel, user: User): ShortcutInfoCompat? { + val userId = user.id ?: run { + Log.w(TAG, "Cannot create shortcut: user ID is null") + return null } - // 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 shortcutId = getShortcutId(conversation.token, userId) 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!!) + putLong(BundleKeys.KEY_INTERNAL_USER_ID, userId) } val intent = Intent(context, MainActivity::class.java).apply { @@ -107,11 +77,12 @@ object ShortcutManagerHelper { * @param conversations List of all conversations * @param user The current user */ - fun updateDynamicShortcuts( - context: Context, - conversations: List, - user: User - ) { + fun updateDynamicShortcuts(context: Context, conversations: List, user: User) { + val userId = user.id ?: run { + Log.w(TAG, "Cannot update shortcuts: user ID is null") + return + } + // Remove existing conversation shortcuts (keep Note To Self shortcut) val existingShortcuts = ShortcutManagerCompat.getDynamicShortcuts(context) val conversationShortcutIds = existingShortcuts @@ -131,8 +102,9 @@ object ShortcutManagerHelper { // Create and push shortcuts topConversations.forEach { conversation -> - val shortcut = createConversationShortcutWithBundle(context, conversation, user) - ShortcutManagerCompat.pushDynamicShortcut(context, shortcut) + createConversationShortcut(context, conversation, user)?.let { shortcut -> + ShortcutManagerCompat.pushDynamicShortcut(context, shortcut) + } } } @@ -144,17 +116,13 @@ object ShortcutManagerHelper { * @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 { + 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) + val shortcut = createConversationShortcut(context, conversation, user) ?: return false return ShortcutManagerCompat.requestPinShortcut(context, shortcut, null) } @@ -167,16 +135,17 @@ object ShortcutManagerHelper { * @return true if the broadcast was sent successfully */ @Suppress("DEPRECATION") - private fun createLegacyShortcut( - context: Context, - conversation: ConversationModel, - user: User - ): Boolean { + private fun createLegacyShortcut(context: Context, conversation: ConversationModel, user: User): Boolean { + val userId = user.id ?: run { + Log.w(TAG, "Cannot create legacy shortcut: user ID is null") + return false + } + 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!!) + putLong(BundleKeys.KEY_INTERNAL_USER_ID, userId) } val launchIntent = Intent(context, MainActivity::class.java).apply { @@ -196,7 +165,11 @@ object ShortcutManagerHelper { return try { context.sendBroadcast(shortcutIntent) true - } catch (e: Exception) { + } catch (e: SecurityException) { + Log.e(TAG, "Failed to create legacy shortcut: permission denied", e) + false + } catch (e: IllegalArgumentException) { + Log.e(TAG, "Failed to create legacy shortcut: invalid arguments", e) false } } @@ -228,7 +201,6 @@ object ShortcutManagerHelper { /** * Generates a unique shortcut ID for a conversation. */ - private fun getShortcutId(roomToken: String, userId: Long): String { - return "${CONVERSATION_SHORTCUT_PREFIX}${userId}_$roomToken" - } + private fun getShortcutId(roomToken: String, userId: Long): String = + "${CONVERSATION_SHORTCUT_PREFIX}${userId}_$roomToken" }