Skip to content

Commit 7d75712

Browse files
authored
Merge pull request #6207 from nextcloud/feature/deep-link-launcher-support
Feature/deep link launcher support
2 parents 85af584 + 483a46e commit 7d75712

13 files changed

Lines changed: 580 additions & 31 deletions

File tree

app/src/main/AndroidManifest.xml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@
104104
<activity
105105
android:name=".activities.MainActivity"
106106
android:exported="true"
107+
android:launchMode="singleTask"
107108
android:windowSoftInputMode="adjustResize">
108109
<intent-filter>
109110
<action android:name="android.intent.action.MAIN" />
@@ -127,6 +128,14 @@
127128
<meta-data
128129
android:name="android.app.shortcuts"
129130
android:resource="@xml/shortcuts" />
131+
132+
<!-- Custom URI scheme for opening conversations from external apps/launchers -->
133+
<intent-filter>
134+
<action android:name="android.intent.action.VIEW" />
135+
<category android:name="android.intent.category.DEFAULT" />
136+
<category android:name="android.intent.category.BROWSABLE" />
137+
<data android:scheme="nextcloudtalk" />
138+
</intent-filter>
130139
</activity>
131140

132141
<activity

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

Lines changed: 147 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ package com.nextcloud.talk.activities
1111

1212
import android.app.KeyguardManager
1313
import android.content.Intent
14+
import android.net.Uri
1415
import android.os.Bundle
1516
import android.provider.ContactsContract
1617
import android.text.TextUtils
@@ -33,17 +34,18 @@ import com.nextcloud.talk.data.user.model.User
3334
import com.nextcloud.talk.databinding.ActivityMainBinding
3435
import com.nextcloud.talk.invitation.InvitationsActivity
3536
import com.nextcloud.talk.lock.LockedActivity
36-
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
4142
import com.nextcloud.talk.utils.UnifiedPushUtils
43+
import com.nextcloud.talk.utils.ShortcutManagerHelper
4244
import com.nextcloud.talk.utils.bundle.BundleKeys
4345
import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_ROOM_TOKEN
44-
import io.reactivex.Observer
4546
import io.reactivex.SingleObserver
4647
import io.reactivex.android.schedulers.AndroidSchedulers
48+
import io.reactivex.disposables.CompositeDisposable
4749
import io.reactivex.disposables.Disposable
4850
import io.reactivex.schedulers.Schedulers
4951
import javax.inject.Inject
@@ -61,6 +63,8 @@ class MainActivity :
6163
@Inject
6264
lateinit var userManager: UserManager
6365

