diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f51f3245ccb..d77c2beacf1 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -199,7 +199,7 @@ dependencies { } implementation("androidx.media3:media3-session:1.11.1") kapt("org.jetbrains.kotlin:kotlin-metadata-jvm:$kotlinVersion") - implementation("androidx.room:room-testing-android:$roomVersion") + androidTestImplementation("androidx.room:room-testing-android:$roomVersion") implementation("androidx.compose.foundation:foundation-layout:1.12.1") spotbugsPlugins("com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0") spotbugsPlugins("com.mebigfatguy.fb-contrib:fb-contrib:7.7.4") @@ -209,7 +209,7 @@ dependencies { implementation("androidx.preference:preference-ktx:1.2.1") implementation("androidx.datastore:datastore-core:1.3.0") implementation("androidx.datastore:datastore-preferences:1.3.0") - implementation("androidx.test.ext:junit-ktx:1.3.0") + androidTestImplementation("androidx.test.ext:junit-ktx:1.3.0") implementation(fileTree(mapOf("include" to listOf("*"), "dir" to "libs"))) @@ -329,6 +329,7 @@ dependencies { } implementation("androidx.core:core-ktx:1.19.1") + implementation("androidx.core:core-splashscreen:1.2.0") implementation("androidx.sharetarget:sharetarget:1.2.0") implementation("androidx.activity:activity-ktx:1.13.0") implementation("com.github.nextcloud.android-common:ui:0.34.0") diff --git a/app/src/androidTest/java/com/nextcloud/talk/activities/MainActivityTest.kt b/app/src/androidTest/java/com/nextcloud/talk/account/ServerSelectionActivityTest.kt similarity index 87% rename from app/src/androidTest/java/com/nextcloud/talk/activities/MainActivityTest.kt rename to app/src/androidTest/java/com/nextcloud/talk/account/ServerSelectionActivityTest.kt index 3e4a9c9b4ef..e5b65a91968 100644 --- a/app/src/androidTest/java/com/nextcloud/talk/activities/MainActivityTest.kt +++ b/app/src/androidTest/java/com/nextcloud/talk/account/ServerSelectionActivityTest.kt @@ -6,7 +6,7 @@ * SPDX-FileCopyrightText: 2021 Nextcloud GmbH * SPDX-License-Identifier: GPL-3.0-or-later */ -package com.nextcloud.talk.activities +package com.nextcloud.talk.account import androidx.test.espresso.intent.rule.IntentsTestRule import com.nextcloud.talk.users.UserManager @@ -15,10 +15,10 @@ import org.junit.Assert.assertNotNull import org.junit.Rule import org.junit.Test -class MainActivityTest { +class ServerSelectionActivityTest { @get:Rule - val activityRule: IntentsTestRule = IntentsTestRule( - MainActivity::class.java, + val activityRule: IntentsTestRule = IntentsTestRule( + ServerSelectionActivity::class.java, true, false ) diff --git a/app/src/androidTest/java/com/nextcloud/talk/ui/LoginIT.java b/app/src/androidTest/java/com/nextcloud/talk/ui/LoginIT.java index 4503a65f6eb..e2c5fa415a1 100644 --- a/app/src/androidTest/java/com/nextcloud/talk/ui/LoginIT.java +++ b/app/src/androidTest/java/com/nextcloud/talk/ui/LoginIT.java @@ -11,7 +11,8 @@ import android.os.Bundle; import com.nextcloud.talk.R; -import com.nextcloud.talk.activities.MainActivity; +import com.nextcloud.talk.conversationlist.ConversationsListActivity; +import com.nextcloud.talk.utils.AppTasks; import org.junit.Ignore; import org.junit.Test; @@ -19,6 +20,7 @@ import java.util.Objects; import androidx.test.core.app.ActivityScenario; +import androidx.test.core.app.ApplicationProvider; import androidx.test.espresso.NoMatchingViewException; import androidx.test.espresso.web.webdriver.DriverAtoms; import androidx.test.espresso.web.webdriver.Locator; @@ -44,7 +46,9 @@ public class LoginIT { @Test public void login() throws InterruptedException { - ActivityScenario activityScenario = ActivityScenario.launch(MainActivity.class); + // started like by the launcher, without an account + ActivityScenario activityScenario = ActivityScenario.launch( + AppTasks.INSTANCE.createLauncherIntent(ApplicationProvider.getApplicationContext())); Bundle arguments = androidx.test.platform.app.InstrumentationRegistry.getArguments(); diff --git a/app/src/gplay/AndroidManifest.xml b/app/src/gplay/AndroidManifest.xml index ee962f64dbe..615ba05294d 100644 --- a/app/src/gplay/AndroidManifest.xml +++ b/app/src/gplay/AndroidManifest.xml @@ -17,7 +17,7 @@ android:label="@string/nc_app_name" android:networkSecurityConfig="@xml/network_security_config" android:supportsRtl="true" - android:theme="@style/AppTheme.Launcher" + android:theme="@style/AppTheme" tools:replace="label, icon, theme, name, allowBackup" tools:ignore="UnusedAttribute, ExportedService"> diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 8884e456bb1..360160604f9 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -86,6 +86,7 @@ @@ -106,16 +107,14 @@ android:name="com.google.android.gms.car.application" android:resource="@xml/automotive_app_desc"/> + - - - - - + android:noHistory="true" + android:theme="@android:style/Theme.NoDisplay"> @@ -125,10 +124,6 @@ - - @@ -167,16 +162,27 @@ - - + android:theme="@style/Theme.App.Starting"> + + + + + + @@ -191,6 +197,29 @@ + + + + + + + + + + + + + diff --git a/app/src/main/java/com/nextcloud/talk/account/AccountVerificationActivity.kt b/app/src/main/java/com/nextcloud/talk/account/AccountVerificationActivity.kt index b2cfa342f98..9d2f3b6dfb3 100644 --- a/app/src/main/java/com/nextcloud/talk/account/AccountVerificationActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/account/AccountVerificationActivity.kt @@ -437,7 +437,7 @@ class AccountVerificationActivity : BaseActivity() { ApplicationWideMessageHolder.getInstance().messageType = ApplicationWideMessageHolder.MessageType.ACCOUNT_WAS_IMPORTED } - val intent = Intent(context, ConversationsListActivity::class.java) + val intent = ConversationsListActivity.createIntent(context) intent.putExtra(BundleKeys.KEY_INTERNAL_USER_ID, userToSetAsActive.id) intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) startActivity(intent) diff --git a/app/src/main/java/com/nextcloud/talk/account/BrowserLoginActivity.kt b/app/src/main/java/com/nextcloud/talk/account/BrowserLoginActivity.kt index fabf8a4a673..08b5b4b5fb8 100644 --- a/app/src/main/java/com/nextcloud/talk/account/BrowserLoginActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/account/BrowserLoginActivity.kt @@ -27,10 +27,10 @@ import com.nextcloud.talk.R import com.nextcloud.talk.account.data.LoginRepository import com.nextcloud.talk.account.viewmodels.BrowserLoginActivityViewModel import com.nextcloud.talk.activities.BaseActivity -import com.nextcloud.talk.activities.MainActivity import com.nextcloud.talk.application.NextcloudTalkApplication import com.nextcloud.talk.application.NextcloudTalkApplication.Companion.sharedApplication import com.nextcloud.talk.databinding.ActivityWebViewLoginBinding +import com.nextcloud.talk.utils.AppTasks import com.nextcloud.talk.utils.bundle.BundleKeys import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_BASE_URL import kotlinx.coroutines.launch @@ -49,9 +49,7 @@ class BrowserLoginActivity : BaseActivity() { private var reauthorizeAccount = false private val onBackPressedCallback = object : OnBackPressedCallback(true) { override fun handleOnBackPressed() { - val intent = Intent(context, MainActivity::class.java) - intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) - startActivity(intent) + startActivity(AppTasks.createRestartIntent(context)) } } @@ -187,9 +185,7 @@ class BrowserLoginActivity : BaseActivity() { } private fun restartApp() { - val intent = Intent(context, MainActivity::class.java) - intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) - startActivity(intent) + startActivity(AppTasks.createRestartIntent(context)) } public override fun onDestroy() { diff --git a/app/src/main/java/com/nextcloud/talk/account/ServerSelectionActivity.kt b/app/src/main/java/com/nextcloud/talk/account/ServerSelectionActivity.kt index bf1b5825ad2..3fd41830a11 100644 --- a/app/src/main/java/com/nextcloud/talk/account/ServerSelectionActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/account/ServerSelectionActivity.kt @@ -11,6 +11,7 @@ package com.nextcloud.talk.account import android.Manifest import android.accounts.Account import android.annotation.SuppressLint +import android.content.Context import android.content.Intent import android.content.pm.ActivityInfo import android.os.Bundle @@ -520,5 +521,18 @@ class ServerSelectionActivity : BaseActivity() { private val TAG = ServerSelectionActivity::class.java.simpleName const val MIN_SERVER_MAJOR_VERSION = 13 private const val QR_URI = "com.blikoon.qrcodescanner.got_qr_scan_relult" + + /** + * Creates the intent that starts the login: the web login on the server of a branded client, or else the + * selection of the server. + */ + fun createLoginIntent(context: Context): Intent { + val brandedServerUrl = context.getString(R.string.weblogin_url) + return if (brandedServerUrl.isNotEmpty()) { + Intent(context, BrowserLoginActivity::class.java).putExtra(BundleKeys.KEY_BASE_URL, brandedServerUrl) + } else { + Intent(context, ServerSelectionActivity::class.java) + } + } } } diff --git a/app/src/main/java/com/nextcloud/talk/account/SwitchAccountActivity.kt b/app/src/main/java/com/nextcloud/talk/account/SwitchAccountActivity.kt index b7242e848e4..3980a570d2d 100644 --- a/app/src/main/java/com/nextcloud/talk/account/SwitchAccountActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/account/SwitchAccountActivity.kt @@ -88,8 +88,8 @@ class SwitchAccountActivity : BaseActivity() { if (userManager.setUserAsActive(user)) { DirectShareHelper.removeAllShareTargetShortcuts(this@SwitchAccountActivity) cookieManager.cookieStore.removeAll() - val intent = ConversationsListActivity.createAccountSwitchIntent(this@SwitchAccountActivity, user.id!!) - startActivity(intent) + val context = this@SwitchAccountActivity + startActivity(ConversationsListActivity.createInPlaceAccountSwitchIntent(context, user.id!!)) finish() } } diff --git a/app/src/main/java/com/nextcloud/talk/activities/ActionBarProvider.java b/app/src/main/java/com/nextcloud/talk/activities/ActionBarProvider.java deleted file mode 100644 index 9713f266c97..00000000000 --- a/app/src/main/java/com/nextcloud/talk/activities/ActionBarProvider.java +++ /dev/null @@ -1,13 +0,0 @@ -/* - * Nextcloud Talk - Android Client - * - * SPDX-FileCopyrightText: 2016 BlueLine Labs, Inc. - * SPDX-License-Identifier: Apache-2.0 - */ -package com.nextcloud.talk.activities; - -import androidx.appcompat.app.ActionBar; - -public interface ActionBarProvider { - ActionBar getSupportActionBar(); -} diff --git a/app/src/main/java/com/nextcloud/talk/activities/BaseActivity.kt b/app/src/main/java/com/nextcloud/talk/activities/BaseActivity.kt index 16c09454d5e..8ea18495cb7 100644 --- a/app/src/main/java/com/nextcloud/talk/activities/BaseActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/activities/BaseActivity.kt @@ -28,6 +28,9 @@ import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity import androidx.core.content.res.ResourcesCompat import androidx.core.view.ViewCompat +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import autodagger.AutoInjector import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.nextcloud.talk.BuildConfig @@ -41,28 +44,32 @@ import com.nextcloud.talk.chat.ChatActivity import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.events.CertificateEvent import com.nextcloud.talk.lock.LockedActivity -import com.nextcloud.talk.users.DefaultAccountProvider -import com.nextcloud.talk.users.UserManager -import com.nextcloud.talk.utils.SecurityUtils +import com.nextcloud.talk.logger.Logger import com.nextcloud.talk.ui.theme.ViewThemeUtils import com.nextcloud.talk.ui.theme.ViewThemeUtilsFactory +import com.nextcloud.talk.users.DefaultAccountProvider +import com.nextcloud.talk.users.UserManager +import com.nextcloud.talk.utils.AppTasks import com.nextcloud.talk.utils.DisplayUtils import com.nextcloud.talk.utils.FileViewerUtils +import com.nextcloud.talk.utils.SecurityUtils import com.nextcloud.talk.utils.UriUtils import com.nextcloud.talk.utils.adjustUIForAPILevel35 import com.nextcloud.talk.utils.bundle.BundleKeys import com.nextcloud.talk.utils.message.MessageUtils import com.nextcloud.talk.utils.preferences.AppPreferences -import com.nextcloud.talk.logger.Logger import com.nextcloud.talk.utils.ssl.TrustManager -import kotlinx.coroutines.runBlocking -import org.greenrobot.eventbus.EventBus -import org.greenrobot.eventbus.Subscribe -import org.greenrobot.eventbus.ThreadMode import java.security.cert.CertificateParsingException import java.security.cert.X509Certificate import java.text.DateFormat import javax.inject.Inject +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import org.greenrobot.eventbus.EventBus +import org.greenrobot.eventbus.Subscribe +import org.greenrobot.eventbus.ThreadMode @AutoInjector(NextcloudTalkApplication::class) open class BaseActivity : AppCompatActivity() { @@ -142,13 +149,14 @@ open class BaseActivity : AppCompatActivity() { private val boundUser: User? by boundUserLazy /** - * Returns [boundUser], or finishes the activity and returns null if the account doesn't exist. + * Returns [boundUser], or finishes the activity and returns null if the account doesn't exist. Without an account + * at all, an activity with [allowsDefaultAccount] opens the login. */ private fun requireBoundUserOrFinish(): User? = boundUser ?: run { Log.e(TAG, "No user found for id ${resolveUserIdFromIntent()}") if (allowsDefaultAccount) { - startActivity(Intent(this, MainActivity::class.java)) + startActivity(ServerSelectionActivity.createLoginIntent(this)) } finish() null @@ -180,6 +188,13 @@ open class BaseActivity : AppCompatActivity() { open val view: View? get() = null + /** + * Whether this activity started the lock screen, which covers it until it is resumed again after unlocking. A + * screen it would open meanwhile, e.g. the chat of a deep link, would show above the lock screen. + */ + var isCoveredByLockScreen = false + private set + fun lockScreenIfConditionsApply() { val keyguardManager = getSystemService(KEYGUARD_SERVICE) as KeyguardManager val isKeyguardSecure = keyguardManager.isKeyguardSecure @@ -198,13 +213,23 @@ open class BaseActivity : AppCompatActivity() { "elapsedSeconds=$elapsedSeconds" ) if (timeoutSeconds == 0 || elapsedSeconds >= timeoutSeconds) { - startActivity(Intent(this, LockedActivity::class.java)) + NextcloudTalkApplication.isLocked = true + showLockScreen() } } catch (e: Exception) { Log.e(TAG, "Error checking screen lock conditions", e) } } + private fun showLockScreen() { + isCoveredByLockScreen = true + startActivity(Intent(this, LockedActivity::class.java)) + } + + /** Whether the app lock is enabled and can be used, as the device has a secure lock screen. */ + private fun isAppLockEnabled(): Boolean = + (getSystemService(KEYGUARD_SERVICE) as KeyguardManager).isKeyguardSecure && appPreferences.isScreenLocked + override fun onCreate(savedInstanceState: Bundle?) { NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) logger.i(this::class.java.simpleName, "onCreate") @@ -217,6 +242,10 @@ open class BaseActivity : AppCompatActivity() { super.onCreate(savedInstanceState) cleanTempCertPreference() + // e.g. a notification creates only its screen, not the conversation list below it, which would remove them + if (savedInstanceState == null && intent.getBooleanExtra(BundleKeys.KEY_TOP_OF_NEW_BACK_STACK, false)) { + AppTasks.removeTasksLeftInRecents(this) + } } public override fun onStart() { @@ -224,8 +253,6 @@ open class BaseActivity : AppCompatActivity() { eventBus.register(this) } - protected open val skipLockCheckOnResume: Boolean = false - public override fun onResume() { logger.d(this::class.java.simpleName, "onResume") super.onResume() @@ -235,7 +262,17 @@ open class BaseActivity : AppCompatActivity() { "pendingLockCheck=${NextcloudTalkApplication.pendingLockCheck} " + "activity=${this.javaClass.simpleName}" ) - if (!skipLockCheckOnResume && NextcloudTalkApplication.pendingLockCheck) { + isCoveredByLockScreen = false + // e.g. the app lock was turned off with the screen lock of the device + if (NextcloudTalkApplication.isLocked && !isAppLockEnabled()) { + NextcloudTalkApplication.isLocked = false + } + if (NextcloudTalkApplication.isLocked) { + // Still locked, e.g. as the lock screen was closed by a screen opened from outside the app (a deep link, a + // shortcut or a notification). It is unlocked only by authenticating. + NextcloudTalkApplication.pendingLockCheck = false + showLockScreen() + } else if (NextcloudTalkApplication.pendingLockCheck) { NextcloudTalkApplication.pendingLockCheck = false lockScreenIfConditionsApply() } @@ -404,6 +441,12 @@ open class BaseActivity : AppCompatActivity() { override fun startActivity(intent: Intent) { // Links to the own server are opened for the account of this screen. Screens without an account (boundUser // never loaded) use the default account, without resolving one from their intent. + // Only implicit intents (opened links) are rewritten: an explicit intent, like the forward of another app's + // link to DeepLinkActivity, starts the screen it names, so it can't open a chat without asking first. + if (intent.component != null) { + super.startActivity(intent) + return + } val user = (if (boundUserLazy.isInitialized()) boundUser else null) ?: defaultAccountProvider.getDefaultUserBlocking() if (intent.data != null && TextUtils.equals(intent.action, Intent.ACTION_VIEW)) { @@ -445,3 +488,16 @@ open class BaseActivity : AppCompatActivity() { private val TAG = BaseActivity::class.java.simpleName } } + +/** + * Collects [flow] for the screens its values open, while this activity is resumed and not covered by the lock screen it + * started, so they don't show above the lock screen. After unlocking, the activity is resumed again and [flow] + * collected anew, so e.g. a [StateFlow] gives the value that waited for it then. + */ +fun BaseActivity.collectWhileUnlocked(flow: Flow, action: suspend (T) -> Unit) { + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.RESUMED) { + flow.collect { if (!isCoveredByLockScreen) action(it) } + } + } +} diff --git a/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt b/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt index 2ad7065a292..21cac2f2242 100644 --- a/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt @@ -124,6 +124,8 @@ import com.nextcloud.talk.signaling.SignalingMessageSender import com.nextcloud.talk.ui.dialog.AudioOutputDialog import com.nextcloud.talk.ui.dialog.MoreCallActionsDialog import com.nextcloud.talk.utils.ApiUtils +import com.nextcloud.talk.utils.AppTasks +import com.nextcloud.talk.utils.taskIdCompat import com.nextcloud.talk.utils.CapabilitiesUtil import com.nextcloud.talk.utils.CapabilitiesUtil.hasSpreedFeatureCapability import com.nextcloud.talk.utils.CapabilitiesUtil.isCallRecordingAvailable @@ -151,10 +153,15 @@ import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_ROOM_ONE_TO_ONE import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_ROOM_TOKEN import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_START_CALL_AFTER_ROOM_SWITCH import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_SWITCH_TO_ROOM +import com.nextcloud.talk.utils.finishAndRemoveTaskIfPresent +import com.nextcloud.talk.utils.isShareTask +import com.nextcloud.talk.utils.moveToFrontIfPresent import com.nextcloud.talk.utils.permissions.PlatformPermissionUtil import com.nextcloud.talk.utils.power.PowerManagerUtils import com.nextcloud.talk.utils.registerPermissionHandlerBroadcastReceiver import com.nextcloud.talk.utils.singletons.ApplicationWideCurrentRoomHolder +import com.nextcloud.talk.utils.startActivityIfPresent +import com.nextcloud.talk.utils.taskInfoOrNull import com.nextcloud.talk.viewmodels.CallRecordingViewModel import com.nextcloud.talk.viewmodels.CallRecordingViewModel.RecordingConfirmStopState import com.nextcloud.talk.viewmodels.CallRecordingViewModel.RecordingErrorState @@ -2426,58 +2433,50 @@ class CallActivity : CallBaseActivity() { val activityManager = getSystemService(Context.ACTIVITY_SERVICE) as? ActivityManager val appTasks = activityManager ?.appTasks - ?.filter { appTask -> appTask.taskInfo?.baseActivity?.packageName == packageName } + ?.filter { appTask -> appTask.taskInfoOrNull()?.baseActivity?.packageName == packageName } .orEmpty() - val primaryTask = appTasks.firstOrNull { appTask -> - val taskInfo = appTask.taskInfo - val baseActivityName = taskInfo?.baseActivity?.className - taskInfo != null && - taskInfo.taskIdCompat != taskId && - baseActivityName == MainActivity::class.java.name - } ?: appTasks.firstOrNull { appTask -> - val taskInfo = appTask.taskInfo - val baseActivityName = taskInfo?.baseActivity?.className - taskInfo != null && - taskInfo.taskIdCompat != taskId && - baseActivityName != CallActivity::class.java.name && - baseActivityName != CallNotificationActivity::class.java.name - } - - if (primaryTask != null) { - val intent = Intent(context, ConversationsListActivity::class.java).apply { - putExtra(KEY_INTERNAL_USER_ID, conversationUser.id) - addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP) - } - primaryTask.startActivity(context, intent, null) - primaryTask.moveToFront() - val primaryTaskId = primaryTask.taskInfo?.taskIdCompat - appTasks - .filter { appTask -> - val taskInfo = appTask.taskInfo - val baseActivityName = taskInfo?.baseActivity?.className - val isCallTask = baseActivityName == CallActivity::class.java.name || - baseActivityName == CallNotificationActivity::class.java.name - taskInfo != null && - !isCallTask && - taskInfo.taskIdCompat != primaryTaskId && - taskInfo.taskIdCompat != taskId - } - .forEach { it.finishAndRemoveTask() } - } else { - val mainIntent = Intent(context, MainActivity::class.java).apply { - action = Intent.ACTION_MAIN - addCategory(Intent.CATEGORY_LAUNCHER) - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) + // the task of the app, also when it was started through the internal entry point, e.g. from a notification + val primaryTask = AppTasks.findRunningAppTask(this, appTasks) { it.taskIdCompat != taskId } + ?: appTasks.firstOrNull { appTask -> + val taskInfo = appTask.taskInfoOrNull() + taskInfo != null && taskInfo.taskIdCompat != taskId && !isCallOrShareTask(taskInfo) } - startActivity(mainIntent) + + val listIntent = ConversationsListActivity.createInPlaceAccountSwitchIntent(context, conversationUser.id!!) + val isShownInPrimaryTask = primaryTask != null && + primaryTask.startActivityIfPresent(context, listIntent) && + primaryTask.moveToFrontIfPresent() + if (isShownInPrimaryTask) { + removeOtherTasks(appTasks, primaryTask.taskInfoOrNull()?.taskIdCompat) + } else { + startActivity(AppTasks.createRestartIntent(context, conversationUser.id!!)) } } - // TaskInfo.taskId only exists since API 29; before that persistentId is the task id - @Suppress("DEPRECATION") - private val ActivityManager.RecentTaskInfo.taskIdCompat: Int - get() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) taskId else persistentId + /** Removes the [appTasks] other than the one of [primaryTaskId], this one and those of calls and shares. */ + private fun removeOtherTasks(appTasks: List, primaryTaskId: Int?) { + appTasks + .filter { appTask -> + val taskInfo = appTask.taskInfoOrNull() + taskInfo != null && + !isCallOrShareTask(taskInfo) && + taskInfo.taskIdCompat != primaryTaskId && + taskInfo.taskIdCompat != taskId + } + .forEach { it.finishAndRemoveTaskIfPresent() } + } + + /** + * Whether [taskInfo] is the task of a call, or of a share (see ShareActivity), whose list would lose the shared + * content if the conversation list of the call was opened in it, or the task was removed. + */ + private fun isCallOrShareTask(taskInfo: ActivityManager.RecentTaskInfo): Boolean { + val baseActivityName = taskInfo.baseActivity?.className + return baseActivityName == CallActivity::class.java.name || + baseActivityName == CallNotificationActivity::class.java.name || + taskInfo.isShareTask + } private fun startVideoCapture(isPortrait: Boolean) { val (width, height) = if (isPortrait) { @@ -3545,16 +3544,12 @@ class CallActivity : CallBaseActivity() { if (isPipModePossible) { val actions = ArrayList() val icon = Icon.createWithResource(this, iconId) - val intentFlag: Int = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - PendingIntent.FLAG_IMMUTABLE - } else { - 0 - } + // immutable also before Android 12, where it was mutable by default val intent = PendingIntent.getBroadcast( this, requestCode, Intent(MICROPHONE_PIP_INTENT_NAME).putExtra(MICROPHONE_PIP_INTENT_EXTRA_ACTION, requestCode), - intentFlag + PendingIntent.FLAG_IMMUTABLE ) actions.add(RemoteAction(icon, title!!, title, intent)) mPictureInPictureParamsBuilder.setActions(actions) diff --git a/app/src/main/java/com/nextcloud/talk/activities/DeepLinkActivity.kt b/app/src/main/java/com/nextcloud/talk/activities/DeepLinkActivity.kt new file mode 100644 index 00000000000..a07ce20bdcb --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/activities/DeepLinkActivity.kt @@ -0,0 +1,66 @@ +/* + * 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.app.Activity +import android.app.ActivityOptions +import android.content.Context +import android.content.Intent +import android.os.Build +import android.os.Bundle +import android.window.SplashScreen +import com.nextcloud.talk.activities.DeepLinkRoutingViewModel.Request +import com.nextcloud.talk.conversationlist.ConversationsListActivity + +/** + * Exported entry point for the intents of other apps that open a conversation: nextcloudtalk:// deep links and the + * chat action of a phone book contact. It shows nothing and passes only the uri and type of such an intent on to the + * conversation list, which routes it in the task of the app, wherever this activity was started. + */ +class DeepLinkActivity : Activity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val viewIntent = createViewIntent(this, intent) + // A link that opens no conversation does nothing: passed on, it would close the screens open in the app above + // the conversation list. Other intents open the app. + val isInvalidLink = intent.action == Intent.ACTION_VIEW && Request.of(viewIntent) == null + if (!isInvalidLink) startActivity(viewIntent, splashScreenWithIcon()) + // the theme shows nothing, which requires to finish before onResume + finish() + } + + /** + * The conversation list keeps the splash screen while it routes the link. Since Android 13, the system shows it + * without the icon of the app for a start that isn't one of the launcher, unless the icon is asked for. + */ + private fun splashScreenWithIcon(): Bundle? = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + ActivityOptions.makeBasic().setSplashScreenStyle(SplashScreen.SPLASH_SCREEN_STYLE_ICON).toBundle() + } else { + null + } + + companion object { + /** + * Creates an intent for the internal entry point of the conversation list that opens the nextcloudtalk:// deep + * link or the phone book contact of the VIEW [intent] of another app, in the task of the app, where the + * screens above the list are closed. Only its uri and type are taken, as its extras could come from any app, + * and a read grant of the uri is passed on. + */ + fun createViewIntent(context: Context, intent: Intent): Intent { + val viewIntent = ConversationsListActivity.createIntent(context).addFlags( + Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP + ) + val uri = intent.data?.takeIf { intent.action == Intent.ACTION_VIEW } ?: return viewIntent + return viewIntent + .setAction(Intent.ACTION_VIEW) + .setDataAndType(uri, intent.type) + .addFlags(intent.flags and Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + } +} diff --git a/app/src/main/java/com/nextcloud/talk/activities/DeepLinkRouting.kt b/app/src/main/java/com/nextcloud/talk/activities/DeepLinkRouting.kt new file mode 100644 index 00000000000..9f5f7fe21ef --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/activities/DeepLinkRouting.kt @@ -0,0 +1,202 @@ +/* + * 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 android.os.Bundle +import android.os.SystemClock +import android.widget.Toast +import androidx.annotation.StringRes +import androidx.appcompat.app.AlertDialog +import androidx.core.os.BundleCompat +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.nextcloud.talk.R +import com.nextcloud.talk.account.ServerSelectionActivity +import com.nextcloud.talk.activities.DeepLinkRoutingViewModel.Destination +import com.nextcloud.talk.activities.DeepLinkRoutingViewModel.Request +import com.nextcloud.talk.activities.DeepLinkRoutingViewModel.RoutingState +import com.nextcloud.talk.chat.ChatActivity +import com.nextcloud.talk.conversationlist.ConversationsListActivity +import com.nextcloud.talk.utils.AppTasks +import com.nextcloud.talk.utils.ShortcutManagerHelper + +/** + * Shows the routing of the deep links and contact chats that [DeepLinkActivity] passes on to the conversation list + * [activity], see [DeepLinkRoutingViewModel]: asks its questions as dialogs over the list and opens the chat on top. + * The list shows the account [currentUserId] gives, so a chat of another account opens with the list of that one. + */ +class DeepLinkRouting( + private val activity: BaseActivity, + private val viewModel: DeepLinkRoutingViewModel, + private val currentUserId: () -> Long? +) { + + private var confirmationDialog: AlertDialog? = null + + // the question the confirmation dialog asks, so a newer one replaces it + private var shownQuestion: RoutingState.Question? = null + + // from opening a screen above the list until the list is stopped below it + private var isOpeningScreen = false + + // the launch screen is kept at most MAX_LAUNCH_SCREEN_MS from here + private val createdAt = SystemClock.elapsedRealtime() + + /** + * Whether the launch screen of a deep link that starts the app is kept: while it is routed, see + * [DeepLinkRoutingViewModel.keepsLaunchScreen], and until the screen it opens covers the list. At most + * [MAX_LAUNCH_SCREEN_MS], e.g. while a slow server creates the conversation with a contact: the list shows then + * until the chat opens. + */ + val keepsLaunchScreen: Boolean + get() = (viewModel.keepsLaunchScreen || isOpeningScreen) && + SystemClock.elapsedRealtime() - createdAt < MAX_LAUNCH_SCREEN_MS + + init { + // the chat or question of a deep link that started the app follows after unlocking + activity.collectWhileUnlocked(viewModel.state, ::render) + activity.lifecycle.addObserver(object : DefaultLifecycleObserver { + override fun onStop(owner: LifecycleOwner) { + isOpeningScreen = false + } + + override fun onDestroy(owner: LifecycleOwner) { + // dismissing doesn't decline the question: it is asked again if the activity is recreated + confirmationDialog?.dismiss() + } + }) + } + + /** Routes the [intent] the list was created with, unless it was routed already or is still being routed. */ + fun start(intent: Intent, savedInstanceState: Bundle?) { + val state = viewModel.state.value + // by the view model kept across a configuration change, or before process death + val isRoutedAlready = state != RoutingState.Idle || savedInstanceState?.getBoolean(KEY_ROUTING_DONE) == true + if (isRoutedAlready) return + val routedIntent = savedInstanceState?.let { + BundleCompat.getParcelable(it, KEY_ROUTED_INTENT, Intent::class.java) + } + // the intent of a task relaunched from the recents was routed already + val routed = routedIntent ?: intent.takeUnless(AppTasks::isRelaunchedFromRecents) ?: return + route(routed) + } + + /** Routes the new [intent] of the list and returns true if it asks for a deep link or contact chat. */ + fun onNewIntent(intent: Intent): Boolean { + if (Request.of(intent) == null) return false + route(intent) + return true + } + + fun onSaveInstanceState(outState: Bundle) { + outState.putBoolean(KEY_ROUTING_DONE, !viewModel.isRouting) + // recreated after process death, the list gets the intent it was started with, not a newer one it routes + if (viewModel.isRouting) { + outState.putParcelable(KEY_ROUTED_INTENT, viewModel.routedIntent) + } + } + + private fun route(intent: Intent) { + val request = Request.of(intent) ?: return + viewModel.routedIntent = intent + viewModel.route(request) + } + + private fun render(state: RoutingState) { + if (state != shownQuestion) { + // e.g. answered, or replaced by the routing of a newer intent, also while the activity was stopped, when + // the question of that intent can follow the old one directly. Dismissing doesn't trigger the cancel + // listener. + confirmationDialog?.dismiss() + confirmationDialog = null + shownQuestion = null + } + when (state) { + is RoutingState.Question -> ask(state) + is RoutingState.Open -> { + state.message?.let { + Toast.makeText(activity, it, if (state.isLongMessage) Toast.LENGTH_LONG else Toast.LENGTH_SHORT) + .show() + } + open(state.destination) + viewModel.onOpened() + } + RoutingState.Idle, RoutingState.Routing, RoutingState.Done -> Unit + } + } + + private fun open(destination: Destination) { + when (destination) { + Destination.ConversationList -> Unit + Destination.Login -> { + isOpeningScreen = true + activity.startActivity(ServerSelectionActivity.createLoginIntent(activity)) + } + is Destination.Chat -> { + isOpeningScreen = true + val chatIntent = ChatActivity.createIntent(activity, destination.userId, destination.roomToken) + if (destination.userId == currentUserId()) { + activity.startActivity(chatIntent) + } else { + // the conversation list of its account below the chat, so back shows its conversations + val listIntent = ConversationsListActivity.createAccountSwitchIntent(activity, destination.userId) + activity.startActivities(arrayOf(listIntent, chatIntent)) + } + } + // the list of its account asks whether to join the call, see ConversationsListActivity.handleConversation + is Destination.VoiceRoom -> activity.startActivity( + ShortcutManagerHelper.createConversationIntent(activity, destination.roomToken, destination.userId) + ) + } + } + + /** Asks [question], unless it is shown already, e.g. when the activity is started again. */ + private fun ask(question: RoutingState.Question) { + if (confirmationDialog?.isShowing == true) return + when (question) { + is RoutingState.AskToJoin -> showConfirmationDialog( + title = R.string.nc_deep_link_join_title, + message = activity.getString(R.string.nc_deep_link_join_message, question.server), + confirmButton = R.string.nc_deep_link_join + ) + is RoutingState.AskToStartContactChat -> showConfirmationDialog( + title = R.string.nc_contact_chat_start_title, + message = activity.getString(R.string.nc_contact_chat_start_message, question.cloudId), + confirmButton = R.string.nc_contact_chat_start + ) + } + shownQuestion = question + } + + private fun showConfirmationDialog(@StringRes title: Int, message: String, @StringRes confirmButton: Int) { + val dialogBuilder = MaterialAlertDialogBuilder(activity) + .setTitle(title) + .setMessage(message) + .setPositiveButton(confirmButton) { _, _ -> viewModel.confirm() } + .setNegativeButton(R.string.nc_cancel) { _, _ -> viewModel.decline() } + .setOnCancelListener { viewModel.decline() } + activity.viewThemeUtils.dialog.colorMaterialAlertDialogBackground(activity, dialogBuilder) + val dialog = dialogBuilder.show() + confirmationDialog = dialog + activity.viewThemeUtils.platform.colorTextButtons( + dialog.getButton(AlertDialog.BUTTON_POSITIVE), + dialog.getButton(AlertDialog.BUTTON_NEGATIVE) + ) + } + + companion object { + private const val MAX_LAUNCH_SCREEN_MS = 2000L + + // saved state: whether the intent was routed when the activity was stopped + private const val KEY_ROUTING_DONE = "KEY_DEEP_LINK_ROUTING_DONE" + + // saved state: the intent that was still being routed when the activity was stopped + private const val KEY_ROUTED_INTENT = "KEY_DEEP_LINK_ROUTED_INTENT" + } +} diff --git a/app/src/main/java/com/nextcloud/talk/activities/DeepLinkRoutingViewModel.kt b/app/src/main/java/com/nextcloud/talk/activities/DeepLinkRoutingViewModel.kt new file mode 100644 index 00000000000..d67c8f4a965 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/activities/DeepLinkRoutingViewModel.kt @@ -0,0 +1,411 @@ +/* + * 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.ContentResolver +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.provider.ContactsContract +import androidx.annotation.StringRes +import androidx.annotation.VisibleForTesting +import androidx.core.net.toUri +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.nextcloud.talk.R +import com.nextcloud.talk.contacts.ContactsRepository +import com.nextcloud.talk.conversationlist.ConversationsListActivity +import com.nextcloud.talk.conversationlist.data.OfflineConversationsRepository +import com.nextcloud.talk.conversationlist.data.network.OfflineFirstConversationsRepository.ConversationResult +import com.nextcloud.talk.data.user.model.User +import com.nextcloud.talk.logger.Logger +import com.nextcloud.talk.models.domain.ConversationModel +import com.nextcloud.talk.models.json.conversations.ConversationEnums +import com.nextcloud.talk.users.DefaultAccountProvider +import com.nextcloud.talk.users.UserManager +import com.nextcloud.talk.utils.ConversationUtils.checkIfVoiceRoom +import com.nextcloud.talk.utils.DeepLinkHandler +import com.nextcloud.talk.utils.InternalEntryPoint +import com.nextcloud.talk.utils.ShortcutManagerHelper +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import javax.inject.Inject + +/** + * Routes the nextcloudtalk:// deep links and phone book contact chats that [DeepLinkActivity] passes on to the + * conversation list: works out which screen a [Request] opens, asks first where opening it would join or start a + * conversation, and keeps doing so across configuration changes. The list shows the [state] through + * [DeepLinkRouting] and opens its [RoutingState.Open.destination]. + */ +@Suppress("TooManyFunctions") +class DeepLinkRoutingViewModel @Inject constructor( + private val context: Context, + private val userManager: UserManager, + private val defaultAccountProvider: DefaultAccountProvider, + private val conversationsRepository: OfflineConversationsRepository, + private val contactsRepository: ContactsRepository, + private val logger: Logger +) : ViewModel() { + + /** What a passed on intent asks for, see [of]. */ + sealed interface Request { + /** A nextcloudtalk:// deep link to a conversation. */ + data class DeepLink(val link: DeepLinkHandler.DeepLinkResult) : Request + + /** The chat with the phone book contact of [contactUri], from the contacts app. */ + data class ContactChat(val contactUri: Uri) : Request + + companion object { + /** + * The request of [intent], or null if it asks for none: only the uri of a VIEW intent of another app that + * DeepLinkActivity passed on through the internal entry point of the conversation list asks for one. + */ + fun of(intent: Intent): Request? { + val uri = intent.data + return when { + !InternalEntryPoint.isStartedThrough(intent, ConversationsListActivity::class.java) -> null + intent.action != Intent.ACTION_VIEW || uri == null -> null + else -> DeepLinkHandler.parseDeepLink(uri)?.let { DeepLink(it) } + ?: if (isContactChat(intent, uri)) ContactChat(uri) else null + } + } + + private fun isContactChat(intent: Intent, uri: Uri) = + uri.scheme == ContentResolver.SCHEME_CONTENT && + uri.authority == ContactsContract.AUTHORITY && + intent.type == CONTACT_CHAT_MIME_TYPE + } + } + + /** A screen that the conversation list opens. */ + sealed interface Destination { + /** The conversation list itself, which is shown already. */ + data object ConversationList : Destination + + data object Login : Destination + + /** The chat of [roomToken], above the conversation list of the account [userId]. */ + data class Chat(val userId: Long, val roomToken: String) : Destination + + /** + * The voice room [roomToken] of the account [userId], which its conversation list opens like a conversation + * shortcut: it asks whether to join the call, which opening its chat would start right away. + */ + data class VoiceRoom(val userId: Long, val roomToken: String) : Destination + } + + sealed interface RoutingState { + /** No request yet. */ + data object Idle : RoutingState + + data object Routing : RoutingState + + /** A question whether to open what the request asks for, answered with [confirm] or [decline]. */ + sealed interface Question : RoutingState + + /** Whether to open the conversation on [server] that is not in the list, which can join it. */ + data class AskToJoin(val server: String) : Question + + /** Whether to start a conversation with the phone book contact [cloudId], which adds it to both lists. */ + data class AskToStartContactChat(val cloudId: String) : Question + + /** [destination] is to be opened, with a toast of [message]; the list reports it with [onOpened]. */ + data class Open( + val destination: Destination, + @StringRes val message: Int? = null, + val isLongMessage: Boolean = false + ) : RoutingState + + /** The request was routed. */ + data object Done : RoutingState + } + + private val _state = MutableStateFlow(RoutingState.Idle) + val state: StateFlow = _state.asStateFlow() + + /** Whether a request is still being routed, including the question whether to open it and the opening. */ + val isRouting: Boolean + get() = _state.value.let { it != RoutingState.Idle && it != RoutingState.Done } + + /** + * Whether the launch screen is kept while the request is routed: until what it opens is opened, but not while a + * question waits for an answer. + */ + val keepsLaunchScreen: Boolean + get() = _state.value.let { it == RoutingState.Routing || it is RoutingState.Open } + + /** The intent of the request that is routed, kept for a recreation of the list after process death. */ + var routedIntent: Intent? = null + + // the coroutine that routes the latest request: a newer one cancels it, so a slower older one opens nothing + private var routingJob: Job? = null + + // what confirming the question of the state opens + private var onConfirm: (suspend () -> Unit)? = null + + @VisibleForTesting + internal var ioDispatcher: CoroutineDispatcher = Dispatchers.IO + + /** Routes [request], instead of a request that is still being routed. */ + fun route(request: Request) { + launchRouting { + when (request) { + is Request.DeepLink -> routeDeepLink(request.link) + is Request.ContactChat -> routeContactChat(request.contactUri) + } + } + } + + /** Opens what the request of the current question asks for. */ + fun confirm() { + val action = onConfirm?.takeIf { _state.value is RoutingState.Question } ?: return + launchRouting(action) + } + + /** Stays in the conversation list instead of opening what the request of the current question asks for. */ + fun decline() { + if (_state.value !is RoutingState.Question) return + onConfirm = null + _state.value = RoutingState.Open(Destination.ConversationList) + } + + /** Reports that the destination of [RoutingState.Open] was opened. */ + fun onOpened() { + if (_state.value is RoutingState.Open) _state.value = RoutingState.Done + } + + /** + * Runs [block] to route the latest request. A step that fails, also one confirmed later, stays in the conversation + * list with an error. + */ + @Suppress("TooGenericExceptionCaught") + private fun launchRouting(block: suspend () -> Unit) { + routingJob?.cancel() + onConfirm = null + _state.value = RoutingState.Routing + routingJob = viewModelScope.launch { + try { + block() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + logger.e(TAG, "Error routing the intent", e) + open(Destination.ConversationList, R.string.nc_common_error_sorry) + } + } + } + + private fun open(destination: Destination, @StringRes message: Int? = null, isLongMessage: Boolean = false) { + _state.value = RoutingState.Open(destination, message, isLongMessage) + } + + private fun ask(question: RoutingState.Question, action: suspend () -> Unit) { + onConfirm = action + _state.value = question + } + + /** Opens [destination] or, without an account, e.g. after the last one was removed meanwhile, the login. */ + private suspend fun openOrLogin(destination: Destination, @StringRes message: Int? = null) { + if (userManager.getUsersNotScheduledForDeletion().isEmpty()) { + open(Destination.Login) + return + } + open(destination, message, isLongMessage = true) + } + + private suspend fun routeDeepLink(link: DeepLinkHandler.DeepLinkResult) { + val users = userManager.getUsersNotScheduledForDeletion() + if (users.isEmpty()) { + open(Destination.Login) + return + } + val user = resolveTargetUser(users, link, defaultUserAmong(users)) + if (user == null) { + open(Destination.ConversationList, R.string.nc_no_account_for_server, isLongMessage = true) + return + } + val conversation = knownConversation(user, link.roomToken) + when { + conversation == null -> ask(RoutingState.AskToJoin(link.serverUrl.toUri().host ?: link.serverUrl)) { + openDeepLinkConversation(user, link.roomToken) + } + // another app must not start a call + conversation.checkIfVoiceRoom() -> open(Destination.VoiceRoom(user.id!!, link.roomToken)) + else -> openDeepLinkConversation(user, link.roomToken) + } + } + + /** The default account if it is one of [users], e.g. not when it is scheduled for deletion. */ + private suspend fun defaultUserAmong(users: List): User? = + defaultAccountProvider.getDefaultUser()?.takeIf { default -> users.any { it.id == default.id } } + + /** The conversation [roomToken] of [user], stored or after a sync like [isStoredOrSynced], else null. */ + private suspend fun knownConversation(user: User, roomToken: String): ConversationModel? { + var conversation: ConversationModel? = null + isStoredOrSynced(user) { + val result = conversationsRepository.observeConversation(user.id!!, roomToken).first() + conversation = (result as? ConversationResult.Found)?.conversation + conversation != null + } + return conversation + } + + // The account isn't made active here: while the lock screen covers the list, or while it is stopped, its + // onRestart would relaunch it for the new default account and drop this pending chat. The list of the account that + // Destination.Chat starts makes it active. + private fun openDeepLinkConversation(user: User, roomToken: String) { + // Report shortcut usage for ranking + ShortcutManagerHelper.reportShortcutUsed(context, roomToken, user.id!!) + open(Destination.Chat(user.id!!, roomToken)) + } + + private suspend fun routeContactChat(contactUri: Uri) { + val (cloudId, account) = withContext(ioDispatcher) { + val cloudId = readUserIdOfContact(contactUri) + val users = userManager.getUsersNotScheduledForDeletion() + cloudId to accountForContactServer(cloudId.substringAfterLast("@"), users, defaultUserAmong(users)) + } + val contactUserId = cloudId.substringBeforeLast("@") + when { + account == null -> openOrLogin( + Destination.ConversationList, + R.string.nc_phone_book_integration_account_not_found + ) + hasOneToOneConversation(account, contactUserId) -> openChatWithContact(contactUserId, account) + else -> ask(RoutingState.AskToStartContactChat(cloudId)) { openChatWithContact(contactUserId, account) } + } + } + + // the contacts provider lives in another process: a binder call, not for the main thread + // empty without the contacts permission, which any app can make it need by sending such an intent, and for a uri + // of another app that is no contact data row of Talk, e.g. of a contact, an unknown path or a data row of another + // type, such as the email address of a contact, which has the form of a user id at a server as well + private fun readUserIdOfContact(contactUri: Uri): String { + var userId = "" + try { + context.contentResolver.query(contactUri, null, null, null, null)?.use { cursor -> + if (cursor.moveToFirst() && + cursor.getString(cursor.getColumnIndexOrThrow(ContactsContract.Data.MIMETYPE)) == + CONTACT_CHAT_MIME_TYPE + ) { + // userId @ server + userId = cursor.getString(cursor.getColumnIndexOrThrow(ContactsContract.Data.DATA1)).orEmpty() + } + } + } catch (e: SecurityException) { + logger.w(TAG, "Can't read the contact without the contacts permission", e) + } catch (e: IllegalArgumentException) { + logger.w(TAG, "Can't read the user id of the contact $contactUri", e) + } + return userId + } + + /** Whether [user] has a one-to-one conversation with [userId], see [isStoredOrSynced]. */ + private suspend fun hasOneToOneConversation(user: User, userId: String): Boolean = + isStoredOrSynced(user) { + conversationsRepository.roomListFlow(user.id!!).first().any { + it.type == ConversationEnums.ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL && it.name == userId + } + } + + /** + * Whether [isStored] holds for the local conversations of [user], or after they were synced, as the local list can + * miss a conversation, e.g. of an account whose conversations were not synced yet. A sync that fails or takes + * longer than [ROOM_SYNC_TIMEOUT_MS] counts as no change. + */ + private suspend fun isStoredOrSynced(user: User, isStored: suspend () -> Boolean): Boolean = + isStored() || + conversationsRepository.syncRooms(user, roomListTimeoutMillis = ROOM_SYNC_TIMEOUT_MS) != null && + isStored() + + /** + * Opens the one-to-one chat of [account] with the contact [contactUserId]. A conversation that can't be created, + * e.g. offline or rejected by the server, stays in the list with an error, like any failed step. + */ + private suspend fun openChatWithContact(contactUserId: String, account: User) { + val roomToken = createRoomWithContact(contactUserId, account) + if (roomToken == null) { + logger.e(TAG, "The server returned no conversation with the contact") + open(Destination.ConversationList, R.string.nc_common_error_sorry) + return + } + openOrLogin(Destination.Chat(account.id!!, roomToken)) + } + + /** Creates or gets the one-to-one conversation of [account] with [userId], null without one in the response. */ + private suspend fun createRoomWithContact(userId: String, account: User): String? = + contactsRepository.createRoom( + account, + roomType = ONE_TO_ONE_ROOM_TYPE, + sourceType = null, + userId = userId, + conversationName = null + ).ocs?.data?.token?.takeIf { it.isNotBlank() } + + companion object { + private val TAG = DeepLinkRoutingViewModel::class.java.simpleName + + private const val ROOM_SYNC_TIMEOUT_MS = 5000L + private const val ONE_TO_ONE_ROOM_TYPE = "1" + + private const val CONTACT_CHAT_MIME_TYPE = "vnd.android.cursor.item/vnd.com.nextcloud.talk2.chat" + + /** + * Resolves which account of [users] opens [deepLinkResult], with [defaultUser] the current one. + * + * Priority: + * 1. User matching both username and server URL + * 2. Current active user, if it is on the server + * 3. Another user on the server + */ + @VisibleForTesting + internal fun resolveTargetUser( + users: List, + deepLinkResult: DeepLinkHandler.DeepLinkResult, + defaultUser: User? + ): User? { + val deepLinkHost = deepLinkResult.serverUrl.toUri().host?.lowercase() + if (deepLinkHost.isNullOrBlank()) { + return defaultUser + } + val hostOf = { user: User -> user.baseUrl?.let { it.toUri().host?.lowercase() } } + val username = deepLinkResult.username + val exactMatch = username?.let { + users.find { user -> hostOf(user) == deepLinkHost && user.username?.lowercase() == it.lowercase() } + } + val serverMatch = users.find { user -> hostOf(user) == deepLinkHost } + val currentUserMatch = defaultUser?.takeIf { hostOf(it) == deepLinkHost } + return exactMatch ?: currentUserMatch ?: serverMatch + } + + /** + * The account to chat with a phone book contact whose cloud ID is on [server] (host[:port][/path], without + * scheme). The phone book integration syncs for the account that is the default at the time, so its entries + * can belong to another account than the current default one: prefer the default account if it is on that + * server, otherwise take another account on it. Null if no account is on that server. + */ + @VisibleForTesting + internal fun accountForContactServer(server: String, users: List, defaultUser: User?): User? { + if (server.isBlank() || "https://$server".toHttpUrlOrNull() == null) { + return null + } + val isOnServer = { user: User? -> + user?.baseUrl?.substringAfter("://")?.trimEnd('/').equals(server.trimEnd('/'), ignoreCase = true) + } + return defaultUser?.takeIf(isOnServer) ?: users.firstOrNull(isOnServer) + } + } +} diff --git a/app/src/main/java/com/nextcloud/talk/activities/MainActivity.kt b/app/src/main/java/com/nextcloud/talk/activities/MainActivity.kt deleted file mode 100644 index 2fca99c028f..00000000000 --- a/app/src/main/java/com/nextcloud/talk/activities/MainActivity.kt +++ /dev/null @@ -1,440 +0,0 @@ -/* - * Nextcloud Talk - Android Client - * - * SPDX-FileCopyrightText: 2023 Marcel Hibbe - * SPDX-FileCopyrightText: 2023 Ezhil Shanmugham - * SPDX-FileCopyrightText: 2021 Andy Scherzinger - * SPDX-FileCopyrightText: 2017 Mario Danic - * SPDX-License-Identifier: GPL-3.0-or-later - */ -package com.nextcloud.talk.activities - -import android.content.ContentResolver -import android.content.Intent -import android.net.Uri -import android.os.Bundle -import android.provider.ContactsContract -import android.text.TextUtils -import android.util.Log -import android.widget.Toast -import androidx.activity.OnBackPressedCallback -import androidx.annotation.VisibleForTesting -import androidx.core.net.toUri -import androidx.lifecycle.lifecycleScope -import autodagger.AutoInjector -import com.google.android.material.snackbar.Snackbar -import com.nextcloud.talk.BuildConfig -import com.nextcloud.talk.R -import com.nextcloud.talk.account.BrowserLoginActivity -import com.nextcloud.talk.account.ServerSelectionActivity -import com.nextcloud.talk.api.NcApi -import com.nextcloud.talk.application.NextcloudTalkApplication -import com.nextcloud.talk.chat.ChatActivity -import com.nextcloud.talk.conversationlist.ConversationsListActivity -import com.nextcloud.talk.data.user.model.User -import com.nextcloud.talk.databinding.ActivityMainBinding -import com.nextcloud.talk.invitation.InvitationsActivity -import com.nextcloud.talk.utils.ApiUtils -import com.nextcloud.talk.utils.ClosedInterfaceImpl -import com.nextcloud.talk.utils.DeepLinkHandler -import com.nextcloud.talk.utils.SecurityUtils -import com.nextcloud.talk.utils.ShortcutManagerHelper -import com.nextcloud.talk.utils.UnifiedPushUtils -import com.nextcloud.talk.utils.bundle.BundleKeys -import io.reactivex.android.schedulers.AndroidSchedulers -import io.reactivex.disposables.CompositeDisposable -import io.reactivex.schedulers.Schedulers -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import okhttp3.HttpUrl.Companion.toHttpUrlOrNull -import javax.inject.Inject - -@AutoInjector(NextcloudTalkApplication::class) -class MainActivity : - BaseActivity(), - ActionBarProvider { - - lateinit var binding: ActivityMainBinding - - @Inject - lateinit var ncApi: NcApi - - // MainActivity only routes to ConversationsListActivity or ChatActivity and is never - // visible to the user. The lock check must run in the actual destination activity. - override val skipLockCheckOnResume: Boolean = true - - private val disposables = CompositeDisposable() - - private var contactLookupJob: Job? = null - - private val onBackPressedCallback = object : OnBackPressedCallback(true) { - override fun handleOnBackPressed() { - finish() - } - } - - override fun onCreate(savedInstanceState: Bundle?) { - Log.d(TAG, "onCreate: Activity: " + System.identityHashCode(this).toString()) - - super.onCreate(savedInstanceState) - - if (BuildConfig.DEBUG) { - Toast.makeText(this, "Build flavor: " + BuildConfig.FLAVOR, Toast.LENGTH_LONG).show() - } - - // Set the default theme to replace the launch screen theme. - setTheme(R.style.AppTheme) - binding = ActivityMainBinding.inflate(layoutInflater) - setContentView(binding.root) - - NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) - - setSupportActionBar(binding.toolbar) - - handleIntent(intent) - - onBackPressedDispatcher.addCallback(this, onBackPressedCallback) - } - - override fun onDestroy() { - super.onDestroy() - disposables.dispose() - } - - private fun launchServerSelection() { - if (isBrandingUrlSet()) { - val intent = Intent(context, BrowserLoginActivity::class.java) - val bundle = Bundle() - bundle.putString(BundleKeys.KEY_BASE_URL, resources.getString(R.string.weblogin_url)) - intent.putExtras(bundle) - startActivity(intent) - } else { - val intent = Intent(context, ServerSelectionActivity::class.java) - startActivity(intent) - } - } - - private fun isBrandingUrlSet() = !TextUtils.isEmpty(resources.getString(R.string.weblogin_url)) - - override fun onStart() { - Log.d(TAG, "onStart: Activity: " + System.identityHashCode(this).toString()) - super.onStart() - } - - override fun onResume() { - Log.d(TAG, "onResume: Activity: " + System.identityHashCode(this).toString()) - super.onResume() - if (appPreferences.isScreenLocked) { - SecurityUtils.createKey(appPreferences.screenLockTimeout) - } - } - - override fun onPause() { - Log.d(TAG, "onPause: Activity: " + System.identityHashCode(this).toString()) - super.onPause() - } - - override fun onStop() { - Log.d(TAG, "onStop: Activity: " + System.identityHashCode(this).toString()) - super.onStop() - } - - private fun openConversationList() { - val intent = Intent(this, ConversationsListActivity::class.java) - intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) - intent.putExtras(Bundle()) - startActivity(intent) - } - - private fun handleActionFromContact(intent: Intent) { - if (intent.action == Intent.ACTION_VIEW && intent.data != null && isTrustedContactsUri(intent.data!!)) { - val contactUri = intent.data!! - contactLookupJob = lifecycleScope.launch { - val (cloudId, account) = withContext(Dispatchers.IO) { - val cloudId = readUserIdOfContact(contactUri) - val account = accountForContactServer( - cloudId.substringAfterLast("@"), - userManager.getUsersNotScheduledForDeletion(), - defaultAccountProvider.getDefaultUserBlocking() - ) - cloudId to account - } - openChatOfContact(cloudId.substringBeforeLast("@"), account, intent.type) - } - } - } - - // the contacts provider lives in another process: a binder call, not for the main thread - private fun readUserIdOfContact(contactUri: Uri): String { - var userId = "" - contentResolver.query(contactUri, null, null, null, null)?.use { cursor -> - if (cursor.moveToFirst()) { - // userId @ server - userId = cursor.getString(cursor.getColumnIndexOrThrow(ContactsContract.Data.DATA1)) - } - } - return userId - } - - private fun openChatOfContact(contactUserId: String, account: User?, intentType: String?) { - when (intentType) { - "vnd.android.cursor.item/vnd.com.nextcloud.talk2.chat" -> { - if (account != null) { - startConversation(contactUserId, account) - } else { - Snackbar.make( - binding.root, - R.string.nc_phone_book_integration_account_not_found, - Snackbar.LENGTH_LONG - ).show() - } - } - } - } - - private fun isTrustedContactsUri(uri: Uri): Boolean = - uri.scheme == ContentResolver.SCHEME_CONTENT && uri.authority == ContactsContract.AUTHORITY - - private fun startConversation(userId: String, currentUser: User) { - val roomType = "1" - - val apiVersion = ApiUtils.getConversationApiVersion(currentUser, intArrayOf(ApiUtils.API_V4, 1)) - val credentials = ApiUtils.getCredentials(currentUser.username, currentUser.token) - val retrofitBucket = ApiUtils.getRetrofitBucketForCreateRoom( - version = apiVersion, - baseUrl = currentUser.baseUrl!!, - roomType = roomType, - invite = userId - ) - - val disposable = ncApi.createRoom( - credentials, - retrofitBucket.url, - retrofitBucket.queryMap - ) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe( - { roomOverall -> - if (isFinishing || isDestroyed) return@subscribe - val chatIntent = ChatActivity.createIntent( - context, - currentUser.id!!, - roomOverall.ocs!!.data!!.token - ) - startActivity(chatIntent) - }, - { e -> - Log.e(TAG, "Error creating room", e) - } - ) - disposables.add(disposable) - } - - override fun onNewIntent(intent: Intent) { - super.onNewIntent(intent) - Log.d(TAG, "onNewIntent Activity: " + System.identityHashCode(this).toString()) - // the contact lookup is asynchronous: keep the new intent for a recreation before it finishes - setIntent(intent) - // a slower lookup of an older intent must not open its chat after this newer intent - contactLookupJob?.cancel() - handleIntent(intent) - } - - @Suppress("TooGenericExceptionCaught") - private fun handleIntent(intent: Intent) { - // Handle deep links first (nextcloudtalk:// scheme) - if (handleDeepLink(intent)) { - return - } - - handleActionFromContact(intent) - - val internalUserId = intent.extras?.getLong(BundleKeys.KEY_INTERNAL_USER_ID) - - lifecycleScope.launch { - val user: User? = if (internalUserId != null && internalUserId != 0L) { - userManager.getUserWithId(internalUserId) - } else { - null - } - - if (user != null && userManager.setUserAsActive(user)) { - if (intent.hasExtra(BundleKeys.KEY_REMOTE_TALK_SHARE)) { - if (intent.getBooleanExtra(BundleKeys.KEY_REMOTE_TALK_SHARE, false)) { - val invitationsIntent = Intent(this@MainActivity, InvitationsActivity::class.java) - invitationsIntent.putExtra(BundleKeys.KEY_INTERNAL_USER_ID, user.id) - startActivity(invitationsIntent) - } - } else { - val chatIntent = Intent(context, ChatActivity::class.java) - chatIntent.putExtras(intent.extras!!) - startActivity(chatIntent) - } - } else { - try { - val users = userManager.getUsersNotScheduledForDeletion() - if (isFinishing || isDestroyed) return@launch - - if (users.isNotEmpty()) { - if (appPreferences.useUnifiedPush) { - UnifiedPushUtils.setPeriodicPushRegistrationWorker(this@MainActivity) - } else { - ClosedInterfaceImpl().setUpPushTokenRegistration() - } - if (isFinishing || isDestroyed) return@launch - openConversationList() - } else { - if (isFinishing || isDestroyed) return@launch - launchServerSelection() - } - } catch (e: Exception) { - logger.e(TAG, "Error loading existing users", e) - Toast.makeText( - context, - context.resources.getString(R.string.nc_common_error_sorry), - Toast.LENGTH_SHORT - ).show() - } - } - } - } - - /** - * Handles deep link URIs for opening conversations. - * - * Supports: - * - nextcloudtalk://[user@]server/call/token - * - * @param intent The intent to process - * @return true if the intent was handled as a deep link, false otherwise - */ - @Suppress("TooGenericExceptionCaught", "LongMethod") - private fun handleDeepLink(intent: Intent): Boolean { - val deepLinkResult = intent.data?.let { DeepLinkHandler.parseDeepLink(it) } ?: return false - - lifecycleScope.launch { - try { - val users = userManager.getUsers() - if (isFinishing || isDestroyed) return@launch - - if (users.isEmpty()) { - launchServerSelection() - return@launch - } - - val targetUser = resolveTargetUser(users, deepLinkResult) - - if (targetUser == null) { - Toast.makeText( - context, - context.resources.getString(R.string.nc_no_account_for_server), - Toast.LENGTH_LONG - ).show() - openConversationList() - return@launch - } - - if (userManager.setUserAsActive(targetUser)) { - // Report shortcut usage for ranking - targetUser.id?.let { userId -> - ShortcutManagerHelper.reportShortcutUsed( - context, - deepLinkResult.roomToken, - userId - ) - } - - if (isFinishing || isDestroyed) return@launch - - // Open conversation list first so back press shows correct user's conversations - val listIntent = Intent(context, ConversationsListActivity::class.java) - listIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) - listIntent.putExtra(BundleKeys.KEY_INTERNAL_USER_ID, targetUser.id) - - val chatIntent = ChatActivity.createIntent(context, targetUser.id!!, deepLinkResult.roomToken) - - startActivities(arrayOf(listIntent, chatIntent)) - } else { - logger.e(TAG, "Failed to set deep link target user as active") - Toast.makeText( - context, - context.resources.getString(R.string.nc_common_error_sorry), - Toast.LENGTH_SHORT - ).show() - } - } catch (e: Exception) { - logger.e(TAG, "Error loading users for deep link", e) - if (isFinishing || isDestroyed) return@launch - Toast.makeText( - context, - context.resources.getString(R.string.nc_common_error_sorry), - Toast.LENGTH_SHORT - ).show() - } - } - - return true - } - - /** - * Resolves which user account to use for a deep link. - * - * Priority: - * 1. User matching both username and server URL - * 2. User matching the server URL only - * 3. Current active user as fallback (if server matches) - */ - private fun resolveTargetUser(users: List, deepLinkResult: DeepLinkHandler.DeepLinkResult): User? { - val deepLinkHost = deepLinkResult.serverUrl.toUri().host?.lowercase() - if (deepLinkHost.isNullOrBlank()) { - return defaultAccountProvider.getDefaultUserBlocking() - } - - // Priority: exact match (username + server) > server match > current user fallback - val username = deepLinkResult.username - val exactMatch = if (username != null) { - users.find { user -> - val userHost = user.baseUrl?.let { it.toUri().host?.lowercase() } - userHost == deepLinkHost && user.username?.lowercase() == username.lowercase() - } - } else { - null - } - - val serverMatch = users.find { user -> - val userHost = user.baseUrl?.let { it.toUri().host?.lowercase() } - userHost == deepLinkHost - } - - val currentUser = defaultAccountProvider.getDefaultUserBlocking() - val currentUserMatch = currentUser?.takeIf { - it.baseUrl?.let { url -> url.toUri().host?.lowercase() } == deepLinkHost - } - - return exactMatch ?: serverMatch ?: currentUserMatch - } - - companion object { - private val TAG = MainActivity::class.java.simpleName - - /** - * The account to chat with a phone book contact whose cloud ID is on [server] (host[:port][/path], without - * scheme). The phone book integration syncs for the account that is the default at the time, so its entries - * can belong to another account than the current default one: prefer the default account if it is on that - * server, otherwise take another account on it. Null if no account is on that server. - */ - @VisibleForTesting - internal fun accountForContactServer(server: String, users: List, defaultUser: User?): User? { - if (server.isBlank() || "https://$server".toHttpUrlOrNull() == null) { - return null - } - val isOnServer = { user: User? -> - user?.baseUrl?.substringAfter("://")?.trimEnd('/').equals(server.trimEnd('/'), ignoreCase = true) - } - return defaultUser?.takeIf(isOnServer) ?: users.firstOrNull(isOnServer) - } - } -} diff --git a/app/src/main/java/com/nextcloud/talk/application/NextcloudTalkApplication.kt b/app/src/main/java/com/nextcloud/talk/application/NextcloudTalkApplication.kt index 2b592961139..64011b5c6b6 100644 --- a/app/src/main/java/com/nextcloud/talk/application/NextcloudTalkApplication.kt +++ b/app/src/main/java/com/nextcloud/talk/application/NextcloudTalkApplication.kt @@ -59,7 +59,10 @@ import com.nextcloud.talk.ui.theme.ThemeModule import com.nextcloud.talk.users.UserManager import com.nextcloud.talk.utils.ClosedInterfaceImpl import com.nextcloud.talk.utils.DeviceUtils +import com.nextcloud.talk.utils.LiveActivities import com.nextcloud.talk.utils.NotificationUtils +import com.nextcloud.talk.utils.SharedFileCopies +import com.nextcloud.talk.utils.UploadSourceGrants import com.nextcloud.talk.utils.database.arbitrarystorage.ArbitraryStorageModule import com.nextcloud.talk.utils.database.user.UserModule import com.nextcloud.talk.utils.preferences.AppPreferences @@ -120,6 +123,9 @@ class NextcloudTalkApplication : @field:ApplicationScope lateinit var appScope: CoroutineScope + // the activities of the app, e.g. to finish only those in a task of another app, see LockedActivity + val liveActivities = LiveActivities().also { registerActivityLifecycleCallbacks(it) } + @Inject lateinit var logger: LoggerImpl //endregion @@ -197,7 +203,8 @@ class NextcloudTalkApplication : val activities = packageManager.getPackageInfo(packageName, PackageManager.GET_ACTIVITIES).activities activities?.forEach { activityInfo -> try { - val activityClass = Class.forName(activityInfo.name) + // an activity-alias is listed by its own name, which is not a class + val activityClass = Class.forName(activityInfo.targetActivity ?: activityInfo.name) if (activityClass != AccountVerificationActivity::class.java && activityClass != BrowserLoginActivity::class.java ) { @@ -222,14 +229,7 @@ class NextcloudTalkApplication : componentApplication.inject(this) installExceptionHandler() - ProcessLifecycleOwner.get().lifecycle.addObserver(object : DefaultLifecycleObserver { - override fun onStop(owner: LifecycleOwner) { - val ts = System.currentTimeMillis() - Log.d(TAG, "ProcessLifecycle onStop: saving lockTimestamp=$ts") - appPreferences.setLockTimestamp(ts) - pendingLockCheck = true - } - }) + observeAppInBackground() Coil.setImageLoader(buildDefaultImageLoader()) setAppTheme(appPreferences.theme) @@ -249,6 +249,10 @@ class NextcloudTalkApplication : private fun initWorkers() { appScope.launch { userManager.repairMultipleActiveUsers() } + // the copies of shares that are done, also when no new share starts + appScope.launch { SharedFileCopies.removeDoneShares(this@NextcloudTalkApplication) } + // the read access to picked files whose uploads are done, also one that was cancelled before it started + appScope.launch { UploadSourceGrants.releaseUnused(this@NextcloudTalkApplication) } val accountRemovalWork = OneTimeWorkRequest.Builder(AccountRemovalWorker::class.java).build() val capabilitiesUpdateWork = OneTimeWorkRequest.Builder(CapabilitiesSyncWorker::class.java).build() @@ -318,6 +322,22 @@ class NextcloudTalkApplication : return imageLoaderBuilder.build() } + /** Checks the app lock when the app is used again after it went to the background. */ + private fun observeAppInBackground() { + ProcessLifecycleOwner.get().lifecycle.addObserver(object : DefaultLifecycleObserver { + override fun onStop(owner: LifecycleOwner) { + // While locked, the time of the last use stays, so the app is still locked after its process was + // killed, which resets isLocked. + if (!isLocked) { + val ts = System.currentTimeMillis() + Log.d(TAG, "ProcessLifecycle onStop: saving lockTimestamp=$ts") + appPreferences.setLockTimestamp(ts) + } + pendingLockCheck = true + } + }) + } + companion object { private val TAG = NextcloudTalkApplication::class.java.simpleName const val FIFTY_PERCENT = 0.5 @@ -331,6 +351,15 @@ class NextcloudTalkApplication : @Volatile var pendingLockCheck = true + + /** + * Whether the app lock is showing or due: set when the lock screen is started, and cleared only by + * authenticating in it (or when the app lock can't be used anymore). While it is set, every screen that is + * resumed shows the lock screen, even after the lock screen was closed without authenticating, e.g. by a + * deep link or a notification that clears the task. + */ + @Volatile + var isLocked = false //endregion //region Setters diff --git a/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewFragment.kt b/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewFragment.kt index 42677896a8c..c67d4bc739b 100644 --- a/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewFragment.kt +++ b/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewFragment.kt @@ -7,6 +7,7 @@ package com.nextcloud.talk.attachmentpreview import android.app.Dialog +import android.content.DialogInterface import android.graphics.Color import android.os.Bundle import android.view.LayoutInflater @@ -20,12 +21,14 @@ import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsControllerCompat import androidx.fragment.app.DialogFragment +import androidx.lifecycle.Lifecycle import androidx.lifecycle.ViewModelProvider import autodagger.AutoInjector import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.nextcloud.talk.application.NextcloudTalkApplication import com.nextcloud.talk.ui.theme.ViewThemeUtils import com.nextcloud.talk.ui.theme.hostViewThemeUtils +import com.nextcloud.talk.utils.SharedFileCopies import com.nextcloud.talk.utils.preferences.AppPreferences import javax.inject.Inject @@ -39,6 +42,9 @@ class FileAttachmentPreviewFragment : DialogFragment() { { _, _, _, _ -> } private var composeView: ComposeView? = null + // set when the files are sent, so their copies are kept for the upload + private var isSent = false + @Inject lateinit var viewThemeUtils: ViewThemeUtils @@ -118,6 +124,7 @@ class FileAttachmentPreviewFragment : DialogFragment() { onDismiss = { dismiss() }, onSend = { files, caption, compressImages, allowUpdate -> uploadFiles(files.toMutableList(), caption, compressImages, allowUpdate) + isSent = true dismiss() } ) @@ -131,6 +138,15 @@ class FileAttachmentPreviewFragment : DialogFragment() { composeView = null } + override fun onDismiss(dialog: DialogInterface) { + super.onDismiss(dialog) + // unsent shared files remove their copies (see SharedFileCopies), not when the chat is only recreated + val isClosedWhileShown = lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED) + if (!isSent && (isClosedWhileShown || activity?.isFinishing == true)) { + SharedFileCopies.removeSharesLater(filesList.toList()) + } + } + companion object { private const val LIGHT_LUMINANCE_THRESHOLD = 0.5f diff --git a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt index 4345a3d45ad..ad029e4f65b 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt @@ -191,6 +191,7 @@ import com.nextcloud.talk.utils.ConversationUtils import com.nextcloud.talk.utils.ConversationUtils.checkIfVoiceRoom import com.nextcloud.talk.utils.DateConstants import com.nextcloud.talk.utils.DateUtils +import com.nextcloud.talk.utils.DeepLinkHandler import com.nextcloud.talk.utils.DisplayUtils import com.nextcloud.talk.utils.FileUtils import com.nextcloud.talk.utils.FileViewerUtils @@ -403,11 +404,9 @@ class ChatActivity : var mentionAutocomplete: Autocomplete<*>? = null var layoutManager: LinearLayoutManager? = null var pullChatMessagesPending = false - var startCallFromNotification: Boolean = false var startCallFromRoomSwitch: Boolean = false var voiceOnly: Boolean = true - var focusInput: Boolean = false private lateinit var path: String var myFirstMessage: CharSequence? = null @@ -434,6 +433,11 @@ class ChatActivity : lateinit var sharedText: String private var sharedFilePaths: List = emptyList() + // Whether the shared text and files of the intent were taken. A chat recreated after its process was killed gets + // the intent it was started with again, with the shared content, as changes to the intent aren't kept. + private var isSharedTextTaken = false + private var areSharedFilesTaken = false + private val _participantPermissionsFlow = MutableStateFlow(null) val participantPermissionsFlow: StateFlow = _participantPermissionsFlow.asStateFlow() @@ -456,10 +460,9 @@ class ChatActivity : isEnabled = false onBackPressedDispatcher.onBackPressed() } else { - val intent = Intent(this@ChatActivity, ConversationsListActivity::class.java) - intent.putExtra(KEY_INTERNAL_USER_ID, conversationUserId) - intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP) - startActivity(intent) + startActivity( + ConversationsListActivity.createInPlaceAccountSwitchIntent(this@ChatActivity, conversationUserId) + ) finish() } } @@ -565,6 +568,12 @@ class ChatActivity : NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) // Not loaded in a coroutine: initData() must have run before onResume() (activity and ChatViewModel), // and registerForActivityResult() must be called before the activity is started. + // before initialUser is set, as onDestroy() then cleans up the chat of that room token + if (intent.getStringExtra(KEY_ROOM_TOKEN)?.let(DeepLinkHandler::isValidToken) != true) { + Log.e(TAG, "Started without a valid room token") + finish() + return + } initialUser = setUpBoundUserOrFinish() ?: return chatAudioPlayer = ChatAudioPlayer(this, chatAudioStore, lifecycleScope) @@ -623,10 +632,15 @@ class ChatActivity : .launchIn(lifecycleScope) } handleIntent(intent) + isSharedTextTaken = savedInstanceState?.getBoolean(KEY_SHARED_TEXT_TAKEN) == true + areSharedFilesTaken = savedInstanceState?.getBoolean(KEY_SHARED_FILES_TAKEN) == true + if (isSharedTextTaken) sharedText = "" + if (areSharedFilesTaken) sharedFilePaths = emptyList() chatViewModel.initData() if (sharedText.isNotEmpty()) { chatViewModel.messageDraft.messageText = sharedText chatViewModel.saveMessageDraft() + isSharedTextTaken = true } conversationThreadId?.let { @@ -1092,14 +1106,16 @@ class ChatActivity : } private fun openAudioMessage(key: ChatAudioKey, threadId: Long?) { - val isThisView = key.roomToken == roomToken && - threadId == openThreadId() && + val isThisRoom = key.roomToken == roomToken && ::initialUser.isInitialized && key.internalUserId == conversationUser.id - if (isThisView) { + if (isThisRoom && threadId == openThreadId()) { chatViewModel.jumpToQuotedMessage(key.messageId.toLong()) } else { startActivity(key.openMessageIntent(this, threadId)) + // The chat of another room replaces this one, so chats don't pile up. A thread and the chat of its room + // stay, as Back leads from one to the other. + if (!isThisRoom) finish() } } @@ -1279,7 +1295,7 @@ class ChatActivity : val startCallAfterRoomSwitch = extras?.getBoolean(KEY_START_CALL_AFTER_ROOM_SWITCH, false) == true val isVoiceOnlyCall = extras?.getBoolean(KEY_CALL_VOICE_ONLY, false) == true - if (newRoomToken != roomToken) { + if (newRoomToken != roomToken && DeepLinkHandler.isValidToken(newRoomToken)) { switchToRoom(newRoomToken, startCallAfterRoomSwitch, isVoiceOnlyCall) } } else { @@ -1293,8 +1309,11 @@ class ChatActivity : openedViaNotification = extras?.getBoolean(KEY_OPENED_VIA_NOTIFICATION) ?: false sharedText = extras?.getString(BundleKeys.KEY_SHARED_TEXT).orEmpty() - sharedFilePaths = extras?.getStringArrayList(BundleKeys.KEY_SHARED_FILE_PATHS) ?: emptyList() - intent.removeExtra(BundleKeys.KEY_SHARED_FILE_PATHS) + // Checked by the conversation list, the only screen that shares files to the chat, and again here, so a screen + // that passes on shared files without that check can't make the attachment preview upload another file. + sharedFilePaths = extras?.getStringArrayList(BundleKeys.KEY_SHARED_FILE_PATHS) + ?.filter { FileUtils.isTrustedShareUri(it.toUri(), this) } + ?: emptyList() Log.d(TAG, " roomToken = $roomToken") if (roomToken.isEmpty()) { @@ -1309,13 +1328,10 @@ class ChatActivity : ApiUtils.getCredentials(conversationUser!!.username, conversationUser!!.token) } - startCallFromNotification = extras?.getBoolean(BundleKeys.KEY_FROM_NOTIFICATION_START_CALL, false) == true startCallFromRoomSwitch = extras?.getBoolean(KEY_START_CALL_AFTER_ROOM_SWITCH, false) == true voiceOnly = extras?.getBoolean(KEY_CALL_VOICE_ONLY, false) == true - focusInput = extras?.getBoolean(BundleKeys.KEY_FOCUS_INPUT) == true - pendingTargetMessageId = extras?.getString(BundleKeys.KEY_MESSAGE_ID)?.toLongOrNull()?.takeIf { it > 0L } ?: extras?.getLong(BundleKeys.KEY_MESSAGE_ID)?.takeIf { it > 0L } pendingTargetThreadId = extras?.getString(KEY_THREAD_ID)?.toLongOrNull()?.takeIf { it > 0L } @@ -1333,6 +1349,8 @@ class ChatActivity : if (isChangingConfigurations) { chatViewModel.handleOrientationChange() } + outState.putBoolean(KEY_SHARED_TEXT_TAKEN, isSharedTextTaken) + outState.putBoolean(KEY_SHARED_FILES_TAKEN, areSharedFilesTaken) super.onSaveInstanceState(outState) } @@ -1481,11 +1499,6 @@ class ChatActivity : supportFragmentManager.commit { setReorderingAllowed(true) // optimizes out redundant replace operations replace(R.id.fragment_container_activity_chat, messageInputFragment) - runOnCommit { - if (focusInput) { - messageInputFragment.binding.fragmentMessageInputView.requestFocus() - } - } } if (isScreenResumed) { @@ -1567,11 +1580,6 @@ class ChatActivity : startACall(false, true) } - if (startCallFromNotification) { - startCallFromNotification = false - startACall(voiceOnly, false) - } - if (startCallFromRoomSwitch) { startCallFromRoomSwitch = false startACall(voiceOnly, true) @@ -1984,9 +1992,6 @@ class ChatActivity : private fun returnToChat(conversation: ConversationModel) { sendSharedFiles() - if (focusInput && messageInputFragment.isAdded) { - messageInputFragment.binding.fragmentMessageInputView.requestFocus() - } refreshConversationExtras(conversation) updateRoomTimerHandler(MILLIS_250) } @@ -1995,6 +2000,8 @@ class ChatActivity : if (sharedFilePaths.isNotEmpty()) { onChooseFileResult(sharedFilePaths.map { it.toUri() }) sharedFilePaths = emptyList() + // not before, so a chat recreated before its room was loaded still sends them + areSharedFilesTaken = true } } @@ -2894,9 +2901,9 @@ class ChatActivity : override fun onDestroy() { super.onDestroy() - logConversationInfos("onDestroy") if (::initialUser.isInitialized) { + logConversationInfos("onDestroy") chatViewModel.leaveRoomViewState.removeObserver(leaveRoomObserver) } @@ -3498,11 +3505,8 @@ class ChatActivity : bundle.putString(BundleKeys.KEY_FORWARD_MSG_TEXT, message?.message) bundle.putString(BundleKeys.KEY_FORWARD_HIDE_SOURCE_ROOM, roomToken) - val intent = Intent(this, ConversationsListActivity::class.java) - intent.putExtras(bundle) - intent.putExtra(KEY_INTERNAL_USER_ID, conversationUserId) - intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP) - startActivity(intent) + val intent = ConversationsListActivity.createInPlaceAccountSwitchIntent(this, conversationUserId) + startActivity(intent.putExtras(bundle)) finish() } @@ -4150,6 +4154,8 @@ class ChatActivity : private const val REQUEST_READ_CONTACT_PERMISSION = 234 private const val REQUEST_CAMERA_PERMISSION = 223 private const val FILE_DATE_PATTERN = "yyyy-MM-dd HH-mm-ss" + private const val KEY_SHARED_TEXT_TAKEN = "sharedTextTaken" + private const val KEY_SHARED_FILES_TAKEN = "sharedFilesTaken" private const val VIDEO_SUFFIX = ".mp4" private const val PICTURE_SUFFIX = ".jpg" private const val HTTP_BAD_REQUEST = 400 diff --git a/app/src/main/java/com/nextcloud/talk/chat/audio/ChatAudioIntents.kt b/app/src/main/java/com/nextcloud/talk/chat/audio/ChatAudioIntents.kt index 5c73eb42739..988d2ffbfa2 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/audio/ChatAudioIntents.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/audio/ChatAudioIntents.kt @@ -10,22 +10,22 @@ package com.nextcloud.talk.chat.audio import android.content.Context import android.content.Intent import android.os.Bundle -import com.nextcloud.talk.activities.MainActivity +import com.nextcloud.talk.chat.ChatActivity +import com.nextcloud.talk.conversationlist.ConversationsListActivity import com.nextcloud.talk.utils.bundle.BundleKeys +/** Opens the chat of the message at the message, in its thread if it has one, from a screen of the app. */ +fun ChatAudioKey.openMessageIntent(context: Context, threadId: Long?): Intent = + ChatActivity.createIntent(context, internalUserId, roomToken, openMessageExtras(threadId)) + /** - * Opens the conversation of the audio message at the message, switching to its account if needed. A message of - * the thread [threadId] is opened in that thread. + * Like [openMessageIntent], but from outside the screens of the app, e.g. the media notification: as a new back stack + * in the task of the app, see [ConversationsListActivity.createChatBackStack]. */ -fun ChatAudioKey.openMessageIntent(context: Context, threadId: Long?): Intent = - Intent(context, MainActivity::class.java).apply { - putExtra(BundleKeys.KEY_INTERNAL_USER_ID, internalUserId) - putExtra(BundleKeys.KEY_ROOM_TOKEN, roomToken) - putExtras(openMessageExtras(threadId)) - } +fun ChatAudioKey.openMessageBackStack(context: Context, threadId: Long?): Array = + ConversationsListActivity.createChatBackStack(context, internalUserId, roomToken, openMessageExtras(threadId)) -/** The extras that make the chat jump to the audio message, in the thread [threadId] if it belongs to one. */ -fun ChatAudioKey.openMessageExtras(threadId: Long?): Bundle = +private fun ChatAudioKey.openMessageExtras(threadId: Long?): Bundle = Bundle().apply { putString(BundleKeys.KEY_MESSAGE_ID, messageId.toString()) threadId?.let { putLong(BundleKeys.KEY_THREAD_ID, it) } diff --git a/app/src/main/java/com/nextcloud/talk/chat/data/io/VoiceMessageMediaService.kt b/app/src/main/java/com/nextcloud/talk/chat/data/io/VoiceMessageMediaService.kt index e31c781acb0..316b0a0aecf 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/data/io/VoiceMessageMediaService.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/data/io/VoiceMessageMediaService.kt @@ -8,8 +8,8 @@ package com.nextcloud.talk.chat.data.io import android.app.PendingIntent -import android.content.Intent import androidx.annotation.OptIn +import androidx.core.app.TaskStackBuilder import androidx.media3.common.AudioAttributes import androidx.media3.common.C import androidx.media3.common.MediaItem @@ -41,7 +41,7 @@ import com.nextcloud.talk.chat.audio.VoiceMessageWaveformLoader import com.nextcloud.talk.chat.audio.chatAudioKind import com.nextcloud.talk.chat.audio.chatAudioRepeatModeOf import com.nextcloud.talk.chat.audio.chatAudioThreadId -import com.nextcloud.talk.chat.audio.openMessageIntent +import com.nextcloud.talk.chat.audio.openMessageBackStack import com.nextcloud.talk.chat.audio.toChatAudioError import com.nextcloud.talk.chat.audio.toPlayerRepeatMode import com.nextcloud.talk.data.network.NetworkMonitor @@ -193,14 +193,13 @@ class VoiceMessageMediaService : MediaSessionService() { } private fun sessionActivityFor(key: ChatAudioKey, threadId: Long?): PendingIntent { - val intent = key.openMessageIntent(this, threadId).apply { - flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP - } - return PendingIntent.getActivity( - this, - SESSION_ACTIVITY_REQUEST_CODE, - intent, - PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + val backStack = TaskStackBuilder.create(this) + key.openMessageBackStack(this, threadId).forEach { backStack.addNextIntent(it) } + return checkNotNull( + backStack.getPendingIntent( + SESSION_ACTIVITY_REQUEST_CODE, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + ) ) } diff --git a/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt b/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt index 95399a9533a..e3a45d6f6dc 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt @@ -80,6 +80,7 @@ import com.nextcloud.talk.utils.Mimetype import com.nextcloud.talk.utils.MimetypeUtils import com.nextcloud.talk.utils.ParticipantPermissions import com.nextcloud.talk.utils.SpreedFeatures +import com.nextcloud.talk.utils.UploadSourceGrants import com.nextcloud.talk.utils.UserIdUtils import com.nextcloud.talk.utils.throttleLatest import com.nextcloud.talk.utils.bundle.BundleKeys @@ -437,7 +438,7 @@ class ChatViewModel @AssistedInject constructor( // also cancel. Without this, cancel silently did nothing for any such placeholder, leaving // the user with no way to clear a permanently stuck upload. uploadReferenceToWorkId.remove(referenceId)?.let { workId -> - UploadAndShareFilesWorker.cancelUpload(referenceId, workId) + UploadAndShareFilesWorker.cancelUpload(referenceId, workId, _uploadedLocalPreviewMap.value[referenceId]) } viewModelScope.launch { chatRepository.deleteTempMessageByReferenceId(referenceId) @@ -2400,6 +2401,8 @@ class ChatViewModel @AssistedInject constructor( // enqueued right here, in the order of the calls: the queue of a conversation is chained, and the // slow lookup of the file info below must neither reorder the files nor die with the view model + // the access the picker granted ends with the chat, while the upload can wait for the network + UploadSourceGrants.keep(NextcloudTalkApplication.sharedApplication!!, fileUri) val workerId = enqueueUpload() if (!isVoiceMessage) { uploadReferenceToWorkId[referenceId] = workerId diff --git a/app/src/main/java/com/nextcloud/talk/chooseaccount/ChooseAccountDialogCompose.kt b/app/src/main/java/com/nextcloud/talk/chooseaccount/ChooseAccountDialogCompose.kt index e19a9d23bf7..3a0201d225e 100644 --- a/app/src/main/java/com/nextcloud/talk/chooseaccount/ChooseAccountDialogCompose.kt +++ b/app/src/main/java/com/nextcloud/talk/chooseaccount/ChooseAccountDialogCompose.kt @@ -320,7 +320,7 @@ class ChooseAccountDialogCompose { if (userManager.setUserAsActive(userItem.user)) { cookieManager.cookieStore.removeAll() activity.startActivity( - ConversationsListActivity.createAccountSwitchIntent(activity, userItem.user.id!!) + ConversationsListActivity.createInPlaceAccountSwitchIntent(activity, userItem.user.id!!) ) onSelected() } diff --git a/app/src/main/java/com/nextcloud/talk/conversationinfo/ConversationInfoActivity.kt b/app/src/main/java/com/nextcloud/talk/conversationinfo/ConversationInfoActivity.kt index 144d961f8af..7ee0abe4758 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationinfo/ConversationInfoActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationinfo/ConversationInfoActivity.kt @@ -47,7 +47,6 @@ import autodagger.AutoInjector import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.nextcloud.talk.R import com.nextcloud.talk.activities.BaseActivity -import com.nextcloud.talk.activities.MainActivity import com.nextcloud.talk.api.NcApi import com.nextcloud.talk.application.NextcloudTalkApplication import com.nextcloud.talk.chat.ChatActivity @@ -61,6 +60,7 @@ import com.nextcloud.talk.conversationinfo.ui.LobbyTimerPickerDialog import com.nextcloud.talk.conversationinfo.ui.ParticipantOpsAction import com.nextcloud.talk.conversationinfo.viewmodel.ConversationInfoViewModel import com.nextcloud.talk.conversationinfoedit.ConversationInfoEditActivity +import com.nextcloud.talk.conversationlist.ConversationsListActivity import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.databinding.DialogBanParticipantBinding import com.nextcloud.talk.events.EventStatus @@ -606,11 +606,7 @@ class ConversationInfoActivity : BaseActivity() { ) } - startActivity( - Intent(context, MainActivity::class.java).apply { - addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) - } - ) + showConversationList() } WorkInfo.State.FAILED -> { val errorType = workInfo.outputData.getString("error_type") @@ -676,12 +672,19 @@ class ConversationInfoActivity : BaseActivity() { ) } - startActivity( - Intent(context, MainActivity::class.java).apply { addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) } - ) + showConversationList() } } + /** + * Shows the conversation list of the account of the conversation, which was left or deleted: the list in the task + * is recreated for it and the screens above it are closed, e.g. when the chat was opened from a notification of an + * account that isn't the default one. + */ + private fun showConversationList() { + startActivity(ConversationsListActivity.createAccountSwitchIntent(context, conversationUser.id!!)) + } + private fun toggleModeratorStatus(apiVersion: Int, participant: ParticipantDto) { val subscriber = participantActionObserver() if (participant.type == ParticipantDto.ParticipantType.MODERATOR || diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt index c45cea86cd5..a2de0ee8a22 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt @@ -10,7 +10,6 @@ import android.Manifest import android.annotation.SuppressLint import android.app.NotificationManager import android.content.ActivityNotFoundException -import android.content.ContentResolver import android.content.Context import android.content.Intent import android.content.pm.PackageManager @@ -24,6 +23,7 @@ import android.util.Log import android.widget.Toast import androidx.activity.OnBackPressedCallback import androidx.activity.compose.setContent +import androidx.annotation.VisibleForTesting import androidx.appcompat.app.AlertDialog import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarHostState @@ -32,11 +32,14 @@ import androidx.core.content.pm.ShortcutInfoCompat import androidx.core.content.pm.ShortcutManagerCompat import androidx.core.graphics.drawable.IconCompat import androidx.core.net.toUri +import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.compose.runtime.getValue import androidx.lifecycle.Lifecycle +import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle +import androidx.lifecycle.withResumed import androidx.work.Data import androidx.work.OneTimeWorkRequest import androidx.work.WorkInfo @@ -50,7 +53,10 @@ import com.nextcloud.talk.account.BrowserLoginActivity import com.nextcloud.talk.account.ServerSelectionActivity import com.nextcloud.talk.activities.BaseActivity import com.nextcloud.talk.activities.CallActivity -import com.nextcloud.talk.activities.MainActivity +import com.nextcloud.talk.activities.DeepLinkActivity +import com.nextcloud.talk.activities.DeepLinkRouting +import com.nextcloud.talk.activities.DeepLinkRoutingViewModel +import com.nextcloud.talk.activities.collectWhileUnlocked import com.nextcloud.talk.api.NcApiCoroutines import com.nextcloud.talk.application.NextcloudTalkApplication import com.nextcloud.talk.chat.ChatActivity @@ -58,7 +64,6 @@ import com.nextcloud.talk.chat.audio.ChatAudioKey import com.nextcloud.talk.chat.audio.ChatAudioPlayer import com.nextcloud.talk.chat.audio.ChatAudioSpeeds import com.nextcloud.talk.chat.audio.ChatAudioStore -import com.nextcloud.talk.chat.audio.openMessageExtras import com.nextcloud.talk.chat.audio.openMessageIntent import com.nextcloud.talk.contacts.ContactsActivity import com.nextcloud.talk.contacts.ContactsViewModel @@ -80,7 +85,6 @@ import com.nextcloud.talk.jobs.ContactAddressBookWorker.Companion.run import com.nextcloud.talk.jobs.DeleteConversationWorker import com.nextcloud.talk.jobs.LeaveConversationWorker import com.nextcloud.talk.jobs.RemoteWipeSuccessWorker -import com.nextcloud.talk.jobs.UploadAndShareFilesWorker import com.nextcloud.talk.models.domain.ConversationModel import com.nextcloud.talk.models.domain.SearchMessageEntry import com.nextcloud.talk.models.json.conversations.ConversationEnums @@ -94,6 +98,7 @@ import com.nextcloud.talk.ui.dialog.FilterConversationFragment.Companion.ARCHIVE import com.nextcloud.talk.ui.dialog.FilterConversationFragment.Companion.MENTION import com.nextcloud.talk.ui.dialog.FilterConversationFragment.Companion.UNREAD import com.nextcloud.talk.utils.ApiUtils +import com.nextcloud.talk.utils.AppTasks import com.nextcloud.talk.utils.BrandingUtils import com.nextcloud.talk.utils.CapabilitiesUtil import com.nextcloud.talk.utils.CapabilitiesUtil.hasSpreedFeatureCapability @@ -101,12 +106,15 @@ import com.nextcloud.talk.utils.CapabilitiesUtil.isServerEOL import com.nextcloud.talk.utils.ClosedInterfaceImpl import com.nextcloud.talk.utils.ConversationUtils import com.nextcloud.talk.utils.ConversationUtils.checkIfVoiceRoom +import com.nextcloud.talk.utils.DeepLinkHandler import com.nextcloud.talk.utils.FileUtils -import com.nextcloud.talk.utils.Mimetype +import com.nextcloud.talk.utils.InternalEntryPoint import com.nextcloud.talk.utils.NotificationUtils import com.nextcloud.talk.utils.ParticipantPermissions import com.nextcloud.talk.utils.RemoteWipeHandler +import com.nextcloud.talk.utils.SecurityUtils import com.nextcloud.talk.utils.ShareUtils +import com.nextcloud.talk.utils.SharedFileCopies import com.nextcloud.talk.utils.ShortcutManagerHelper import com.nextcloud.talk.utils.SpreedFeatures import com.nextcloud.talk.utils.UnifiedPushUtils @@ -132,8 +140,8 @@ import com.nextcloud.talk.utils.singletons.ApplicationWideCurrentRoomHolder import java.util.concurrent.TimeUnit import javax.inject.Inject import kotlinx.coroutines.delay -import kotlinx.coroutines.Job import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.onEach @@ -153,6 +161,9 @@ class ConversationsListActivity : BaseActivity() { // Started without an account on app start, when sharing from other apps and after login. override val allowsDefaultAccount = true + @Inject + lateinit var viewModelFactory: ViewModelProvider.Factory + @Inject lateinit var ncApiCoroutines: NcApiCoroutines @@ -218,8 +229,11 @@ class ConversationsListActivity : BaseActivity() { private var selectedConversation: ConversationModel? = null private var textToPaste: String? = "" private var selectedMessageId: String? = null - private var pendingDirectShareToken: String? = null - private var isDirectShareTarget = false + private var pendingShortcutToken: String? = null + + // set when the list is shown again, see switchToDefaultAccountIfChanged + private var isDefaultAccountCheckDue = false + private var unauthorizedDialog: AlertDialog? = null private var unauthorizedHandling: Job? = null @@ -244,7 +258,7 @@ class ConversationsListActivity : BaseActivity() { } // The source chat finished itself when forwarding started and this list may be the one below it, so finishing - // here would fall back to MainActivity. Instead leave the forward mode and return to the source chat. + // here would leave the app. Instead leave the forward mode and return to the source chat. private fun cancelForwarding() { val sourceRoomToken = intent.getStringExtra(KEY_FORWARD_HIDE_SOURCE_ROOM) intent.removeExtra(KEY_FORWARD_MSG_FLAG) @@ -258,9 +272,24 @@ class ConversationsListActivity : BaseActivity() { } override fun onCreate(savedInstanceState: Bundle?) { + // before super.onCreate(), it switches from the launch theme to postSplashScreenTheme + val splashScreen = installSplashScreen() + // before BaseActivity reads the account + removeExtrasOfApp(intent) super.onCreate(savedInstanceState) NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) + if (isLauncherTapOnAppTask() || finishIfNotShown()) { + // finishIfNotShown() finished the list already + if (!isFinishing) finish() + return + } currentUser = setUpBoundUserOrFinish() ?: return + // kept for a recreation, which would otherwise remove the account from the intent again + intent = intentOfAccount(this, intent, currentUser.id) + if (savedInstanceState == null) { + AppTasks.removeTasksLeftInRecents(this) + } + setUpOncePerProcess() ecosystemManager = EcosystemManager(this@ConversationsListActivity) chatAudioPlayer = ChatAudioPlayer(this, chatAudioStore, lifecycleScope) lifecycle.addObserver(chatAudioPlayer) @@ -274,9 +303,7 @@ class ConversationsListActivity : BaseActivity() { setSupportActionBar(null) forwardMessageState.value = intent.getBooleanExtra(KEY_FORWARD_MSG_FLAG, false) - if (savedInstanceState != null) { - showAccountDialogState.value = savedInstanceState.getBoolean(KEY_ACCOUNT_DIALOG_VISIBLE, false) - } + savedInstanceState?.let(::restoreState) onBackPressedDispatcher.addCallback(this, onBackPressedCallback) val audioPlayerCallbacks = chatAudioPlayerCallbacks( @@ -302,27 +329,120 @@ class ConversationsListActivity : BaseActivity() { handleEcoSystemIntent(it) } initObservers() + + val deepLinkRoutingViewModel = ViewModelProvider(this, viewModelFactory)[DeepLinkRoutingViewModel::class.java] + deepLinkRouting = DeepLinkRouting(this, deepLinkRoutingViewModel) { currentUser.id } + deepLinkRouting.start(intent, savedInstanceState) + // a deep link that starts the app shows the launch screen until its chat covers the list or it asks first + splashScreen.setKeepOnScreenCondition { deepLinkRouting.keepsLaunchScreen } + } + + /** + * Whether the launcher started this list on top of a task of the app it isn't the root of, e.g. one a notification + * or a deep link started: the launcher only brings a task to the front whose root has its intent, so it adds a list + * to any other one, while finishing this list just shows that task. + */ + private fun isLauncherTapOnAppTask(): Boolean = + !isTaskRoot && + intent.action == Intent.ACTION_MAIN && + intent.hasCategory(Intent.CATEGORY_LAUNCHER) && + AppTasks.isRunningAppTask(this, taskId) + + /** + * Passes the intent of another app on, finishes this list and returns true if it is not shown for its intent. + * Without an account, setUpBoundUserOrFinish opens the login. + */ + private fun finishIfNotShown(): Boolean { + val isViewOfOtherApp = isViewOfOtherApp(intent) + val otherIntent = when { + isShareOfOtherApp(intent) -> InternalEntryPoint.createForwardIntent(this, intent, ShareActivity::class.java) + isViewOfOtherApp -> createDeepLinkForwardIntent(intent) + else -> null + } ?: return false + startActivity(otherIntent) + // a task of its own is removed before DeepLinkActivity passes the intent on, so it doesn't end up there + if (isViewOfOtherApp && isTaskRoot) finishAndRemoveTask() else finish() + return true + } + + private fun setUpOncePerProcess() { + if (isAppStartSetUpDone) return + isAppStartSetUpDone = true + setUpPushRegistration() + // a key that became invalid, e.g. after the lock screen of the device was reset, is created anew when it is + // checked + if (appPreferences.isScreenLocked) { + SecurityUtils.createKey(appPreferences.screenLockTimeout) + } + } + + /** Registers for push messages, as the app was started. */ + private fun setUpPushRegistration() { + if (appPreferences.useUnifiedPush) { + UnifiedPushUtils.setPeriodicPushRegistrationWorker(context) + } else { + ClosedInterfaceImpl().setUpPushTokenRegistration() + } + } + + private fun createDeepLinkForwardIntent(intent: Intent): Intent = + InternalEntryPoint.createForwardIntent( + this, + intent, + DeepLinkActivity::class.java, + Intent.FLAG_ACTIVITY_NEW_TASK + ) + + private fun restoreState(savedInstanceState: Bundle) { + showAccountDialogState.value = savedInstanceState.getBoolean(KEY_ACCOUNT_DIALOG_VISIBLE, false) + // the conversation of a shortcut that wasn't opened yet; the one of the intent was taken already, which the + // intent restored after the process was killed has again + pendingShortcutToken = savedInstanceState.getString(KEY_PENDING_SHORTCUT_TOKEN) + intent.removeExtra(KEY_ROOM_TOKEN) + intent.removeExtra(ShortcutManagerCompat.EXTRA_SHORTCUT_ID) } override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) outState.putBoolean(KEY_ACCOUNT_DIALOG_VISIBLE, showAccountDialogState.value) + // Only for a recreated list: the conversation of a shortcut isn't opened anymore once the list is left, and + // before Android 9 the state is saved before the list is stopped, which would restore the one the user left + if (isChangingConfigurations) outState.putString(KEY_PENDING_SHORTCUT_TOKEN, pendingShortcutToken) + if (::deepLinkRouting.isInitialized) deepLinkRouting.onSaveInstanceState(outState) } + // the routing of the deep links and contact chats DeepLinkActivity passes on to this list + private lateinit var deepLinkRouting: DeepLinkRouting + // Set once this list is replaced by one for another account. onRestart and onNewIntent can both trigger that when // the list is brought back with an intent for another account, which must only relaunch it once. private var isRelaunching = false + // set once the shared content was passed on to the chat that was picked for it + private var isSharePassedOn = false + override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) - if (isRelaunching) return + isDefaultAccountCheckDue = false + removeExtrasOfApp(intent) val newUserId = intent.getLongExtra(KEY_INTERNAL_USER_ID, 0L) - if (newUserId != 0L && newUserId != currentUser.id) { - relaunchForAccount(newUserId, intent) - return - } - currentUser.id?.let { intent.putExtra(KEY_INTERNAL_USER_ID, it) } - setIntent(intent) + when { + isShareOfOtherApp(intent) -> + startActivity(InternalEntryPoint.createForwardIntent(this, intent, ShareActivity::class.java)) + isViewOfOtherApp(intent) -> startActivity(createDeepLinkForwardIntent(intent)) + // a list that finished in onCreate has no account or routing; the system doesn't pass intents to a + // finishing activity, so this only guards against reading them uninitialized + !::deepLinkRouting.isInitialized -> Unit + // the list stays for its account and only routes the deep link + deepLinkRouting.onNewIntent(intent) -> Unit + // while relaunching, relaunchForAccount ignores the intent + isRelaunching || newUserId != 0L && newUserId != currentUser.id -> relaunchForAccount(newUserId, intent) + else -> showNewIntent(intent) + } + } + + private fun showNewIntent(intent: Intent) { + setIntent(intentOfAccount(this, intent, currentUser.id)) forwardMessageState.value = intent.getBooleanExtra(KEY_FORWARD_MSG_FLAG, false) conversationsListViewModel.setHideRoomToken(intent.getStringExtra(KEY_FORWARD_HIDE_SOURCE_ROOM)) handleEcoSystemIntent(intent) @@ -446,7 +566,7 @@ class ConversationsListActivity : BaseActivity() { user.username == trimmedAccountName && baseUrl == user.baseUrl?.toUri()?.host } if (user != null && userManager.setUserAsActive(user)) { - startActivity(createAccountSwitchIntent(context, user.id!!)) + startActivity(createInPlaceAccountSwitchIntent(context, user.id!!)) } else { showSnackbar(getString(R.string.nc_no_account_found)) } @@ -483,40 +603,36 @@ class ConversationsListActivity : BaseActivity() { return usesPush } - override fun onRestart() { - super.onRestart() - // The list shows the default account. If another account became the default while the list was in the - // background (e.g. by joining a call for it), switch to that account. Not while forwarding or sharing, which - // would lose the pending message. + /** + * The list shows the default account. If another account became the default while the list was in the background + * (e.g. by joining a call for it), switch to that account. Not while forwarding or sharing, which would lose the + * pending message. The new list gets a plain intent: the list's own one may hold a deep link it already routed, + * which would be routed again, or a contact uri it may not grant. + */ + private fun switchToDefaultAccountIfChanged() { if (isRelaunching) return val defaultUserId = defaultAccountProvider.getDefaultUserBlocking()?.id ?: return - val isPickingConversation = forwardMessage || hasActivityActionSendIntent() + val isPickingConversation = forwardMessage || isShare(intent) if (defaultUserId != currentUser.id && !isPickingConversation) { - isRelaunching = true - startActivity(createAccountSwitchIntent(this, defaultUserId)) + relaunchForAccount(defaultUserId, createIntent(this)) } } override fun onResume() { super.onResume() + // Not while the lock screen covers the list: the list of the default account would start above it and open + // a lock screen of its own. The check stays due for the resume after unlocking. + if (isDefaultAccountCheckDue && !isCoveredByLockScreen) { + isDefaultAccountCheckDue = false + switchToDefaultAccountIfChanged() + // the list of the default account loads its own data + if (isRelaunching) return + } showNotificationWarningState.value = shouldShowNotificationWarning() - showShareToScreenState.value = hasActivityActionSendIntent() - - // Home screen shortcut tap: shortcut uses ACTION_VIEW with KEY_ROOM_TOKEN to open the - // conversation directly without showing the share picker. - if (Intent.ACTION_VIEW == intent.action && intent.hasExtra(KEY_ROOM_TOKEN)) { - pendingDirectShareToken = intent.getStringExtra(KEY_ROOM_TOKEN) - } + showShareToScreenState.value = isShare(intent) - // Share sheet shortcut tap: Android delivers ACTION_SEND with EXTRA_SHORTCUT_ID. - // Extract the room token from the shortcut ID so we can pre-select the conversation. - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && hasActivityActionSendIntent()) { - val shortcutId = intent.getStringExtra(Intent.EXTRA_SHORTCUT_ID) - if (shortcutId != null) { - pendingDirectShareToken = DirectShareHelper.extractTokenFromShortcutId(shortcutId) - } - } + takeConversationOfShortcut() if (!eventBus.isRegistered(this)) { eventBus.register(this) @@ -533,10 +649,23 @@ class ConversationsListActivity : BaseActivity() { } lifecycleScope.launch { - hasMultipleAccountsState.value = userManager.getUsers().size > 1 + val users = userManager.getUsers() + hasMultipleAccountsState.value = users.size > 1 + if (!areShortcutsOfEarlierVersionsUpdated) { + // reset if they couldn't be updated, so a later list tries again + areShortcutsOfEarlierVersionsUpdated = true + var isUpdated = false + try { + isUpdated = withContext(Dispatchers.IO) { + ShortcutManagerHelper.updateShortcutsOfEarlierVersions(context, users) + } + } finally { + areShortcutsOfEarlierVersionsUpdated = isUpdated + } + } } conversationsListViewModel.setHideRoomToken(intent.getStringExtra(KEY_FORWARD_HIDE_SOURCE_ROOM)) - fetchRooms() + openConversationOfShortcutAfter(fetchRooms()) fetchPendingInvitations() conversationsListViewModel.checkIfThreadsExist() @@ -544,6 +673,49 @@ class ConversationsListActivity : BaseActivity() { conversationTagsViewModel.loadConversationTags() } + /** Takes the conversation of the shortcut that started the list from its intent, see [pendingShortcutToken]. */ + private fun takeConversationOfShortcut() { + // Shortcut tap, e.g. on the home screen or of a notification: the shortcut uses ACTION_VIEW with + // KEY_ROOM_TOKEN to open the conversation directly without showing the share picker. It is opened once, not + // again when the list is shown again, e.g. by going back from the conversation or from the recents. + if (Intent.ACTION_VIEW == intent.action && !AppTasks.isRelaunchedFromRecents(intent)) { + intent.getStringExtra(KEY_ROOM_TOKEN)?.takeIf(DeepLinkHandler::isValidToken)?.let { + pendingShortcutToken = it + } + intent.removeExtra(KEY_ROOM_TOKEN) + } + + // Share sheet shortcut tap: Android delivers ACTION_SEND with EXTRA_SHORTCUT_ID, before Android 10 through + // ChooserTargetServiceCompat. Extract the room token from the shortcut ID so we can pre-select the + // conversation. + if (isShare(intent)) { + val target = intent.getStringExtra(ShortcutManagerCompat.EXTRA_SHORTCUT_ID) + ?.let(ShortcutManagerHelper::conversationOfShortcutId) + if (target != null && target.first != currentUser.id) { + // a target of an account shown before, which the system keeps cached: shared with that account + val share = Intent(intent) + lifecycleScope.launch { + if (userManager.getUserWithInternalId(target.first) != null) { + relaunchForAccount(target.first, share) + } + } + } else if (target != null) { + pendingShortcutToken = target.second + } + // preselected once, like the conversation of a shortcut above + intent.removeExtra(ShortcutManagerCompat.EXTRA_SHORTCUT_ID) + } + } + + override fun onStop() { + super.onStop() + // checked when the list is shown again, unless onNewIntent() gets an intent, which chooses its account itself + isDefaultAccountCheckDue = true + // The conversation of a shortcut that isn't in the list yet isn't opened anymore once the list is left. Not + // when it is only paused, e.g. below the request for the notification permission. + if (!isCoveredByLockScreen && !isChangingConfigurations) pendingShortcutToken = null + } + override fun onPause() { super.onPause() conversationListLazyListState?.let { state -> @@ -588,17 +760,6 @@ class ConversationsListActivity : BaseActivity() { DirectShareHelper.publishShareTargetShortcuts(this@ConversationsListActivity, currentUser, list) } - // check for Direct Share - val token = pendingDirectShareToken - if (token != null) { - pendingDirectShareToken = null - val conversation = list.firstOrNull { it.token == token } - if (conversation != null) { - isDirectShareTarget = true - handleConversation(conversation) - } - } - // Update dynamic shortcuts for frequent/favorite conversations ShortcutManagerHelper.updateDynamicShortcuts(context, list, currentUser) @@ -612,6 +773,25 @@ class ConversationsListActivity : BaseActivity() { }.collect() } + // Direct Share: the conversation of a share sheet or home screen shortcut, also after unlocking, as soon as the + // list has it. One that isn't stored yet, e.g. a new one, opens once the sync has added it, at the latest when + // the sync ends, see openConversationOfShortcutAfter. The shared room list isn't queried again on every resume. + collectWhileUnlocked(conversationsListViewModel.getRoomsStateFlow) { list -> + val token = pendingShortcutToken + val conversation = list.firstOrNull { it.token == token } + if (conversation != null) { + pendingShortcutToken = null + handleConversation(conversation) + } + } + + // the conversation of a shortcut doesn't open over a search the user started meanwhile + lifecycleScope.launch { + conversationsListViewModel.isSearchActiveFlow.collect { isSearchActive -> + if (isSearchActive) pendingShortcutToken = null + } + } + lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { contactsViewModel.roomViewState.collect { state -> @@ -690,9 +870,8 @@ class ConversationsListActivity : BaseActivity() { private fun handleNoteToSelfShortcut(noteToSelfAvailable: Boolean, noteToSelfToken: String) { if (noteToSelfAvailable) { - val intent = ChatActivity.createIntent(context, currentUser.id!!, noteToSelfToken) - intent.putExtra(BundleKeys.KEY_FOCUS_INPUT, true) - intent.action = Intent.ACTION_VIEW + // through the list like the other conversation shortcuts, so the chat opens in the task of the app + val intent = ShortcutManagerHelper.createConversationIntent(context, noteToSelfToken, currentUser) ?: return val openNotesString = resources.getString(R.string.open_notes) val shortcut = ShortcutInfoCompat.Builder(context, NOTE_TO_SELF_SHORTCUT_ID) @@ -743,16 +922,11 @@ class ConversationsListActivity : BaseActivity() { } /** - * Opens the chat of an audio message at the message, in its thread if it has one; messages of another account - * switch to that account. + * Opens the chat of an audio message at the message, in its thread if it has one, also of another account: its + * conversation list follows on back. */ private fun openAudioMessage(key: ChatAudioKey, threadId: Long?) { - if (key.internalUserId == currentUser?.id) { - val extras = key.openMessageExtras(threadId) - startActivity(ChatActivity.createIntent(context, key.internalUserId, key.roomToken, extras)) - } else { - startActivity(key.openMessageIntent(this, threadId)) - } + startActivity(key.openMessageIntent(this, threadId)) } fun filterConversation() { @@ -775,21 +949,20 @@ class ConversationsListActivity : BaseActivity() { fun relaunchForAccount(userId: Long, source: Intent = intent) { if (isRelaunching) return isRelaunching = true - val accountIntent = Intent(this, ConversationsListActivity::class.java).apply { + val accountIntent = createIntent(this).apply { action = source.action setDataAndType(source.data, source.type) source.extras?.let { putExtras(it) } clipData = source.clipData putExtra(KEY_INTERNAL_USER_ID, userId) addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + // a share stays out of the recents, see ShareActivity + addFlags(source.flags and Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS) } startActivity(accountIntent) finish() } - private fun hasActivityActionSendIntent(): Boolean = - Intent.ACTION_SEND == intent.action || Intent.ACTION_SEND_MULTIPLE == intent.action - fun showSnackbar(text: String) { lifecycleScope.launch { snackbarHostState.showSnackbar(text) } } @@ -810,8 +983,31 @@ class ConversationsListActivity : BaseActivity() { } } - fun fetchRooms(forceFullSync: Boolean = false) { + fun fetchRooms(forceFullSync: Boolean = false): Job = conversationsListViewModel.getRooms(currentUser, forceFullSync) + + /** + * Opens the conversation of the pending shortcut once [sync] stored the conversations, if the list didn't show it + * before, or tells that it wasn't found. While the lock screen covers the list, it waits for the resume after + * unlocking, which waits for a sync of its own. + */ + private fun openConversationOfShortcutAfter(sync: Job) { + val token = pendingShortcutToken ?: return + lifecycleScope.launch { + sync.join() + // also one the list doesn't show yet + val conversation = conversationsListViewModel.storedRooms().firstOrNull { it.token == token } + withResumed { + if (pendingShortcutToken == token && !isCoveredByLockScreen) { + pendingShortcutToken = null + if (conversation != null) { + handleConversation(conversation) + } else { + showSnackbar(getString(R.string.nc_shortcut_conversation_not_found)) + } + } + } + } } private fun fetchPendingInvitations() { @@ -926,6 +1122,13 @@ class ConversationsListActivity : BaseActivity() { public override fun onDestroy() { super.onDestroy() + // a share left in the list removes its copies, unless they were passed on to the chat or the list of another + // account, see SharedFileCopies + // only a share of ShareActivity, not one of another app that the list only passed on to it + val isShareOfApp = isShare(intent) && !isShareOfOtherApp(intent) + if (isFinishing && isShareOfApp && !isSharePassedOn && !isRelaunching) { + SharedFileCopies.removeSharesLater(ShareListIntentFactory.sharedUrisOf(intent)) + } } @Suppress("Detekt.ComplexMethod") @@ -980,74 +1183,15 @@ class ConversationsListActivity : BaseActivity() { if (textToPaste!!.isNotEmpty()) { openConversation(textToPaste) } else if (filesToShare != null && filesToShare!!.isNotEmpty()) { - showSendFilesConfirmDialog() + // the attachment preview of the chat shows the files and asks before uploading them; they are copies in + // the app (see SharedFileCopies), so reading them needs no permission + openConversation() } else { logger.e(TAG, "no text or files to share") showSnackbar(context.resources.getString(R.string.nc_common_error_sorry)) } } - private fun showSendFilesConfirmDialog() { - if (platformPermissionUtil.isFilesPermissionGranted()) { - if (isDirectShareTarget) { - // Skip the "Send this file to X?" confirmation: the user already picked this exact - // conversation directly from Android's share sheet, so re-confirming the target here - // would just be a redundant extra tap. Go straight to the upload preview screen. - isDirectShareTarget = false - openConversation() - return - } - - val conversation = selectedConversation - val files = filesToShare ?: return - lifecycleScope.launch { - // the share intent hands over uris of other apps: querying their names is a binder call - val fileNamesWithLineBreaks = withContext(Dispatchers.IO) { - files.joinToString(separator = "\n", prefix = "\n", postfix = "\n") { - FileUtils.getFileName(it.toUri(), context) - } - } - // another conversation was picked while the names were read: that click shows its own dialog - if (selectedConversation !== conversation || filesToShare !== files) return@launch - showSendFilesConfirmDialog(fileNamesWithLineBreaks) - } - } else { - UploadAndShareFilesWorker.requestStoragePermission(this) - } - } - - private fun showSendFilesConfirmDialog(fileNamesWithLineBreaks: String) { - val confirmationQuestion: String = if (filesToShare!!.size == 1) { - String.format( - resources!!.getString(R.string.nc_upload_confirm_send_single), - selectedConversation!!.displayName - ) - } else { - String.format( - resources!!.getString(R.string.nc_upload_confirm_send_multiple), - selectedConversation!!.displayName - ) - } - val dialogBuilder = MaterialAlertDialogBuilder(this) - .setIcon(viewThemeUtils.dialog.colorMaterialAlertDialogIcon(context, R.drawable.upload)) - .setTitle(confirmationQuestion) - .setMessage(fileNamesWithLineBreaks) - .setPositiveButton(R.string.nc_yes) { _, _ -> - openConversation() - } - .setNegativeButton(R.string.nc_no) { _, _ -> - Log.d(TAG, "sharing files aborted, going back to share-to screen") - } - - viewThemeUtils.dialog - .colorMaterialAlertDialogBackground(this, dialogBuilder) - val dialog = dialogBuilder.show() - viewThemeUtils.platform.colorTextButtons( - dialog.getButton(AlertDialog.BUTTON_POSITIVE), - dialog.getButton(AlertDialog.BUTTON_NEGATIVE) - ) - } - private fun clearIntentAction() { intent.action = "" } @@ -1055,16 +1199,13 @@ class ConversationsListActivity : BaseActivity() { @Suppress("Detekt.TooGenericExceptionCaught") private fun collectDataFromIntent() { filesToShare = ArrayList() - val intentAction = intent?.action ?: return - if (intentAction != Intent.ACTION_SEND && intentAction != Intent.ACTION_SEND_MULTIPLE) return + textToPaste = "" + if (!isShare(intent)) return try { - val mimeType = intent.type - if (Mimetype.TEXT_PLAIN == mimeType && intent.getStringExtra(Intent.EXTRA_TEXT) != null) { - // Share from Google Chrome sets text/plain MIME type, but also provides a content:// URI - // with a *screenshot* of the current page in getClipData(). - // Here we assume that when sharing a web page the user would prefer to send the URL - // of the current page rather than a screenshot. - textToPaste = intent.getStringExtra(Intent.EXTRA_TEXT) + // ShareActivity only passes the text of a shared text on, without its files, see ShareListIntentFactory + val sharedText = intent.getStringExtra(Intent.EXTRA_TEXT) + if (sharedText != null) { + textToPaste = sharedText } else { extractFilesFromClipData() } @@ -1083,13 +1224,10 @@ class ConversationsListActivity : BaseActivity() { if (clipData != null) { for (i in 0 until clipData.itemCount) { val item = clipData.getItemAt(i) + // all files, so none of the copies ShareActivity made is left behind, and the first text when { item.uri != null -> addFileToShareIfTrusted(item.uri) - item.text != null -> { - textToPaste = item.text.toString() - return - } - + item.text != null -> if (textToPaste!!.isEmpty()) textToPaste = item.text.toString() else -> Log.w(TAG, "datatype not yet implemented for share-to") } } @@ -1099,10 +1237,7 @@ class ConversationsListActivity : BaseActivity() { } private fun addFileToShareIfTrusted(uri: Uri?) { - val isTrusted = uri != null && - ContentResolver.SCHEME_CONTENT == uri.scheme && - (uri.authority != packageName || FileUtils.isSharedAttachmentProviderPath(cacheDir, uri.pathSegments)) - if (isTrusted) { + if (FileUtils.isTrustedShareUri(uri, this)) { filesToShare!!.add(uri.toString()) } else { Log.w(TAG, "Rejecting untrusted uri from share intent: $uri") @@ -1113,15 +1248,6 @@ class ConversationsListActivity : BaseActivity() { super.onRequestPermissionsResult(requestCode, permissions, grantResults) when (requestCode) { - UploadAndShareFilesWorker.REQUEST_PERMISSION -> { - if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) { - Log.d(TAG, "upload starting after permissions were granted") - showSendFilesConfirmDialog() - } else { - showSnackbar(context.getString(R.string.read_storage_no_permission)) - } - } - REQUEST_POST_NOTIFICATIONS_PERMISSION -> { if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) { Log.d(TAG, "Notification permission was granted") @@ -1294,12 +1420,29 @@ class ConversationsListActivity : BaseActivity() { selectedMessageId = null } - val intent = ChatActivity.createIntent(context, currentUser.id!!, selectedConversation!!.token, bundle) - startActivity(intent) + if (isShare(intent)) { + openSharedConversationInAppTask(bundle) + } else { + startActivity(ChatActivity.createIntent(context, currentUser.id!!, selectedConversation!!.token, bundle)) + } clearIntentAction() } + /** + * Opens the chat with the content shared by another app, given in [extras], in the task of the app, and closes + * this list. Back from the chat opens the list of its account, see ChatActivity. + * + * The share sheet starts a share in a task of its own (see [ShareActivity]), which is removed then. + */ + private fun openSharedConversationInAppTask(extras: Bundle) { + val chatIntent = ChatActivity.createIntent(context, currentUser.id!!, selectedConversation!!.token, extras) + // in the task of the app: a plain start could land in the task of this share instead + AppTasks.openChatInAppTask(this, chatIntent) + isSharePassedOn = true + if (isTaskRoot) finishAndRemoveTask() else finish() + } + @Subscribe(sticky = true, threadMode = ThreadMode.BACKGROUND) fun onMessageEvent(eventStatus: EventStatus) { if (eventStatus.userId == currentUser.id) { @@ -1609,9 +1752,7 @@ class ConversationsListActivity : BaseActivity() { } private fun restartApp() { - val intent = Intent(context, MainActivity::class.java) - intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) - startActivity(intent) + startActivity(AppTasks.createRestartIntent(context)) } private fun showOutdatedClientDialog() { @@ -1775,6 +1916,21 @@ class ConversationsListActivity : BaseActivity() { companion object { private val TAG = ConversationsListActivity::class.java.simpleName + // once per process, after they could be updated: the shortcuts of earlier versions don't change while the app + // runs + private var areShortcutsOfEarlierVersionsUpdated = false + + // once per process, not per list: the app starts lists also in further tasks, e.g. for a notification, a + // share or another account + private var isAppStartSetUpDone = false + + /** + * Creates an intent for this activity through its non-exported alias, so that it takes the account + * [BundleKeys.KEY_INTERNAL_USER_ID] and the conversation [BundleKeys.KEY_ROOM_TOKEN] from it. + */ + fun createIntent(context: Context): Intent = + InternalEntryPoint.createIntent(context, ConversationsListActivity::class.java) + /** * Creates an intent that shows the conversation list of the account with the internal id [userId]. * A conversation list already in the task is recreated for that account and all screens above it are @@ -1784,11 +1940,98 @@ class ConversationsListActivity : BaseActivity() { * a second task instead of reusing the current one. */ fun createAccountSwitchIntent(context: Context, userId: Long): Intent = - Intent(context, ConversationsListActivity::class.java).apply { + createIntent(context).apply { putExtra(KEY_INTERNAL_USER_ID, userId) addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) } + /** + * Creates an intent that switches the conversation list in the task, shown or below the screen that starts + * it, to the account [userId]: the list closes the screens above it and starts the list of that account in + * its place, see [relaunchForAccount]. Unlike [createAccountSwitchIntent], the list at the root of the task + * isn't replaced by a new one, which Android shows with the color of the splash screen. + */ + fun createInPlaceAccountSwitchIntent(context: Context, userId: Long): Intent = + createAccountSwitchIntent(context, userId).addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP) + + /** + * The intents that open [screen] of the account [userId] from outside the app, e.g. from a notification, as a + * new back stack in the task of the app, with the conversation list of the account below [screen]. Only + * [screen] is created, and the list when going back, so [screen] removes the tasks of the app that are only + * left in the recents, see [BundleKeys.KEY_TOP_OF_NEW_BACK_STACK]. + */ + fun createBackStack(context: Context, userId: Long, screen: Intent): Array = + arrayOf( + createAccountSwitchIntent(context, userId), + screen.putExtra(BundleKeys.KEY_TOP_OF_NEW_BACK_STACK, true) + ) + + /** Like [createBackStack], for the chat [roomToken] of [userId] with [extras]. */ + fun createChatBackStack( + context: Context, + userId: Long, + roomToken: String, + extras: Bundle? = null + ): Array { + val chatIntent = ChatActivity.createIntent(context, userId, roomToken, extras) + return createBackStack(context, userId, chatIntent) + } + + /** + * Removes the extras that only the app itself may set, the account, the conversation to open and a message to + * forward with the conversation it came from, from an intent that was not started through its + * [InternalEntryPoint]. The activity is exported, so it could come from any app. + */ + @VisibleForTesting + internal fun removeExtrasOfApp(intent: Intent) { + if (!InternalEntryPoint.isStartedThrough(intent, ConversationsListActivity::class.java)) { + intent.removeExtra(KEY_INTERNAL_USER_ID) + intent.removeExtra(KEY_ROOM_TOKEN) + intent.removeExtra(KEY_FORWARD_MSG_FLAG) + intent.removeExtra(KEY_FORWARD_MSG_TEXT) + intent.removeExtra(KEY_FORWARD_HIDE_SOURCE_ROOM) + } + } + + /** + * The new [intent], whose extras of the app were removed, as the app itself started it for the account + * [userId]: kept for a recreation of the activity, which would otherwise remove the account again. The uri of + * a VIEW intent of another app is removed, as the list would take it as a deep link of the app: one that is + * shown is a task relaunched from the recents, whose link was taken already, see [isViewOfOtherApp]. + */ + @VisibleForTesting + internal fun intentOfAccount(context: Context, intent: Intent, userId: Long?): Intent { + val isOfOtherApp = !InternalEntryPoint.isStartedThrough(intent, ConversationsListActivity::class.java) + return Intent(intent) + .setClassName(context, InternalEntryPoint.nameOf(ConversationsListActivity::class.java)) + .apply { + if (isOfOtherApp && action == Intent.ACTION_VIEW) setDataAndType(null, type) + userId?.let { putExtra(KEY_INTERNAL_USER_ID, it) } + } + } + + /** + * Whether [intent] is a VIEW intent of another app with a uri, e.g. of a pinned shortcut that targets the + * launcher activity, which is taken through [DeepLinkActivity]. Not when a task is relaunched from the recents + * with the intent it was started with, whose link was taken already: before Android 10, that intent names the + * target of the alias it was started through, so a task of a deep link that DeepLinkActivity passed on would + * look like one of another app, and passing it on again would route its link again. + */ + @VisibleForTesting + internal fun isViewOfOtherApp(intent: Intent): Boolean = + !InternalEntryPoint.isStartedThrough(intent, ConversationsListActivity::class.java) && + intent.action == Intent.ACTION_VIEW && + intent.data != null && + !AppTasks.isRelaunchedFromRecents(intent) + + /** Whether [intent] shares content of another app, which is taken through [ShareActivity]. */ + @VisibleForTesting + internal fun isShareOfOtherApp(intent: Intent): Boolean = + !InternalEntryPoint.isStartedThrough(intent, ConversationsListActivity::class.java) && isShare(intent) + + private fun isShare(intent: Intent): Boolean = + Intent.ACTION_SEND == intent.action || Intent.ACTION_SEND_MULTIPLE == intent.action + private const val FOREGROUND_REFRESH_INTERVAL_MILLIS = 30_000L const val BOTTOM_SHEET_DELAY: Long = 2500 const val SEARCH_DEBOUNCE_INTERVAL_MS = 300 @@ -1804,5 +2047,6 @@ class ConversationsListActivity : BaseActivity() { const val ROOM_TYPE_ONE_ONE = "1" private const val NOTE_TO_SELF_SHORTCUT_ID = "NOTE_TO_SELF_SHORTCUT_ID" private const val KEY_ACCOUNT_DIALOG_VISIBLE = "account_dialog_visible" + private const val KEY_PENDING_SHORTCUT_TOKEN = "pending_shortcut_token" } } diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/DirectShareHelper.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/DirectShareHelper.kt index 75e8919077d..11baa306cec 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/DirectShareHelper.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/DirectShareHelper.kt @@ -29,7 +29,7 @@ import com.nextcloud.talk.models.domain.ConversationModel import com.nextcloud.talk.utils.ApiUtils import com.nextcloud.talk.utils.AvatarImageLoader import com.nextcloud.talk.utils.ConversationUtils -import com.nextcloud.talk.utils.bundle.BundleKeys +import com.nextcloud.talk.utils.ShortcutManagerHelper import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -37,8 +37,7 @@ object DirectShareHelper { private val TAG = DirectShareHelper::class.java.simpleName private const val SHARE_TARGET_CATEGORY = "com.nextcloud.talk.sharing.SHARE_TARGET_CATEGORY" - private const val SHORTCUT_ID_PREFIX = "direct_share_" - private const val SHORTCUT_ID_MIN_PARTS = 4 + const val SHORTCUT_ID_PREFIX = "direct_share_" private const val AVATAR_SIZE_PX = 256 private val publishMutex = Mutex() @@ -169,18 +168,15 @@ object DirectShareHelper { } /** - * Parses the room token from a shortcut ID of the form "direct_share_{userId}_{token}". - * Returns null if the ID does not belong to this app's direct share shortcuts. + * Whether [shortcutId] is a direct share shortcut the app published, so one the share sheet can show: still + * dynamic, or cached by the system after a later publication replaced it. A binder call. */ - fun extractTokenFromShortcutId(shortcutId: String): String? { - if (!shortcutId.startsWith(SHORTCUT_ID_PREFIX)) return null - val parts = shortcutId.split("_") - return if (parts.size < SHORTCUT_ID_MIN_PARTS) { - null - } else { - parts.drop(SHORTCUT_ID_MIN_PARTS - 1).joinToString("_") - } - } + fun isPublishedShareTarget(context: Context, shortcutId: String): Boolean = + shortcutId.startsWith(SHORTCUT_ID_PREFIX) && + ShortcutManagerCompat.getShortcuts( + context, + ShortcutManagerCompat.FLAG_MATCH_DYNAMIC or ShortcutManagerCompat.FLAG_MATCH_CACHED + ).any { it.id == shortcutId } private fun shortcutId(user: User, token: String): String = "$SHORTCUT_ID_PREFIX${user.id}_$token" @@ -280,13 +276,11 @@ object DirectShareHelper { displayName: String, icon: IconCompat ): ShortcutInfoCompat.Builder { - // ACTION_VIEW is used so that tapping this shortcut on the home screen opens the - // conversation directly. When used from the share sheet, Android delivers ACTION_SEND - // with the shared content and EXTRA_SHORTCUT_ID, not this intent. - val intent = Intent(Intent.ACTION_VIEW, null, context, ConversationsListActivity::class.java).apply { - putExtra(BundleKeys.KEY_ROOM_TOKEN, token) - putExtra(BundleKeys.KEY_INTERNAL_USER_ID, user.id) - } + // Tapping this shortcut, e.g. on the home screen, opens the conversation directly. When used from the share + // sheet, Android delivers ACTION_SEND with the shared content and EXTRA_SHORTCUT_ID, not this intent. + // without an account id only the list, with an action, which a shortcut needs + val intent = ShortcutManagerHelper.createConversationIntent(context, token, user) + ?: ConversationsListActivity.createIntent(context).setAction(Intent.ACTION_VIEW) val person = Person.Builder() .setName(displayName) diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/ShareActivity.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/ShareActivity.kt new file mode 100644 index 00000000000..f6cf0fc651c --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ShareActivity.kt @@ -0,0 +1,74 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.conversationlist + +import android.os.Bundle +import android.view.Gravity +import android.widget.FrameLayout +import android.widget.Toast +import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.withStarted +import com.google.android.material.progressindicator.CircularProgressIndicator +import com.nextcloud.talk.R +import com.nextcloud.talk.utils.SharedFileCopies +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.launch + +/** + * Exported entry point for the content other apps share to Talk, from the share sheet or one of its direct share + * targets. It copies the shared files into the app (see [SharedFileCopies]), so the chat and the upload don't depend + * on the read access of the other app, and opens the conversation list to pick the conversation with only that + * content, see [ShareListIntentFactory]. + * + * The share sheet starts it in a task of its own, which is kept out of the recents, also after the list replaced it + * there: the chat is opened in the task of the app (see ConversationsListActivity.openSharedConversationInAppTask). + */ +class ShareActivity : AppCompatActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + // shown while large files are copied + setContentView( + FrameLayout(this).apply { + addView( + CircularProgressIndicator(context).apply { isIndeterminate = true }, + FrameLayout.LayoutParams( + FrameLayout.LayoutParams.WRAP_CONTENT, + FrameLayout.LayoutParams.WRAP_CONTENT, + Gravity.CENTER + ) + ) + } + ) + lifecycleScope.launch { + val shareListIntent = ShareListIntentFactory(this@ShareActivity).create(intent) + try { + // e.g. the screen was turned off while the files were copied: the list opens when it is shown again + lifecycle.withStarted { + if (shareListIntent != null) { + startActivity(shareListIntent) + } else { + Toast.makeText(this@ShareActivity, R.string.nc_common_error_sorry, Toast.LENGTH_LONG).show() + } + finish() + } + } catch (e: CancellationException) { + // closed before it opened the list, which otherwise removes the copies of a share that is left there + shareListIntent?.let { SharedFileCopies.removeSharesLater(ShareListIntentFactory.sharedUrisOf(it)) } + throw e + } + } + } + + // Left with Home or the recents while the files are copied: its task is kept out of the recents, so the share + // couldn't be continued and is left. + override fun onUserLeaveHint() { + super.onUserLeaveHint() + finish() + } +} diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/ShareListIntentFactory.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/ShareListIntentFactory.kt new file mode 100644 index 00000000000..e001eed765b --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ShareListIntentFactory.kt @@ -0,0 +1,170 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.conversationlist + +import android.content.ClipData +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Parcelable +import android.util.Log +import androidx.core.content.FileProvider +import androidx.core.content.IntentCompat +import androidx.core.content.pm.ShortcutManagerCompat +import com.nextcloud.talk.utils.FileUtils +import com.nextcloud.talk.utils.Mimetype +import com.nextcloud.talk.utils.SharedFileCopies +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.withContext +import java.io.File +import java.io.InputStream +import java.io.OutputStream + +/** + * Creates the intent of the conversation list in share mode for the content another app shared to [ShareActivity], + * with the text or the copies of the shared files, or none if neither is left, e.g. when no shared file could be read. + * The other extras of a shared intent could come from any app, so + * only the id of a direct share target is taken over, and only if the app published it. + * + * [uriOfCopy] gives the uri a copy is shared with, and [isPublishedShareTarget] whether the app published a direct + * share shortcut; both can be replaced in tests, which have neither the file provider paths nor the shortcuts. + */ +internal class ShareListIntentFactory( + private val context: Context, + private val uriOfCopy: (File) -> Uri = { FileProvider.getUriForFile(context, context.packageName, it) }, + private val isPublishedShareTarget: (String) -> Boolean = { DirectShareHelper.isPublishedShareTarget(context, it) } +) { + + @Suppress("Detekt.TooGenericExceptionCaught") + suspend fun create(sharedIntent: Intent): Intent? = + try { + createListIntent(sharedIntent) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + // the extras of another app can be malformed, e.g. a bundle that can't be read + Log.w(TAG, "Failed to read the shared content", e) + null + } + + private suspend fun createListIntent(sharedIntent: Intent): Intent? { + val listIntent = ConversationsListActivity.createIntent(context) + .setAction(sharedIntent.action) + .setType(sharedIntent.type) + .addFlags(Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS) + // Android starts a direct share as the sharing app, so any app can set a shortcut id: the list only preselects + // the conversation of one the share sheet can show, and the chat still asks before sending the content + val shortcutId = sharedIntent.getStringExtra(ShortcutManagerCompat.EXTRA_SHORTCUT_ID) + val isPublished = shortcutId != null && withContext(Dispatchers.IO) { isPublishedShareTarget(shortcutId) } + if (isPublished) listIntent.putExtra(ShortcutManagerCompat.EXTRA_SHORTCUT_ID, shortcutId) + + // of a shared text only the text is passed on, e.g. not the screenshot a browser adds to a shared page: the + // list takes the text if there is one, and the files otherwise + val text = sharedIntent.getStringExtra(Intent.EXTRA_TEXT) + if (sharedIntent.type == Mimetype.TEXT_PLAIN && text != null) { + return listIntent.putExtra(Intent.EXTRA_TEXT, text) + } + // created with the first file that is copied: a share without one leaves no directory behind, and doesn't wait + // for WorkManager to tell which copies of older shares can be removed + val directoryLazy = lazy { SharedFileCopies.createDirectory(context) } + val items = try { + withContext(Dispatchers.IO) { copySharedItems(sharedIntent, directoryLazy) } + } catch (e: CancellationException) { + // also when it is cancelled right after the last file was copied + if (directoryLazy.isInitialized()) directoryLazy.value?.deleteRecursively() + throw e + } + // none without items, as the list could only show an error when a conversation is picked + return if (items.isEmpty()) null else listIntent.apply { clipData = clipDataOf(sharedIntent.type, items) } + } + + private fun clipDataOf(type: String?, items: List): ClipData = + ClipData(null, arrayOf(type ?: "*/*"), items.first()).apply { items.drop(1).forEach { addItem(it) } } + + /** + * The shared texts, and copies of the shared files in the directory of [directoryLazy] that may be uploaded, see + * [FileUtils.isTrustedShareUri]. Stops copying when it is cancelled, e.g. when the share is left. + */ + private suspend fun copySharedItems(sharedIntent: Intent, directoryLazy: Lazy): List { + val clipData = sharedIntent.clipData + val sharedItems = if (clipData != null) { + (0 until clipData.itemCount).map { clipData.getItemAt(it) } + } else { + sharedStreams(sharedIntent).map { ClipData.Item(it) } + } + return sharedItems.mapIndexedNotNull { index, item -> + currentCoroutineContext().ensureActive() + when { + item.uri != null -> + copyToShareDirectory(item.uri) { directoryLazy.value?.let { File(it, index.toString()) } } + ?.let { ClipData.Item(it) } + item.text != null -> ClipData.Item(item.text) + else -> null + } + } + } + + // without clip data, e.g. if the sharing app didn't start it with Context.startActivity, or if Android couldn't + // take the streams into it because one isn't a uri: below API 34 the list isn't checked when it's read + private fun sharedStreams(sharedIntent: Intent): List = + sharedIntent.data?.let { listOf(it) } + ?: IntentCompat.getParcelableArrayListExtra(sharedIntent, Intent.EXTRA_STREAM, Parcelable::class.java) + ?.filterIsInstance() + ?: listOfNotNull(IntentCompat.getParcelableExtra(sharedIntent, Intent.EXTRA_STREAM, Uri::class.java)) + + /** + * Copies the shared file [uri] into the directory [directoryOf] gives and returns the uri of the copy, or null if + * it can't be used. + */ + @Suppress("Detekt.TooGenericExceptionCaught") + private suspend fun copyToShareDirectory(uri: Uri, directoryOf: () -> File?): Uri? { + if (!FileUtils.isTrustedShareUri(uri, context)) { + Log.w(TAG, "Rejecting untrusted uri from share intent: $uri") + return null + } + return try { + val file = directoryOf()?.takeIf { it.mkdirs() }?.let { + FileUtils.resolveFileInDirectory(it, FileUtils.getFileName(uri, context)) ?: File(it, FILE_NAME) + } + val isCopied = file != null && + context.contentResolver.openInputStream(uri) + ?.use { input -> file.outputStream().use { input.copyToCancellable(it) } } != null + if (isCopied) uriOfCopy(file) else null + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + // the provider of another app can throw anything + Log.w(TAG, "Failed to copy the shared file $uri", e) + null + } + } + + /** Copies this stream to [output] like [InputStream.copyTo], but stops between two buffers when cancelled. */ + private suspend fun InputStream.copyToCancellable(output: OutputStream) { + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + while (true) { + currentCoroutineContext().ensureActive() + val count = read(buffer) + if (count < 0) break + output.write(buffer, 0, count) + } + } + + companion object { + private val TAG = ShareListIntentFactory::class.java.simpleName + + /** The uris of the shared files the list intent of a share passes on, the copies of [SharedFileCopies]. */ + fun sharedUrisOf(listIntent: Intent): List { + val clip = listIntent.clipData ?: return emptyList() + return (0 until clip.itemCount).mapNotNull { clip.getItemAt(it).uri?.toString() } + } + private const val FILE_NAME = "file" + } +} diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/viewmodels/ConversationsListViewModel.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/viewmodels/ConversationsListViewModel.kt index 47668f917c7..8d29a9c5f21 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/viewmodels/ConversationsListViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/viewmodels/ConversationsListViewModel.kt @@ -62,6 +62,7 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.launchIn @@ -187,6 +188,9 @@ class ConversationsListViewModel @AssistedInject constructor( } .stateIn(viewModelScope, SharingStarted.Eagerly, listOf()) + /** The conversations stored now, also those a sync stored that [getRoomsStateFlow] doesn't have yet. */ + suspend fun storedRooms(): List = repository.roomListFlow(currentUser.id!!).first() + /** * Drives the shimmer skeleton visibility. Visible while rooms are still being loaded for * the first time (no data in the list yet). Hidden as soon as either the room list is diff --git a/app/src/main/java/com/nextcloud/talk/dagger/modules/ViewModelModule.kt b/app/src/main/java/com/nextcloud/talk/dagger/modules/ViewModelModule.kt index ac432bfd240..49e846df44a 100644 --- a/app/src/main/java/com/nextcloud/talk/dagger/modules/ViewModelModule.kt +++ b/app/src/main/java/com/nextcloud/talk/dagger/modules/ViewModelModule.kt @@ -13,6 +13,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import com.nextcloud.talk.account.viewmodels.BrowserLoginActivityViewModel import com.nextcloud.talk.activities.CallViewModel +import com.nextcloud.talk.activities.DeepLinkRoutingViewModel import com.nextcloud.talk.attachmentpreview.FileAttachmentPreviewViewModel import com.nextcloud.talk.contextchat.ContextChatViewModel import com.nextcloud.talk.conversationinfo.viewmodel.ConversationInfoViewModel @@ -154,6 +155,11 @@ abstract class ViewModelModule { @ViewModelKey(CallViewModel::class) abstract fun callViewModel(viewModel: CallViewModel): ViewModel + @Binds + @IntoMap + @ViewModelKey(DeepLinkRoutingViewModel::class) + abstract fun deepLinkRoutingViewModel(viewModel: DeepLinkRoutingViewModel): ViewModel + @Binds @IntoMap @ViewModelKey(FileAttachmentPreviewViewModel::class) diff --git a/app/src/main/java/com/nextcloud/talk/errorhandling/ExceptionHandler.kt b/app/src/main/java/com/nextcloud/talk/errorhandling/ExceptionHandler.kt index dac4e485ce2..56e61fecf3f 100644 --- a/app/src/main/java/com/nextcloud/talk/errorhandling/ExceptionHandler.kt +++ b/app/src/main/java/com/nextcloud/talk/errorhandling/ExceptionHandler.kt @@ -12,6 +12,8 @@ import android.content.Intent import android.util.Log import com.nextcloud.talk.application.NextcloudTalkApplication import com.nextcloud.talk.dagger.modules.UtilsModule +import com.nextcloud.talk.utils.finishAndRemoveTaskIfPresent +import com.nextcloud.talk.utils.taskInfoOrNull import java.io.File class ExceptionHandler( @@ -77,11 +79,11 @@ class ExceptionHandler( val am = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager val crashClass = ShowErrorActivity::class.java.name for (task in am.appTasks) { - val info = task.taskInfo ?: continue + val info = task.taskInfoOrNull() ?: continue val isCrashTask = info.topActivity?.className == crashClass || info.baseActivity?.className == crashClass if (!isCrashTask) { - task.finishAndRemoveTask() + task.finishAndRemoveTaskIfPresent() } } } catch (fatal: Exception) { diff --git a/app/src/main/java/com/nextcloud/talk/invitation/InvitationsActivity.kt b/app/src/main/java/com/nextcloud/talk/invitation/InvitationsActivity.kt index e7766da05c6..46349603917 100644 --- a/app/src/main/java/com/nextcloud/talk/invitation/InvitationsActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/invitation/InvitationsActivity.kt @@ -9,7 +9,6 @@ package com.nextcloud.talk.invitation import android.content.Intent import android.os.Bundle import android.view.View -import androidx.activity.OnBackPressedCallback import androidx.core.graphics.drawable.toDrawable import androidx.lifecycle.ViewModelProvider import autodagger.AutoInjector @@ -19,14 +18,12 @@ import com.nextcloud.talk.activities.BaseActivity import com.nextcloud.talk.api.NcApi import com.nextcloud.talk.application.NextcloudTalkApplication import com.nextcloud.talk.chat.ChatActivity -import com.nextcloud.talk.conversationlist.ConversationsListActivity import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.databinding.ActivityInvitationsBinding import com.nextcloud.talk.invitation.adapters.InvitationsAdapter import com.nextcloud.talk.invitation.data.ActionEnum import com.nextcloud.talk.invitation.data.Invitation import com.nextcloud.talk.invitation.viewmodels.InvitationsViewModel -import com.nextcloud.talk.utils.bundle.BundleKeys import javax.inject.Inject @AutoInjector(NextcloudTalkApplication::class) @@ -46,14 +43,6 @@ class InvitationsActivity : BaseActivity() { private lateinit var currentUser: User - private val onBackPressedCallback = object : OnBackPressedCallback(true) { - override fun handleOnBackPressed() { - val intent = Intent(this@InvitationsActivity, ConversationsListActivity::class.java) - intent.putExtra(BundleKeys.KEY_INTERNAL_USER_ID, currentUser.id) - startActivity(intent) - } - } - override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) @@ -75,8 +64,6 @@ class InvitationsActivity : BaseActivity() { binding.invitationsRecyclerView.adapter = adapter initObservers() - - onBackPressedDispatcher.addCallback(this, onBackPressedCallback) } enum class InvitationAction { diff --git a/app/src/main/java/com/nextcloud/talk/jobs/NotificationWorker.kt b/app/src/main/java/com/nextcloud/talk/jobs/NotificationWorker.kt index e05a7398292..b21b1ad4b92 100644 --- a/app/src/main/java/com/nextcloud/talk/jobs/NotificationWorker.kt +++ b/app/src/main/java/com/nextcloud/talk/jobs/NotificationWorker.kt @@ -32,6 +32,7 @@ import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.app.Person import androidx.core.app.RemoteInput +import androidx.core.app.TaskStackBuilder import androidx.core.content.ContextCompat import androidx.core.content.pm.ShortcutInfoCompat import androidx.core.content.pm.ShortcutManagerCompat @@ -50,15 +51,17 @@ import com.bluelinelabs.logansquare.LoganSquare import com.nextcloud.talk.BuildConfig import com.nextcloud.talk.R import com.nextcloud.talk.activities.CallActivity -import com.nextcloud.talk.activities.MainActivity import com.nextcloud.talk.api.NcApi import com.nextcloud.talk.application.NextcloudTalkApplication import com.nextcloud.talk.application.NextcloudTalkApplication.Companion.sharedApplication import com.nextcloud.talk.arbitrarystorage.ArbitraryStorageManager import com.nextcloud.talk.callnotification.CallNotificationActivity +import com.nextcloud.talk.chat.ChatActivity import com.nextcloud.talk.chat.data.network.ChatNetworkDataSource +import com.nextcloud.talk.conversationlist.ConversationsListActivity import com.nextcloud.talk.conversationlist.DirectShareHelper import com.nextcloud.talk.data.user.model.User +import com.nextcloud.talk.invitation.InvitationsActivity import com.nextcloud.talk.logger.Logger import com.nextcloud.talk.models.domain.ConversationModel import com.nextcloud.talk.models.json.chat.ChatUtils.Companion.getParsedMessage @@ -78,7 +81,9 @@ import com.nextcloud.talk.utils.ActorAvatar import com.nextcloud.talk.utils.ApiUtils import com.nextcloud.talk.utils.CharacterAvatarUtils import com.nextcloud.talk.utils.ConversationUtils +import com.nextcloud.talk.utils.DeepLinkHandler import com.nextcloud.talk.utils.DisplayUtils +import com.nextcloud.talk.utils.InternalEntryPoint import com.nextcloud.talk.utils.NotificationUtils import com.nextcloud.talk.utils.NotificationUtils.cancelAllNotificationsForAccount import com.nextcloud.talk.utils.NotificationUtils.cancelNotification @@ -87,6 +92,7 @@ import com.nextcloud.talk.utils.NotificationUtils.getCallRingtoneUri import com.nextcloud.talk.utils.NotificationUtils.loadAvatarSync import com.nextcloud.talk.utils.ParticipantPermissions import com.nextcloud.talk.utils.PushUtils +import com.nextcloud.talk.utils.ShortcutManagerHelper import com.nextcloud.talk.utils.bundle.BundleKeys import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_DISMISS_RECORDING_URL import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_FROM_NOTIFICATION_START_CALL @@ -96,7 +102,6 @@ import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_NOTIFICATION_ID import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_NOTIFICATION_RESTRICT_DELETION import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_NOTIFICATION_TIMESTAMP import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_OPENED_VIA_NOTIFICATION -import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_REMOTE_TALK_SHARE import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_ROOM_ONE_TO_ONE import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_ROOM_TOKEN import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_SHARE_RECORDING_TO_CHAT_URL @@ -212,14 +217,11 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor } private fun handleInternalPushMessage() { - val intent = Intent(context, MainActivity::class.java) - intent.flags = getIntentFlags() - showNotification(intent, null) + showNotification(ConversationsListActivity.createAccountSwitchIntent(context!!, user.id!!), null) } private fun handleNonCallPushMessage() { - val mainActivityIntent = createMainActivityIntent() - getNcDataAndShowNotification(mainActivityIntent) + getNcDataAndShowNotification(createContentIntent()) } /** @@ -239,13 +241,9 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor } private fun handleRemoteTalkSharePushMessage() { - val mainActivityIntent = Intent(context, MainActivity::class.java) - mainActivityIntent.flags = getIntentFlags() - val bundle = Bundle() - bundle.putLong(KEY_INTERNAL_USER_ID, user.id!!) - bundle.putBoolean(KEY_REMOTE_TALK_SHARE, true) - mainActivityIntent.putExtras(bundle) - getNcDataAndShowNotification(mainActivityIntent) + getNcDataAndShowNotification( + Intent(context, InvitationsActivity::class.java).putExtra(KEY_INTERNAL_USER_ID, user.id!!) + ) } @Suppress("LongMethod", "TooGenericExceptionCaught") @@ -299,18 +297,10 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor context, requestCode, fullScreenIntent, - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT - } else { - PendingIntent.FLAG_UPDATE_CURRENT - } + pendingIntentFlags() ) - val pendingIntentFlags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT - } else { - PendingIntent.FLAG_UPDATE_CURRENT - } + val pendingIntentFlags = pendingIntentFlags() val isVideoCall = (conversation.callFlag and ParticipantDto.InCallFlags.WITH_VIDEO) > 0 @@ -945,9 +935,11 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor private fun pushConversationShortcut(notificationBuilder: NotificationCompat.Builder, avatarBitmap: Bitmap?) { val notificationUser = pushMessage.notificationUser ?: return - val roomToken = pushMessage.id ?: return + val roomToken = pushMessage.id?.takeIf(DeepLinkHandler::isValidToken) ?: return + // like the conversation shortcuts of the conversation list, which use the same id + val intent = ShortcutManagerHelper.createConversationIntent(context!!, roomToken, user) ?: return - val shortcutId = "conversation_${user.id}_$roomToken" + val shortcutId = ShortcutManagerHelper.getShortcutId(roomToken, user.id!!) val personBuilder = Person.Builder() .setKey(user.id.toString() + "@" + notificationUser.id) @@ -957,12 +949,6 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor personBuilder.setIcon(IconCompat.createWithBitmap(avatarBitmap)) } - val intent = Intent(context, MainActivity::class.java).apply { - action = Intent.ACTION_VIEW - putExtra(KEY_ROOM_TOKEN, roomToken) - putExtra(KEY_INTERNAL_USER_ID, user.id) - } - val shortcut = ShortcutInfoCompat.Builder(context!!, shortcutId) .setShortLabel(pushMessage.subject.ifEmpty { notificationUser.name ?: "Chat" }) .setLongLived(true) @@ -989,13 +975,7 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor actualIntent.putExtra(KEY_ROOM_TOKEN, pushMessage.id) actualIntent.putExtra(KEY_MESSAGE_ID, messageId) - val intentFlag: Int = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - val mutabilityFlag = if (mutable) PendingIntent.FLAG_MUTABLE else PendingIntent.FLAG_IMMUTABLE - mutabilityFlag or PendingIntent.FLAG_UPDATE_CURRENT - } else { - PendingIntent.FLAG_UPDATE_CURRENT - } - return PendingIntent.getBroadcast(context, systemNotificationId, actualIntent, intentFlag) + return PendingIntent.getBroadcast(context, systemNotificationId, actualIntent, pendingIntentFlags(mutable)) } private fun addMarkAsReadAction(notificationBuilder: NotificationCompat.Builder, systemNotificationId: Int) { @@ -1066,11 +1046,7 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor dismissIntent.putExtra(KEY_INTERNAL_USER_ID, user.id) dismissIntent.putExtra(KEY_DISMISS_RECORDING_URL, dismissRecordingUrl) - val intentFlag: Int = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT - } else { - PendingIntent.FLAG_UPDATE_CURRENT - } + val intentFlag = pendingIntentFlags() val dismissPendingIntent = PendingIntent.getBroadcast(context, systemNotificationId, dismissIntent, intentFlag) val dismissAction = NotificationCompat.Action.Builder(R.drawable.ic_delete, dismissLabel, dismissPendingIntent) @@ -1101,11 +1077,7 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor shareRecordingIntent.putExtra(KEY_SHARE_RECORDING_TO_CHAT_URL, shareToChatUrl) shareRecordingIntent.putExtra(KEY_ROOM_TOKEN, pushMessage.id) - val intentFlag: Int = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT - } else { - PendingIntent.FLAG_UPDATE_CURRENT - } + val intentFlag = pendingIntentFlags() val shareRecordingPendingIntent = PendingIntent.getBroadcast( context, systemNotificationId, @@ -1251,7 +1223,7 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor .NOTIFICATION_CHANNEL_MESSAGES_V4.name ) - val intent = createMainActivityIntent() + val intent = createContentIntent() val notification: Notification = notificationBuilder .setContentTitle( @@ -1285,27 +1257,46 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor } } - private fun createMainActivityIntent(): Intent { - val intent = Intent(context, MainActivity::class.java) - intent.flags = getIntentFlags() - val bundle = Bundle() - bundle.putString(KEY_ROOM_TOKEN, pushMessage.id) - bundle.putLong(KEY_INTERNAL_USER_ID, user.id!!) - bundle.putBoolean(KEY_OPENED_VIA_NOTIFICATION, true) - intent.putExtras(bundle) - return intent + /** + * The intent the notification opens: the chat of its conversation, or else the conversation list of its account. + */ + private fun createContentIntent(): Intent { + val roomToken = pushMessage.id?.takeIf(DeepLinkHandler::isValidToken) + ?: return ConversationsListActivity.createAccountSwitchIntent(context!!, user.id!!) + val extras = Bundle().apply { putBoolean(KEY_OPENED_VIA_NOTIFICATION, true) } + return ChatActivity.createIntent(context!!, user.id!!, roomToken, extras) + } + + /** + * The flags of the pending intents of a notification. They are immutable unless [mutable], e.g. for a direct reply, + * so that apps that get hold of them, like a notification listener, can't add extras when sending them: before + * Android 12 they were mutable by default. + */ + private fun pendingIntentFlags(mutable: Boolean = false): Int { + val mutabilityFlag = when { + !mutable -> PendingIntent.FLAG_IMMUTABLE + Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> PendingIntent.FLAG_MUTABLE + else -> 0 + } + return mutabilityFlag or PendingIntent.FLAG_UPDATE_CURRENT } private fun createUniquePendingIntent(intent: Intent): PendingIntent? { // Use unique request code to make sure that a new PendingIntent gets created for each notification // See https://github.com/nextcloud/talk-android/issues/2111 val requestCode = System.currentTimeMillis().toInt() - val intentFlag: Int = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + val intentFlag = pendingIntentFlags() + val isConversationList = InternalEntryPoint.isStartedThrough(intent, ConversationsListActivity::class.java) + return if (!isConversationList) { + // a new back stack in the task of the app, with the conversation list of its account below the screen, + // which back leads to + val backStack = TaskStackBuilder.create(context!!) + ConversationsListActivity.createBackStack(context!!, user.id!!, intent) + .forEach { backStack.addNextIntent(it) } + backStack.getPendingIntent(requestCode, intentFlag) } else { - PendingIntent.FLAG_UPDATE_CURRENT + PendingIntent.getActivity(context, requestCode, intent, intentFlag) } - return PendingIntent.getActivity(context, requestCode, intent, intentFlag) } private fun getIntentFlags(): Int = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP diff --git a/app/src/main/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorker.kt b/app/src/main/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorker.kt index 0fdda7cb244..f20e6d263b5 100644 --- a/app/src/main/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorker.kt +++ b/app/src/main/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorker.kt @@ -49,6 +49,8 @@ import com.nextcloud.talk.utils.FileUtils import com.nextcloud.talk.utils.ImageCompressor import com.nextcloud.talk.utils.NotificationUtils import com.nextcloud.talk.utils.RemoteFileUtils +import com.nextcloud.talk.utils.SharedFileCopies +import com.nextcloud.talk.utils.UploadSourceGrants import com.nextcloud.talk.utils.VideoCompressor import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_INTERNAL_USER_ID import com.nextcloud.talk.utils.permissions.PlatformPermissionUtil @@ -129,7 +131,11 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa } try { - return doUpload() + return doUpload().also { result -> + // not for a retry, which reads the file again + val sourceFile = inputData.getString(DEVICE_SOURCE_FILE) + if (result != Result.retry() && sourceFile != null) UploadSourceGrants.release(context, sourceFile, id) + } } finally { referenceId?.let { cancelledReferenceIds.remove(it) } } @@ -576,6 +582,10 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa WorkRequest.MIN_BACKOFF_MILLIS, TimeUnit.MILLISECONDS ) + // keeps a copy of a shared file until its upload is done + .apply { SharedFileCopies.uploadTagOf(fileUri)?.let { addTag(it) } } + // keeps the read access to a picked file until its upload is done + .apply { UploadSourceGrants.uploadTagOf(fileUri)?.let { addTag(it) } } .build() // Chained per conversation (not enqueueUniqueWork(fileUri, ...), which ran every upload // fully independently) so multiple files - whether from one multi-file share or several @@ -599,9 +609,11 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa // Cancellation is by the WorkRequest's own id (not enqueueUniqueWork's name) since that name // is now shared by every file queued in the same conversation - cancelling by name would // cancel the whole queue instead of just this one upload. - fun cancelUpload(referenceId: String, workerId: UUID) { + fun cancelUpload(referenceId: String, workerId: UUID, fileUri: String?) { cancelledReferenceIds.add(referenceId) WorkManager.getInstance().cancelWorkById(workerId) + // an upload that didn't start doesn't release the read access to its file itself + fileUri?.let { UploadSourceGrants.releaseLater(it, exceptWorkId = workerId) } } } } diff --git a/app/src/main/java/com/nextcloud/talk/lock/LockedActivity.kt b/app/src/main/java/com/nextcloud/talk/lock/LockedActivity.kt index 85840f558b5..2b664b9041b 100644 --- a/app/src/main/java/com/nextcloud/talk/lock/LockedActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/lock/LockedActivity.kt @@ -9,24 +9,28 @@ package com.nextcloud.talk.lock import android.app.Activity +import android.app.ActivityManager import android.app.KeyguardManager import android.content.Context import android.os.Bundle import android.util.Log import android.view.View +import androidx.activity.addCallback import androidx.activity.result.ActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AppCompatActivity import androidx.biometric.BiometricPrompt +import androidx.core.content.ContextCompat import autodagger.AutoInjector import com.nextcloud.talk.R import com.nextcloud.talk.application.NextcloudTalkApplication import com.nextcloud.talk.databinding.ActivityLockedBinding import com.nextcloud.talk.utils.BrandingUtils import com.nextcloud.talk.utils.SecurityUtils +import com.nextcloud.talk.utils.isShareTask import com.nextcloud.talk.utils.preferences.AppPreferences -import java.util.concurrent.Executor -import java.util.concurrent.Executors +import com.nextcloud.talk.utils.taskIdCompat +import com.nextcloud.talk.utils.taskInfoOrNull import javax.inject.Inject @AutoInjector(NextcloudTalkApplication::class) @@ -47,22 +51,53 @@ class LockedActivity : AppCompatActivity() { private lateinit var binding: ActivityLockedBinding + // set when the app is left from the screen lock of the device, so the resume that follows doesn't ask again + private var isLeaving = false + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) binding = ActivityLockedBinding.inflate(layoutInflater) setContentView(binding.root) + + onBackPressedDispatcher.addCallback(this) { leaveApp() } + } + + /** + * Leaves the app instead of closing the lock screen, which the still locked screen below would show again right + * away. In the task of a share, also one of another app, only the screens of this app are left, which leaves the + * share. In the task of another app, finishAffinity() would also finish the screens of that app below with the + * same, empty task affinity. + */ + private fun leaveApp() { + val task = getSystemService(ActivityManager::class.java).appTasks + .firstNotNullOfOrNull { it.taskInfoOrNull()?.takeIf { info -> info.taskIdCompat == taskId } } + when { + task == null -> NextcloudTalkApplication.sharedApplication!!.liveActivities.finishInTask(taskId) + task.isShareTask -> finishAffinity() + else -> moveTaskToBack(true) + } } override fun onResume() { super.onResume() + // unlocked meanwhile on the lock screen of another task, e.g. of a share + if (!NextcloudTalkApplication.isLocked) { + finish() + return + } hideLogoForBrandedClients() binding.unlockContainer.setOnClickListener { checkIfWeAreSecure() } - checkIfWeAreSecure() + if (!isLeaving) checkIfWeAreSecure() + } + + override fun onStop() { + super.onStop() + isLeaving = false } private fun hideLogoForBrandedClients() { @@ -78,6 +113,10 @@ class LockedActivity : AppCompatActivity() { // this activity is only shown when the inactivity timeout has already elapsed. SecurityUtils.checkIfWeAreAuthenticated(appPreferences.screenLockTimeout) showBiometricDialog() + } else { + // e.g. the screen lock of the device was removed while the lock screen was shown, which then couldn't + // be left + unlock() } } @@ -91,16 +130,14 @@ class LockedActivity : AppCompatActivity() { ) .setNegativeButtonText(context.getString(R.string.nc_cancel)) .build() - val executor: Executor = Executors.newSingleThreadExecutor() val biometricPrompt = BiometricPrompt( this, - executor, + ContextCompat.getMainExecutor(this), object : BiometricPrompt.AuthenticationCallback() { override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { super.onAuthenticationSucceeded(result) Log.d(TAG, "Fingerprint recognised successfully") - appPreferences.setLockTimestamp(System.currentTimeMillis()) - finish() + unlock() } override fun onAuthenticationFailed() { @@ -110,7 +147,11 @@ class LockedActivity : AppCompatActivity() { override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { super.onAuthenticationError(errorCode, errString) - showAuthenticationScreen() + // closed with Back or by the system: the lock screen stays, where Back leaves the app and a tap + // asks again + val isClosed = errorCode == BiometricPrompt.ERROR_USER_CANCELED || + errorCode == BiometricPrompt.ERROR_CANCELED + if (!isClosed) showAuthenticationScreen() } } ) @@ -133,13 +174,23 @@ class LockedActivity : AppCompatActivity() { private fun onConfirmDeviceCredentials(result: ActivityResult) { if (result.resultCode == Activity.RESULT_OK) { - appPreferences.setLockTimestamp(System.currentTimeMillis()) - finish() + unlock() } else { + // Left with Back, like the lock screen: shown again, it would ask again right away Log.d(TAG, "Authorization failed") + isLeaving = true + leaveApp() } } + private fun unlock() { + appPreferences.setLockTimestamp(System.currentTimeMillis()) + NextcloudTalkApplication.isLocked = false + // e.g. set while the screen lock of the device was asked for, which would lock again with no timeout + NextcloudTalkApplication.pendingLockCheck = false + finish() + } + companion object { private val TAG = LockedActivity::class.java.simpleName } diff --git a/app/src/main/java/com/nextcloud/talk/settings/SettingsActivity.kt b/app/src/main/java/com/nextcloud/talk/settings/SettingsActivity.kt index f4e46e72851..a78db86faaa 100644 --- a/app/src/main/java/com/nextcloud/talk/settings/SettingsActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/settings/SettingsActivity.kt @@ -43,6 +43,7 @@ import androidx.core.net.toUri import androidx.core.view.ViewCompat import androidx.lifecycle.lifecycleScope import androidx.work.OneTimeWorkRequest +import com.nextcloud.talk.utils.AppTasks import com.nextcloud.talk.utils.bundle.BundleKeys import com.nextcloud.talk.utils.setExpeditedIfSupported import androidx.work.WorkInfo @@ -55,7 +56,6 @@ import com.nextcloud.android.common.ui.theme.utils.ColorRole import com.nextcloud.talk.BuildConfig import com.nextcloud.talk.R import com.nextcloud.talk.activities.BaseActivity -import com.nextcloud.talk.activities.MainActivity import com.nextcloud.talk.api.NcApi import com.nextcloud.talk.api.NcApiCoroutines import com.nextcloud.talk.application.NextcloudTalkApplication @@ -927,9 +927,7 @@ class SettingsActivity : } private fun restartApp() { - val intent = Intent(context, MainActivity::class.java) - intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) - startActivity(intent) + startActivity(AppTasks.createRestartIntent(context)) } private fun getRingtoneName(context: Context, ringtoneUri: Uri?): String = diff --git a/app/src/main/java/com/nextcloud/talk/utils/AppTasks.kt b/app/src/main/java/com/nextcloud/talk/utils/AppTasks.kt new file mode 100644 index 00000000000..1d502c2c73f --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/utils/AppTasks.kt @@ -0,0 +1,210 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.utils + +import android.annotation.SuppressLint +import android.app.ActivityManager +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.os.Build +import android.util.Log +import com.nextcloud.talk.application.NextcloudTalkApplication +import com.nextcloud.talk.conversationlist.ConversationsListActivity +import com.nextcloud.talk.conversationlist.ShareActivity +import com.nextcloud.talk.utils.bundle.BundleKeys +import kotlinx.coroutines.launch + +// A task listed by ActivityManager.getAppTasks can be removed before it is used, e.g. when it is swiped from the +// recents or finished by an activity of another task, and the calls on it throw an IllegalArgumentException then. + +private const val TAG = "AppTasks" + +/** The info of this task, or null if it was removed meanwhile. */ +fun ActivityManager.AppTask.taskInfoOrNull(): ActivityManager.RecentTaskInfo? = + try { + taskInfo + } catch (e: IllegalArgumentException) { + Log.w(TAG, "Task was removed before its info was read", e) + null + } + +/** Removes this task, unless it was removed meanwhile. */ +fun ActivityManager.AppTask.finishAndRemoveTaskIfPresent() { + try { + finishAndRemoveTask() + } catch (e: IllegalArgumentException) { + Log.w(TAG, "Task was removed before it was finished", e) + } +} + +/** Moves this task to the front and returns true, or returns false if the task was removed meanwhile. */ +fun ActivityManager.AppTask.moveToFrontIfPresent(): Boolean = + try { + moveToFront() + true + } catch (e: IllegalArgumentException) { + Log.w(TAG, "Task was removed before it was moved to the front", e) + false + } + +/** Starts [intent] on top of this task and returns true, or returns false if the task was removed meanwhile. */ +fun ActivityManager.AppTask.startActivityIfPresent(context: Context, intent: Intent): Boolean = + try { + startActivity(context, intent, null) + true + } catch (e: IllegalArgumentException) { + Log.w(TAG, "Task was removed before the activity was started in it", e) + false + } + +/** The task of the app that the launcher starts, and the screens opened in it from outside the app. */ +object AppTasks { + + /** + * The launcher activity, an alias of the conversation list, by its name in the manifest, which home-screen icons + * and pinned shortcuts of earlier versions refer to. + */ + const val LAUNCHER_ACTIVITY = "com.nextcloud.talk.activities.MainActivity" + + /** Creates the intent the launcher starts the app with, in a new task or the running one of the app. */ + fun createLauncherIntent(context: Context): Intent = + Intent() + .setClassName(context, LAUNCHER_ACTIVITY) + .setAction(Intent.ACTION_MAIN) + .addCategory(Intent.CATEGORY_LAUNCHER) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + + /** Starts the app anew in a fresh task, e.g. after the account or conversation shown was removed. */ + fun createRestartIntent(context: Context): Intent = + createLauncherIntent(context).addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK) + + /** + * Starts the app anew in a fresh task with the conversation list of the account [userId], e.g. from a call whose + * account isn't the default one, when no task of the app is left to show it in. + */ + fun createRestartIntent(context: Context, userId: Long): Intent = + ConversationsListActivity.createAccountSwitchIntent(context, userId) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) + + /** + * Opens [chatIntent] on top of the screens open in the running task of the app, or else in a new task with the + * conversation list of its account below it, which the launcher and the internal entry point find again, unlike a + * task rooted at the chat. Only the chat is created, and the list when going back. + */ + fun openChatInAppTask( + context: Context, + chatIntent: Intent, + appTasks: List = appTasksOf(context) + ) { + val isStartedInAppTask = + findRunningAppTask(context, appTasks)?.startActivityIfPresent(context, chatIntent) == true + if (!isStartedInAppTask) { + val userId = chatIntent.getLongExtra(BundleKeys.KEY_INTERNAL_USER_ID, 0L) + val listIntent = ConversationsListActivity.createAccountSwitchIntent(context, userId) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + context.startActivities(arrayOf(listIntent, chatIntent)) + } + } + + /** + * The tasks of the app among [appTasks] whose info matches [predicate]: those rooted at the conversation list, + * started from the launcher or through its internal entry point, unlike the tasks of a share (see ShareActivity), + * of a call or one another app started the list in. A task removed meanwhile is left out. + */ + fun findAppTasks( + context: Context, + appTasks: List = appTasksOf(context), + predicate: (ActivityManager.RecentTaskInfo) -> Boolean + ): List = tasksRootedAt(appTasks, appTaskRoots(), predicate) + + /** + * The running task of the app among [appTasks] whose info matches [predicate]: one of [findAppTasks] with + * activities, unlike a task that is only left in the recents, which starting a screen in would bring back. + */ + fun findRunningAppTask( + context: Context, + appTasks: List = appTasksOf(context), + predicate: (ActivityManager.RecentTaskInfo) -> Boolean = { true } + ): ActivityManager.AppTask? = + findAppTasks(context, appTasks) { it.numActivities > 0 && predicate(it) }.firstOrNull() + + /** + * Whether the task [taskId] is one of [findRunningAppTask], e.g. not a task of another app that started the + * conversation list in it. + */ + fun isRunningAppTask(context: Context, taskId: Int): Boolean = + findRunningAppTask(context) { it.taskIdCompat == taskId } != null + + /** + * The tasks among [appTasks] that are only left in the recents, without activities: those of [findAppTasks], + * and those another app started the conversation list in. + */ + fun findTasksLeftInRecents( + context: Context, + appTasks: List = appTasksOf(context) + ): List = + tasksRootedAt(appTasks, appTaskRoots() + ConversationsListActivity::class.java.name) { it.numActivities == 0 } + + /** + * Removes the tasks of the app that are only left in the recents, see [findTasksLeftInRecents], when a screen + * that can start a task of the app is created: the conversation list, or the top of a new back stack, see + * [ConversationsListActivity.createBackStack]. Without a task affinity, the recents only replace a task by one + * with the same root intent, so e.g. a task a deep link started would stay there next to the one a notification + * starts. In the scope of the app, as it asks ActivityManager for every task while the screen is created. + */ + fun removeTasksLeftInRecents(context: Context) { + val applicationContext = context.applicationContext + NextcloudTalkApplication.sharedApplication!!.appScope.launch { + findTasksLeftInRecents(applicationContext).forEach { it.finishAndRemoveTaskIfPresent() } + } + } + + /** Whether [intent] relaunches a task from the recents: the intent it was started with, taken already. */ + fun isRelaunchedFromRecents(intent: Intent): Boolean = + intent.flags and Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY != 0 + + private fun appTaskRoots(): Set = + setOf(LAUNCHER_ACTIVITY, InternalEntryPoint.nameOf(ConversationsListActivity::class.java)) + + private fun appTasksOf(context: Context): List = + context.getSystemService(ActivityManager::class.java).appTasks + + /** + * The tasks among [appTasks] whose root is one of the activities named [roots] and whose info matches + * [predicate]. A task removed meanwhile is left out. + */ + private fun tasksRootedAt( + appTasks: List, + roots: Set, + predicate: (ActivityManager.RecentTaskInfo) -> Boolean + ): List = + appTasks.filter { task -> + val info = task.taskInfoOrNull() + info != null && info.rootActivity?.className in roots && predicate(info) + } +} + +/** + * The activity that the root of this task was started as. Before Android 10, the base intent of a task that an alias + * started names the target of the alias instead, while origActivity names the alias on every version. + * + * Lint takes origActivity for the field of TaskInfo, added in API 29. RecentTaskInfo declares it itself since API 1 + * and only inherits it since API 29, and the bytecode reads it from RecentTaskInfo, so it exists on every version. + */ +@get:SuppressLint("NewApi") +private val ActivityManager.RecentTaskInfo.rootActivity: ComponentName? + get() = origActivity ?: baseIntent.component + +/** Whether this is the task of a share, which ShareActivity started in a task of its own. */ +val ActivityManager.RecentTaskInfo.isShareTask: Boolean + get() = rootActivity?.className == ShareActivity::class.java.name + +// TaskInfo.taskId only exists since API 29; before that persistentId is the task id +@Suppress("DEPRECATION") +val ActivityManager.RecentTaskInfo.taskIdCompat: Int + get() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) taskId else persistentId diff --git a/app/src/main/java/com/nextcloud/talk/utils/DeepLinkHandler.kt b/app/src/main/java/com/nextcloud/talk/utils/DeepLinkHandler.kt index 792d0ca1d7b..337ed937941 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/DeepLinkHandler.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/DeepLinkHandler.kt @@ -99,7 +99,7 @@ object DeepLinkHandler { * Validates that a token matches the expected format. * Tokens should be alphanumeric and between 4-32 characters. */ - private fun isValidToken(token: String): Boolean = TOKEN_PATTERN.matches(token) + fun isValidToken(token: String): Boolean = TOKEN_PATTERN.matches(token) /** * Creates a custom scheme URI for a conversation. diff --git a/app/src/main/java/com/nextcloud/talk/utils/FileUtils.kt b/app/src/main/java/com/nextcloud/talk/utils/FileUtils.kt index 9e44d9f1402..3b90abc4192 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/FileUtils.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/FileUtils.kt @@ -12,6 +12,7 @@ package com.nextcloud.talk.utils import android.content.ContentResolver import android.content.Context import android.net.Uri +import android.os.Process import android.provider.OpenableColumns import android.util.Log import android.webkit.MimeTypeMap @@ -27,7 +28,12 @@ object FileUtils { private val TAG = FileUtils::class.java.simpleName private const val RADIX: Int = 16 private const val MD5_LENGTH: Int = 32 - private const val SHARED_ATTACHMENTS_DIRECTORY = "shared_attachments" + internal const val SHARED_ATTACHMENTS_DIRECTORY = "shared_attachments" + + // the copies of the files other apps share, see SharedFileCopies; kept apart from the shared attachments, which are + // named by their senders, so an attachment can't take the place of the copies. In the files directory, not in the + // cache, which the system clears when the storage runs low, also while their uploads still wait for the network. + internal const val SHARED_FILE_COPIES_DIRECTORY = "shared_file_copies" /** * Returns the dedicated cache directory used for externally shared attachments. @@ -64,16 +70,54 @@ object FileUtils { } /** - * Checks whether the [pathSegments] of an own FileProvider uri point to a file inside the - * dedicated shared attachments cache directory. + * The file that the [pathSegments] of an own FileProvider uri point to, if it is inside the dedicated shared + * attachments directory in [cacheDir] or the one of the copies of shared files in [filesDir], else null. */ - fun isSharedAttachmentProviderPath(cacheDir: File, pathSegments: List): Boolean { - if (pathSegments.size < 2 || pathSegments.first() != SHARED_ATTACHMENTS_DIRECTORY) return false - var resolvedFile: File? = File(cacheDir, SHARED_ATTACHMENTS_DIRECTORY) + fun sharedAttachmentProviderFile(cacheDir: File, filesDir: File, pathSegments: List): File? { + val root = when (pathSegments.firstOrNull()) { + SHARED_ATTACHMENTS_DIRECTORY -> cacheDir + SHARED_FILE_COPIES_DIRECTORY -> filesDir + else -> null + } + if (pathSegments.size < 2 || root == null) return null + var resolvedFile: File? = File(root, pathSegments.first()) for (segment in pathSegments.drop(1)) { resolvedFile = resolvedFile?.let { resolveFileInDirectory(it, segment) } } - return resolvedFile != null + return resolvedFile + } + + /** + * Checks whether an externally provided [uri] may be uploaded: only content uris are accepted, + * and uris of the own app only when they point into the shared attachments cache or the copies + * of shared files of its FileProvider. + */ + fun isTrustedShareUri(uri: Uri?, context: Context): Boolean = + isTrustedShareUri(uri, context.packageName, context.cacheDir, context.filesDir) { authority -> + context.packageManager.resolveContentProvider(authority, 0)?.packageName + } + + /** + * See [isTrustedShareUri]. Every provider of the own app ([packageName]) is rejected, as e.g. the one of the + * ImagePicker library serves all its files, except the paths of its FileProvider (authority [packageName]) that + * [sharedAttachmentProviderFile] allows. A user id prefix ("10@authority") is ignored, so the providers of the + * app of another user, e.g. in another profile, are rejected the same way. [packageOfAuthority] gives the package + * of the provider of an authority. + */ + fun isTrustedShareUri( + uri: Uri?, + packageName: String, + cacheDir: File, + filesDir: File, + packageOfAuthority: (String) -> String? + ): Boolean { + val contentUri = uri?.takeIf { ContentResolver.SCHEME_CONTENT == it.scheme } + val authority = contentUri?.authorityWithoutUser ?: return false + val isOwnProvider = authority == packageName || packageOfAuthority(authority) == packageName + val isSharedFileOfFileProvider = + authority == packageName && + sharedAttachmentProviderFile(cacheDir, filesDir, contentUri.pathSegments) != null + return !isOwnProvider || isSharedFileOfFileProvider } /** @@ -163,7 +207,11 @@ object FileUtils { Log.d(TAG, "relative uri: " + sourceFileUri.path) throw IllegalArgumentException("relative paths are not supported") } else if (ContentResolver.SCHEME_CONTENT == scheme) { - copyFileToCache(context, sourceFileUri, fileName) + // a shared file the app already copied is used as it is, instead of copying it again + val ownSharedFile = sourceFileUri.takeIf { it.authorityOfOwnUser == context.packageName } + ?.let { sharedAttachmentProviderFile(context.cacheDir, context.filesDir, it.pathSegments) } + ?.takeIf { it.isFile } + ownSharedFile ?: copyFileToCache(context, sourceFileUri, fileName) } else if (ContentResolver.SCHEME_FILE == scheme) { val path = sourceFileUri.path ?: throw IllegalArgumentException("uri does not contain path") @@ -283,3 +331,22 @@ object FileUtils { return md5String.toString() } } + +// the authority without a user id prefix ("10@authority"), which names the user whose provider serves the uri +internal val Uri.authorityWithoutUser: String? + get() = authority?.substringAfterLast('@') + +/** + * The authority of a provider of the own user, without a prefix that names the own user ("0@authority"). Null for a + * provider of another user, e.g. of the app in another profile, whose files aren't the ones of the app here. + */ +internal val Uri.authorityOfOwnUser: String? + get() { + val authority = authority ?: return null + val userId = authority.substringBeforeLast('@', missingDelimiterValue = "") + val isOwnUser = userId.isEmpty() || userId == (Process.myUid() / PER_USER_UID_RANGE).toString() + return if (isOwnUser) authority.substringAfterLast('@') else null + } + +// the range of uids of each user, see android.os.UserHandle.PER_USER_RANGE +private const val PER_USER_UID_RANGE = 100_000 diff --git a/app/src/main/java/com/nextcloud/talk/utils/InternalEntryPoint.kt b/app/src/main/java/com/nextcloud/talk/utils/InternalEntryPoint.kt new file mode 100644 index 00000000000..6ecd1d97f38 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/utils/InternalEntryPoint.kt @@ -0,0 +1,40 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.utils + +import android.app.Activity +import android.content.Context +import android.content.Intent + +/** + * The internal entry point of an exported activity: a non-exported activity-alias of it, named "Internal" plus the name + * of the activity, in its package. Only the app itself can start the activity through it, so the activity can trust + * the extras of intents that come through it, unlike those of intents that other apps send to the activity itself. + */ +object InternalEntryPoint { + + /** The class name of the internal entry point of [activity], as declared in the manifest. */ + fun nameOf(activity: Class): String = + activity.name.substringBeforeLast('.') + ".Internal" + activity.simpleName + + /** Creates an intent that starts [activity] through its internal entry point. */ + fun createIntent(context: Context, activity: Class): Intent = + Intent().setClassName(context, nameOf(activity)) + + /** Whether [intent] was started through the internal entry point of [activity], so by the app itself. */ + fun isStartedThrough(intent: Intent, activity: Class): Boolean = + intent.component?.className == nameOf(activity) + + /** + * Passes [intent], which another app sent to an exported activity that doesn't handle it, on to [target], with + * [flags] and the read grant of its uri. + */ + fun createForwardIntent(context: Context, intent: Intent, target: Class, flags: Int = 0): Intent = + Intent(intent) + .setClass(context, target) + .setFlags(flags or (intent.flags and Intent.FLAG_GRANT_READ_URI_PERMISSION)) +} diff --git a/app/src/main/java/com/nextcloud/talk/utils/LiveActivities.kt b/app/src/main/java/com/nextcloud/talk/utils/LiveActivities.kt new file mode 100644 index 00000000000..b36e06e2203 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/utils/LiveActivities.kt @@ -0,0 +1,47 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.utils + +import android.app.Activity +import android.app.Application +import android.os.Bundle + +/** + * The activities of the app that are created and not destroyed yet. Unlike [Activity.finishAffinity], which also + * finishes the activities of another app below that have the same task affinity, it can finish only those of the app + * in a task, e.g. one of another app that started a screen of the app in it. + */ +class LiveActivities : Application.ActivityLifecycleCallbacks { + + private val activities = LinkedHashSet() + + /** + * Finishes the activities of the app in the task [taskId]. Only the ones in memory: those Android didn't create + * again yet after the process was killed are left. + */ + fun finishInTask(taskId: Int) { + activities.filter { it.taskId == taskId && !it.isFinishing }.forEach(Activity::finish) + } + + override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) { + activities.add(activity) + } + + override fun onActivityDestroyed(activity: Activity) { + activities.remove(activity) + } + + override fun onActivityStarted(activity: Activity) = Unit + + override fun onActivityResumed(activity: Activity) = Unit + + override fun onActivityPaused(activity: Activity) = Unit + + override fun onActivityStopped(activity: Activity) = Unit + + override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) = Unit +} diff --git a/app/src/main/java/com/nextcloud/talk/utils/SecurityUtils.java b/app/src/main/java/com/nextcloud/talk/utils/SecurityUtils.java index 79287fb2024..a27c7550b71 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/SecurityUtils.java +++ b/app/src/main/java/com/nextcloud/talk/utils/SecurityUtils.java @@ -49,6 +49,12 @@ public static boolean checkIfWeAreAuthenticated(String screenLockTimeout) { KeyStore keyStore = KeyStore.getInstance("AndroidKeyStore"); keyStore.load(null); SecretKey secretKey = (SecretKey) keyStore.getKey(CREDENTIALS_KEY, null); + if (secretKey == null) { + // not created yet, e.g. when the app is started at a chat of a notification: the next check after + // the user authenticated can use it + createKey(screenLockTimeout); + return false; + } Cipher cipher = Cipher.getInstance(KeyProperties.KEY_ALGORITHM_AES + "/" + KeyProperties.BLOCK_MODE_GCM + "/" + KeyProperties.ENCRYPTION_PADDING_NONE); @@ -65,10 +71,8 @@ public static boolean checkIfWeAreAuthenticated(String screenLockTimeout) { return false; } catch (KeyPermanentlyInvalidatedException e) { // This happens if the lock screen has been disabled or reset after the key was - // generated. - // Shouldn't really happen because we regenerate the key every time an activity - // is created, but oh well - // Create key, and attempt again + // generated. The key is created once per start of the app, so create it anew, and + // attempt again createKey(screenLockTimeout); return false; } catch (BadPaddingException | IllegalBlockSizeException | KeyStoreException | diff --git a/app/src/main/java/com/nextcloud/talk/utils/SharedFileCopies.kt b/app/src/main/java/com/nextcloud/talk/utils/SharedFileCopies.kt new file mode 100644 index 00000000000..ee090c42e57 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/utils/SharedFileCopies.kt @@ -0,0 +1,128 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.utils + +import android.content.ContentResolver +import android.content.Context +import android.util.Log +import androidx.core.net.toUri +import androidx.work.WorkInfo +import androidx.work.WorkManager +import androidx.work.WorkQuery +import com.nextcloud.talk.BuildConfig +import com.nextcloud.talk.application.NextcloudTalkApplication +import kotlinx.coroutines.launch +import java.io.File +import java.util.UUID +import java.util.concurrent.ExecutionException +import java.util.concurrent.TimeUnit + +/** + * The copies that ShareActivity makes of the files other apps share, one directory per share inside a directory of + * their own, which [FileUtils.isTrustedShareUri] trusts like the shared attachments directory. That one isn't used, + * as the attachments in it are named by their senders, and an attachment named like the directory of the copies would + * take its place. + * + * An upload of a copy can wait for the network for days, so it is tagged with the directory of its share (see + * [uploadTagOf]), and the directory is only removed once no upload with that tag is unfinished. So the copies are in + * the files directory, which the system, unlike the cache, doesn't clear when the storage runs low, and they are + * removed by the app: when their share is left, and once they are done (see [removeDoneShares]). + */ +object SharedFileCopies { + + private val TAG = SharedFileCopies::class.java.simpleName + + private const val UPLOAD_TAG_PREFIX = "shared_file_copy_" + + // so the preview of an upload that failed still shows the file for a while + private const val KEPT_DAYS = 1L + + // the provider path of a copy: directory of the copies, directory of the share, directory of the item, file + private const val SHARE_DIRECTORY_SEGMENT = 1 + + /** + * Creates the directory for the copies of a new share, after removing those of older shares that are done (see + * [removeDoneShares]). Blocks while it asks WorkManager for their uploads. + */ + fun createDirectory(context: Context): File? { + removeDoneShares(context) + return File(sharesDirectory(context), UUID.randomUUID().toString()).takeIf { it.mkdirs() } + } + + /** + * Removes the copies of the shares that are done: older than [KEPT_DAYS] and without an unfinished upload, e.g. + * those that were uploaded or left. Run when the app starts and when a share starts. Blocks while it asks + * WorkManager for their uploads. + */ + fun removeDoneShares(context: Context) { + val expiry = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(KEPT_DAYS) + val expired = sharesDirectory(context).listFiles()?.filter { it.lastModified() < expiry }.orEmpty() + val uploading = sharesWithUnfinishedUpload(context, expired.map { it.name }) + expired.filterNot { it.name in uploading }.forEach { it.deleteRecursively() } + } + + /** + * Removes the copies of the shares that the [fileUris] are copies of, unless one of them has an unfinished upload: + * for a share that was left before its files were sent, e.g. in the conversation list or the attachment preview. + * Other uris are ignored. Blocks while it asks WorkManager for their uploads. + */ + fun removeSharesOf(context: Context, fileUris: Collection) { + val sharesDirectory = sharesDirectory(context) + val names = fileUris.mapNotNull(::shareDirectoryNameOf).distinct() + val uploading = sharesWithUnfinishedUpload(context, names) + names.filterNot { it in uploading } + .mapNotNull { FileUtils.resolveFileInDirectory(sharesDirectory, it) } + .forEach { it.deleteRecursively() } + } + + /** [removeSharesOf] in the scope of the app, so also after the screen that left the share is gone. */ + fun removeSharesLater(fileUris: Collection) { + if (fileUris.isEmpty()) return + val application = NextcloudTalkApplication.sharedApplication!! + application.appScope.launch { removeSharesOf(application, fileUris) } + } + + /** The tag for the upload of [fileUri] if it is a copy of a share: it keeps the copy until the upload is done. */ + fun uploadTagOf(fileUri: String): String? = shareDirectoryNameOf(fileUri)?.let { UPLOAD_TAG_PREFIX + it } + + /** The name of the directory of the share that [fileUri] is a copy of, else null. */ + private fun shareDirectoryNameOf(fileUri: String): String? { + val uri = fileUri.toUri() + val segments = uri.pathSegments + val isCopy = uri.scheme == ContentResolver.SCHEME_CONTENT && + uri.authorityOfOwnUser == BuildConfig.APPLICATION_ID && + segments.size > SHARE_DIRECTORY_SEGMENT + 1 && + segments[0] == FileUtils.SHARED_FILE_COPIES_DIRECTORY + return if (isCopy) segments[SHARE_DIRECTORY_SEGMENT] else null + } + + private fun sharesDirectory(context: Context) = File(context.filesDir, FileUtils.SHARED_FILE_COPIES_DIRECTORY) + + /** + * The ones of the share directories [directoryNames] with an unfinished upload, all of them when WorkManager + * can't tell, so the copies are kept when in doubt. One query for all of them. + */ + private fun sharesWithUnfinishedUpload(context: Context, directoryNames: Collection): Set { + if (directoryNames.isEmpty()) return emptySet() + val query = WorkQuery.Builder.fromTags(directoryNames.map { UPLOAD_TAG_PREFIX + it }) + .addStates(listOf(WorkInfo.State.ENQUEUED, WorkInfo.State.RUNNING, WorkInfo.State.BLOCKED)) + .build() + return try { + WorkManager.getInstance(context).getWorkInfos(query).get() + .flatMap { it.tags } + .filter { it.startsWith(UPLOAD_TAG_PREFIX) } + .map { it.removePrefix(UPLOAD_TAG_PREFIX) } + .toSet() + } catch (e: ExecutionException) { + Log.w(TAG, "Failed to get the uploads of shared files", e) + directoryNames.toSet() + } catch (e: InterruptedException) { + Thread.currentThread().interrupt() + directoryNames.toSet() + } + } +} diff --git a/app/src/main/java/com/nextcloud/talk/utils/ShortcutManagerHelper.kt b/app/src/main/java/com/nextcloud/talk/utils/ShortcutManagerHelper.kt index 430a95e3bd6..bbd271072b9 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/ShortcutManagerHelper.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/ShortcutManagerHelper.kt @@ -14,9 +14,12 @@ import androidx.core.content.pm.ShortcutInfoCompat import androidx.core.content.pm.ShortcutManagerCompat import androidx.core.graphics.drawable.IconCompat import com.nextcloud.talk.R -import com.nextcloud.talk.activities.MainActivity +import com.nextcloud.talk.activities.DeepLinkActivity +import com.nextcloud.talk.conversationlist.ConversationsListActivity +import com.nextcloud.talk.conversationlist.DirectShareHelper import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.models.domain.ConversationModel +import com.nextcloud.talk.utils.bundle.BundleKeys /** * Helper class for managing Android shortcuts for conversations. @@ -31,8 +34,8 @@ object ShortcutManagerHelper { private const val CONVERSATION_SHORTCUT_PREFIX = "conversation_" /** - * Creates a shortcut for a conversation using a nextcloudtalk:// deep link URI. - * This ensures proper multi-account user resolution when the shortcut is opened. + * Creates a shortcut for a conversation with the intent of [createConversationIntent], which opens it for exactly + * its account. * * @param context Application context * @param conversation The conversation to create a shortcut for @@ -41,18 +44,10 @@ object ShortcutManagerHelper { */ fun createConversationShortcut(context: Context, conversation: ConversationModel, user: User): ShortcutInfoCompat? { val userId = user.id - val baseUrl = user.baseUrl - val uri = baseUrl?.let { - DeepLinkHandler.createConversationUri(conversation.token, it, user.username) - .takeIf { built -> built != Uri.EMPTY } - } - if (userId == null || uri == null) return null + val intent = createConversationIntent(context, conversation.token, user) + if (userId == null || intent == null) return null val displayName = conversation.displayName.ifBlank { conversation.name } - val intent = Intent(context, MainActivity::class.java).apply { - action = Intent.ACTION_VIEW - data = uri - } return ShortcutInfoCompat.Builder(context, getShortcutId(conversation.token, userId)) .setShortLabel(displayName) @@ -101,6 +96,79 @@ object ShortcutManagerHelper { } } + /** + * Gives the conversation and direct share shortcuts of earlier versions, also pinned and cached ones, the intent + * of [createConversationIntent], with the account and conversation taken from the shortcut id. Their old intents + * open only the list (extras from outside the app are removed) or name the account only by host and user name. + * + * @param context Application context + * @param users The accounts whose shortcuts can be updated + * @return false if the shortcuts couldn't be read or updated, e.g. while the user is locked, so it is tried again + */ + @Suppress("TooGenericExceptionCaught") + fun updateShortcutsOfEarlierVersions(context: Context, users: List): Boolean = + try { + // one by one, so a shortcut that can't be updated doesn't keep the others from it + shortcutsOfEarlierVersionsUpdated(context, users).forEach { shortcut -> + try { + ShortcutManagerCompat.updateShortcuts(context, listOf(shortcut)) + } catch (e: IllegalArgumentException) { + // Old shortcuts must not crash the conversation list. + Log.w(TAG, "Failed to update the shortcut ${shortcut.id} of an earlier version", e) + } + } + true + } catch (e: IllegalStateException) { + // Thrown while the user is locked + Log.w(TAG, "Failed to update the shortcuts of earlier versions", e) + false + } catch (e: RuntimeException) { + // e.g. an old shortcut the system can't give as it is. Not tried again, as it would fail again. + Log.e(TAG, "Failed to update the shortcuts of earlier versions", e) + true + } + + /** The shortcuts of [updateShortcutsOfEarlierVersions] with the intent of their conversation. */ + private fun shortcutsOfEarlierVersionsUpdated(context: Context, users: List): List { + val usersById = users.associateBy { it.id } + val matchFlags = ShortcutManagerCompat.FLAG_MATCH_DYNAMIC or ShortcutManagerCompat.FLAG_MATCH_PINNED or + ShortcutManagerCompat.FLAG_MATCH_CACHED + val internalEntryPoint = InternalEntryPoint.nameOf(ConversationsListActivity::class.java) + return ShortcutManagerCompat.getShortcuts(context, matchFlags) + .filter { it.intent.component?.className != internalEntryPoint } + .mapNotNull { shortcut -> + val (userId, roomToken) = conversationOfShortcutId(shortcut.id) ?: return@mapNotNull null + val intent = usersById[userId]?.let { createConversationIntent(context, roomToken, it) } + // The system keeps the fields that aren't set, e.g. the icon, person and categories. Without + // categories, androidx.sharetarget also keeps the share target it stores for the share sheet before + // Android 10, with its icon, which it would replace by one without. + intent?.let { + ShortcutInfoCompat.Builder(context, shortcut.id) + .setShortLabel(shortcut.shortLabel) + .apply { + shortcut.longLabel?.let(::setLongLabel) + shortcut.locusId?.let(::setLocusId) + } + .setIntent(it) + .build() + } + } + } + + /** + * The user id and room token of the conversation shortcut [shortcutId] of [getShortcutId], or of a direct share + * shortcut of [DirectShareHelper], else null. + */ + fun conversationOfShortcutId(shortcutId: String): Pair? { + val prefix = listOf(CONVERSATION_SHORTCUT_PREFIX, DirectShareHelper.SHORTCUT_ID_PREFIX) + .firstOrNull { shortcutId.startsWith(it) } + val userIdAndToken = prefix?.let { shortcutId.removePrefix(it) } + val userId = userIdAndToken?.substringBefore('_')?.toLongOrNull() + val roomToken = userIdAndToken?.substringAfter('_', missingDelimiterValue = "") + ?.takeIf(DeepLinkHandler::isValidToken) + return if (userId != null && roomToken != null) userId to roomToken else null + } + /** * Requests to pin a shortcut to the home screen. * @@ -128,25 +196,14 @@ object ShortcutManagerHelper { */ @Suppress("DEPRECATION") private fun createLegacyShortcut(context: Context, conversation: ConversationModel, user: User): Boolean { - val baseUrl = user.baseUrl - if (user.id == null || baseUrl == null) { - Log.w(TAG, "Cannot create legacy shortcut: user ID or base URL is null") + val launchIntent = createConversationDeepLinkIntent(context, conversation.token, user) + if (launchIntent == null) { + Log.w(TAG, "Cannot create legacy shortcut: base URL is missing") return false } val displayName = conversation.displayName.ifBlank { conversation.name } - val uri = DeepLinkHandler.createConversationUri( - roomToken = conversation.token, - serverUrl = baseUrl, - username = user.username - ) - - val launchIntent = Intent(context, MainActivity::class.java).apply { - action = Intent.ACTION_VIEW - data = uri - } - val shortcutIntent = Intent("com.android.launcher.action.INSTALL_SHORTCUT").apply { putExtra(Intent.EXTRA_SHORTCUT_NAME, displayName) putExtra(Intent.EXTRA_SHORTCUT_INTENT, launchIntent) @@ -195,9 +252,45 @@ object ShortcutManagerHelper { ShortcutManagerCompat.disableShortcuts(context, listOf(shortcutId), disabledMessage) } + /** + * Creates the intent that opens the conversation [roomToken] of exactly the account [user], or null if [user] has + * no id. The intent of all conversation shortcuts, also of those that notifications publish and of the direct + * share targets: the system starts a shortcut as the app that published it, so it can target the internal entry + * point of the conversation list, which takes the account and conversation from its extras. + */ + fun createConversationIntent(context: Context, roomToken: String, user: User): Intent? = + user.id?.let { createConversationIntent(context, roomToken, it) } + + /** Like [createConversationIntent], for the account with the internal id [userId]. */ + fun createConversationIntent(context: Context, roomToken: String, userId: Long): Intent = + ConversationsListActivity.createIntent(context).apply { + action = Intent.ACTION_VIEW + putExtra(BundleKeys.KEY_ROOM_TOKEN, roomToken) + putExtra(BundleKeys.KEY_INTERNAL_USER_ID, userId) + // With NEW_TASK alone, a task an earlier shortcut started is only brought to the front, without this + // intent; the list on top of it gets it in onNewIntent instead. + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP) + } + + /** + * Creates the intent that opens the conversation [roomToken] of [user] through its nextcloudtalk:// deep link, or + * null if no deep link can be built from its server url. Only for legacy home screen shortcuts, which the + * launcher starts as itself, so they can't target the internal entry point. The deep link names only the host and + * user name of the account. + */ + private fun createConversationDeepLinkIntent(context: Context, roomToken: String, user: User): Intent? { + val uri = user.baseUrl + ?.let { DeepLinkHandler.createConversationUri(roomToken, it, user.username) } + ?.takeIf { it != Uri.EMPTY } + ?: return null + return Intent(context, DeepLinkActivity::class.java).apply { + action = Intent.ACTION_VIEW + data = uri + } + } + /** * Generates a unique shortcut ID for a conversation. */ - private fun getShortcutId(roomToken: String, userId: Long): String = - "${CONVERSATION_SHORTCUT_PREFIX}${userId}_$roomToken" + fun getShortcutId(roomToken: String, userId: Long): String = "${CONVERSATION_SHORTCUT_PREFIX}${userId}_$roomToken" } diff --git a/app/src/main/java/com/nextcloud/talk/utils/UploadSourceGrants.kt b/app/src/main/java/com/nextcloud/talk/utils/UploadSourceGrants.kt new file mode 100644 index 00000000000..570b2923284 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/utils/UploadSourceGrants.kt @@ -0,0 +1,113 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.utils + +import android.content.ContentResolver +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.util.Log +import androidx.core.net.toUri +import androidx.work.WorkInfo +import androidx.work.WorkManager +import androidx.work.WorkQuery +import com.nextcloud.talk.application.NextcloudTalkApplication +import kotlinx.coroutines.launch +import java.util.UUID +import java.util.concurrent.ExecutionException + +/** + * The read access to the files picked for an upload, e.g. in the photo picker or the file picker. The pickers grant it + * only to the chat that picked them, until it is closed or the process is killed, while the upload can wait for the + * network for days. So the access is persisted when the upload is queued, and released once no upload needs it. + * + * The app persists the access to files only for uploads: every persisted one without an unfinished upload is released. + */ +object UploadSourceGrants { + + private val TAG = UploadSourceGrants::class.java.simpleName + + private const val UPLOAD_TAG_PREFIX = "upload_source_" + + /** The tag for the upload of [fileUri] if it is a content uri, whose read access is kept until it is done. */ + fun uploadTagOf(fileUri: String): String? = + fileUri.takeIf { it.toUri().scheme == ContentResolver.SCHEME_CONTENT }?.let { UPLOAD_TAG_PREFIX + it } + + /** Keeps the read access to [fileUri] for its upload, if its provider allows it. */ + fun keep(context: Context, fileUri: String) { + if (uploadTagOf(fileUri) == null) return + try { + context.contentResolver.takePersistableUriPermission(fileUri.toUri(), Intent.FLAG_GRANT_READ_URI_PERMISSION) + } catch (e: SecurityException) { + // e.g. a file of the app itself, or of a provider that doesn't allow it + Log.d(TAG, "The read access to $fileUri can't be kept", e) + } + } + + /** + * Releases the persisted read access to [fileUri] unless an upload other than [exceptWorkId], the one that is just + * ending, still needs it. Blocks while it asks WorkManager for its uploads. + */ + fun release(context: Context, fileUri: String, exceptWorkId: UUID) { + val uri = fileUri.toUri() + val isKept = context.contentResolver.persistedUriPermissions.any { it.isReadPermission && it.uri == uri } + if (isKept) releaseUnless(context, listOf(uri), exceptWorkId) + } + + /** [release] in the scope of the app, e.g. for an upload that was cancelled before it started. */ + fun releaseLater(fileUri: String, exceptWorkId: UUID) { + val application = NextcloudTalkApplication.sharedApplication!! + application.appScope.launch { release(application, fileUri, exceptWorkId) } + } + + /** + * Releases the persisted read access to all files without an unfinished upload, e.g. of uploads that were removed + * before they ran. Run when the app starts, before a file can be picked. Blocks while it asks WorkManager for their + * uploads. + */ + fun releaseUnused(context: Context) { + val kept = context.contentResolver.persistedUriPermissions.filter { it.isReadPermission }.map { it.uri } + releaseUnless(context, kept, exceptWorkId = null) + } + + /** Releases the read access to those of [uris] without an unfinished upload other than [exceptWorkId]. */ + private fun releaseUnless(context: Context, uris: List, exceptWorkId: UUID?) { + val uploading = urisWithUnfinishedUpload(context, uris, exceptWorkId) + uris.filterNot { it in uploading }.forEach { uri -> + try { + context.contentResolver.releasePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION) + } catch (e: SecurityException) { + Log.w(TAG, "Failed to release the read access to $uri", e) + } + } + } + + /** + * The ones of [uris] with an unfinished upload other than [exceptWorkId], all of them when WorkManager can't tell, + * so the access is kept when in doubt. One query for all of them. + */ + private fun urisWithUnfinishedUpload(context: Context, uris: List, exceptWorkId: UUID?): Set { + if (uris.isEmpty()) return emptySet() + val query = WorkQuery.Builder.fromTags(uris.mapNotNull { uploadTagOf(it.toString()) }) + .addStates(listOf(WorkInfo.State.ENQUEUED, WorkInfo.State.RUNNING, WorkInfo.State.BLOCKED)) + .build() + return try { + WorkManager.getInstance(context).getWorkInfos(query).get() + .filter { it.id != exceptWorkId } + .flatMap { it.tags } + .filter { it.startsWith(UPLOAD_TAG_PREFIX) } + .map { it.removePrefix(UPLOAD_TAG_PREFIX).toUri() } + .toSet() + } catch (e: ExecutionException) { + Log.w(TAG, "Failed to get the uploads of picked files", e) + uris.toSet() + } catch (e: InterruptedException) { + Thread.currentThread().interrupt() + uris.toSet() + } + } +} diff --git a/app/src/main/java/com/nextcloud/talk/utils/bundle/BundleKeys.kt b/app/src/main/java/com/nextcloud/talk/utils/bundle/BundleKeys.kt index 4041f5218a0..716f8ee13d9 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/bundle/BundleKeys.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/bundle/BundleKeys.kt @@ -78,16 +78,15 @@ object BundleKeys { const val SAVED_TRANSLATED_MESSAGE = "SAVED_TRANSLATED_MESSAGE" const val KEY_REAUTHORIZE_ACCOUNT = "KEY_REAUTHORIZE_ACCOUNT" const val KEY_PASSWORD = "KEY_PASSWORD" - const val KEY_REMOTE_TALK_SHARE = "KEY_REMOTE_TALK_SHARE" const val KEY_CHAT_API_VERSION = "KEY_CHAT_API_VERSION" const val KEY_CALL_FLAG = "KEY_CALL_FLAG" const val KEY_CREDENTIALS: String = "KEY_CREDENTIALS" const val KEY_CHAT_URL: String = "KEY_CHAT_URL" const val KEY_SCROLL_TO_NOTIFICATION_CATEGORY: String = "KEY_SCROLL_TO_NOTIFICATION_CATEGORY" - const val KEY_FOCUS_INPUT: String = "KEY_FOCUS_INPUT" const val KEY_THREAD_ID = "KEY_THREAD_ID" const val KEY_SEARCH_QUERY = "KEY_SEARCH_QUERY" const val KEY_FROM_QR: String = "KEY_FROM_QR" const val KEY_OPENED_VIA_NOTIFICATION: String = "KEY_OPENED_VIA_NOTIFICATION" + const val KEY_TOP_OF_NEW_BACK_STACK: String = "KEY_TOP_OF_NEW_BACK_STACK" const val KEY_UPCOMING_EVENT: String = "KEY_UPCOMING_EVENT" } diff --git a/app/src/main/res/drawable/launch_screen.xml b/app/src/main/res/drawable/launch_screen.xml deleted file mode 100644 index ad384240cc9..00000000000 --- a/app/src/main/res/drawable/launch_screen.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - - diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml deleted file mode 100644 index e861481a181..00000000000 --- a/app/src/main/res/layout/activity_main.xml +++ /dev/null @@ -1,45 +0,0 @@ - - - - - - - - - - - - - diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2ef2c7618a5..848e2948f07 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -47,6 +47,12 @@ How to translate with transifex: Shortcut created This conversation no longer exists No account found for this server + Join conversation? + This conversation on %1$s is not in your conversation list. Opening it can add you as a participant. + Join + Start conversation? + You have no conversation with %1$s yet. Starting one adds it to the conversation list of both of you. + Start Settings @@ -684,8 +690,6 @@ How to translate with transifex: Share from %1$s Sorry, upload failed Choose files - Send these files to %1$s? - Send this file to %1$s? Upload from device Gallery @@ -1099,5 +1103,6 @@ How to translate with transifex: Turn on background blur Turn off background blur Account not found + The conversation of the shortcut was not found Password is secure diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index f9a4ca5f5a7..a835138ff64 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -38,6 +38,13 @@ the cutout mode itself is in values-v28 --> + - - diff --git a/app/src/main/res/xml/backup_config.xml b/app/src/main/res/xml/backup_config.xml index 4be2fa975a3..f67743b78fb 100644 --- a/app/src/main/res/xml/backup_config.xml +++ b/app/src/main/res/xml/backup_config.xml @@ -7,4 +7,8 @@ --> + + diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 00000000000..895e875db2f --- /dev/null +++ b/app/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + diff --git a/app/src/main/res/xml/file_provider_paths.xml b/app/src/main/res/xml/file_provider_paths.xml index dd50e23cce3..22ed29e5515 100644 --- a/app/src/main/res/xml/file_provider_paths.xml +++ b/app/src/main/res/xml/file_provider_paths.xml @@ -8,6 +8,9 @@ + diff --git a/app/src/main/res/xml/shortcuts.xml b/app/src/main/res/xml/shortcuts.xml index b752fcf8c31..db9d65e0f08 100644 --- a/app/src/main/res/xml/shortcuts.xml +++ b/app/src/main/res/xml/shortcuts.xml @@ -4,7 +4,7 @@ ~ SPDX-License-Identifier: GPL-3.0-or-later --> - + diff --git a/app/src/test/java/com/nextcloud/talk/activities/DeepLinkActivityTest.kt b/app/src/test/java/com/nextcloud/talk/activities/DeepLinkActivityTest.kt new file mode 100644 index 00000000000..86a291e948b --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/activities/DeepLinkActivityTest.kt @@ -0,0 +1,108 @@ +/* + * 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.app.Application +import android.content.Intent +import android.net.Uri +import com.nextcloud.talk.activities.DeepLinkRoutingViewModel.Request +import com.nextcloud.talk.conversationlist.ConversationsListActivity +import com.nextcloud.talk.utils.InternalEntryPoint +import com.nextcloud.talk.utils.bundle.BundleKeys +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config + +/** + * DeepLinkActivity is exported: of the intent of another app, only its uri, type and read grant reach the conversation + * list. + */ +@RunWith(RobolectricTestRunner::class) +@Config(application = Application::class, sdk = [33]) +class DeepLinkActivityTest { + + private val context = RuntimeEnvironment.getApplication() + + private fun passOn(intent: Intent): Intent { + val activity = Robolectric.buildActivity(DeepLinkActivity::class.java, intent).create().get() + assertTrue(activity.isFinishing) + return checkNotNull(shadowOf(context).nextStartedActivity) + } + + private fun startedBy(intent: Intent): Intent? { + val activity = Robolectric.buildActivity(DeepLinkActivity::class.java, intent).create().get() + assertTrue(activity.isFinishing) + return shadowOf(context).nextStartedActivity + } + + private fun intentOfOtherApp(action: String, uri: Uri) = + Intent(action, uri) + .setClass(context, DeepLinkActivity::class.java) + .putExtra(BundleKeys.KEY_INTERNAL_USER_ID, 1L) + .putExtra(BundleKeys.KEY_ROOM_TOKEN, "abcd1234") + .putExtra(BundleKeys.KEY_FORWARD_MSG_FLAG, true) + .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION) + + @Test + fun `only the uri, type and read grant of a deep link are passed on`() { + val uri = Uri.parse("nextcloudtalk://alice@cloud.example.com/call/abcd1234") + + val passedOn = passOn(intentOfOtherApp(Intent.ACTION_VIEW, uri).setDataAndType(uri, "text/plain")) + + assertEquals(InternalEntryPoint.nameOf(ConversationsListActivity::class.java), passedOn.component?.className) + assertEquals(Intent.ACTION_VIEW, passedOn.action) + assertEquals(uri, passedOn.data) + assertEquals("text/plain", passedOn.type) + assertTrue(passedOn.extras?.isEmpty ?: true) + assertTrue(passedOn.flags and Intent.FLAG_GRANT_READ_URI_PERMISSION != 0) + assertEquals(0, passedOn.flags and Intent.FLAG_GRANT_WRITE_URI_PERMISSION) + assertTrue(Request.of(passedOn) is Request.DeepLink) + } + + @Test + fun `the uri of an intent other than VIEW is not passed on`() { + val uri = Uri.parse("nextcloudtalk://alice@cloud.example.com/call/abcd1234") + + val passedOn = passOn(intentOfOtherApp(Intent.ACTION_EDIT, uri)) + + assertNull(passedOn.data) + assertTrue(passedOn.extras?.isEmpty ?: true) + // only opens the app + assertNull(Request.of(passedOn)) + } + + @Test + fun `a link that opens no conversation does nothing`() { + val links = listOf( + "nextcloudtalk://alice@cloud.example.com/call/", + "nextcloudtalk://alice@cloud.example.com/call/not-a-token!", + "nextcloudtalk:///call/abcd1234", + "https://cloud.example.com/call/abcd1234", + "content://com.android.contacts/data/1" + ) + links.forEach { link -> + assertNull(link, startedBy(intentOfOtherApp(Intent.ACTION_VIEW, Uri.parse(link)))) + } + assertNull("without uri", startedBy(Intent(Intent.ACTION_VIEW).setClass(context, DeepLinkActivity::class.java))) + } + + @Test + fun `the chat action of a phone book contact is passed on`() { + val uri = Uri.parse("content://com.android.contacts/data/1") + val intent = intentOfOtherApp(Intent.ACTION_VIEW, uri) + .setDataAndType(uri, "vnd.android.cursor.item/vnd.com.nextcloud.talk2.chat") + + assertEquals(Request.ContactChat(uri), Request.of(passOn(intent))) + } +} diff --git a/app/src/test/java/com/nextcloud/talk/activities/MainActivityContactAccountTest.kt b/app/src/test/java/com/nextcloud/talk/activities/DeepLinkRoutingViewModelContactAccountTest.kt similarity index 93% rename from app/src/test/java/com/nextcloud/talk/activities/MainActivityContactAccountTest.kt rename to app/src/test/java/com/nextcloud/talk/activities/DeepLinkRoutingViewModelContactAccountTest.kt index 60ef539e34b..7470125c4d3 100644 --- a/app/src/test/java/com/nextcloud/talk/activities/MainActivityContactAccountTest.kt +++ b/app/src/test/java/com/nextcloud/talk/activities/DeepLinkRoutingViewModelContactAccountTest.kt @@ -6,13 +6,13 @@ */ package com.nextcloud.talk.activities -import com.nextcloud.talk.activities.MainActivity.Companion.accountForContactServer +import com.nextcloud.talk.activities.DeepLinkRoutingViewModel.Companion.accountForContactServer import com.nextcloud.talk.data.user.model.User import org.junit.Assert.assertEquals import org.junit.Assert.assertNull import org.junit.Test -class MainActivityContactAccountTest { +class DeepLinkRoutingViewModelContactAccountTest { private val accountA = User(id = 1, baseUrl = "https://a.example.com") private val accountB = User(id = 2, baseUrl = "https://b.example.com/nextcloud") diff --git a/app/src/test/java/com/nextcloud/talk/activities/DeepLinkRoutingViewModelTargetUserTest.kt b/app/src/test/java/com/nextcloud/talk/activities/DeepLinkRoutingViewModelTargetUserTest.kt new file mode 100644 index 00000000000..a3a482a7f11 --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/activities/DeepLinkRoutingViewModelTargetUserTest.kt @@ -0,0 +1,53 @@ +/* + * 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.app.Application +import com.nextcloud.talk.activities.DeepLinkRoutingViewModel.Companion.resolveTargetUser +import com.nextcloud.talk.data.user.model.User +import com.nextcloud.talk.utils.DeepLinkHandler.DeepLinkResult +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** The account that opens a deep link: the one it names, else the current one on its server, else another one. */ +@RunWith(RobolectricTestRunner::class) +@Config(application = Application::class, sdk = [33]) +class DeepLinkRoutingViewModelTargetUserTest { + + private val alice = User(id = 1L, username = "alice", baseUrl = "https://cloud.example.com") + private val bob = User(id = 2L, username = "bob", baseUrl = "https://cloud.example.com") + private val carol = User(id = 3L, username = "carol", baseUrl = "https://other.example.com") + private val users = listOf(alice, bob, carol) + + private fun linkTo(username: String?) = DeepLinkResult("abcd1234", "https://cloud.example.com", username) + + @Test + fun `the account the link names opens it`() { + assertEquals(alice, resolveTargetUser(users, linkTo("alice"), defaultUser = bob)) + } + + @Test + fun `a link without a user name is opened by the current account on its server`() { + assertEquals(bob, resolveTargetUser(users, linkTo(null), defaultUser = bob)) + } + + @Test + fun `a link to another server than the current account is opened by an account on it`() { + assertEquals(alice, resolveTargetUser(users, linkTo(null), defaultUser = carol)) + } + + @Test + fun `a link to a server without an account is opened by none`() { + val link = DeepLinkResult("abcd1234", "https://unknown.example.com", "alice") + + assertNull(resolveTargetUser(users, link, defaultUser = alice)) + } +} diff --git a/app/src/test/java/com/nextcloud/talk/activities/DeepLinkRoutingViewModelTest.kt b/app/src/test/java/com/nextcloud/talk/activities/DeepLinkRoutingViewModelTest.kt new file mode 100644 index 00000000000..c3c64f4d35f --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/activities/DeepLinkRoutingViewModelTest.kt @@ -0,0 +1,527 @@ +/* + * 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.app.Application +import android.content.ContentProvider +import android.content.ContentValues +import android.content.Intent +import android.database.Cursor +import android.database.MatrixCursor +import android.database.sqlite.SQLiteException +import android.net.Uri +import android.provider.ContactsContract +import com.nextcloud.talk.R +import com.nextcloud.talk.contacts.ContactsRepository +import com.nextcloud.talk.conversationlist.ConversationsListActivity +import com.nextcloud.talk.activities.DeepLinkRoutingViewModel.Destination +import com.nextcloud.talk.activities.DeepLinkRoutingViewModel.Request +import com.nextcloud.talk.activities.DeepLinkRoutingViewModel.RoutingState +import com.nextcloud.talk.conversationlist.data.OfflineConversationsRepository +import com.nextcloud.talk.conversationlist.data.network.OfflineFirstConversationsRepository.ConversationResult +import com.nextcloud.talk.data.user.model.User +import com.nextcloud.talk.models.domain.ConversationModel +import com.nextcloud.talk.models.json.capabilities.CapabilitiesDto +import com.nextcloud.talk.models.json.capabilities.SpreedCapabilityDto +import com.nextcloud.talk.models.json.conversations.ConversationDto +import com.nextcloud.talk.models.json.conversations.ConversationEnums +import com.nextcloud.talk.models.json.conversations.RoomOCS +import com.nextcloud.talk.models.json.conversations.RoomOverall +import com.nextcloud.talk.users.DefaultAccountProvider +import com.nextcloud.talk.users.UserManager +import com.nextcloud.talk.utils.DeepLinkHandler +import com.nextcloud.talk.utils.InternalEntryPoint +import com.nextcloud.talk.utils.bundle.BundleKeys +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.doSuspendableAnswer +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verifyBlocking +import org.mockito.kotlin.wheneverBlocking +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(application = Application::class, sdk = [33]) +@Suppress("TooManyFunctions") +class DeepLinkRoutingViewModelTest { + + private val dispatcher = StandardTestDispatcher() + + private val alice = User(id = 1, username = "alice", baseUrl = "https://cloud.example.com") + private val bob = User(id = 2, username = "bob", baseUrl = "https://other.example.com") + + private val userManager = mock { + on { getUsersNotScheduledForDeletion() } doReturn listOf(alice, bob) + on { getUserWithInternalId(alice.id!!) } doReturn alice + on { getUserWithInternalId(bob.id!!) } doReturn bob + } + private val defaultAccountProvider = mock { + on { getDefaultUser() } doReturn alice + } + private val conversationsRepository = mock { + on { observeConversation(any(), any()) } doReturn flowOf(ConversationResult.NotFound) + on { syncRooms(any(), any(), anyOrNull()) } doReturn emptyList() + } + + private lateinit var viewModel: DeepLinkRoutingViewModel + + @Before + fun setUp() { + Dispatchers.setMain(dispatcher) + viewModel = createViewModel(conversationsRepository) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + FakeContactsProvider.cursor = null + FakeContactsProvider.error = null + } + + private fun createViewModel( + repository: OfflineConversationsRepository, + userManager: UserManager = this.userManager, + contactsRepository: ContactsRepository = mock() + ) = DeepLinkRoutingViewModel( + context = RuntimeEnvironment.getApplication(), + userManager = userManager, + defaultAccountProvider = defaultAccountProvider, + conversationsRepository = repository, + contactsRepository = contactsRepository, + logger = mock() + ).also { it.ioDispatcher = dispatcher } + + // passed on by DeepLinkActivity + private fun internalIntent() = + InternalEntryPoint.createIntent(RuntimeEnvironment.getApplication(), ConversationsListActivity::class.java) + .setAction(Intent.ACTION_VIEW) + + private val deepLink = Request.DeepLink(DeepLinkHandler.DeepLinkResult(TOKEN, "https://cloud.example.com", "alice")) + + @Test + fun `a deep link of another app asks for nothing`() { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse("nextcloudtalk://alice@cloud.example.com/call/$TOKEN")) + .setClass(RuntimeEnvironment.getApplication(), ConversationsListActivity::class.java) + + assertNull(Request.of(intent)) + } + + @Test + fun `a passed on deep link asks for its conversation`() { + val intent = internalIntent().setData(Uri.parse("nextcloudtalk://alice@cloud.example.com/call/$TOKEN")) + + assertEquals(deepLink, Request.of(intent)) + } + + @Test + fun `an intent of the app without a uri asks for nothing`() { + assertNull(Request.of(internalIntent().putExtra(BundleKeys.KEY_ROOM_TOKEN, TOKEN))) + } + + @Test + fun `a passed on contact intent of another type asks for nothing`() { + val intent = internalIntent() + .setDataAndType(Uri.parse("content://com.android.contacts/data/1"), "text/plain") + + assertNull(Request.of(intent)) + } + + @Test + fun `a passed on contact chat intent asks for the chat with the contact`() { + val uri = Uri.parse("content://com.android.contacts/data/1") + val intent = internalIntent() + .setDataAndType(uri, "vnd.android.cursor.item/vnd.com.nextcloud.talk2.chat") + + assertEquals(Request.ContactChat(uri), Request.of(intent)) + } + + @Test + fun `the launch screen stays while a deep link is routed, but not for its question`() = + runTest(dispatcher) { + assertFalse(viewModel.keepsLaunchScreen) + + viewModel.route(deepLink) + assertTrue(viewModel.keepsLaunchScreen) + advanceUntilIdle() + + assertEquals(RoutingState.AskToJoin("cloud.example.com"), viewModel.state.value) + assertFalse(viewModel.keepsLaunchScreen) + + viewModel.confirm() + advanceUntilIdle() + assertTrue(viewModel.keepsLaunchScreen) + + viewModel.onOpened() + assertFalse(viewModel.keepsLaunchScreen) + } + + @Test + fun `a request is routing until what it opens was opened`() = + runTest(dispatcher) { + viewModel.route(deepLink) + assertTrue(viewModel.isRouting) + advanceUntilIdle() + viewModel.decline() + + assertEquals(RoutingState.Open(Destination.ConversationList), viewModel.state.value) + assertTrue(viewModel.isRouting) + + viewModel.onOpened() + + assertEquals(RoutingState.Done, viewModel.state.value) + assertFalse(viewModel.isRouting) + } + + @Test + fun `a deep link to an unknown conversation asks first and opens it when confirmed`() = + runTest(dispatcher) { + viewModel.route(deepLink) + advanceUntilIdle() + + assertEquals(RoutingState.AskToJoin("cloud.example.com"), viewModel.state.value) + assertTrue(viewModel.isRouting) + + viewModel.confirm() + advanceUntilIdle() + + assertEquals(RoutingState.Open(Destination.Chat(alice.id!!, TOKEN)), viewModel.state.value) + } + + @Test + fun `a deep link to a voice room lets the conversation list ask whether to join its call`() = + runTest(dispatcher) { + // its chat would start the call right away + val voiceRoom = mock { + on { attributes } doReturn ConversationEnums.ATTRIBUTE_IS_VOICE_ROOM + } + val repository = mock { + on { observeConversation(alice.id!!, TOKEN) } doReturn flowOf(ConversationResult.Found(voiceRoom)) + } + viewModel = createViewModel(repository) + viewModel.route(deepLink) + advanceUntilIdle() + + assertEquals(RoutingState.Open(Destination.VoiceRoom(alice.id!!, TOKEN)), viewModel.state.value) + } + + @Test + fun `a deep link to a conversation of another account opens it without making that account active`() = + runTest(dispatcher) { + // the list of the account that the chat opens above makes it active: done here, the list of the previous + // account could relaunch for it before the chat was opened, e.g. after unlocking, and drop the chat + val repository = mock { + on { observeConversation(bob.id!!, TOKEN) } doReturn flowOf(ConversationResult.Found(mock())) + } + viewModel = createViewModel(repository) + viewModel.route(Request.DeepLink(DeepLinkHandler.DeepLinkResult(TOKEN, "https://other.example.com", "bob"))) + advanceUntilIdle() + + assertEquals(RoutingState.Open(Destination.Chat(bob.id!!, TOKEN)), viewModel.state.value) + verifyBlocking(userManager, never()) { setUserAsActive(any()) } + } + + @Test + fun `a declined deep link opens the conversation list`() = + runTest(dispatcher) { + viewModel.route(deepLink) + advanceUntilIdle() + + viewModel.decline() + + assertEquals(RoutingState.Open(Destination.ConversationList), viewModel.state.value) + } + + @Test + fun `a newer request replaces one that is still being routed`() = + runTest(dispatcher) { + // the sync of the deep link takes a while, and would then ask whether to join its conversation + val slowRepository = mock { + on { observeConversation(any(), any()) } doReturn flowOf(ConversationResult.NotFound) + on { syncRooms(any(), any(), anyOrNull()) } doSuspendableAnswer { + delay(SLOW_SYNC_MS) + emptyList() + } + } + viewModel = createViewModel(slowRepository) + + viewModel.route(deepLink) + runCurrent() + + assertEquals(noAccountForContact, routeContactChatWith()) + } + + @Test + fun `a newer request drops the question of an older one`() = + runTest(dispatcher) { + viewModel.route(deepLink) + advanceUntilIdle() + routeContactChatWith() + viewModel.onOpened() + + viewModel.confirm() + advanceUntilIdle() + + assertEquals(RoutingState.Done, viewModel.state.value) + } + + private val contactUri = Uri.parse("content://com.android.contacts/data/1") + + // the contact chat of a uri the contacts provider can't answer, as another app can send any uri + private fun routeContactChatWith(cursor: Cursor? = null, error: RuntimeException? = null): RoutingState { + Robolectric.setupContentProvider(FakeContactsProvider::class.java, ContactsContract.AUTHORITY) + FakeContactsProvider.cursor = cursor + FakeContactsProvider.error = error + viewModel.route(Request.ContactChat(contactUri)) + dispatcher.scheduler.advanceUntilIdle() + return viewModel.state.value + } + + private val routingError = RoutingState.Open(Destination.ConversationList, R.string.nc_common_error_sorry) + + private fun databaseError(): Nothing = throw SQLiteException("database is locked") + + private val noAccountForContact = + RoutingState.Open( + Destination.ConversationList, + R.string.nc_phone_book_integration_account_not_found, + isLongMessage = true + ) + + // a data row of the contact chat of Talk, as ContactAddressBookWorker adds it + private fun talkContactRow(cloudId: String?, mimeType: String = TALK_CONTACT_CHAT_MIME_TYPE) = + MatrixCursor(arrayOf(ContactsContract.Data.MIMETYPE, ContactsContract.Data.DATA1)).apply { + addRow(arrayOf(mimeType, cloudId)) + } + + @Test + fun `another data row of a contact with a user id at a server opens the start screen`() { + val email = talkContactRow( + "carol@cloud.example.com", + mimeType = ContactsContract.CommonDataKinds.Email.CONTENT_ITEM_TYPE + ) + + assertEquals(noAccountForContact, routeContactChatWith(cursor = email)) + } + + @Test + fun `a contact uri the provider rejects opens the start screen`() { + assertEquals(noAccountForContact, routeContactChatWith(error = IllegalArgumentException("Unknown URI"))) + } + + @Test + fun `a contact uri without the user id column opens the start screen`() { + val contact = MatrixCursor(arrayOf(ContactsContract.Contacts._ID)).apply { addRow(arrayOf(1)) } + + assertEquals(noAccountForContact, routeContactChatWith(cursor = contact)) + } + + @Test + fun `a contact without a user id opens the start screen`() { + val contactData = talkContactRow(cloudId = null) + + assertEquals(noAccountForContact, routeContactChatWith(cursor = contactData)) + } + + @Test + fun `a routing step that fails shows an error instead of crashing`() = + runTest(dispatcher) { + val failingUserManager = mock { + on { getUsersNotScheduledForDeletion() } doSuspendableAnswer { databaseError() } + } + viewModel = createViewModel(conversationsRepository, failingUserManager) + + viewModel.route(deepLink) + advanceUntilIdle() + + assertEquals(routingError, viewModel.state.value) + } + + @Test + fun `a confirmed step that fails shows an error instead of crashing`() = + runTest(dispatcher) { + // the accounts can be read for the question, but not anymore after it was confirmed + var readsOfAccounts = 0 + val failingUserManager = mock { + on { getUsersNotScheduledForDeletion() } doSuspendableAnswer { + if (readsOfAccounts++ == 0) listOf(alice, bob) else databaseError() + } + } + val repository = mock { + on { roomListFlow(any()) } doReturn flowOf(emptyList()) + on { syncRooms(any(), any(), anyOrNull()) } doReturn emptyList() + } + viewModel = createViewModel(repository, failingUserManager) + val contactData = talkContactRow("carol@cloud.example.com") + val question = routeContactChatWith(contactData) + assertEquals(RoutingState.AskToStartContactChat("carol@cloud.example.com"), question) + + viewModel.confirm() + advanceUntilIdle() + + assertEquals(routingError, viewModel.state.value) + } + + // an account whose server can create conversations, without a one-to-one conversation with carol yet + private val aliceWithConversations = alice.copy( + token = "secret", + capabilities = CapabilitiesDto().apply { + spreedCapability = SpreedCapabilityDto().apply { features = listOf("conversation-v4") } + } + ) + + private fun confirmContactChat(contacts: ContactsRepository): RoutingState { + val userManager = mock { + on { getUsersNotScheduledForDeletion() } doReturn listOf(aliceWithConversations) + } + wheneverBlocking { defaultAccountProvider.getDefaultUser() }.thenReturn(aliceWithConversations) + val repository = mock { + on { roomListFlow(any()) } doReturn flowOf(emptyList()) + on { syncRooms(any(), any(), anyOrNull()) } doReturn emptyList() + } + viewModel = createViewModel(repository, userManager, contacts) + val contactData = talkContactRow("carol@cloud.example.com") + assertEquals(RoutingState.AskToStartContactChat("carol@cloud.example.com"), routeContactChatWith(contactData)) + + viewModel.confirm() + dispatcher.scheduler.advanceUntilIdle() + return viewModel.state.value + } + + @Test + fun `a confirmed contact chat opens the created conversation`() { + val contacts = mock { + onBlocking { createRoom(any(), any(), anyOrNull(), any(), anyOrNull()) } doReturn + RoomOverall(RoomOCS(meta = null, data = ConversationDto(token = TOKEN))) + } + + assertEquals( + RoutingState.Open(Destination.Chat(alice.id!!, TOKEN), isLongMessage = true), + confirmContactChat(contacts) + ) + } + + @Test + fun `a confirmed contact chat that can't be created shows an error`() { + val contacts = mock { + onBlocking { createRoom(any(), any(), anyOrNull(), any(), anyOrNull()) } doSuspendableAnswer { + throw java.io.IOException("offline") + } + } + + assertEquals(routingError, confirmContactChat(contacts)) + } + + @Test + fun `a confirmed contact chat without a conversation in the response shows an error`() { + val contacts = mock { + onBlocking { createRoom(any(), any(), anyOrNull(), any(), anyOrNull()) } doReturn + RoomOverall(RoomOCS(meta = null)) + } + + assertEquals(routingError, confirmContactChat(contacts)) + } + + @Test + fun `a confirmed contact chat with a conversation without a token shows an error`() { + val contacts = mock { + onBlocking { createRoom(any(), any(), anyOrNull(), any(), anyOrNull()) } doReturn + RoomOverall(RoomOCS(meta = null, data = ConversationDto())) + } + + assertEquals(routingError, confirmContactChat(contacts)) + } + + // alice is still the active account, but scheduled for deletion, so only bob is left + private val userManagerWithAliceDeleted = mock { + on { getUsersNotScheduledForDeletion() } doReturn listOf(bob) + on { getUserWithId(alice.id!!) } doReturn alice + on { getUserWithInternalId(bob.id!!) } doReturn bob + } + + @Test + fun `a deep link doesn't open with an account scheduled for deletion`() = + runTest(dispatcher) { + viewModel = createViewModel(conversationsRepository, userManagerWithAliceDeleted) + + viewModel.route(deepLink) + advanceUntilIdle() + + val noAccountForServer = RoutingState.Open( + Destination.ConversationList, + R.string.nc_no_account_for_server, + isLongMessage = true + ) + assertEquals(noAccountForServer, viewModel.state.value) + } + + @Test + fun `a contact chat doesn't open with an account scheduled for deletion`() { + viewModel = createViewModel(conversationsRepository, userManagerWithAliceDeleted) + val contactData = talkContactRow("carol@cloud.example.com") + + assertEquals(noAccountForContact, routeContactChatWith(cursor = contactData)) + } + + companion object { + private const val TOKEN = "abcd1234" + private const val TALK_CONTACT_CHAT_MIME_TYPE = "vnd.android.cursor.item/vnd.com.nextcloud.talk2.chat" + private const val SLOW_SYNC_MS = 1000L + } +} + +/** A contacts provider that answers with [cursor] or throws [error]. */ +class FakeContactsProvider : ContentProvider() { + override fun onCreate() = true + + override fun query( + uri: Uri, + projection: Array?, + selection: String?, + selectionArgs: Array?, + sortOrder: String? + ): Cursor? { + error?.let { throw it } + return cursor + } + + override fun getType(uri: Uri): String? = null + + override fun insert(uri: Uri, values: ContentValues?): Uri? = null + + override fun delete(uri: Uri, selection: String?, selectionArgs: Array?) = 0 + + override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array?) = 0 + + companion object { + var cursor: Cursor? = null + var error: RuntimeException? = null + } +} diff --git a/app/src/test/java/com/nextcloud/talk/chat/audio/ChatAudioIntentsTest.kt b/app/src/test/java/com/nextcloud/talk/chat/audio/ChatAudioIntentsTest.kt new file mode 100644 index 00000000000..862f9d87120 --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/chat/audio/ChatAudioIntentsTest.kt @@ -0,0 +1,55 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.chat.audio + +import android.app.Application +import android.content.Intent +import com.nextcloud.talk.chat.ChatActivity +import com.nextcloud.talk.conversationlist.ConversationsListActivity +import com.nextcloud.talk.utils.InternalEntryPoint +import com.nextcloud.talk.utils.bundle.BundleKeys +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +/** The chat of an audio message opens directly, not through the routing of the conversation list. */ +@RunWith(RobolectricTestRunner::class) +@Config(application = Application::class, sdk = [33]) +class ChatAudioIntentsTest { + + private val context = RuntimeEnvironment.getApplication() + + private val key = ChatAudioKey(internalUserId = 2L, roomToken = "abcd1234", messageId = 42) + + private fun assertOpensMessage(intent: Intent) { + assertEquals(2L, intent.getLongExtra(BundleKeys.KEY_INTERNAL_USER_ID, 0L)) + assertEquals("abcd1234", intent.getStringExtra(BundleKeys.KEY_ROOM_TOKEN)) + assertEquals("42", intent.getStringExtra(BundleKeys.KEY_MESSAGE_ID)) + assertEquals(7L, intent.getLongExtra(BundleKeys.KEY_THREAD_ID, 0L)) + } + + @Test + fun `a screen of the app opens the chat of the message directly`() { + val intent = key.openMessageIntent(context, threadId = 7L) + + assertEquals(ChatActivity::class.java.name, intent.component?.className) + assertOpensMessage(intent) + } + + @Test + fun `the media notification opens the chat of the message above the conversation list of its account`() { + val (list, chat) = key.openMessageBackStack(context, threadId = 7L) + + assertEquals(InternalEntryPoint.nameOf(ConversationsListActivity::class.java), list.component?.className) + assertEquals(2L, list.getLongExtra(BundleKeys.KEY_INTERNAL_USER_ID, 0L)) + assertEquals(ChatActivity::class.java.name, chat.component?.className) + assertOpensMessage(chat) + } +} diff --git a/app/src/test/java/com/nextcloud/talk/chat/viewmodels/ChatViewModelRoomJoinTest.kt b/app/src/test/java/com/nextcloud/talk/chat/viewmodels/ChatViewModelRoomJoinTest.kt index 5c25247f825..02010b57e76 100644 --- a/app/src/test/java/com/nextcloud/talk/chat/viewmodels/ChatViewModelRoomJoinTest.kt +++ b/app/src/test/java/com/nextcloud/talk/chat/viewmodels/ChatViewModelRoomJoinTest.kt @@ -63,6 +63,7 @@ import org.junit.Test import org.junit.runner.RunWith import org.mockito.Mockito import org.mockito.kotlin.any +import org.mockito.kotlin.doReturn import org.mockito.kotlin.doSuspendableAnswer import org.mockito.kotlin.mock import org.mockito.kotlin.never @@ -88,7 +89,12 @@ class ChatViewModelRoomJoinTest { private val conversation = MutableStateFlow(ConversationResult.Found(conversationModel(1))) private val owner = TestOwner() - private val network: ChatNetworkDataSource = mock() + + // a join that never answers: unstubbed, the join after the test's last check failed on its null observable, + // reported by a later runTest of another test class + private val network: ChatNetworkDataSource = mock { + on { joinRoom(any(), any(), any()) } doReturn Observable.never() + } private var pendingLeave: CompletableDeferred? = null private lateinit var viewModel: ChatViewModel diff --git a/app/src/test/java/com/nextcloud/talk/conversationlist/ConversationsListEntryPointTest.kt b/app/src/test/java/com/nextcloud/talk/conversationlist/ConversationsListEntryPointTest.kt new file mode 100644 index 00000000000..e70771b7516 --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/conversationlist/ConversationsListEntryPointTest.kt @@ -0,0 +1,146 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.conversationlist + +import android.app.Application +import android.content.Intent +import android.net.Uri +import com.nextcloud.talk.conversationlist.ConversationsListActivity.Companion.intentOfAccount +import com.nextcloud.talk.conversationlist.ConversationsListActivity.Companion.isShareOfOtherApp +import com.nextcloud.talk.conversationlist.ConversationsListActivity.Companion.isViewOfOtherApp +import com.nextcloud.talk.conversationlist.ConversationsListActivity.Companion.removeExtrasOfApp +import com.nextcloud.talk.utils.AppTasks +import com.nextcloud.talk.utils.InternalEntryPoint +import com.nextcloud.talk.utils.bundle.BundleKeys +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +/** The conversation list is exported: only the app itself, through its internal entry point, may set its extras. */ +@RunWith(RobolectricTestRunner::class) +@Config(application = Application::class, sdk = [33]) +class ConversationsListEntryPointTest { + + private val context = RuntimeEnvironment.getApplication() + + private fun withExtrasOfApp(intent: Intent) = + intent + .putExtra(BundleKeys.KEY_INTERNAL_USER_ID, 1L) + .putExtra(BundleKeys.KEY_ROOM_TOKEN, "abcd1234") + .putExtra(BundleKeys.KEY_FORWARD_MSG_FLAG, true) + .putExtra(BundleKeys.KEY_FORWARD_MSG_TEXT, "forwarded") + .putExtra(BundleKeys.KEY_FORWARD_HIDE_SOURCE_ROOM, "efgh5678") + + @Test + fun `the extras of the app are removed from an intent of another app`() { + val intent = withExtrasOfApp(Intent(context, ConversationsListActivity::class.java)) + .putExtra(Intent.EXTRA_TEXT, "shared") + + removeExtrasOfApp(intent) + + assertEquals(setOf(Intent.EXTRA_TEXT), intent.extras?.keySet()) + } + + @Test + fun `the extras of the app are kept on an intent of the app itself`() { + val intent = withExtrasOfApp(ConversationsListActivity.createIntent(context)) + + removeExtrasOfApp(intent) + + assertEquals(1L, intent.getLongExtra(BundleKeys.KEY_INTERNAL_USER_ID, 0L)) + assertEquals("abcd1234", intent.getStringExtra(BundleKeys.KEY_ROOM_TOKEN)) + assertTrue(intent.getBooleanExtra(BundleKeys.KEY_FORWARD_MSG_FLAG, false)) + assertEquals("forwarded", intent.getStringExtra(BundleKeys.KEY_FORWARD_MSG_TEXT)) + assertEquals("efgh5678", intent.getStringExtra(BundleKeys.KEY_FORWARD_HIDE_SOURCE_ROOM)) + } + + // started like by another app, with the activity itself and not its internal entry point + private fun intentOfOtherApp(action: String) = + Intent(action).setClass(context, ConversationsListActivity::class.java) + + @Test + fun `a share of another app is taken through ShareActivity`() { + assertTrue(isShareOfOtherApp(intentOfOtherApp(Intent.ACTION_SEND))) + assertTrue(isShareOfOtherApp(intentOfOtherApp(Intent.ACTION_SEND_MULTIPLE))) + } + + @Test + fun `a share passed on by ShareActivity and other intents stay in the list`() { + assertFalse(isShareOfOtherApp(ConversationsListActivity.createIntent(context).setAction(Intent.ACTION_SEND))) + assertFalse(isShareOfOtherApp(intentOfOtherApp(Intent.ACTION_VIEW))) + } + + @Test + fun `a new intent of another app keeps the account shown when the list is recreated`() { + val newIntent = intentOfOtherApp("com.nextcloud.intent.OPEN_ECOSYSTEM_APP") + removeExtrasOfApp(newIntent) + + val kept = intentOfAccount(context, newIntent, userId = 2L) + removeExtrasOfApp(kept) + + assertEquals(2L, kept.getLongExtra(BundleKeys.KEY_INTERNAL_USER_ID, 0L)) + assertEquals("com.nextcloud.intent.OPEN_ECOSYSTEM_APP", kept.action) + } + + @Test + fun `a deep link of another app to the launcher activity is taken through DeepLinkActivity`() { + val deepLink = Uri.parse("nextcloudtalk://alice@cloud.example.com/call/abcd1234") + val pinnedShortcut = Intent(Intent.ACTION_VIEW, deepLink).setClassName(context, AppTasks.LAUNCHER_ACTIVITY) + + assertTrue(isViewOfOtherApp(pinnedShortcut)) + } + + @Test + fun `a deep link passed on by DeepLinkActivity and a VIEW intent without uri stay in the list`() { + val passedOn = ConversationsListActivity.createIntent(context) + .setAction(Intent.ACTION_VIEW) + .setData(Uri.parse("nextcloudtalk://alice@cloud.example.com/call/abcd1234")) + + assertFalse(isViewOfOtherApp(passedOn)) + assertFalse(isViewOfOtherApp(intentOfOtherApp(Intent.ACTION_VIEW))) + } + + @Test + fun `a deep link task relaunched from the recents before Android 10 isn't passed on again`() { + // before Android 10, the intent of the task names the target of the internal alias it was started through + val relaunched = Intent(Intent.ACTION_VIEW, Uri.parse("nextcloudtalk://alice@cloud.example.com/call/abcd1234")) + .setClassName(context, ConversationsListActivity::class.java.name) + .addFlags(Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY) + + assertFalse(isViewOfOtherApp(relaunched)) + } + + @Test + fun `the deep link of another app isn't kept as one of the app when the list shows its intent`() { + val deepLink = Uri.parse("nextcloudtalk://alice@cloud.example.com/call/abcd1234") + val relaunched = Intent(Intent.ACTION_VIEW, deepLink) + .setClassName(context, AppTasks.LAUNCHER_ACTIVITY) + .addFlags(Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY) + val passedOn = ConversationsListActivity.createIntent(context).setAction(Intent.ACTION_VIEW).setData(deepLink) + + assertNull(intentOfAccount(context, relaunched, userId = 2L).data) + assertEquals(deepLink, intentOfAccount(context, passedOn, userId = 2L).data) + } + + @Test + fun `a new back stack has the list of the account below its screen, which removes the tasks left in the recents`() { + val invitations = Intent().setClassName(context, "com.nextcloud.talk.invitation.InvitationsActivity") + + val (list, screen) = ConversationsListActivity.createBackStack(context, 2L, invitations) + + assertEquals(InternalEntryPoint.nameOf(ConversationsListActivity::class.java), list.component?.className) + assertEquals(2L, list.getLongExtra(BundleKeys.KEY_INTERNAL_USER_ID, 0L)) + assertFalse(list.getBooleanExtra(BundleKeys.KEY_TOP_OF_NEW_BACK_STACK, false)) + assertTrue(screen.getBooleanExtra(BundleKeys.KEY_TOP_OF_NEW_BACK_STACK, false)) + } +} diff --git a/app/src/test/java/com/nextcloud/talk/conversationlist/ShareListIntentFactoryTest.kt b/app/src/test/java/com/nextcloud/talk/conversationlist/ShareListIntentFactoryTest.kt new file mode 100644 index 00000000000..17ee1917484 --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/conversationlist/ShareListIntentFactoryTest.kt @@ -0,0 +1,238 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.conversationlist + +import android.app.Application +import android.content.ClipData +import android.content.Intent +import android.net.Uri +import android.os.Parcelable +import androidx.core.content.pm.ShortcutManagerCompat +import com.nextcloud.talk.utils.InternalEntryPoint +import com.nextcloud.talk.utils.bundle.BundleKeys +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import java.io.ByteArrayInputStream +import java.io.File +import java.io.InputStream + +/** + * ShareActivity is exported: of the intent another app shares, the conversation list only gets the text or copies of + * the files it may upload, and the id of a direct share target the app published. + */ +// runBlocking, not runTest: the factory copies on the real IO dispatcher, and runTest would also report exceptions +// that coroutines of earlier test classes leaked +@RunWith(RobolectricTestRunner::class) +@Config(application = Application::class, sdk = [33]) +class ShareListIntentFactoryTest { + + private val context = RuntimeEnvironment.getApplication() + + // the test has no file provider paths: a copy is shared with its file uri + private val factory = ShareListIntentFactory( + context, + uriOfCopy = { Uri.fromFile(it) }, + isPublishedShareTarget = { it == PUBLISHED_SHORTCUT_ID } + ) + + private val photoOfOtherApp = Uri.parse("content://com.example.gallery/photos/photo.jpg") + + private val shareDirectories = File(context.filesDir, "shared_file_copies") + + @Before + fun setUp() { + shareDirectories.deleteRecursively() + } + + private fun shareOfOtherApp(type: String) = + Intent(Intent.ACTION_SEND) + .setType(type) + .putExtra(BundleKeys.KEY_INTERNAL_USER_ID, 1L) + .putExtra(BundleKeys.KEY_ROOM_TOKEN, "abcd1234") + .putExtra(BundleKeys.KEY_FORWARD_MSG_FLAG, true) + + @Test + fun `a shared text is passed on as text only, without the files that come with it`() = + runBlocking { + val intent = shareOfOtherApp("text/plain") + .putExtra(Intent.EXTRA_TEXT, "https://example.com/page") + .putExtra(Intent.EXTRA_STREAM, photoOfOtherApp) + intent.clipData = ClipData.newRawUri(null, photoOfOtherApp) + + val listIntent = checkNotNull(factory.create(intent)) + + val listEntryPoint = InternalEntryPoint.nameOf(ConversationsListActivity::class.java) + assertEquals(listEntryPoint, listIntent.component?.className) + assertEquals(Intent.ACTION_SEND, listIntent.action) + assertEquals(setOf(Intent.EXTRA_TEXT), listIntent.extras?.keySet()) + assertEquals("https://example.com/page", listIntent.getStringExtra(Intent.EXTRA_TEXT)) + assertNull(listIntent.clipData) + } + + @Test + fun `a shared file is passed on as a copy in the share directory of the app`() = + runBlocking { + shadowOf(context.contentResolver).registerInputStream(photoOfOtherApp, ByteArrayInputStream(PHOTO)) + val intent = shareOfOtherApp("image/jpeg").putExtra(Intent.EXTRA_STREAM, photoOfOtherApp) + + val listIntent = checkNotNull(factory.create(intent)) + + assertNull(listIntent.extras) + val copy = File(checkNotNull(listIntent.clipData).getItemAt(0).uri.path!!) + assertEquals("photo.jpg", copy.name) + assertEquals(true, copy.canonicalPath.startsWith(shareDirectories.canonicalPath + File.separator)) + assertEquals(PHOTO.toList(), copy.readBytes().toList()) + } + + @Test + fun `a file of the app outside the shared caches is not passed on`() = + runBlocking { + val log = Uri.parse("content://${context.packageName}/logs/log.txt") + shadowOf(context.contentResolver).registerInputStream(log, ByteArrayInputStream(PHOTO)) + val intent = shareOfOtherApp("application/octet-stream") + intent.clipData = ClipData.newRawUri(null, log) + + assertNull(factory.create(intent)) + } + + @Test + fun `a file uri is not passed on`() = + runBlocking { + val intent = shareOfOtherApp("application/octet-stream") + .putExtra(Intent.EXTRA_STREAM, Uri.fromFile(File(context.filesDir, "secret"))) + + assertNull(factory.create(intent)) + } + + @Test + fun `the id of a direct share target is only passed on if the app published it`() = + runBlocking { + fun share(shortcutId: String) = + shareOfOtherApp("text/plain") + .putExtra(Intent.EXTRA_TEXT, "text") + .putExtra(ShortcutManagerCompat.EXTRA_SHORTCUT_ID, shortcutId) + + assertEquals( + PUBLISHED_SHORTCUT_ID, + factory.create(share(PUBLISHED_SHORTCUT_ID))?.getStringExtra(ShortcutManagerCompat.EXTRA_SHORTCUT_ID) + ) + assertNull(factory.create(share("other"))?.getStringExtra(ShortcutManagerCompat.EXTRA_SHORTCUT_ID)) + } + + @Test + fun `a share without a file to copy leaves no directory for copies`() = + runBlocking { + val texts = shareOfOtherApp("text/html") + texts.clipData = ClipData.newPlainText(null, "shared") + val untrusted = shareOfOtherApp("application/octet-stream") + .putExtra(Intent.EXTRA_STREAM, Uri.fromFile(File(context.filesDir, "secret"))) + + assertEquals("shared", factory.create(texts)?.clipData?.getItemAt(0)?.text) + assertNull(factory.create(untrusted)) + assertEquals(0, shareDirectories.listFiles().orEmpty().size) + } + + @Test + fun `a shared file is copied into a directory of its share`() = + runBlocking { + shadowOf(context.contentResolver).registerInputStream(photoOfOtherApp, ByteArrayInputStream(PHOTO)) + + factory.create(shareOfOtherApp("image/jpeg").putExtra(Intent.EXTRA_STREAM, photoOfOtherApp)) + + assertEquals(1, shareDirectories.listFiles().orEmpty().size) + } + + @Test + fun `a share of which no file can be read opens no conversation list`() = + runBlocking { + val unreadable = Uri.parse("content://com.example.gallery/photos/revoked.jpg") + + assertNull(factory.create(shareOfOtherApp("image/jpeg").putExtra(Intent.EXTRA_STREAM, unreadable))) + } + + @Test + fun `the files that can be read are passed on without the others`() = + runBlocking { + shadowOf(context.contentResolver).registerInputStream(photoOfOtherApp, ByteArrayInputStream(PHOTO)) + val unreadable = Uri.parse("content://com.example.gallery/photos/revoked.jpg") + val intent = Intent(Intent.ACTION_SEND_MULTIPLE).setType("image/jpeg") + .putParcelableArrayListExtra(Intent.EXTRA_STREAM, arrayListOf(unreadable, photoOfOtherApp)) + + assertEquals(1, factory.create(intent)?.clipData?.itemCount) + } + + @Test + fun `shared streams that are not uris are left out`() = + runBlocking { + shadowOf(context.contentResolver).registerInputStream(photoOfOtherApp, ByteArrayInputStream(PHOTO)) + val intent = Intent(Intent.ACTION_SEND_MULTIPLE).setType("image/jpeg") + .putParcelableArrayListExtra(Intent.EXTRA_STREAM, arrayListOf(Intent(), photoOfOtherApp)) + + assertEquals(1, factory.create(intent)?.clipData?.itemCount) + } + + @Test + fun `a share that can't be read opens no conversation list`() = + runBlocking { + val failingFactory = ShareListIntentFactory( + context, + uriOfCopy = { Uri.fromFile(it) }, + isPublishedShareTarget = { throw IllegalStateException("user is locked") } + ) + val intent = shareOfOtherApp("text/plain") + .putExtra(Intent.EXTRA_TEXT, "text") + .putExtra(ShortcutManagerCompat.EXTRA_SHORTCUT_ID, PUBLISHED_SHORTCUT_ID) + + assertNull(failingFactory.create(intent)) + } + + @Test + fun `a cancelled share stops copying and leaves no copies behind`() = + runBlocking { + lateinit var share: Job + var reads = 0 + // a file that never ends, e.g. a large video: the share is left while it is copied + val endlessFile = object : InputStream() { + override fun read(): Int = 0 + + override fun read(buffer: ByteArray, offset: Int, length: Int): Int { + if (++reads == 1) share.cancel() + return length + } + } + shadowOf(context.contentResolver).registerInputStream(photoOfOtherApp, endlessFile) + val intent = shareOfOtherApp("image/jpeg").putExtra(Intent.EXTRA_STREAM, photoOfOtherApp) + + share = launch(Dispatchers.IO, start = CoroutineStart.LAZY) { factory.create(intent) } + share.start() + withTimeout(TIMEOUT_MS) { share.join() } + + assertTrue(share.isCancelled) + assertEquals(1, reads) + assertTrue(shareDirectories.listFiles().isNullOrEmpty()) + } + + companion object { + private const val TIMEOUT_MS = 10_000L + private const val PUBLISHED_SHORTCUT_ID = "conversation_1_abcd1234" + private val PHOTO = byteArrayOf(1, 2, 3, 4) + } +} diff --git a/app/src/test/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorkerTest.kt b/app/src/test/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorkerTest.kt index bdd55d53dfa..c43dc84894b 100644 --- a/app/src/test/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorkerTest.kt +++ b/app/src/test/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorkerTest.kt @@ -20,6 +20,9 @@ import androidx.work.testing.WorkManagerTestInitHelper import com.nextcloud.talk.data.database.dao.ChatMessagesDao import com.nextcloud.talk.data.database.model.SendStatus import com.nextcloud.talk.users.UserManager +import com.nextcloud.talk.utils.UploadSourceGrants +import java.util.concurrent.Executor +import java.util.concurrent.Executors import org.junit.Assert.assertEquals import org.junit.Test import org.junit.runner.RunWith @@ -56,6 +59,17 @@ class UploadAndShareFilesWorkerTest { verify(chatDao).updateTempMessageSendStatus(CONVERSATION_ID, "ref-b", SendStatus.FAILED) } + @Test + fun `an upload that ended releases the read access to its file`() { + // on a thread of its own, as in the app: the worker asks WorkManager for the other uploads of its file + val workManager = initWorkManager(Executors.newSingleThreadExecutor()) + UploadSourceGrants.keep(context(), "content://media/ref-a") + + awaitFinished(workManager, upload("ref-a")) + + assertEquals(emptyList(), context().contentResolver.persistedUriPermissions) + } + private fun upload(referenceId: String): UUID = UploadAndShareFilesWorker.upload( userId = ACCOUNT_ID, @@ -67,7 +81,7 @@ class UploadAndShareFilesWorkerTest { internalConversationId = CONVERSATION_ID ) - private fun initWorkManager(): WorkManager { + private fun initWorkManager(executor: Executor = SynchronousExecutor()): WorkManager { val factory = object : WorkerFactory() { override fun createWorker( appContext: Context, @@ -80,7 +94,7 @@ class UploadAndShareFilesWorkerTest { } } val configuration = Configuration.Builder() - .setExecutor(SynchronousExecutor()) + .setExecutor(executor) .setWorkerFactory(factory) .build() WorkManagerTestInitHelper.initializeTestWorkManager(context(), configuration) diff --git a/app/src/test/java/com/nextcloud/talk/utils/AppTasksBeforeAndroid10Test.kt b/app/src/test/java/com/nextcloud/talk/utils/AppTasksBeforeAndroid10Test.kt new file mode 100644 index 00000000000..cca88a12e31 --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/utils/AppTasksBeforeAndroid10Test.kt @@ -0,0 +1,76 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.utils + +import android.app.ActivityManager +import android.app.Application +import android.content.ComponentName +import android.content.Intent +import com.nextcloud.talk.conversationlist.ConversationsListActivity +import com.nextcloud.talk.conversationlist.ShareActivity +import org.junit.Assert.assertEquals +import org.junit.Assert.assertSame +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import org.robolectric.shadows.ShadowAppTask + +/** + * Before Android 10, the base intent of a task that an alias started names the target of the alias, and only + * origActivity names the alias. + */ +@RunWith(RobolectricTestRunner::class) +@Config(application = Application::class, sdk = [28]) +class AppTasksBeforeAndroid10Test { + + private val context = RuntimeEnvironment.getApplication() + + @Suppress("DEPRECATION") + private fun task(alias: String, taskId: Int): ActivityManager.AppTask = + ShadowAppTask.newInstance().also { + shadowOf(it).setTaskInfo( + ActivityManager.RecentTaskInfo().apply { + baseIntent = Intent().setClassName(context, ConversationsListActivity::class.java.name) + origActivity = ComponentName(context, alias) + numActivities = 1 + persistentId = taskId + } + ) + } + + @Test + fun `a task the launcher started is the running task of the app`() { + val launcherTask = task(AppTasks.LAUNCHER_ACTIVITY, taskId = 1) + + assertSame(launcherTask, AppTasks.findRunningAppTask(context, listOf(launcherTask))) + } + + @Test + fun `a task started through the internal entry point is a task of the app`() { + val fromNotification = task(InternalEntryPoint.nameOf(ConversationsListActivity::class.java), taskId = 2) + + assertSame(fromNotification, AppTasks.findRunningAppTask(context, listOf(fromNotification))) + } + + @Test + fun `a task of a share is not a task of the app`() { + val share = ShadowAppTask.newInstance().also { + shadowOf(it).setTaskInfo( + ActivityManager.RecentTaskInfo().apply { + baseIntent = Intent().setClassName(context, ShareActivity::class.java.name) + numActivities = 1 + } + ) + } + val launcherTask = task(AppTasks.LAUNCHER_ACTIVITY, taskId = 1) + + assertEquals(listOf(launcherTask), AppTasks.findAppTasks(context, listOf(share, launcherTask)) { true }) + } +} diff --git a/app/src/test/java/com/nextcloud/talk/utils/AppTasksTest.kt b/app/src/test/java/com/nextcloud/talk/utils/AppTasksTest.kt new file mode 100644 index 00000000000..9278382fcb4 --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/utils/AppTasksTest.kt @@ -0,0 +1,125 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.utils + +import android.app.Activity +import android.app.ActivityManager +import android.app.Application +import android.content.Intent +import com.nextcloud.talk.chat.ChatActivity +import com.nextcloud.talk.conversationlist.ConversationsListActivity +import com.nextcloud.talk.conversationlist.ShareActivity +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import org.robolectric.shadows.ShadowAppTask + +@RunWith(RobolectricTestRunner::class) +@Config(application = Application::class, sdk = [33]) +class AppTasksTest { + + private val context = RuntimeEnvironment.getApplication() + + private fun task(rootClassName: String, numActivities: Int = 1, taskId: Int = 1): ActivityManager.AppTask = + ShadowAppTask.newInstance().also { + shadowOf(it).setTaskInfo( + ActivityManager.RecentTaskInfo().apply { + baseIntent = Intent().setClassName(context, rootClassName) + this.numActivities = numActivities + this.taskId = taskId + } + ) + } + + private val launcherRoot = AppTasks.LAUNCHER_ACTIVITY + private val internalRoot = InternalEntryPoint.nameOf(ConversationsListActivity::class.java) + + @Test + fun `a task only left in the recents is not the running task of the app`() { + val leftInRecents = task(launcherRoot, numActivities = 0) + val running = task(launcherRoot) + + assertSame(running, AppTasks.findRunningAppTask(context, listOf(leftInRecents, running))) + } + + @Test + fun `a task started through the internal entry point is a task of the app`() { + val share = task(ShareActivity::class.java.name) + val fromNotification = task(internalRoot) + + assertSame(fromNotification, AppTasks.findRunningAppTask(context, listOf(share, fromNotification))) + } + + @Test + fun `without a running task of the app there is none`() { + val tasks = listOf(task(launcherRoot, numActivities = 0), task(ShareActivity::class.java.name)) + + assertNull(AppTasks.findRunningAppTask(context, tasks)) + } + + @Test + fun `the predicate leaves out a task`() { + val own = task(launcherRoot, taskId = 1) + val other = task(internalRoot, taskId = 2) + + assertSame(other, AppTasks.findRunningAppTask(context, listOf(own, other)) { it.taskId != 1 }) + } + + private val listRoot = ConversationsListActivity::class.java.name + + @Test + fun `a task rooted at the conversation list that is only left in the recents is removed`() { + val leftInRecents = task(listRoot, numActivities = 0, taskId = 1) + val running = task(listRoot, taskId = 2) + val runningApp = task(launcherRoot, taskId = 3) + val tasks = listOf(leftInRecents, running, runningApp) + + assertEquals(listOf(leftInRecents), AppTasks.findTasksLeftInRecents(context, tasks)) + } + + @Test + fun `the tasks of the app that are only left in the recents are removed, but not those of a share`() { + val app = task(launcherRoot, numActivities = 0, taskId = 1) + val fromNotification = task(internalRoot, numActivities = 0, taskId = 2) + val share = task(ShareActivity::class.java.name, numActivities = 0, taskId = 3) + val tasks = listOf(app, fromNotification, share) + + assertEquals(listOf(app, fromNotification), AppTasks.findTasksLeftInRecents(context, tasks)) + } + + private val chatIntent = Intent().setClassName(context, ChatActivity::class.java.name) + + // the activity that opens the chat, e.g. the one a notification starts + private val activity = Robolectric.buildActivity(Activity::class.java).get() + + @Test + fun `a chat is opened on top of the screens of the running task of the app`() { + AppTasks.openChatInAppTask(activity, chatIntent, listOf(task(launcherRoot))) + + val started = shadowOf(activity).nextStartedActivity + assertEquals(ChatActivity::class.java.name, started.component?.className) + assertNull(shadowOf(activity).nextStartedActivity) + } + + @Test + fun `without a running task of the app a chat is opened in a new task above the conversation list`() { + AppTasks.openChatInAppTask(activity, chatIntent, listOf(task(launcherRoot, numActivities = 0))) + + val started = listOfNotNull(shadowOf(activity).nextStartedActivity, shadowOf(activity).nextStartedActivity) + assertEquals( + setOf(internalRoot, ChatActivity::class.java.name), + started.map { it.component?.className }.toSet() + ) + } +} diff --git a/app/src/test/java/com/nextcloud/talk/utils/DeepLinkHandlerTokenTest.kt b/app/src/test/java/com/nextcloud/talk/utils/DeepLinkHandlerTokenTest.kt new file mode 100644 index 00000000000..4b4527b79dd --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/utils/DeepLinkHandlerTokenTest.kt @@ -0,0 +1,33 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.utils + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class DeepLinkHandlerTokenTest { + + @Test + fun `accepts alphanumeric tokens`() { + assertTrue(DeepLinkHandler.isValidToken("abc123de")) + } + + @Test + fun `rejects tokens that could alter api urls`() { + assertFalse(DeepLinkHandler.isValidToken("../../ocs/v2.php/cloud/user")) + assertFalse(DeepLinkHandler.isValidToken("abc123?format=xml")) + assertFalse(DeepLinkHandler.isValidToken("abc%2F123")) + } + + @Test + fun `rejects tokens of invalid length`() { + assertFalse(DeepLinkHandler.isValidToken("")) + assertFalse(DeepLinkHandler.isValidToken("abc")) + assertFalse(DeepLinkHandler.isValidToken("a".repeat(33))) + } +} diff --git a/app/src/test/java/com/nextcloud/talk/utils/FileUtilsGetFileFromUriTest.kt b/app/src/test/java/com/nextcloud/talk/utils/FileUtilsGetFileFromUriTest.kt new file mode 100644 index 00000000000..182ff6f1c49 --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/utils/FileUtilsGetFileFromUriTest.kt @@ -0,0 +1,128 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.utils + +import android.app.Application +import android.net.Uri +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import java.io.ByteArrayInputStream +import java.io.File + +@RunWith(RobolectricTestRunner::class) +@Config(application = Application::class, sdk = [33]) +class FileUtilsGetFileFromUriTest { + + private val context = RuntimeEnvironment.getApplication() + private val cacheDir: File = context.cacheDir + private val sharesDirectory = File(context.filesDir, "shared_file_copies") + + @Before + fun setUp() { + cacheDir.deleteRecursively() + cacheDir.mkdirs() + sharesDirectory.deleteRecursively() + } + + @After + fun tearDown() { + cacheDir.deleteRecursively() + sharesDirectory.deleteRecursively() + } + + private fun fileIn(directory: File, path: String) = + File(directory, path).apply { + parentFile!!.mkdirs() + writeText("content") + } + + private fun fileInCache(path: String) = fileIn(cacheDir, path) + + private fun copyOfShare(path: String) = fileIn(sharesDirectory, path) + + private fun ownUri(path: String) = Uri.parse("content://${context.packageName}/$path") + + @Test + fun `a copy of a shared file is used without copying it again`() { + val copy = copyOfShare("share1/0/video.mp4") + + val file = FileUtils.getFileFromUri(context, ownUri("shared_file_copies/share1/0/video.mp4")) + + assertEquals(copy.canonicalFile, file?.canonicalFile) + assertFalse(File(cacheDir, "video.mp4").exists()) + } + + @Test + fun `a copy of a shared file with the user in its uri is used without copying it again`() { + val copy = copyOfShare("share1/0/video.mp4") + + val uri = Uri.parse("content://0@${context.packageName}/shared_file_copies/share1/0/video.mp4") + + assertEquals(copy.canonicalFile, FileUtils.getFileFromUri(context, uri)?.canonicalFile) + } + + @Test + fun `a shared attachment is used without copying it again`() { + val attachment = fileInCache("shared_attachments/image.jpg") + + val file = FileUtils.getFileFromUri(context, ownUri("shared_attachments/image.jpg")) + + assertEquals(attachment.canonicalFile, file?.canonicalFile) + } + + @Test + fun `a file in the cache named like a copy of a shared file is copied`() { + fileInCache("shared_file_copies/share1/0/image.jpg") + + assertEquals(PROVIDED, copyOf(ownUri("shared_file_copies/share1/0/image.jpg"))?.readText()) + } + + // read through the content resolver, as from another provider, with other content than the file in the cache + private fun copyOf(uri: Uri): File? { + shadowOf(context.contentResolver).registerInputStream(uri, ByteArrayInputStream(PROVIDED.toByteArray())) + return FileUtils.getFileFromUri(context, uri) + } + + @Test + fun `other files of the own provider are copied`() { + fileInCache("logs/log.txt") + copyOfShare("share1/0/image.jpg") + + assertEquals(PROVIDED, copyOf(ownUri("logs/log.txt"))?.readText()) + assertEquals(PROVIDED, copyOf(ownUri("shared_file_copies/../logs/log.txt"))?.readText()) + } + + @Test + fun `the same path of the provider of the app of another user is copied`() { + copyOfShare("share1/0/image.jpg") + + val uri = Uri.parse("content://10@${context.packageName}/shared_file_copies/share1/0/image.jpg") + + assertEquals(PROVIDED, copyOf(uri)?.readText()) + } + + @Test + fun `the same path of another provider is copied`() { + copyOfShare("share1/0/image.jpg") + + val uri = Uri.parse("content://other.app/shared_file_copies/share1/0/image.jpg") + + assertEquals(PROVIDED, copyOf(uri)?.readText()) + } + + companion object { + private const val PROVIDED = "provided" + } +} diff --git a/app/src/test/java/com/nextcloud/talk/utils/FileUtilsShareUriTest.kt b/app/src/test/java/com/nextcloud/talk/utils/FileUtilsShareUriTest.kt new file mode 100644 index 00000000000..7141a8a6e07 --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/utils/FileUtilsShareUriTest.kt @@ -0,0 +1,96 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.utils + +import android.app.Application +import android.net.Uri +import org.junit.After +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.io.File +import java.nio.file.Files + +@RunWith(RobolectricTestRunner::class) +@Config(application = Application::class, sdk = [33]) +class FileUtilsShareUriTest { + + private lateinit var cacheDir: File + private lateinit var filesDir: File + + @Before + fun setUp() { + cacheDir = Files.createTempDirectory("cache").toFile() + filesDir = Files.createTempDirectory("files").toFile() + } + + @After + fun tearDown() { + cacheDir.deleteRecursively() + filesDir.deleteRecursively() + } + + private fun isTrusted(uri: String?) = + FileUtils.isTrustedShareUri(uri?.let { Uri.parse(it) }, PACKAGE, cacheDir, filesDir) { PROVIDER_PACKAGES[it] } + + @Test + fun `accepts content uris of other apps`() { + assertTrue(isTrusted("content://media/external/images/media/1")) + } + + @Test + fun `accepts own uris inside the shared attachments cache`() { + assertTrue(isTrusted("content://$PACKAGE/shared_attachments/image.jpg")) + } + + @Test + fun `accepts own uris inside the copies of shared files`() { + assertTrue(isTrusted("content://$PACKAGE/shared_file_copies/share1/0/image.jpg")) + } + + @Test + fun `rejects own uris outside the shared attachments cache`() { + assertFalse(isTrusted("content://$PACKAGE/logs/log.txt")) + assertFalse(isTrusted("content://$PACKAGE/shared_attachments/../databases/talk.db")) + assertFalse(isTrusted("content://$PACKAGE/shared_file_copies/../databases/talk.db")) + } + + @Test + fun `rejects uris of other providers of the own app`() { + assertFalse(isTrusted("content://$IMAGE_PICKER_AUTHORITY/root/data/data/$PACKAGE/files/settings.pb")) + assertFalse(isTrusted("content://$IMAGE_PICKER_AUTHORITY/shared_attachments/image.jpg")) + } + + @Test + fun `ignores a user id prefix of the authority`() { + assertFalse(isTrusted("content://0@$PACKAGE/logs/log.txt")) + assertFalse(isTrusted("content://10@$IMAGE_PICKER_AUTHORITY/cache/image.jpg")) + assertTrue(isTrusted("content://0@$PACKAGE/shared_attachments/image.jpg")) + assertTrue(isTrusted("content://10@media/external/images/media/1")) + } + + @Test + fun `rejects non content uris`() { + assertFalse(isTrusted("file:///data/data/$PACKAGE/databases/talk.db")) + assertFalse(isTrusted("https://example.com/file.txt")) + assertFalse(isTrusted(null)) + } + + companion object { + private const val PACKAGE = "com.nextcloud.talk2" + private const val IMAGE_PICKER_AUTHORITY = "$PACKAGE.imagepicker.provider" + private val PROVIDER_PACKAGES = mapOf( + PACKAGE to PACKAGE, + IMAGE_PICKER_AUTHORITY to PACKAGE, + "media" to "com.android.providers.media.module" + ) + } +} diff --git a/app/src/test/java/com/nextcloud/talk/utils/FileUtilsTest.kt b/app/src/test/java/com/nextcloud/talk/utils/FileUtilsTest.kt index 2f48fb7118c..3feac06b9c4 100644 --- a/app/src/test/java/com/nextcloud/talk/utils/FileUtilsTest.kt +++ b/app/src/test/java/com/nextcloud/talk/utils/FileUtilsTest.kt @@ -7,7 +7,6 @@ package com.nextcloud.talk.utils import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull import org.junit.Assert.assertTrue @@ -174,12 +173,21 @@ class FileUtilsTest { } @Test - fun isSharedAttachmentProviderPath_acceptsFileInSharedAttachments() { + fun sharedAttachmentProviderFile_resolvesFileInSharedAttachments() { val baseDir = createTempDir() try { - assertTrue(FileUtils.isSharedAttachmentProviderPath(baseDir, listOf("shared_attachments", "image.jpg"))) - assertTrue( - FileUtils.isSharedAttachmentProviderPath(baseDir, listOf("shared_attachments", "42", "image.jpg")) + val sharedAttachments = File(baseDir, "shared_attachments").canonicalFile + assertEquals( + File(sharedAttachments, "image.jpg"), + FileUtils.sharedAttachmentProviderFile(baseDir, baseDir, listOf("shared_attachments", "image.jpg")) + ) + assertEquals( + File(sharedAttachments, "42/image.jpg"), + FileUtils.sharedAttachmentProviderFile( + baseDir, + baseDir, + listOf("shared_attachments", "42", "image.jpg") + ) ) } finally { baseDir.deleteRecursively() @@ -187,28 +195,28 @@ class FileUtilsTest { } @Test - fun isSharedAttachmentProviderPath_rejectsOtherProviderPaths() { + fun sharedAttachmentProviderFile_rejectsOtherProviderPaths() { val baseDir = createTempDir() try { - assertFalse(FileUtils.isSharedAttachmentProviderPath(baseDir, listOf("logs", "log.txt"))) - assertFalse(FileUtils.isSharedAttachmentProviderPath(baseDir, listOf("photos", "photo.jpg"))) - assertFalse(FileUtils.isSharedAttachmentProviderPath(baseDir, listOf("shared_attachments"))) - assertFalse(FileUtils.isSharedAttachmentProviderPath(baseDir, emptyList())) + assertNull(FileUtils.sharedAttachmentProviderFile(baseDir, baseDir, listOf("logs", "log.txt"))) + assertNull(FileUtils.sharedAttachmentProviderFile(baseDir, baseDir, listOf("photos", "photo.jpg"))) + assertNull(FileUtils.sharedAttachmentProviderFile(baseDir, baseDir, listOf("shared_attachments"))) + assertNull(FileUtils.sharedAttachmentProviderFile(baseDir, baseDir, emptyList())) } finally { baseDir.deleteRecursively() } } @Test - fun isSharedAttachmentProviderPath_rejectsTraversal() { + fun sharedAttachmentProviderFile_rejectsTraversal() { val baseDir = createTempDir() try { - assertFalse( - FileUtils.isSharedAttachmentProviderPath(baseDir, listOf("shared_attachments", "..", "secret.db")) - ) - assertFalse( - FileUtils.isSharedAttachmentProviderPath(baseDir, listOf("shared_attachments", "../secret.db")) + val traversals = listOf( + listOf("shared_attachments", "..", "secret.db"), + listOf("shared_attachments", "../secret.db"), + listOf("shared_file_copies", "..", "secret.db") ) + traversals.forEach { assertNull(FileUtils.sharedAttachmentProviderFile(baseDir, baseDir, it)) } } finally { baseDir.deleteRecursively() } diff --git a/app/src/test/java/com/nextcloud/talk/utils/InternalEntryPointTest.kt b/app/src/test/java/com/nextcloud/talk/utils/InternalEntryPointTest.kt new file mode 100644 index 00000000000..da7f4f2814d --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/utils/InternalEntryPointTest.kt @@ -0,0 +1,24 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.utils + +import com.nextcloud.talk.conversationlist.ConversationsListActivity +import org.junit.Assert.assertEquals +import org.junit.Test + +class InternalEntryPointTest { + + // the activity-aliases declared in the manifest + + @Test + fun nameOfConversationsListActivity() { + assertEquals( + "com.nextcloud.talk.conversationlist.InternalConversationsListActivity", + InternalEntryPoint.nameOf(ConversationsListActivity::class.java) + ) + } +} diff --git a/app/src/test/java/com/nextcloud/talk/utils/LiveActivitiesTest.kt b/app/src/test/java/com/nextcloud/talk/utils/LiveActivitiesTest.kt new file mode 100644 index 00000000000..f2e2aa6667d --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/utils/LiveActivitiesTest.kt @@ -0,0 +1,53 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.utils + +import android.app.Activity +import org.junit.Test +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify + +class LiveActivitiesTest { + + private val liveActivities = LiveActivities() + + private fun activityInTask(taskId: Int, isFinishing: Boolean = false): Activity = + mock { + on { this.taskId } doReturn taskId + on { this.isFinishing } doReturn isFinishing + } + + @Test + fun `the activities of the app in the task are finished, not those of other tasks`() { + val inTask = activityInTask(taskId = 7) + val alsoInTask = activityInTask(taskId = 7) + val inOtherTask = activityInTask(taskId = 8) + listOf(inTask, alsoInTask, inOtherTask).forEach { liveActivities.onActivityCreated(it, null) } + + liveActivities.finishInTask(7) + + verify(inTask).finish() + verify(alsoInTask).finish() + verify(inOtherTask, never()).finish() + } + + @Test + fun `destroyed and finishing activities are not finished again`() { + val destroyed = activityInTask(taskId = 7) + val finishing = activityInTask(taskId = 7, isFinishing = true) + liveActivities.onActivityCreated(destroyed, null) + liveActivities.onActivityCreated(finishing, null) + liveActivities.onActivityDestroyed(destroyed) + + liveActivities.finishInTask(7) + + verify(destroyed, never()).finish() + verify(finishing, never()).finish() + } +} diff --git a/app/src/test/java/com/nextcloud/talk/utils/SharedFileCopiesTest.kt b/app/src/test/java/com/nextcloud/talk/utils/SharedFileCopiesTest.kt new file mode 100644 index 00000000000..8d059e12838 --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/utils/SharedFileCopiesTest.kt @@ -0,0 +1,146 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.utils + +import android.app.Application +import android.content.Context +import androidx.work.Configuration +import androidx.work.Constraints +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequest +import androidx.work.WorkManager +import androidx.work.Worker +import androidx.work.WorkerParameters +import androidx.work.testing.SynchronousExecutor +import androidx.work.testing.WorkManagerTestInitHelper +import com.nextcloud.talk.BuildConfig +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import java.io.File +import java.util.concurrent.TimeUnit + +@RunWith(RobolectricTestRunner::class) +@Config(application = Application::class, sdk = [33]) +class SharedFileCopiesTest { + + private val context = RuntimeEnvironment.getApplication() + private val provider = "content://${BuildConfig.APPLICATION_ID}" + private val sharesDirectory = File(context.filesDir, "shared_file_copies") + + @Before + fun setUp() { + WorkManagerTestInitHelper.initializeTestWorkManager( + context, + Configuration.Builder().setExecutor(SynchronousExecutor()).build() + ) + sharesDirectory.deleteRecursively() + } + + private fun share(name: String, daysOld: Long = 0): File = + File(sharesDirectory, "$name/0").let { item -> + item.mkdirs() + File(item, "photo.jpg").writeText("photo") + File(sharesDirectory, name).apply { + setLastModified(System.currentTimeMillis() - TimeUnit.DAYS.toMillis(daysOld)) + } + } + + private fun copyUri(share: String) = "$provider/shared_file_copies/$share/0/photo.jpg" + + // an upload that waits for the network, as it does without one + private fun enqueueUnfinishedUpload(share: String) { + val upload = OneTimeWorkRequest.Builder(WaitingWorker::class.java) + .setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build()) + .addTag(SharedFileCopies.uploadTagOf(copyUri(share))!!) + .build() + WorkManager.getInstance(context).enqueue(upload).result.get() + } + + @Test + fun copyOfShareIsTaggedWithItsShareDirectory() { + assertEquals( + "shared_file_copy_share1", + SharedFileCopies.uploadTagOf("$provider/shared_file_copies/share1/0/photo.jpg") + ) + } + + @Test + fun copyOfShareWithTheUserInItsUriIsTaggedWithItsShareDirectory() { + val copy = "content://0@${BuildConfig.APPLICATION_ID}/shared_file_copies/share1/0/photo.jpg" + + assertEquals("shared_file_copy_share1", SharedFileCopies.uploadTagOf(copy)) + } + + @Test + fun otherFilesAreNotTagged() { + assertNull(SharedFileCopies.uploadTagOf("$provider/shared_attachments/photo.jpg")) + assertNull(SharedFileCopies.uploadTagOf("$provider/shared_attachments/shares/share1/0/photo.jpg")) + assertNull(SharedFileCopies.uploadTagOf("$provider/shared_file_copies/share1")) + assertNull(SharedFileCopies.uploadTagOf("$provider/photos/share1/0/photo.jpg")) + assertNull(SharedFileCopies.uploadTagOf("content://other.app/shared_file_copies/share1/0/photo.jpg")) + // a copy of the app of another user, e.g. in another profile + assertNull(SharedFileCopies.uploadTagOf("content://10@${BuildConfig.APPLICATION_ID}/shared_file_copies/s/0/a")) + assertNull(SharedFileCopies.uploadTagOf("file:///shared_file_copies/share1/0/photo.jpg")) + } + + @Test + fun leftShareIsRemovedWithoutTheOthers() { + val left = share("left") + val other = share("other") + + SharedFileCopies.removeSharesOf(context, listOf(copyUri("left"), "content://other.app/photos/photo.jpg")) + + assertFalse(left.exists()) + assertTrue(other.exists()) + } + + @Test + fun leftShareWithUnfinishedUploadIsKept() { + val uploading = share("uploading") + enqueueUnfinishedUpload("uploading") + + SharedFileCopies.removeSharesOf(context, listOf(copyUri("uploading"))) + + assertTrue(uploading.exists()) + } + + @Test + fun uriOutsideOfTheSharesRemovesNothing() { + val other = share("other") + + SharedFileCopies.removeSharesOf(context, listOf("$provider/shared_file_copies/../0/photo.jpg")) + + assertTrue(other.exists()) + assertTrue(context.filesDir.exists()) + } + + @Test + fun doneSharesAreRemovedWithoutNewShare() { + val done = share("done", daysOld = 2) + val recent = share("recent") + val uploading = share("uploading", daysOld = 2) + enqueueUnfinishedUpload("uploading") + + SharedFileCopies.removeDoneShares(context) + + assertFalse(done.exists()) + assertTrue(recent.exists()) + assertTrue(uploading.exists()) + } + + class WaitingWorker(context: Context, parameters: WorkerParameters) : Worker(context, parameters) { + override fun doWork(): Result = Result.success() + } +} diff --git a/app/src/test/java/com/nextcloud/talk/utils/ShortcutManagerHelperTest.kt b/app/src/test/java/com/nextcloud/talk/utils/ShortcutManagerHelperTest.kt new file mode 100644 index 00000000000..161c8e1e227 --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/utils/ShortcutManagerHelperTest.kt @@ -0,0 +1,176 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.utils + +import android.annotation.SuppressLint +import android.app.Application +import android.content.Intent +import androidx.core.content.pm.ShortcutInfoCompat +import androidx.core.content.pm.ShortcutManagerCompat +import androidx.sharetarget.ShortcutInfoCompatSaverImpl +import com.nextcloud.talk.activities.DeepLinkActivity +import com.nextcloud.talk.conversationlist.ConversationsListActivity +import com.nextcloud.talk.data.user.model.User +import com.nextcloud.talk.utils.bundle.BundleKeys +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +/** + * The conversation shortcuts open the conversation of exactly their account through the internal entry point of the + * conversation list, and those and the direct share shortcuts of earlier versions are given that intent. + */ +@RunWith(RobolectricTestRunner::class) +@Config(application = Application::class, sdk = [33]) +class ShortcutManagerHelperTest { + + private val context = RuntimeEnvironment.getApplication() + + // two accounts with the same user name on the same host, which a deep link can't tell apart + private val alice = User(id = 1L, username = "alice", baseUrl = "https://example.com/nc1") + private val aliceOnOtherPort = User(id = 2L, username = "alice", baseUrl = "https://example.com:8443") + + private val internalEntryPoint = InternalEntryPoint.nameOf(ConversationsListActivity::class.java) + + private val shareTargetCategory = "com.nextcloud.talk.sharing.SHARE_TARGET_CATEGORY" + + private fun pushShortcut(id: String, intent: Intent) { + ShortcutManagerCompat.pushDynamicShortcut( + context, + ShortcutInfoCompat.Builder(context, id).setShortLabel("Chat").setIntent(intent).build() + ) + } + + // the notification shortcuts of earlier versions + private fun pushShortcutWithExtras(userId: Long, roomToken: String) { + val intent = Intent().setClassName(context, AppTasks.LAUNCHER_ACTIVITY) + .setAction(Intent.ACTION_VIEW) + .putExtra(BundleKeys.KEY_ROOM_TOKEN, roomToken) + .putExtra(BundleKeys.KEY_INTERNAL_USER_ID, userId) + pushShortcut("conversation_${userId}_$roomToken", intent) + } + + // the conversation shortcuts of earlier versions + private fun pushShortcutWithDeepLink(user: User, roomToken: String) { + val intent = Intent(context, DeepLinkActivity::class.java) + .setAction(Intent.ACTION_VIEW) + .setData(DeepLinkHandler.createConversationUri(roomToken, user.baseUrl!!, user.username)) + pushShortcut("conversation_${user.id}_$roomToken", intent) + } + + private fun intentOf(shortcutId: String): Intent = + ShortcutManagerCompat.getShortcuts(context, ShortcutManagerCompat.FLAG_MATCH_DYNAMIC) + .single { it.id == shortcutId } + .intent + + private fun assertOpensConversation(intent: Intent, userId: Long, roomToken: String) { + assertEquals(internalEntryPoint, intent.component?.className) + assertEquals(Intent.ACTION_VIEW, intent.action) + assertEquals(userId, intent.getLongExtra(BundleKeys.KEY_INTERNAL_USER_ID, 0L)) + assertEquals(roomToken, intent.getStringExtra(BundleKeys.KEY_ROOM_TOKEN)) + // delivered to the list of a task an earlier shortcut started, instead of only bringing that task to the front + val deliveredToRunningList = + Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP + assertEquals(deliveredToRunningList, intent.flags and deliveredToRunningList) + } + + @Test + fun `a conversation intent names exactly its account`() { + val intent = ShortcutManagerHelper.createConversationIntent(context, "abcd1234", aliceOnOtherPort)!! + + assertOpensConversation(intent, userId = 2L, roomToken = "abcd1234") + } + + @Test + fun `a conversation shortcut with only extras is given the intent of its conversation`() { + pushShortcutWithExtras(userId = 1L, roomToken = "abcd1234") + + ShortcutManagerHelper.updateShortcutsOfEarlierVersions(context, listOf(alice, aliceOnOtherPort)) + + assertOpensConversation(intentOf("conversation_1_abcd1234"), userId = 1L, roomToken = "abcd1234") + } + + @Test + fun `a conversation shortcut with a deep link is given the intent of exactly its account`() { + pushShortcutWithDeepLink(aliceOnOtherPort, roomToken = "abcd1234") + + ShortcutManagerHelper.updateShortcutsOfEarlierVersions(context, listOf(alice, aliceOnOtherPort)) + + assertOpensConversation(intentOf("conversation_2_abcd1234"), userId = 2L, roomToken = "abcd1234") + } + + // a direct share shortcut of an earlier version, which targets the conversation list + private fun directShareShortcut(): ShortcutInfoCompat { + val intent = Intent(Intent.ACTION_VIEW, null, context, ConversationsListActivity::class.java) + .putExtra(BundleKeys.KEY_ROOM_TOKEN, "abcd1234") + .putExtra(BundleKeys.KEY_INTERNAL_USER_ID, 2L) + return ShortcutInfoCompat.Builder(context, "direct_share_2_abcd1234") + .setShortLabel("Chat") + .setCategories(setOf(shareTargetCategory)) + .setIntent(intent) + .build() + } + + @Test + fun `a direct share shortcut that targets the conversation list is given the intent of its conversation`() { + ShortcutManagerCompat.pushDynamicShortcut(context, directShareShortcut()) + + ShortcutManagerHelper.updateShortcutsOfEarlierVersions(context, listOf(alice, aliceOnOtherPort)) + + assertOpensConversation(intentOf("direct_share_2_abcd1234"), userId = 2L, roomToken = "abcd1234") + } + + // the share target androidx.sharetarget stores for the share sheet before Android 10, which holds its icon + @SuppressLint("RestrictedApi") + @Test + fun `the share target stored for the share sheet of a direct share shortcut is kept as it is`() { + val shortcut = directShareShortcut() + ShortcutManagerCompat.pushDynamicShortcut(context, shortcut) + val saver = ShortcutInfoCompatSaverImpl.getInstance(context) + // Not waited for: it is stored on disk in the files of the test that first used the saver, which is shared by + // the tests and ShortcutManagerCompat, while getShortcuts reads what it holds, after adding it + saver.addShortcuts(listOf(shortcut)) + + ShortcutManagerHelper.updateShortcutsOfEarlierVersions(context, listOf(alice, aliceOnOtherPort)) + + val shareTarget = saver.getShortcuts().single { it.id == shortcut.id } + assertEquals(ConversationsListActivity::class.java.name, shareTarget.intent.component?.className) + } + + @Test + fun `a conversation shortcut of an account that is gone is kept as it is`() { + pushShortcutWithExtras(userId = 3L, roomToken = "abcd1234") + + ShortcutManagerHelper.updateShortcutsOfEarlierVersions(context, listOf(alice, aliceOnOtherPort)) + + assertEquals(AppTasks.LAUNCHER_ACTIVITY, intentOf("conversation_3_abcd1234").component?.className) + } + + @Test + fun `other shortcuts are kept as they are`() { + val other = Intent().setClassName(context, AppTasks.LAUNCHER_ACTIVITY).setAction(Intent.ACTION_VIEW) + pushShortcut("note_to_self", other) + + ShortcutManagerHelper.updateShortcutsOfEarlierVersions(context, listOf(alice)) + + assertEquals(AppTasks.LAUNCHER_ACTIVITY, intentOf("note_to_self").component?.className) + } + + @Test + fun `the account and conversation are read from the id of a conversation shortcut`() { + assertEquals(2L to "abcd1234", ShortcutManagerHelper.conversationOfShortcutId("conversation_2_abcd1234")) + assertEquals(2L to "abcd1234", ShortcutManagerHelper.conversationOfShortcutId("direct_share_2_abcd1234")) + assertNull(ShortcutManagerHelper.conversationOfShortcutId("note_to_self_2_abcd1234")) + assertNull(ShortcutManagerHelper.conversationOfShortcutId("conversation_x_abcd1234")) + assertNull(ShortcutManagerHelper.conversationOfShortcutId("conversation_2")) + assertNull(ShortcutManagerHelper.conversationOfShortcutId("conversation_2_../abcd")) + } +} diff --git a/app/src/test/java/com/nextcloud/talk/utils/UploadSourceGrantsTest.kt b/app/src/test/java/com/nextcloud/talk/utils/UploadSourceGrantsTest.kt new file mode 100644 index 00000000000..bac256bdc20 --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/utils/UploadSourceGrantsTest.kt @@ -0,0 +1,117 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.utils + +import android.app.Application +import android.content.Context +import androidx.core.net.toUri +import androidx.test.core.app.ApplicationProvider +import androidx.work.Configuration +import androidx.work.Constraints +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequest +import androidx.work.WorkManager +import androidx.work.Worker +import androidx.work.WorkerParameters +import androidx.work.testing.SynchronousExecutor +import androidx.work.testing.WorkManagerTestInitHelper +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.util.UUID + +@RunWith(RobolectricTestRunner::class) +@Config(application = Application::class, sdk = [33]) +class UploadSourceGrantsTest { + + private val context: Context = ApplicationProvider.getApplicationContext() + + private val picked = "content://media/picker/0/com.android.providers.media.photopicker/media/1" + + @Before + fun setUp() { + val configuration = Configuration.Builder().setExecutor(SynchronousExecutor()).build() + WorkManagerTestInitHelper.initializeTestWorkManager(context, configuration) + } + + private fun keptUris(): List = context.contentResolver.persistedUriPermissions.map { it.uri.toString() } + + // an upload of [fileUri] that waits for the network, as it does without one + private fun enqueueUnfinishedUpload(fileUri: String): UUID { + val upload = OneTimeWorkRequest.Builder(WaitingWorker::class.java) + .setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build()) + .addTag(UploadSourceGrants.uploadTagOf(fileUri)!!) + .build() + WorkManager.getInstance(context).enqueue(upload).result.get() + return upload.id + } + + @Test + fun `the read access to a picked file is kept for its upload`() { + UploadSourceGrants.keep(context, picked) + + assertEquals(listOf(picked), keptUris()) + } + + @Test + fun `only the uploads of content uris are tagged`() { + assertEquals("upload_source_$picked", UploadSourceGrants.uploadTagOf(picked)) + assertNull(UploadSourceGrants.uploadTagOf("file:///data/voice.mp3")) + assertNull(UploadSourceGrants.uploadTagOf("/data/voice.mp3")) + } + + @Test + fun `the read access is released when its upload ends`() { + UploadSourceGrants.keep(context, picked) + val upload = enqueueUnfinishedUpload(picked) + + UploadSourceGrants.release(context, picked, exceptWorkId = upload) + + assertEquals(emptyList(), keptUris()) + } + + @Test + fun `the read access is kept while another upload of the file is unfinished`() { + UploadSourceGrants.keep(context, picked) + val ending = enqueueUnfinishedUpload(picked) + enqueueUnfinishedUpload(picked) + + UploadSourceGrants.release(context, picked, exceptWorkId = ending) + + assertEquals(listOf(picked), keptUris()) + } + + @Test + fun `the read access without an unfinished upload is released when the app starts`() { + val uploaded = "content://media/picker/0/com.android.providers.media.photopicker/media/2" + UploadSourceGrants.keep(context, picked) + UploadSourceGrants.keep(context, uploaded) + enqueueUnfinishedUpload(picked) + + UploadSourceGrants.releaseUnused(context) + + assertEquals(listOf(picked), keptUris()) + } + + @Test + fun `the access to a file that isn't released stays as it is`() { + UploadSourceGrants.keep(context, picked) + val other = picked.toUri().buildUpon().appendPath("other").toString() + + UploadSourceGrants.release(context, other, UUID.randomUUID()) + + assertEquals(listOf(picked), keptUris()) + } + + class WaitingWorker(context: Context, parameters: WorkerParameters) : Worker(context, parameters) { + override fun doWork(): Result = Result.success() + } +} diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index 4b332bbe362..6e83404ed66 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -653,6 +653,11 @@ + + + + +