diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 29ebb5715c..51e57eb656 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -123,6 +123,14 @@ + + + + + + + + { - 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) { @@ -232,12 +233,17 @@ class MainActivity : } private fun handleIntent(intent: Intent) { + // Handle deep links first (nextcloudtalk:// scheme) + if (handleDeepLink(intent)) { + return + } + handleActionFromContact(intent) 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() } @@ -253,34 +259,146 @@ 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() + } + ) + disposables.add(disposable) + } + } + + /** + * Handles deep link URIs for opening conversations. + * + * Supports: + * - nextcloudtalk://[user@]server/call/token + * + * @param intent The intent to process + * @return true if the intent was handled as a deep link, false otherwise + */ + private fun handleDeepLink(intent: Intent): Boolean { + val deepLinkResult = intent.data?.let { DeepLinkHandler.parseDeepLink(it) } ?: return false + + Log.d(TAG, "Handling deep link: token=${deepLinkResult.roomToken}, server=${deepLinkResult.serverUrl}") + + val disposable = userManager.users + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { users -> + if (isFinishing || isDestroyed) return@subscribe + + if (users.isEmpty()) { + launchServerSelection() + return@subscribe } - } - override fun onError(e: Throwable) { - Log.e(TAG, "Error loading existing users", e) + val targetUser = resolveTargetUser(users, deepLinkResult) + + if (targetUser == null) { + Toast.makeText( + context, + context.resources.getString(R.string.nc_no_account_for_server), + Toast.LENGTH_LONG + ).show() + openConversationList() + return@subscribe + } + + 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 + + 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() + } + }, + { 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 + } + + /** + * Resolves which user account to use for a deep link. + * + * Priority: + * 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? { + val deepLinkHost = Uri.parse(deepLinkResult.serverUrl).host?.lowercase() + if (deepLinkHost.isNullOrBlank()) { + return currentUserProviderOld.currentUser.blockingGet() + } + + // Priority: exact match (username + server) > server match > current user fallback + val username = deepLinkResult.username + val exactMatch = if (username != null) { + users.find { user -> + val userHost = user.baseUrl?.let { Uri.parse(it).host?.lowercase() } + userHost == deepLinkHost && user.username?.lowercase() == username.lowercase() + } + } else { + null } + + val serverMatch = users.find { user -> + val userHost = user.baseUrl?.let { Uri.parse(it).host?.lowercase() } + userHost == deepLinkHost + } + + val currentUser = currentUserProviderOld.currentUser.blockingGet() + val currentUserMatch = currentUser?.takeIf { + it.baseUrl?.let { url -> Uri.parse(url).host?.lowercase() } == deepLinkHost + } + + return exactMatch ?: serverMatch ?: currentUserMatch } companion object { 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 f71683fbe3..ac41dca11e 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt @@ -127,6 +127,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 @@ -464,6 +465,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() @@ -2076,6 +2082,14 @@ class ConversationsListActivity : if (workInfo != null) { when (workInfo.state) { WorkInfo.State.SUCCEEDED -> { + currentUser?.id?.let { userId -> + ShortcutManagerHelper.disableConversationShortcut( + context, + conversation.token, + userId, + context.resources.getString(R.string.nc_shortcut_conversation_deleted) + ) + } showSnackbar( String.format( context.resources.getString(R.string.deleted_conversation), 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 e4c567ddad..dd8a6a9482 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() + } + if (conversation.hasArchived) { binding.conversationArchiveText.setText(R.string.unarchive_conversation) binding.conversationArchiveIcon.setImageResource(R.drawable.ic_unarchive_24px) @@ -419,6 +424,14 @@ class ConversationsListBottomDialog( if (workInfo != null) { when (workInfo.state) { WorkInfo.State.SUCCEEDED -> { + currentUser.id?.let { userId -> + ShortcutManagerHelper.disableConversationShortcut( + context, + conversation.token, + userId, + context.resources.getString(R.string.nc_shortcut_conversation_deleted) + ) + } activity.showSnackbar( String.format( context.resources.getString(R.string.left_conversation), @@ -450,6 +463,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..78d1344a05 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/utils/DeepLinkHandler.kt @@ -0,0 +1,131 @@ +/* + * 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 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_NEXTCLOUD_TALK = "nextcloudtalk" + private const val PATH_CALL = "call" + + // 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 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 serverUrl: String, val username: 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? { + if (uri.scheme?.lowercase() != SCHEME_NEXTCLOUD_TALK) { + return null + } + return parseNextcloudTalkUri(uri) + } + + /** + * Parses a nextcloudtalk:// URI. + * Format: nextcloudtalk://[userid@]server_host/[server_base/]call/token + */ + @Suppress("ReturnCount") + private fun parseNextcloudTalkUri(uri: Uri): DeepLinkResult? { + val authority = uri.authority ?: return null + val path = uri.path ?: return null + val (username, serverHost) = parseAuthority(authority) + val token = extractTokenFromPath(path) + + return if (serverHost.isBlank() || token == null || !isValidToken(token)) { + null + } else { + DeepLinkResult(roomToken = token, serverUrl = "https://$serverHost", username = username) + } + } + + /** + * Parses the authority part to extract optional username and server host. + * Format: [userid@]server_host + * + * @return Pair of (username or null, serverHost) + */ + 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) + } + + /** + * 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 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, 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 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 new file mode 100644 index 0000000000..77593942be --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/utils/ShortcutManagerHelper.kt @@ -0,0 +1,214 @@ +/* + * 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.net.Uri +import android.util.Log +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 + +/** + * 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 TAG = "ShortcutManagerHelper" + private const val MAX_DYNAMIC_SHORTCUTS = 4 + private const val CONVERSATION_SHORTCUT_PREFIX = "conversation_" + + /** + * Creates a shortcut for a conversation using a nextcloudtalk:// deep link URI. + * This ensures proper multi-account user resolution when the shortcut is opened. + * + * @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, or null if user ID is invalid + */ + fun createConversationShortcut(context: Context, conversation: ConversationModel, user: User): ShortcutInfoCompat? { + val userId = user.id + val baseUrl = user.baseUrl + val uri = baseUrl?.let { + DeepLinkHandler.createConversationUri(conversation.token, it, user.username) + .takeIf { built -> built != Uri.EMPTY } + } + if (userId == null || uri == null) return null + + val displayName = conversation.displayName.ifBlank { conversation.name } + val intent = Intent(context, MainActivity::class.java).apply { + action = Intent.ACTION_VIEW + data = uri + } + + return ShortcutInfoCompat.Builder(context, getShortcutId(conversation.token, userId)) + .setShortLabel(displayName) + .setLongLabel(displayName) + .setIcon(IconCompat.createWithResource(context, R.drawable.baseline_chat_bubble_outline_24)) + .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) { + 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 + .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 -> + createConversationShortcut(context, conversation, user)?.let { shortcut -> + 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)) { + return createLegacyShortcut(context, conversation, user) + } + + val shortcut = createConversationShortcut(context, conversation, user) + return shortcut != null && 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 { + if (user.id == null || user.baseUrl == null) { + Log.w(TAG, "Cannot create legacy shortcut: user ID or base URL is null") + return false + } + + val displayName = conversation.displayName.ifBlank { conversation.name } + + val uri = DeepLinkHandler.createConversationUri( + roomToken = conversation.token, + serverUrl = user.baseUrl!!, + username = user.username + ) + + val launchIntent = Intent(context, MainActivity::class.java).apply { + action = Intent.ACTION_VIEW + data = uri + } + + 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: 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 + } + } + + /** + * 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)) + } + + /** + * Disables all shortcuts (dynamic and pinned) for a deleted conversation. + * Dynamic shortcuts are removed; pinned shortcuts are disabled with a message. + * + * @param context Application context + * @param roomToken The conversation token + * @param userId The user ID + * @param disabledMessage Message shown when a disabled pinned shortcut is tapped + */ + fun disableConversationShortcut(context: Context, roomToken: String, userId: Long, disabledMessage: String) { + val shortcutId = getShortcutId(roomToken, userId) + ShortcutManagerCompat.removeDynamicShortcuts(context, listOf(shortcutId)) + ShortcutManagerCompat.disableShortcuts(context, listOf(shortcutId), disabledMessage) + } + + /** + * Generates a unique shortcut ID for a conversation. + */ + private fun getShortcutId(roomToken: String, userId: Long): String = + "${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 d8847227c0..968c665eed 100644 --- a/app/src/main/res/layout/dialog_conversation_operations.xml +++ b/app/src/main/res/layout/dialog_conversation_operations.xml @@ -196,6 +196,36 @@ android:textSize="@dimen/bottom_sheet_text_size" /> + + + + + + + Copied to clipboard More options + + Add to home screen + Shortcut created + This conversation no longer exists + No account found for this server + Settings Add