Skip to content

Commit d4fa72b

Browse files
committed
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 <github.visibly626@slmails.com>
1 parent c3b38f3 commit d4fa72b

File tree

9 files changed

+570
-0
lines changed

9 files changed

+570
-0
lines changed

app/src/main/AndroidManifest.xml

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,33 @@
122122
<data android:scheme="content" />
123123
<data android:scheme="file" />
124124
</intent-filter>
125+
126+
<!-- Custom URI scheme for opening conversations from external apps/launchers -->
127+
<intent-filter>
128+
<action android:name="android.intent.action.VIEW" />
129+
<category android:name="android.intent.category.DEFAULT" />
130+
<category android:name="android.intent.category.BROWSABLE" />
131+
<data android:scheme="nctalk" />
132+
</intent-filter>
133+
134+
<!-- HTTP/HTTPS deep links for opening Talk conversation links -->
135+
<intent-filter android:autoVerify="true">
136+
<action android:name="android.intent.action.VIEW" />
137+
<category android:name="android.intent.category.DEFAULT" />
138+
<category android:name="android.intent.category.BROWSABLE" />
139+
<data android:scheme="https" />
140+
<data android:host="*" />
141+
<data android:pathPrefix="/call/" />
142+
</intent-filter>
143+
144+
<intent-filter android:autoVerify="true">
145+
<action android:name="android.intent.action.VIEW" />
146+
<category android:name="android.intent.category.DEFAULT" />
147+
<category android:name="android.intent.category.BROWSABLE" />
148+
<data android:scheme="https" />
149+
<data android:host="*" />
150+
<data android:pathPrefix="/index.php/call/" />
151+
</intent-filter>
125152
</activity>
126153

127154
<activity

app/src/main/java/com/nextcloud/talk/activities/MainActivity.kt

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,9 @@ import com.nextcloud.talk.models.json.conversations.RoomOverall
3737
import com.nextcloud.talk.users.UserManager
3838
import com.nextcloud.talk.utils.ApiUtils
3939
import com.nextcloud.talk.utils.ClosedInterfaceImpl
40+
import com.nextcloud.talk.utils.DeepLinkHandler
4041
import com.nextcloud.talk.utils.SecurityUtils
42+
import com.nextcloud.talk.utils.ShortcutManagerHelper
4143
import com.nextcloud.talk.utils.bundle.BundleKeys
4244
import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_ROOM_TOKEN
4345
import io.reactivex.Observer
@@ -232,6 +234,11 @@ class MainActivity :
232234
}
233235

234236
private fun handleIntent(intent: Intent) {
237+
// Handle deep links first (nctalk:// scheme and https:// web links)
238+
if (handleDeepLink(intent)) {
239+
return
240+
}
241+
235242
handleActionFromContact(intent)
236243

237244
val internalUserId = intent.extras?.getLong(BundleKeys.KEY_INTERNAL_USER_ID)
@@ -283,6 +290,113 @@ class MainActivity :
283290
}
284291
}
285292