66+
private val disposables = CompositeDisposable()
67+
6468
private val onBackPressedCallback = object : OnBackPressedCallback(true) {
6569
override fun handleOnBackPressed() {
6670
finish()
@@ -92,6 +96,11 @@ class MainActivity :
9296
onBackPressedDispatcher.addCallback(this, onBackPressedCallback)
9397
}
9498

99+
override fun onDestroy() {
100+
super.onDestroy()
101+
disposables.dispose()
102+
}
103+
95104
fun lockScreenIfConditionsApply() {
96105
val keyguardManager = getSystemService(KEYGUARD_SERVICE) as KeyguardManager
97106
if (keyguardManager.isKeyguardSecure && appPreferences.isScreenLocked) {
@@ -167,7 +176,8 @@ class MainActivity :
167176
val user = userId.substringBeforeLast("@")
168177
val baseUrl = userId.substringAfterLast("@")
169178

170-
if (currentUserProviderOld.currentUser.blockingGet()?.baseUrl!!.endsWith(baseUrl) == true) {
179+
val currentUser = currentUserProviderOld.currentUser.blockingGet()
180+
if (currentUser?.baseUrl?.endsWith(baseUrl) == true) {
171181
startConversation(user)
172182
} else {
173183
Snackbar.make(
@@ -195,35 +205,28 @@ class MainActivity :
195205
invite = userId
196206
)
197207

198-
ncApi.createRoom(
208+
val disposable = ncApi.createRoom(
199209
credentials,
200210
retrofitBucket.url,
201211
retrofitBucket.queryMap
202212
)
203213
.subscribeOn(Schedulers.io())
204214
.observeOn(AndroidSchedulers.mainThread())
205-
.subscribe(object : Observer<RoomOverall> {
206-
override fun onSubscribe(d: Disposable) {
207-
// unused atm
208-
}
209-
210-
override fun onNext(roomOverall: RoomOverall) {
215+
.subscribe(
216+
{ roomOverall ->
217+
if (isFinishing || isDestroyed) return@subscribe
211218
val bundle = Bundle()
212219
bundle.putString(KEY_ROOM_TOKEN, roomOverall.ocs!!.data!!.token)
213220

214221
val chatIntent = Intent(context, ChatActivity::class.java)
215222
chatIntent.putExtras(bundle)
216223
startActivity(chatIntent)
224+
},
225+
{ e ->
226+
Log.e(TAG, "Error creating room", e)
217227
}
218-
219-
override fun onError(e: Throwable) {
220-
// unused atm
221-
}
222-
223-
override fun onComplete() {
224-
// unused atm
225-
}
226-
})
228+
)
229+
disposables.add(disposable)
227230
}
228231

229232
override fun onNewIntent(intent: Intent) {
@@ -233,12 +236,17 @@ class MainActivity :
233236
}
234237

235238
private fun handleIntent(intent: Intent) {
239+
// Handle deep links first (nextcloudtalk:// scheme)
240+
if (handleDeepLink(intent)) {
241+
return
242+
}
243+
236244
handleActionFromContact(intent)
237245

238246
val internalUserId = intent.extras?.getLong(BundleKeys.KEY_INTERNAL_USER_ID)
239247

240248
var user: User? = null
241-
if (internalUserId != null) {
249+
if (internalUserId != null && internalUserId != 0L) {
242250
user = userManager.getUserWithId(internalUserId).blockingGet()
243251
}
244252

@@ -288,6 +296,125 @@ class MainActivity :
288296
}
289297
}
290298

299+
/**
300+
* Handles deep link URIs for opening conversations.
301+
*
302+
* Supports:
303+
* - nextcloudtalk://[user@]server/call/token
304+
*
305+
* @param intent The intent to process
306+
* @return true if the intent was handled as a deep link, false otherwise
307+
*/
308+
private fun handleDeepLink(intent: Intent): Boolean {
309+
val deepLinkResult = intent.data?.let { DeepLinkHandler.parseDeepLink(it) } ?: return false
310+
311+
val disposable = userManager.users
312+
.subscribeOn(Schedulers.io())
313+
.observeOn(AndroidSchedulers.mainThread())
314+
.subscribe(
315+
{ users ->
316+
if (isFinishing || isDestroyed) return@subscribe
317+
318+
if (users.isEmpty()) {
319+
launchServerSelection()
320+
return@subscribe
321+
}
322+
323+
val targetUser = resolveTargetUser(users, deepLinkResult)
324+
325+
if (targetUser == null) {
326+
Toast.makeText(
327+
context,
328+
context.resources.getString(R.string.nc_no_account_for_server),
329+
Toast.LENGTH_LONG
330+
).show()
331+
openConversationList()
332+
return@subscribe
333+
}
334+
335+
if (userManager.setUserAsActive(targetUser).blockingGet()) {
336+
// Report shortcut usage for ranking
337+
targetUser.id?.let { userId ->
338+
ShortcutManagerHelper.reportShortcutUsed(
339+
context,
340+
deepLinkResult.roomToken,
341+
userId
342+
)
343+
}
344+
345+
if (isFinishing || isDestroyed) return@subscribe
346+
347+
// Open conversation list first so back press shows correct user's conversations
348+
val listIntent = Intent(context, ConversationsListActivity::class.java)
349+
listIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
350+
listIntent.putExtra(BundleKeys.KEY_INTERNAL_USER_ID, targetUser.id)
351+
352+
val chatIntent = Intent(context, ChatActivity::class.java)
353+
chatIntent.putExtra(KEY_ROOM_TOKEN, deepLinkResult.roomToken)
354+
chatIntent.putExtra(BundleKeys.KEY_INTERNAL_USER_ID, targetUser.id)
355+
356+
startActivities(arrayOf(listIntent, chatIntent))
357+
} else {
358+
Toast.makeText(
359+
context,
360+
context.resources.getString(R.string.nc_common_error_sorry),
361+
Toast.LENGTH_SHORT
362+
).show()
363+
}
364+
},
365+
{ e ->
366+
Log.e(TAG, "Error loading users for deep link", e)
367+
if (isFinishing || isDestroyed) return@subscribe
368+
Toast.makeText(
369+
context,
370+
context.resources.getString(R.string.nc_common_error_sorry),
371+
Toast.LENGTH_SHORT
372+
).show()
373+
}
374+
)
375+
disposables.add(disposable)
376+
377+
return true
378+
}
379+
380+
/**
381+
* Resolves which user account to use for a deep link.
382+
*
383+
* Priority:
384+
* 1. User matching both username and server URL
385+
* 2. User matching the server URL only
386+
* 3. Current active user as fallback (if server matches)
387+
*/
388+
private fun resolveTargetUser(users: List<User>, deepLinkResult: DeepLinkHandler.DeepLinkResult): User? {
389+
val deepLinkHost = Uri.parse(deepLinkResult.serverUrl).host?.lowercase()
390+
if (deepLinkHost.isNullOrBlank()) {
391+
return currentUserProviderOld.currentUser.blockingGet()
392+
}
393+
394+
// Priority: exact match (username + server) > server match > current user fallback
395+
val username = deepLinkResult.username
396+
val exactMatch = if (username != null) {
397+
users.find { user ->
398+
val userHost = user.baseUrl?.let { Uri.parse(it).host?.lowercase() }
399+
userHost == deepLinkHost && user.username?.lowercase() == username.lowercase()
400+
}
401+
} else {
402+
null
403+
}
404+
405+
val serverMatch = users.find { user ->
406+
val userHost = user.baseUrl?.let { Uri.parse(it).host?.lowercase() }
407+
userHost == deepLinkHost
408+
}
409+
410+
val currentUser = currentUserProviderOld.currentUser.blockingGet()
411+
val currentUserMatch = currentUser?.takeIf {
412+
it.baseUrl?.let { url -> Uri.parse(url).host?.lowercase() } == deepLinkHost
413+
}
414+
415+
return exactMatch ?: serverMatch ?: currentUserMatch
416+
}
417+
291418
companion object {
292419
private val TAG = MainActivity::class.java.simpleName
293420
}

app/src/main/java/com/nextcloud/talk/conversationinfo/ConversationInfoActivity.kt

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,11 +64,12 @@ import com.nextcloud.talk.jobs.DeleteConversationWorker
6464
import com.nextcloud.talk.jobs.LeaveConversationWorker
6565
import com.nextcloud.talk.models.json.autocomplete.AutocompleteUser
6666
import com.nextcloud.talk.models.json.conversations.ConversationEnums
67+
import com.nextcloud.talk.models.json.converters.EnumActorTypeConverter
68+
import com.nextcloud.talk.models.json.generic.GenericOverall
6769
import com.nextcloud.talk.models.json.participants.Participant
6870
import com.nextcloud.talk.models.json.participants.Participant.ActorType.CIRCLES
6971
import com.nextcloud.talk.models.json.participants.Participant.ActorType.GROUPS
7072
import com.nextcloud.talk.models.json.participants.Participant.ActorType.USERS
71-
import com.nextcloud.talk.models.json.generic.GenericOverall
7273
import com.nextcloud.talk.models.json.upcomingEvents.UpcomingEvent
7374
import com.nextcloud.talk.shareditems.activities.SharedItemsActivity
7475
import com.nextcloud.talk.threadsoverview.ThreadsOverviewActivity
@@ -79,9 +80,9 @@ import com.nextcloud.talk.utils.ConversationUtils
7980
import com.nextcloud.talk.utils.DateConstants
8081
import com.nextcloud.talk.utils.DateUtils
8182
import com.nextcloud.talk.utils.ShareUtils
83+
import com.nextcloud.talk.utils.ShortcutManagerHelper
8284
import com.nextcloud.talk.utils.bundle.BundleKeys
8385
import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_ROOM_TOKEN
84-
import com.nextcloud.talk.models.json.converters.EnumActorTypeConverter
8586
import io.reactivex.Observer
8687
import io.reactivex.android.schedulers.AndroidSchedulers
8788
import io.reactivex.disposables.Disposable
@@ -524,6 +525,15 @@ class ConversationInfoActivity : BaseActivity() {
524525
if (workInfo != null) {
525526
when (workInfo.state) {
526527
WorkInfo.State.SUCCEEDED -> {
528+
conversationUser?.id?.let { userId ->
529+
ShortcutManagerHelper.disableConversationShortcut(
530+
context,
531+
conversationToken,
532+
userId,
533+
context.resources.getString(R.string.nc_shortcut_conversation_deleted)
534+
)
535+
}
536+
527537
startActivity(
528538
Intent(context, MainActivity::class.java).apply {
529539
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
@@ -578,6 +588,16 @@ class ConversationInfoActivity : BaseActivity() {
578588
WorkManager.getInstance(context).enqueue(
579589
OneTimeWorkRequest.Builder(DeleteConversationWorker::class.java).setInputData(it).build()
580590
)
591+
592+
conversationUser?.id?.let { userId ->
593+
ShortcutManagerHelper.disableConversationShortcut(
594+
context,
595+
conversationToken,
596+
userId,
597+
context.resources.getString(R.string.nc_shortcut_conversation_deleted)
598+
)
599+
}
600+
581601
startActivity(
582602
Intent(context, MainActivity::class.java).apply { addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) }
583603
)

0 commit comments

Comments
 (0)