diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 4c27ccc2c8..65cf60c075 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 e82a40d3b9..5e66bc194c 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 0000000000..45e4061ca1 --- /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 0000000000..4e11aba74a --- /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) + } +}