293+
/**
294+
* Handles deep link URIs for opening conversations.
295+
*
296+
* Supports:
297+
* - nctalk://conversation/{token}?user={userId}
298+
* - https://{server}/call/{token}
299+
* - https://{server}/index.php/call/{token}
300+
*
301+
* @param intent The intent to process
302+
* @return true if the intent was handled as a deep link, false otherwise
303+
*/
304+
private fun handleDeepLink(intent: Intent): Boolean {
305+
val uri = intent.data ?: return false
306+
val deepLinkResult = DeepLinkHandler.parseDeepLink(uri) ?: return false
307+
308+
Log.d(TAG, "Handling deep link: $uri -> token=${deepLinkResult.roomToken}")
309+
310+
userManager.users.subscribe(object : SingleObserver<List<User>> {
311+
override fun onSubscribe(d: Disposable) {
312+
// unused atm
313+
}
314+
315+
override fun onSuccess(users: List<User>) {
316+
if (users.isEmpty()) {
317+
runOnUiThread {
318+
launchServerSelection()
319+
}
320+
return
321+
}
322+
323+
val targetUser = resolveTargetUser(users, deepLinkResult)
324+
325+
if (targetUser == null) {
326+
runOnUiThread {
327+
Toast.makeText(
328+
context,
329+
context.resources.getString(R.string.nc_no_account_for_server),
330+
Toast.LENGTH_LONG
331+
).show()
332+
openConversationList()
333+
}
334+
return
335+
}
336+
337+
if (userManager.setUserAsActive(targetUser).blockingGet()) {
338+
// Report shortcut usage for ranking
339+
ShortcutManagerHelper.reportShortcutUsed(
340+
context,
341+
deepLinkResult.roomToken,
342+
targetUser.id!!
343+
)
344+
345+
runOnUiThread {
346+
val chatIntent = Intent(context, ChatActivity::class.java)
347+
chatIntent.putExtra(KEY_ROOM_TOKEN, deepLinkResult.roomToken)
348+
chatIntent.putExtra(BundleKeys.KEY_INTERNAL_USER_ID, targetUser.id)
349+
startActivity(chatIntent)
350+
}
351+
}
352+
}
353+
354+
override fun onError(e: Throwable) {
355+
Log.e(TAG, "Error loading users for deep link", e)
356+
runOnUiThread {
357+
Toast.makeText(
358+
context,
359+
context.resources.getString(R.string.nc_common_error_sorry),
360+
Toast.LENGTH_SHORT
361+
).show()
362+
}
363+
}
364+
})
365+
366+
return true
367+
}
368+
369+
/**
370+
* Resolves which user account to use for a deep link.
371+
*
372+
* Priority:
373+
* 1. User ID specified in deep link (for nctalk:// URIs)
374+
* 2. User matching the server URL (for https:// web links)
375+
* 3. Current active user as fallback
376+
*/
377+
private fun resolveTargetUser(
378+
users: List<User>,
379+
deepLinkResult: DeepLinkHandler.DeepLinkResult
380+
): User? {
381+
// If user ID is specified, use that user
382+
deepLinkResult.internalUserId?.let { userId ->
383+
return userManager.getUserWithId(userId).blockingGet()
384+
}
385+
386+
// If server URL is specified, find matching account
387+
deepLinkResult.serverUrl?.let { serverUrl ->
388+
val matchingUser = users.find { user ->
389+
user.baseUrl?.lowercase()?.contains(serverUrl.lowercase()) == true
390+
}
391+
if (matchingUser != null) {
392+
return matchingUser
393+
}
394+
}
395+
396+
// Fall back to current user
397+
return currentUserProviderOld.currentUser.blockingGet()
398+
}
399+
286400
companion object {
287401
private val TAG = MainActivity::class.java.simpleName
288402
}

app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,7 @@ import com.nextcloud.talk.utils.FileUtils
131131
import com.nextcloud.talk.utils.Mimetype
132132
import com.nextcloud.talk.utils.NotificationUtils
133133
import com.nextcloud.talk.utils.ParticipantPermissions
134+
import com.nextcloud.talk.utils.ShortcutManagerHelper
134135
import com.nextcloud.talk.utils.SpreedFeatures
135136
import com.nextcloud.talk.utils.UserIdUtils
136137
import com.nextcloud.talk.utils.bundle.BundleKeys
@@ -505,6 +506,11 @@ class ConversationsListActivity :
505506
val isNoteToSelfAvailable = noteToSelf != null
506507
handleNoteToSelfShortcut(isNoteToSelfAvailable, noteToSelf?.token ?: "")
507508

509+
// Update dynamic shortcuts for frequent/favorite conversations
510+
currentUser?.let { user ->
511+
ShortcutManagerHelper.updateDynamicShortcuts(context, list, user)
512+
}
513+
508514
val pair = appPreferences.conversationListPositionAndOffset
509515
layoutManager?.scrollToPositionWithOffset(pair.first, pair.second)
510516
}.collect()

app/src/main/java/com/nextcloud/talk/ui/dialog/ConversationsListBottomDialog.kt

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import com.nextcloud.talk.utils.ApiUtils
3939
import com.nextcloud.talk.utils.CapabilitiesUtil
4040
import com.nextcloud.talk.utils.ConversationUtils
4141
import com.nextcloud.talk.utils.ShareUtils
42+
import com.nextcloud.talk.utils.ShortcutManagerHelper
4243
import com.nextcloud.talk.utils.SpreedFeatures
4344
import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_INTERNAL_USER_ID
4445
import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_ROOM_TOKEN
@@ -194,6 +195,10 @@ class ConversationsListBottomDialog(
194195
dismiss()
195196
}
196197

