Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -106,10 +106,10 @@
android:name="com.google.android.gms.car.application"
android:resource="@xml/automotive_app_desc"/>

<!-- No singleTask: it would clear the open screens on every launcher tap. See MainActivityLaunch. -->
<activity
android:name=".activities.MainActivity"
android:exported="true"
android:launchMode="singleTask"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
Expand Down
43 changes: 42 additions & 1 deletion app/src/main/java/com/nextcloud/talk/activities/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
}
Expand All @@ -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)
}
Expand All @@ -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)
Expand Down Expand Up @@ -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"
}
}
Original file line number Diff line number Diff line change
@@ -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<String>?,
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<String>?): Boolean =
action == Intent.ACTION_MAIN && categories?.contains(Intent.CATEGORY_LAUNCHER) == true
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading