From 45b1a9c6aa24def1ee2e6ae3e2aeb01ff2626772 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Thu, 22 Oct 2026 16:41:58 +0200 Subject: [PATCH] fix(launch): keep the open screens when the app is started from the launcher MainActivity was singleTask, so every launcher tap cleared the activities above it and routed to the conversation list. Use the standard launch mode again: a launcher tap on a running task now only brings it to front. To keep what singleTask guaranteed: - A MainActivity started on top of an existing task by a launcher intent finishes immediately, so the previous screen stays visible. - A MainActivity started in the task of another app without FLAG_ACTIVITY_NEW_TASK starts again in the app's own task, so there is still only one main task. - A task created by a VIEW intent (deep link, shortcut, contacts) is replaced by one with the launcher intent as root. Android only brings a task to front for an intent equal to its root intent, so the same link or shortcut used again would otherwise be dropped. Assisted-by: Claude Code:claude-opus-5-5 Signed-off-by: Marcel Hibbe --- app/src/main/AndroidManifest.xml | 2 +- .../nextcloud/talk/activities/MainActivity.kt | 43 ++++- .../talk/activities/MainActivityLaunch.kt | 50 ++++++ .../talk/activities/MainActivityLaunchTest.kt | 148 ++++++++++++++++++ 4 files changed, 241 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/com/nextcloud/talk/activities/MainActivityLaunch.kt create mode 100644 app/src/test/java/com/nextcloud/talk/activities/MainActivityLaunchTest.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 4c27ccc2c81..65cf60c075b 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -106,10 +106,10 @@ android:name="com.google.android.gms.car.application" android:resource="@xml/automotive_app_desc"/> + 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 e82a40d3b91..5e66bc194c5 100644 --- a/app/src/main/java/com/nextcloud/talk/activities/MainActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/activities/MainActivity.kt @@ -18,6 +18,7 @@ import android.text.TextUtils import android.util.Log import android.widget.Toast import androidx.activity.OnBackPressedCallback +import androidx.core.content.IntentCompat import androidx.core.net.toUri import androidx.lifecycle.lifecycleScope import autodagger.AutoInjector @@ -79,6 +80,10 @@ class MainActivity : super.onCreate(savedInstanceState) + if (finishWithoutRouting()) { + return + } + if (BuildConfig.DEBUG) { Toast.makeText(this, "Build flavor: " + BuildConfig.FLAVOR, Toast.LENGTH_LONG).show() } @@ -92,7 +97,7 @@ class MainActivity : setSupportActionBar(binding.toolbar) - handleIntent(intent) + handleIntent(IntentCompat.getParcelableExtra(intent, KEY_ROUTED_INTENT, Intent::class.java) ?: intent) onBackPressedDispatcher.addCallback(this, onBackPressedCallback) } @@ -102,6 +107,39 @@ class MainActivity : disposables.dispose() } + /** + * Finishes this activity and returns true if the intent is not routed here, see [MainActivityLaunch.Decision]. + */ + private fun finishWithoutRouting(): Boolean { + val decision = MainActivityLaunch.decide( + action = intent.action, + categories = intent.categories, + flags = intent.flags, + isTaskRoot = isTaskRoot, + isStartedByOtherApp = referrer?.host.let { it != null && it != packageName } + ) + when (decision) { + MainActivityLaunch.Decision.ROUTE -> return false + MainActivityLaunch.Decision.SHOW_EXISTING_TASK -> finish() + MainActivityLaunch.Decision.MOVE_TO_OWN_TASK -> { + startActivity(Intent(intent).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) + finish() + } + MainActivityLaunch.Decision.REPLACE_TASK_ROOT -> { + // a task keeps the intent it was created with, so the launcher intent needs a new task + startActivity( + Intent(this, MainActivity::class.java) + .setAction(Intent.ACTION_MAIN) + .addCategory(Intent.CATEGORY_LAUNCHER) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_MULTIPLE_TASK) + .putExtra(KEY_ROUTED_INTENT, intent) + ) + finishAndRemoveTask() + } + } + return true + } + private fun launchServerSelection() { if (isBrandingUrlSet()) { val intent = Intent(context, BrowserLoginActivity::class.java) @@ -416,5 +454,8 @@ class MainActivity : companion object { private val TAG = MainActivity::class.java.simpleName + + // The intent to route instead of the launcher intent that replaced it as task root + private const val KEY_ROUTED_INTENT = "KEY_ROUTED_INTENT" } } diff --git a/app/src/main/java/com/nextcloud/talk/activities/MainActivityLaunch.kt b/app/src/main/java/com/nextcloud/talk/activities/MainActivityLaunch.kt new file mode 100644 index 00000000000..45e4061ca16 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/activities/MainActivityLaunch.kt @@ -0,0 +1,50 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.activities + +import android.content.Intent + +/** + * Decides how a newly created [MainActivity] handles its intent. MainActivity uses the standard launch mode, so a + * launcher tap keeps the activities of a running task and an intent of another app can start it in that app's task. + */ +object MainActivityLaunch { + + enum class Decision { + /** Route the intent to the conversation list, a chat or the login. */ + ROUTE, + + /** Launcher tap on a running task: finish, so the task shows the screen that was open. */ + SHOW_EXISTING_TASK, + + /** Started in the task of another app: start again in the app's own task. */ + MOVE_TO_OWN_TASK, + + /** + * Started as task root by a VIEW intent: replace the task by one with the launcher intent as root. Android only + * brings a task to front for an intent equal to its root intent, so the same VIEW intent would be dropped. + */ + REPLACE_TASK_ROOT + } + + fun decide( + action: String?, + categories: Set?, + flags: Int, + isTaskRoot: Boolean, + isStartedByOtherApp: Boolean + ): Decision = + when { + isStartedByOtherApp && flags and Intent.FLAG_ACTIVITY_NEW_TASK == 0 -> Decision.MOVE_TO_OWN_TASK + isLauncherIntent(action, categories) && !isTaskRoot -> Decision.SHOW_EXISTING_TASK + action == Intent.ACTION_VIEW && isTaskRoot -> Decision.REPLACE_TASK_ROOT + else -> Decision.ROUTE + } + + private fun isLauncherIntent(action: String?, categories: Set?): Boolean = + action == Intent.ACTION_MAIN && categories?.contains(Intent.CATEGORY_LAUNCHER) == true +} diff --git a/app/src/test/java/com/nextcloud/talk/activities/MainActivityLaunchTest.kt b/app/src/test/java/com/nextcloud/talk/activities/MainActivityLaunchTest.kt new file mode 100644 index 00000000000..4e11aba74a9 --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/activities/MainActivityLaunchTest.kt @@ -0,0 +1,148 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.activities + +import android.content.Intent +import com.nextcloud.talk.activities.MainActivityLaunch.Decision +import org.junit.Assert.assertEquals +import org.junit.Test + +class MainActivityLaunchTest { + + private val launcherCategories = setOf(Intent.CATEGORY_LAUNCHER) + private val launcherFlags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED + + @Test + fun launcherTapOnRunningTaskShowsExistingTask() { + val decision = MainActivityLaunch.decide( + action = Intent.ACTION_MAIN, + categories = launcherCategories, + flags = launcherFlags, + isTaskRoot = false, + isStartedByOtherApp = true + ) + + assertEquals(Decision.SHOW_EXISTING_TASK, decision) + } + + @Test + fun launcherTapOnColdStartRoutes() { + val decision = MainActivityLaunch.decide( + action = Intent.ACTION_MAIN, + categories = launcherCategories, + flags = launcherFlags, + isTaskRoot = true, + isStartedByOtherApp = true + ) + + assertEquals(Decision.ROUTE, decision) + } + + @Test + fun launcherIntentWithClearTaskFromInsideTheAppRoutes() { + val decision = MainActivityLaunch.decide( + action = Intent.ACTION_MAIN, + categories = launcherCategories, + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK, + isTaskRoot = true, + isStartedByOtherApp = false + ) + + assertEquals(Decision.ROUTE, decision) + } + + @Test + fun deepLinkOfOtherAppWithoutNewTaskMovesToOwnTask() { + val decision = MainActivityLaunch.decide( + action = Intent.ACTION_VIEW, + categories = setOf(Intent.CATEGORY_BROWSABLE), + flags = 0, + isTaskRoot = false, + isStartedByOtherApp = true + ) + + assertEquals(Decision.MOVE_TO_OWN_TASK, decision) + } + + @Test + fun launcherIntentOfOtherAppWithoutNewTaskMovesToOwnTask() { + val decision = MainActivityLaunch.decide( + action = Intent.ACTION_MAIN, + categories = launcherCategories, + flags = 0, + isTaskRoot = false, + isStartedByOtherApp = true + ) + + assertEquals(Decision.MOVE_TO_OWN_TASK, decision) + } + + @Test + fun deepLinkOfOtherAppWithNewTaskRoutes() { + val decision = MainActivityLaunch.decide( + action = Intent.ACTION_VIEW, + categories = setOf(Intent.CATEGORY_BROWSABLE), + flags = Intent.FLAG_ACTIVITY_NEW_TASK, + isTaskRoot = false, + isStartedByOtherApp = true + ) + + assertEquals(Decision.ROUTE, decision) + } + + @Test + fun deepLinkStartingNewTaskReplacesTaskRoot() { + val decision = MainActivityLaunch.decide( + action = Intent.ACTION_VIEW, + categories = setOf(Intent.CATEGORY_BROWSABLE), + flags = Intent.FLAG_ACTIVITY_NEW_TASK, + isTaskRoot = true, + isStartedByOtherApp = true + ) + + assertEquals(Decision.REPLACE_TASK_ROOT, decision) + } + + @Test + fun shortcutStartingNewTaskReplacesTaskRoot() { + val decision = MainActivityLaunch.decide( + action = Intent.ACTION_VIEW, + categories = null, + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK, + isTaskRoot = true, + isStartedByOtherApp = true + ) + + assertEquals(Decision.REPLACE_TASK_ROOT, decision) + } + + @Test + fun notificationIntentRoutes() { + val decision = MainActivityLaunch.decide( + action = null, + categories = null, + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP, + isTaskRoot = true, + isStartedByOtherApp = true + ) + + assertEquals(Decision.ROUTE, decision) + } + + @Test + fun startFromInsideTheAppWithoutFlagsRoutes() { + val decision = MainActivityLaunch.decide( + action = null, + categories = null, + flags = 0, + isTaskRoot = false, + isStartedByOtherApp = false + ) + + assertEquals(Decision.ROUTE, decision) + } +}