198+
binding.conversationAddToHomeScreen.setOnClickListener {
199+
addConversationToHomeScreen()
200+
}
201+
197202
binding.conversationArchiveText.text = if (conversation.hasArchived) {
198203
this.activity.resources.getString(R.string.unarchive_conversation)
199204
} else {
@@ -448,6 +453,16 @@ class ConversationsListBottomDialog(
448453
dismiss()
449454
}
450455

456+
private fun addConversationToHomeScreen() {
457+
val success = ShortcutManagerHelper.requestPinShortcut(context, conversation, currentUser)
458+
if (success) {
459+
activity.showSnackbar(context.resources.getString(R.string.nc_shortcut_created))
460+
} else {
461+
activity.showSnackbar(context.resources.getString(R.string.nc_common_error_sorry))
462+
}
463+
dismiss()
464+
}
465+
451466
private fun chatApiVersion(): Int =
452467
ApiUtils.getChatApiVersion(currentUser.capabilities!!.spreedCapability!!, intArrayOf(ApiUtils.API_V1))
453468

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
/*
2+
* Nextcloud Talk - Android Client
3+
*
4+
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
5+
* SPDX-License-Identifier: GPL-3.0-or-later
6+
*/
7+
package com.nextcloud.talk.utils
8+
9+
import android.net.Uri
10+
11+
/**
12+
* Handles parsing of deep links for opening conversations.
13+
*
14+
* Supported URI formats:
15+
* - nctalk://conversation/{token}
16+
* - nctalk://conversation/{token}?user={internalUserId}
17+
* - https://{server}/call/{token}
18+
* - https://{server}/index.php/call/{token}
19+
*/
20+
object DeepLinkHandler {
21+
22+
private const val SCHEME_NCTALK = "nctalk"
23+
private const val HOST_CONVERSATION = "conversation"
24+
private const val QUERY_PARAM_USER = "user"
25+
private const val PATH_CALL = "call"
26+
private const val PATH_INDEX_PHP = "index.php"
27+
28+
/**
29+
* Result of parsing a deep link URI.
30+
*
31+
* @property roomToken The conversation/room token to open
32+
* @property internalUserId Optional internal user ID for multi-account support
33+
* @property serverUrl Optional server URL extracted from web links
34+
*/
35+
data class DeepLinkResult(
36+
val roomToken: String,
37+
val internalUserId: Long? = null,
38+
val serverUrl: String? = null
39+
)
40+
41+
/**
42+
* Parses a deep link URI and extracts conversation information.
43+
*
44+
* @param uri The URI to parse
45+
* @return DeepLinkResult if the URI is valid, null otherwise
46+
*/
47+
fun parseDeepLink(uri: Uri): DeepLinkResult? {
48+
return when (uri.scheme?.lowercase()) {
49+
SCHEME_NCTALK -> parseNcTalkUri(uri)
50+
"http", "https" -> parseWebUri(uri)
51+
else -> null
52+
}
53+
}
54+
55+
/**
56+
* Parses a custom scheme URI (nctalk://conversation/{token}).
57+
*/
58+
private fun parseNcTalkUri(uri: Uri): DeepLinkResult? {
59+
if (uri.host?.lowercase() != HOST_CONVERSATION) {
60+
return null
61+
}
62+
63+
val pathSegments = uri.pathSegments
64+
if (pathSegments.isEmpty()) {
65+
return null
66+
}
67+
68+
val token = pathSegments[0]
69+
if (token.isBlank()) {
70+
return null
71+
}
72+
73+
val userId = uri.getQueryParameter(QUERY_PARAM_USER)?.toLongOrNull()
74+
75+
return DeepLinkResult(
76+
roomToken = token,
77+
internalUserId = userId
78+
)
79+
}
80+
81+
/**
82+
* Parses a web URL (https://{server}/call/{token} or https://{server}/index.php/call/{token}).
83+
*/
84+
private fun parseWebUri(uri: Uri): DeepLinkResult? {
85+
val path = uri.path ?: return null
86+
val host = uri.host ?: return null
87+
88+
// Match /call/{token} or /index.php/call/{token}
89+
val tokenRegex = Regex("^(?:/$PATH_INDEX_PHP)?/$PATH_CALL/([^/]+)/?$")
90+
val match = tokenRegex.find(path) ?: return null
91+
val token = match.groupValues[1]
92+
93+
if (token.isBlank()) {
94+
return null
95+
}
96+
97+
val serverUrl = "${uri.scheme}://$host"
98+
99+
return DeepLinkResult(
100+
roomToken = token,
101+
serverUrl = serverUrl
102+
)
103+
}
104+
105+
/**
106+
* Creates a custom scheme URI for a conversation.
107+
*
108+
* @param roomToken The conversation token
109+
* @param internalUserId Optional user ID for multi-account support
110+
* @return URI in the format nctalk://conversation/{token}?user={userId}
111+
*/
112+
fun createConversationUri(roomToken: String, internalUserId: Long? = null): Uri {
113+
val builder = Uri.Builder()
114+
.scheme(SCHEME_NCTALK)
115+
.authority(HOST_CONVERSATION)
116+
.appendPath(roomToken)
117+
118+
internalUserId?.let {
119+
builder.appendQueryParameter(QUERY_PARAM_USER, it.toString())
120+
}
121+
122+
return builder.build()
123+
}
124+
}

0 commit comments

Comments
 (0)