From 2c8f392ccb298a0e2f2023d28db0e4e8659102e7 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Fri, 30 Oct 2026 16:29:19 +0100 Subject: [PATCH 01/75] feat(share): take the shares of other apps through ShareActivity ShareActivity is now the entry point of the share sheet and its direct share targets. It copies the shared files into the cache of the app, so the chat and the upload don't depend on the read access of the other app and no media permission is needed, and opens the list in share mode with that content. A direct share target is only preselected if the app published it, uris and files that can't be read are left out, and the copies are kept until their upload is done. The chosen chat opens in the running task of the app, or in a new one with the conversation list below it, and the attachment preview asks before uploading instead of a dialog in the list. Assisted-by: Claude Code:claude-opus-5-5 Signed-off-by: Marcel Hibbe --- app/src/main/AndroidManifest.xml | 25 ++- .../application/NextcloudTalkApplication.kt | 3 +- .../com/nextcloud/talk/chat/ChatActivity.kt | 6 +- .../ConversationsListActivity.kt | 162 ++++++-------- .../conversationlist/DirectShareHelper.kt | 13 +- .../talk/conversationlist/ShareActivity.kt | 59 +++++ .../ShareListIntentFactory.kt | 136 ++++++++++++ .../talk/jobs/UploadAndShareFilesWorker.kt | 3 + .../java/com/nextcloud/talk/utils/AppTasks.kt | 107 +++++++++ .../com/nextcloud/talk/utils/FileUtils.kt | 24 ++- .../talk/utils/InternalEntryPoint.kt | 40 ++++ .../nextcloud/talk/utils/SharedFileCopies.kt | 77 +++++++ app/src/main/res/values/strings.xml | 2 - app/src/main/res/values/styles.xml | 7 + app/src/main/res/xml/file_provider_paths.xml | 3 + app/src/main/res/xml/shortcuts.xml | 2 +- .../viewmodels/ChatViewModelRoomJoinTest.kt | 8 +- .../ConversationsListEntryPointTest.kt | 42 ++++ .../ShareListIntentFactoryTest.kt | 204 ++++++++++++++++++ .../com/nextcloud/talk/utils/AppTasksTest.kt | 103 +++++++++ .../talk/utils/FileUtilsShareUriTest.kt | 72 +++++++ .../talk/utils/InternalEntryPointTest.kt | 24 +++ .../talk/utils/SharedFileCopiesTest.kt | 41 ++++ 23 files changed, 1047 insertions(+), 116 deletions(-) create mode 100644 app/src/main/java/com/nextcloud/talk/conversationlist/ShareActivity.kt create mode 100644 app/src/main/java/com/nextcloud/talk/conversationlist/ShareListIntentFactory.kt create mode 100644 app/src/main/java/com/nextcloud/talk/utils/AppTasks.kt create mode 100644 app/src/main/java/com/nextcloud/talk/utils/InternalEntryPoint.kt create mode 100644 app/src/main/java/com/nextcloud/talk/utils/SharedFileCopies.kt create mode 100644 app/src/test/java/com/nextcloud/talk/conversationlist/ConversationsListEntryPointTest.kt create mode 100644 app/src/test/java/com/nextcloud/talk/conversationlist/ShareListIntentFactoryTest.kt create mode 100644 app/src/test/java/com/nextcloud/talk/utils/AppTasksTest.kt create mode 100644 app/src/test/java/com/nextcloud/talk/utils/FileUtilsShareUriTest.kt create mode 100644 app/src/test/java/com/nextcloud/talk/utils/InternalEntryPointTest.kt create mode 100644 app/src/test/java/com/nextcloud/talk/utils/SharedFileCopiesTest.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 8884e456bb1..7d791f36340 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -169,14 +169,24 @@ android:exported="true" android:theme="@style/AppTheme.DisplayCutout"> - - + + + + + + @@ -191,6 +201,13 @@ + + + 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..53bd59bca74 100644 --- a/app/src/main/java/com/nextcloud/talk/application/NextcloudTalkApplication.kt +++ b/app/src/main/java/com/nextcloud/talk/application/NextcloudTalkApplication.kt @@ -197,7 +197,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 ) { 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..e6ff27005b0 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt @@ -1293,7 +1293,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() + // 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(), packageName, cacheDir) } + ?: emptyList() intent.removeExtra(BundleKeys.KEY_SHARED_FILE_PATHS) Log.d(TAG, " roomToken = $roomToken") 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..ffcfc4bd3ae 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 @@ -80,7 +80,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 +93,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 @@ -102,7 +102,7 @@ import com.nextcloud.talk.utils.ClosedInterfaceImpl import com.nextcloud.talk.utils.ConversationUtils import com.nextcloud.talk.utils.ConversationUtils.checkIfVoiceRoom 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 @@ -133,14 +133,12 @@ import java.util.concurrent.TimeUnit import javax.inject.Inject import kotlinx.coroutines.delay import kotlinx.coroutines.Job -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.withContext import org.greenrobot.eventbus.Subscribe import org.greenrobot.eventbus.ThreadMode import retrofit2.HttpException @@ -219,7 +217,6 @@ class ConversationsListActivity : BaseActivity() { private var textToPaste: String? = "" private var selectedMessageId: String? = null private var pendingDirectShareToken: String? = null - private var isDirectShareTarget = false private var unauthorizedDialog: AlertDialog? = null private var unauthorizedHandling: Job? = null @@ -259,6 +256,11 @@ class ConversationsListActivity : BaseActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + if (isShareOfOtherApp(intent)) { + startActivity(InternalEntryPoint.createForwardIntent(this, intent, ShareActivity::class.java)) + finish() + return + } NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) currentUser = setUpBoundUserOrFinish() ?: return ecosystemManager = EcosystemManager(this@ConversationsListActivity) @@ -316,6 +318,10 @@ class ConversationsListActivity : BaseActivity() { override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) if (isRelaunching) return + if (isShareOfOtherApp(intent)) { + startActivity(InternalEntryPoint.createForwardIntent(this, intent, ShareActivity::class.java)) + return + } val newUserId = intent.getLongExtra(KEY_INTERNAL_USER_ID, 0L) if (newUserId != 0L && newUserId != currentUser.id) { relaunchForAccount(newUserId, intent) @@ -490,7 +496,7 @@ class ConversationsListActivity : BaseActivity() { // would lose the pending message. 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)) @@ -501,7 +507,7 @@ class ConversationsListActivity : BaseActivity() { super.onResume() showNotificationWarningState.value = shouldShowNotificationWarning() - showShareToScreenState.value = hasActivityActionSendIntent() + showShareToScreenState.value = isShare(intent) // Home screen shortcut tap: shortcut uses ACTION_VIEW with KEY_ROOM_TOKEN to open the // conversation directly without showing the share picker. @@ -511,7 +517,7 @@ class ConversationsListActivity : BaseActivity() { // 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()) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && isShare(intent)) { val shortcutId = intent.getStringExtra(Intent.EXTRA_SHORTCUT_ID) if (shortcutId != null) { pendingDirectShareToken = DirectShareHelper.extractTokenFromShortcutId(shortcutId) @@ -594,7 +600,6 @@ class ConversationsListActivity : BaseActivity() { pendingDirectShareToken = null val conversation = list.firstOrNull { it.token == token } if (conversation != null) { - isDirectShareTarget = true handleConversation(conversation) } } @@ -775,21 +780,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) } } @@ -980,74 +984,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 cache of the app (see ShareActivity), 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 +1000,12 @@ 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 + 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() } @@ -1099,10 +1040,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, packageName, cacheDir)) { filesToShare!!.add(uri.toString()) } else { Log.w(TAG, "Rejecting untrusted uri from share intent: $uri") @@ -1113,15 +1051,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 +1223,28 @@ 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) + if (isTaskRoot) finishAndRemoveTask() else finish() + } + @Subscribe(sticky = true, threadMode = ThreadMode.BACKGROUND) fun onMessageEvent(eventStatus: EventStatus) { if (eventStatus.userId == currentUser.id) { @@ -1775,6 +1720,13 @@ class ConversationsListActivity : BaseActivity() { companion object { private val TAG = ConversationsListActivity::class.java.simpleName + /** + * 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 +1736,19 @@ 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) } + /** 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 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..7812f89198b 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/DirectShareHelper.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/DirectShareHelper.kt @@ -182,6 +182,17 @@ object DirectShareHelper { } } + /** + * 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 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" /** @@ -283,7 +294,7 @@ object DirectShareHelper { // 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 { + val intent = ConversationsListActivity.createIntent(context).setAction(Intent.ACTION_VIEW).apply { putExtra(BundleKeys.KEY_ROOM_TOKEN, token) putExtra(BundleKeys.KEY_INTERNAL_USER_ID, user.id) } 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..9652cf2efb0 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ShareActivity.kt @@ -0,0 +1,59 @@ +/* + * 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 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 cache of the app, 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) + // left while the files were copied: the list opens when the share 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() + } + } + } +} 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..e62b0b0dac0 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ShareListIntentFactory.kt @@ -0,0 +1,136 @@ +/* + * 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.withContext +import java.io.File + +/** + * 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) + } + // none without items, as the list could only show an error when a conversation is picked + val items = withContext(Dispatchers.IO) { copySharedItems(sharedIntent) } + 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 that may be uploaded, see [FileUtils.isTrustedShareUri]. */ + private fun copySharedItems(sharedIntent: Intent): List { + // 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 directory by lazy { SharedFileCopies.createDirectory(context) } + 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 -> + when { + item.uri != null -> + copyToCache(item.uri) { directory?.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 fun copyToCache(uri: Uri, directoryOf: () -> File?): Uri? { + if (!FileUtils.isTrustedShareUri(uri, context.packageName, context.cacheDir)) { + 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.copyTo(it) } } != null + if (isCopied) uriOfCopy(file) else null + } catch (e: Exception) { + // the provider of another app can throw anything + Log.w(TAG, "Failed to copy the shared file $uri", e) + null + } + } + + companion object { + private val TAG = ShareListIntentFactory::class.java.simpleName + private const val FILE_NAME = "file" + } +} 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..12fed6f15e5 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,7 @@ 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.VideoCompressor import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_INTERNAL_USER_ID import com.nextcloud.talk.utils.permissions.PlatformPermissionUtil @@ -576,6 +577,8 @@ 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) } } .build() // Chained per conversation (not enqueueUniqueWork(fileUri, ...), which ran every upload // fully independently) so multiple files - whether from one multi-file share or several 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..616d24372d3 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/utils/AppTasks.kt @@ -0,0 +1,107 @@ +/* + * 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.content.Context +import android.content.Intent +import android.util.Log +import com.nextcloud.talk.conversationlist.ConversationsListActivity +import com.nextcloud.talk.utils.bundle.BundleKeys + +// 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 + } + +/** 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, by its name in the manifest, which home-screen icons and pinned shortcuts refer to. */ + const val LAUNCHER_ACTIVITY = "com.nextcloud.talk.activities.MainActivity" + + /** + * 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 launcher activity or + * at the internal entry point of the conversation list, 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() + + 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.baseIntent.component?.className in roots && predicate(info) + } +} 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..a3e2010595d 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/FileUtils.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/FileUtils.kt @@ -27,7 +27,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 + internal const val SHARED_FILE_COPIES_DIRECTORY = "shared_file_copies" + private val SHARED_PROVIDER_DIRECTORIES = setOf(SHARED_ATTACHMENTS_DIRECTORY, SHARED_FILE_COPIES_DIRECTORY) /** * Returns the dedicated cache directory used for externally shared attachments. @@ -65,17 +70,28 @@ object FileUtils { /** * Checks whether the [pathSegments] of an own FileProvider uri point to a file inside the - * dedicated shared attachments cache directory. + * dedicated shared attachments cache directory or the one of the copies of shared files. */ 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) + val directory = pathSegments.firstOrNull()?.takeIf { it in SHARED_PROVIDER_DIRECTORIES } + if (pathSegments.size < 2 || directory == null) return false + var resolvedFile: File? = File(cacheDir, directory) for (segment in pathSegments.drop(1)) { resolvedFile = resolvedFile?.let { resolveFileInDirectory(it, segment) } } return resolvedFile != null } + /** + * Checks whether an externally provided [uri] may be uploaded: only content uris are accepted, + * and uris of the own app ([packageName]) only when they point into the shared attachments cache + * or the copies of shared files. + */ + fun isTrustedShareUri(uri: Uri?, packageName: String, cacheDir: File): Boolean = + uri != null && + ContentResolver.SCHEME_CONTENT == uri.scheme && + (uri.authority != packageName || isSharedAttachmentProviderPath(cacheDir, uri.pathSegments)) + /** * Resolves an untrusted file name inside [baseDirectory] and rejects traversal attempts. */ 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/SharedFileCopies.kt b/app/src/main/java/com/nextcloud/talk/utils/SharedFileCopies.kt new file mode 100644 index 00000000000..6eba4347e3c --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/utils/SharedFileCopies.kt @@ -0,0 +1,77 @@ +/* + * 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.WorkManager +import com.nextcloud.talk.BuildConfig +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 cache 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. + */ +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. Blocks + * while it asks WorkManager for their uploads. + */ + fun createDirectory(context: Context): File? { + val sharesDirectory = File(context.cacheDir, FileUtils.SHARED_FILE_COPIES_DIRECTORY) + val expiry = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(KEPT_DAYS) + sharesDirectory.listFiles() + ?.filter { it.lastModified() < expiry && !hasUnfinishedUpload(context, it.name) } + ?.forEach { it.deleteRecursively() } + return File(sharesDirectory, UUID.randomUUID().toString()).takeIf { it.mkdirs() } + } + + /** 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? { + val uri = fileUri.toUri() + val segments = uri.pathSegments + val isCopy = uri.scheme == ContentResolver.SCHEME_CONTENT && + uri.authority == BuildConfig.APPLICATION_ID && + segments.size > SHARE_DIRECTORY_SEGMENT + 1 && + segments[0] == FileUtils.SHARED_FILE_COPIES_DIRECTORY + return if (isCopy) UPLOAD_TAG_PREFIX + segments[SHARE_DIRECTORY_SEGMENT] else null + } + + private fun hasUnfinishedUpload(context: Context, directoryName: String): Boolean = + try { + WorkManager.getInstance(context).getWorkInfosByTag(UPLOAD_TAG_PREFIX + directoryName).get() + .any { !it.state.isFinished } + } catch (e: ExecutionException) { + // keep the copies when in doubt + Log.w(TAG, "Failed to get the uploads of shared files", e) + true + } catch (e: InterruptedException) { + Thread.currentThread().interrupt() + true + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2ef2c7618a5..308a1bb8b59 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -684,8 +684,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 diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index f9a4ca5f5a7..f8057bbb4e8 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/test/java/com/nextcloud/talk/activities/DeepLinkRoutingViewModelTest.kt b/app/src/test/java/com/nextcloud/talk/activities/DeepLinkRoutingViewModelTest.kt index c38517c3679..bc2e0299513 100644 --- a/app/src/test/java/com/nextcloud/talk/activities/DeepLinkRoutingViewModelTest.kt +++ b/app/src/test/java/com/nextcloud/talk/activities/DeepLinkRoutingViewModelTest.kt @@ -152,6 +152,26 @@ class DeepLinkRoutingViewModelTest { 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) { From 15a28bd2d7eeb19590e7f42b1a45f21a8270073a Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Fri, 30 Oct 2026 18:10:10 +0100 Subject: [PATCH 04/75] fix(lock): route a deep link once the app is unlocked The conversation list, which routes a deep link, showed the lock screen and routed the link at the same time. The routing now waits until the list is resumed after unlocking. Assisted-by: Claude Code:claude-opus-5-5 Signed-off-by: Marcel Hibbe --- .../java/com/nextcloud/talk/activities/BaseActivity.kt | 9 +++++++++ .../com/nextcloud/talk/activities/DeepLinkRouting.kt | 7 ++++--- 2 files changed, 13 insertions(+), 3 deletions(-) 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 0e3c1336f65..b0c27444b4d 100644 --- a/app/src/main/java/com/nextcloud/talk/activities/BaseActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/activities/BaseActivity.kt @@ -182,6 +182,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 @@ -200,6 +207,7 @@ open class BaseActivity : AppCompatActivity() { "elapsedSeconds=$elapsedSeconds" ) if (timeoutSeconds == 0 || elapsedSeconds >= timeoutSeconds) { + isCoveredByLockScreen = true startActivity(Intent(this, LockedActivity::class.java)) } } catch (e: Exception) { @@ -241,6 +249,7 @@ open class BaseActivity : AppCompatActivity() { "pendingLockCheck=${NextcloudTalkApplication.pendingLockCheck} " + "activity=${this.javaClass.simpleName}" ) + isCoveredByLockScreen = false if (!skipLockCheckOnResume && NextcloudTalkApplication.pendingLockCheck) { NextcloudTalkApplication.pendingLockCheck = false lockScreenIfConditionsApply() diff --git a/app/src/main/java/com/nextcloud/talk/activities/DeepLinkRouting.kt b/app/src/main/java/com/nextcloud/talk/activities/DeepLinkRouting.kt index f188d363345..06ffcd3564f 100644 --- a/app/src/main/java/com/nextcloud/talk/activities/DeepLinkRouting.kt +++ b/app/src/main/java/com/nextcloud/talk/activities/DeepLinkRouting.kt @@ -61,10 +61,11 @@ class DeepLinkRouting( SystemClock.elapsedRealtime() - createdAt < MAX_LAUNCH_SCREEN_MS init { - // opened only while started, as an activity in the background can't start others + // shown only while resumed and not covered by the lock screen this activity started: the chat or question + // follows after unlocking, when the list is resumed again activity.lifecycleScope.launch { - activity.repeatOnLifecycle(Lifecycle.State.STARTED) { - viewModel.state.collect(::render) + activity.repeatOnLifecycle(Lifecycle.State.RESUMED) { + viewModel.state.collect { if (!activity.isCoveredByLockScreen) render(it) } } } activity.lifecycle.addObserver(object : DefaultLifecycleObserver { From 7a00a8819e4cf4c8d33c1b2b3d6aa3e3cc1c0c9e Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Mon, 2 Nov 2026 11:30:10 +0100 Subject: [PATCH 05/75] fix(deeplink): only rewrite implicit view intents to open a chat BaseActivity.startActivity turned any ACTION_VIEW of a call link of the own server into a ChatActivity intent, also the explicit forward of a link to DeepLinkActivity, which then skipped the routing of the link. Explicit intents now start the screen they name unchanged. Assisted-by: Claude Code:claude-opus-5-5 Signed-off-by: Marcel Hibbe --- .../main/java/com/nextcloud/talk/activities/BaseActivity.kt | 6 ++++++ 1 file changed, 6 insertions(+) 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 b0c27444b4d..cd86e51f4bf 100644 --- a/app/src/main/java/com/nextcloud/talk/activities/BaseActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/activities/BaseActivity.kt @@ -419,6 +419,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)) { From 1f10ff3c53b7aead1b0bed037c6f15c4499c395c Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Mon, 2 Nov 2026 11:36:10 +0100 Subject: [PATCH 06/75] fix(shortcuts): open the conversation of a shortcut once the app is unlocked The pending shortcut token was used by the room list collector, which runs regardless of the lifecycle of the list. The token is now only used while the list is resumed and not covered by the lock screen, as DeepLinkRouting does. After unlocking, the list is resumed and the room list emitted again, so the conversation opens then. Assisted-by: Claude Code:claude-opus-5-5 Signed-off-by: Marcel Hibbe --- .../ConversationsListActivity.kt | 25 +++++++++++-------- 1 file changed, 15 insertions(+), 10 deletions(-) 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 b20c04ad5fb..c05c6a09ab3 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt @@ -669,16 +669,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) { - handleConversation(conversation) - } - } - // Update dynamic shortcuts for frequent/favorite conversations ShortcutManagerHelper.updateDynamicShortcuts(context, list, currentUser) @@ -692,6 +682,21 @@ class ConversationsListActivity : BaseActivity() { }.collect() } + // Direct Share: the conversation of a share sheet or home screen shortcut opens only while the list is resumed + // and not covered by the lock screen it started, as the chat would otherwise show above the lock screen. After + // unlocking, the list is resumed again and the room list emitted anew, so the conversation opens then. + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.RESUMED) { + conversationsListViewModel.getRoomsFlow.collect { list -> + val token = pendingDirectShareToken + if (token != null && !isCoveredByLockScreen) { + pendingDirectShareToken = null + list.firstOrNull { it.token == token }?.let { handleConversation(it) } + } + } + } + } + lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { contactsViewModel.roomViewState.collect { state -> From 049e756af796a141c008634dcb9f5991bfe6e472 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Mon, 2 Nov 2026 11:41:51 +0100 Subject: [PATCH 07/75] fix(share): recognise the uris of every provider of the own app isTrustedShareUri only treated a uri as the app's own when its authority equaled the package name, which missed the other providers of the app, e.g. of the ImagePicker library, and uris with a user id prefix. The user id prefix is now ignored, and only the shared attachments and copies of shared files of its FileProvider are taken from the own app. Assisted-by: Claude Code:claude-opus-5-5 Signed-off-by: Marcel Hibbe --- .../com/nextcloud/talk/chat/ChatActivity.kt | 2 +- .../ConversationsListActivity.kt | 2 +- .../ShareListIntentFactory.kt | 2 +- .../com/nextcloud/talk/utils/FileUtils.kt | 31 +++++++++++++++---- .../talk/utils/FileUtilsShareUriTest.kt | 23 +++++++++++++- 5 files changed, 50 insertions(+), 10 deletions(-) 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 7ab269db2fb..b4b138ce3b1 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt @@ -1295,7 +1295,7 @@ class ChatActivity : // 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(), packageName, cacheDir) } + ?.filter { FileUtils.isTrustedShareUri(it.toUri(), this) } ?: emptyList() intent.removeExtra(BundleKeys.KEY_SHARED_FILE_PATHS) 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 c05c6a09ab3..d9094b5c7ed 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt @@ -1115,7 +1115,7 @@ class ConversationsListActivity : BaseActivity() { } private fun addFileToShareIfTrusted(uri: Uri?) { - if (FileUtils.isTrustedShareUri(uri, packageName, cacheDir)) { + if (FileUtils.isTrustedShareUri(uri, this)) { filesToShare!!.add(uri.toString()) } else { Log.w(TAG, "Rejecting untrusted uri from share intent: $uri") diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/ShareListIntentFactory.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/ShareListIntentFactory.kt index e62b0b0dac0..469aa3700c6 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ShareListIntentFactory.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ShareListIntentFactory.kt @@ -110,7 +110,7 @@ internal class ShareListIntentFactory( */ @Suppress("Detekt.TooGenericExceptionCaught") private fun copyToCache(uri: Uri, directoryOf: () -> File?): Uri? { - if (!FileUtils.isTrustedShareUri(uri, context.packageName, context.cacheDir)) { + if (!FileUtils.isTrustedShareUri(uri, context)) { Log.w(TAG, "Rejecting untrusted uri from share intent: $uri") return null } 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 a3e2010595d..fb22f9d86a4 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/FileUtils.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/FileUtils.kt @@ -84,13 +84,32 @@ object FileUtils { /** * Checks whether an externally provided [uri] may be uploaded: only content uris are accepted, - * and uris of the own app ([packageName]) only when they point into the shared attachments cache - * or the copies of shared files. + * 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?, packageName: String, cacheDir: File): Boolean = - uri != null && - ContentResolver.SCHEME_CONTENT == uri.scheme && - (uri.authority != packageName || isSharedAttachmentProviderPath(cacheDir, uri.pathSegments)) + fun isTrustedShareUri(uri: Uri?, context: Context): Boolean = + isTrustedShareUri(uri, context.packageName, context.cacheDir) { 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 + * [isSharedAttachmentProviderPath] allows. A user id prefix ("0@authority") is ignored, as the content resolver + * resolves it to the own user. [packageOfAuthority] gives the package of the provider of an authority. + */ + fun isTrustedShareUri( + uri: Uri?, + packageName: String, + cacheDir: File, + packageOfAuthority: (String) -> String? + ): Boolean { + if (uri == null || ContentResolver.SCHEME_CONTENT != uri.scheme) return false + val authority = uri.authority?.substringAfterLast('@') ?: return false + val isOwnProvider = authority == packageName || packageOfAuthority(authority) == packageName + return !isOwnProvider || + authority == packageName && isSharedAttachmentProviderPath(cacheDir, uri.pathSegments) + } /** * Resolves an untrusted file name inside [baseDirectory] and rejects traversal attempts. diff --git a/app/src/test/java/com/nextcloud/talk/utils/FileUtilsShareUriTest.kt b/app/src/test/java/com/nextcloud/talk/utils/FileUtilsShareUriTest.kt index 4120cbc426f..3873b4d9d3b 100644 --- a/app/src/test/java/com/nextcloud/talk/utils/FileUtilsShareUriTest.kt +++ b/app/src/test/java/com/nextcloud/talk/utils/FileUtilsShareUriTest.kt @@ -35,7 +35,8 @@ class FileUtilsShareUriTest { cacheDir.deleteRecursively() } - private fun isTrusted(uri: String?) = FileUtils.isTrustedShareUri(uri?.let { Uri.parse(it) }, PACKAGE, cacheDir) + private fun isTrusted(uri: String?) = + FileUtils.isTrustedShareUri(uri?.let { Uri.parse(it) }, PACKAGE, cacheDir) { PROVIDER_PACKAGES[it] } @Test fun `accepts content uris of other apps`() { @@ -59,6 +60,20 @@ class FileUtilsShareUriTest { 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.preferences_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")) @@ -68,5 +83,11 @@ class FileUtilsShareUriTest { 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" + ) } } From 842f42ac4d44c7ae87dc98a852d10f9662e108f2 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Mon, 2 Nov 2026 11:48:41 +0100 Subject: [PATCH 08/75] fix(account): relaunch the list for the new default account with a plain intent onRestart relaunched the list with a copy of its own intent. If the list was created for a deep link, the new list routed that link again: a declined "Join conversation?" was asked again, the "no account" toast showed again, or the old chat reopened and switched the default account back. For a contact chat link Talk can't read, the read permission grant made startActivity throw a SecurityException. Forwarding and sharing are already excluded there, so the new list gets a plain intent with only the account. Assisted-by: Claude Code:claude-opus-5-5 Signed-off-by: Marcel Hibbe --- .../talk/conversationlist/ConversationsListActivity.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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 d9094b5c7ed..4fd12abc1a5 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt @@ -562,12 +562,13 @@ class ConversationsListActivity : BaseActivity() { 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. + // 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. if (isRelaunching) return val defaultUserId = defaultAccountProvider.getDefaultUserBlocking()?.id ?: return val isPickingConversation = forwardMessage || isShare(intent) if (defaultUserId != currentUser.id && !isPickingConversation) { - relaunchForAccount(defaultUserId) + relaunchForAccount(defaultUserId, createIntent(this)) } } From e82165a64f78f41514301a7f6d00bbe67bd0265c Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Mon, 2 Nov 2026 12:17:49 +0100 Subject: [PATCH 09/75] fix(account): keep the account of the list when it is recreated BaseActivity writes the default account into a launcher intent, so that a recreation after a configuration change stays on that account. But the recreation reuses the same intent, and removeExtrasOfApp removed the account again because the component is the launcher alias. The list then bound the default account of that time, while its view models stayed on the previous one: e.g. after picking another account while sharing, rotating and going back, the list showed the conversations of one account with the other account, and opened them with it. The intent is now set to the one of the internal entry point with the account, as showNewIntent does. The intents of other apps that the list doesn't show were passed on before, and their extras of the app removed. Assisted-by: Claude Code:claude-opus-5-5 Signed-off-by: Marcel Hibbe --- .../talk/conversationlist/ConversationsListActivity.kt | 5 +++++ 1 file changed, 5 insertions(+) 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 4fd12abc1a5..6e0fcf303a6 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt @@ -273,6 +273,11 @@ class ConversationsListActivity : BaseActivity() { NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) if (finishIfNotShown()) return currentUser = setUpBoundUserOrFinish() ?: return + // A recreation after a configuration change reuses this intent. Through the launcher alias, + // removeExtrasOfApp would remove the account written into it again, and the list would switch to the default + // account of that time while its view models stay on this one. The intents of other apps that the list + // doesn't show were passed on above, and their extras of the app removed. + intent = intentOfAccount(this, intent, currentUser.id) if (savedInstanceState == null) { AppTasks.removeTasksLeftInRecents(this) setUpPushRegistration() From 71dac1268b895f6bb32c337ad89496148ca0fc69 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Mon, 2 Nov 2026 12:30:24 +0100 Subject: [PATCH 10/75] fix(deeplink): don't make the account of a deep link active before its chat opens The deep link routing made the account of the conversation active while its chat was still held back, e.g. by the lock screen covering the list. After unlocking, the list of the previous account ran onRestart first, saw the new default account and relaunched for it with its old intent. The pending chat was dropped with the finishing list, so only the list of the other account showed. The same happened when the default changed while the list was stopped. The account is no longer made active there. The list of the account that the chat opens above makes it active in onCreate. Assisted-by: Claude Code:claude-opus-5-5 Signed-off-by: Marcel Hibbe --- .../activities/DeepLinkRoutingViewModel.kt | 10 ++--- .../DeepLinkRoutingViewModelTest.kt | 40 +++++++++++++++---- 2 files changed, 37 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/activities/DeepLinkRoutingViewModel.kt b/app/src/main/java/com/nextcloud/talk/activities/DeepLinkRoutingViewModel.kt index c2b44b65a9a..b3952f94209 100644 --- a/app/src/main/java/com/nextcloud/talk/activities/DeepLinkRoutingViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/activities/DeepLinkRoutingViewModel.kt @@ -245,12 +245,10 @@ class DeepLinkRoutingViewModel @Inject constructor( conversationsRepository.observeConversation(user.id!!, roomToken).first() is ConversationResult.Found } - private suspend fun openDeepLinkConversation(user: User, roomToken: String) { - if (!userManager.setUserAsActive(user)) { - logger.e(TAG, "Failed to set deep link target user as active") - open(Destination.ConversationList, R.string.nc_common_error_sorry) - return - } + // 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)) diff --git a/app/src/test/java/com/nextcloud/talk/activities/DeepLinkRoutingViewModelTest.kt b/app/src/test/java/com/nextcloud/talk/activities/DeepLinkRoutingViewModelTest.kt index bc2e0299513..b7cbd406520 100644 --- a/app/src/test/java/com/nextcloud/talk/activities/DeepLinkRoutingViewModelTest.kt +++ b/app/src/test/java/com/nextcloud/talk/activities/DeepLinkRoutingViewModelTest.kt @@ -51,6 +51,8 @@ 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.robolectric.Robolectric import org.robolectric.RobolectricTestRunner import org.robolectric.RuntimeEnvironment @@ -71,7 +73,6 @@ class DeepLinkRoutingViewModelTest { on { getUsersNotScheduledForDeletion() } doReturn listOf(alice, bob) on { getUserWithInternalId(alice.id!!) } doReturn alice on { getUserWithInternalId(bob.id!!) } doReturn bob - on { setUserAsActive(any()) } doReturn true } private val defaultAccountProvider = mock { on { getDefaultUser() } doReturn alice @@ -204,6 +205,22 @@ class DeepLinkRoutingViewModelTest { assertEquals(RoutingState.Open(Destination.Chat(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) { @@ -307,13 +324,23 @@ class DeepLinkRoutingViewModelTest { @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() } doReturn listOf(alice, bob) - on { setUserAsActive(any()) } doSuspendableAnswer { databaseError() } + on { getUsersNotScheduledForDeletion() } doSuspendableAnswer { + if (readsOfAccounts++ == 0) listOf(alice, bob) else databaseError() + } } - viewModel = createViewModel(conversationsRepository, failingUserManager) - viewModel.route(deepLink) - advanceUntilIdle() + val repository = mock { + on { roomListFlow(any()) } doReturn flowOf(emptyList()) + on { syncRooms(any(), any(), anyOrNull()) } doReturn emptyList() + } + viewModel = createViewModel(repository, failingUserManager) + val contactData = MatrixCursor(arrayOf(ContactsContract.Data.DATA1)).apply { + addRow(arrayOf("carol@cloud.example.com")) + } + val question = routeContactChatWith(contactData) + assertEquals(RoutingState.AskToStartContactChat("carol@cloud.example.com"), question) viewModel.confirm() advanceUntilIdle() @@ -326,7 +353,6 @@ class DeepLinkRoutingViewModelTest { on { getUsersNotScheduledForDeletion() } doReturn listOf(bob) on { getUserWithId(alice.id!!) } doReturn alice on { getUserWithInternalId(bob.id!!) } doReturn bob - on { setUserAsActive(any()) } doReturn true } @Test From f8da88da75c88debfc12dfc17b40972982db838f Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Mon, 2 Nov 2026 12:30:25 +0100 Subject: [PATCH 11/75] style(share): fix the ktlint findings of the shared uri check Assisted-by: Claude Code:claude-opus-5-5 Signed-off-by: Marcel Hibbe --- app/src/main/java/com/nextcloud/talk/utils/FileUtils.kt | 3 ++- .../java/com/nextcloud/talk/utils/FileUtilsShareUriTest.kt | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) 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 fb22f9d86a4..331147a0110 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/FileUtils.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/FileUtils.kt @@ -107,8 +107,9 @@ object FileUtils { if (uri == null || ContentResolver.SCHEME_CONTENT != uri.scheme) return false val authority = uri.authority?.substringAfterLast('@') ?: return false val isOwnProvider = authority == packageName || packageOfAuthority(authority) == packageName - return !isOwnProvider || + val isSharedFileOfFileProvider = authority == packageName && isSharedAttachmentProviderPath(cacheDir, uri.pathSegments) + return !isOwnProvider || isSharedFileOfFileProvider } /** diff --git a/app/src/test/java/com/nextcloud/talk/utils/FileUtilsShareUriTest.kt b/app/src/test/java/com/nextcloud/talk/utils/FileUtilsShareUriTest.kt index 3873b4d9d3b..06e96d669cd 100644 --- a/app/src/test/java/com/nextcloud/talk/utils/FileUtilsShareUriTest.kt +++ b/app/src/test/java/com/nextcloud/talk/utils/FileUtilsShareUriTest.kt @@ -62,7 +62,7 @@ class FileUtilsShareUriTest { @Test fun `rejects uris of other providers of the own app`() { - assertFalse(isTrusted("content://$IMAGE_PICKER_AUTHORITY/root/data/data/$PACKAGE/files/settings.preferences_pb")) + assertFalse(isTrusted("content://$IMAGE_PICKER_AUTHORITY/root/data/data/$PACKAGE/files/settings.pb")) assertFalse(isTrusted("content://$IMAGE_PICKER_AUTHORITY/shared_attachments/image.jpg")) } From f16e7d001fc4aef75674de793f3ceb96d37a2e24 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Mon, 2 Nov 2026 12:36:08 +0100 Subject: [PATCH 12/75] fix(launch): don't stack conversation lists on a task a notification or deep link started The launcher is an alias of the conversation list with the standard launch mode. A task that a notification or DeepLinkActivity started has no MAIN/LAUNCHER root intent, so a launcher tap added another list on top of it instead of bringing it to the front, and each list ran the start-up code again. A list that the launcher starts on top of a running task of the app, not as its root, now finishes right away, which shows that task. A list that another app starts in its own task with the launcher intent is still shown. taskIdCompat moves from CallActivity to AppTasks for that check. Assisted-by: Claude Code:claude-opus-5-5 Signed-off-by: Marcel Hibbe --- .../com/nextcloud/talk/activities/CallActivity.kt | 6 +----- .../conversationlist/ConversationsListActivity.kt | 15 +++++++++++++++ .../java/com/nextcloud/talk/utils/AppTasks.kt | 13 +++++++++++++ 3 files changed, 29 insertions(+), 5 deletions(-) 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 38bc4c5af73..c3818726d99 100644 --- a/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt @@ -125,6 +125,7 @@ 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 @@ -2475,11 +2476,6 @@ class CallActivity : CallBaseActivity() { .forEach { it.finishAndRemoveTaskIfPresent() } } - // 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 - private fun startVideoCapture(isPortrait: Boolean) { val (width, height) = if (isPortrait) { WIDTH_4_TO_3_RATIO to HEIGHT_4_TO_3_RATIO 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 6e0fcf303a6..0167d0f84b7 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt @@ -271,6 +271,10 @@ class ConversationsListActivity : BaseActivity() { removeExtrasOfApp(intent) super.onCreate(savedInstanceState) NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) + if (isLauncherTapOnAppTask()) { + finish() + return + } if (finishIfNotShown()) return currentUser = setUpBoundUserOrFinish() ?: return // A recreation after a configuration change reuses this intent. Through the launcher alias, @@ -336,6 +340,17 @@ class ConversationsListActivity : BaseActivity() { 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, or opens the login without an account, finishes this list and returns true * if it is not shown for its intent. diff --git a/app/src/main/java/com/nextcloud/talk/utils/AppTasks.kt b/app/src/main/java/com/nextcloud/talk/utils/AppTasks.kt index e3fbba52127..35489fb62ba 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/AppTasks.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/AppTasks.kt @@ -9,6 +9,7 @@ package com.nextcloud.talk.utils import android.app.ActivityManager import android.content.Context import android.content.Intent +import android.os.Build import android.util.Log import com.nextcloud.talk.conversationlist.ConversationsListActivity import com.nextcloud.talk.utils.bundle.BundleKeys @@ -119,6 +120,13 @@ object AppTasks { ): 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. @@ -159,3 +167,8 @@ object AppTasks { info != null && info.baseIntent.component?.className in roots && predicate(info) } } + +// 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 From 937aa2acac0c1a5c72feed0b0b7e909ec8c7db31 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Mon, 2 Nov 2026 12:48:50 +0100 Subject: [PATCH 13/75] fix(lock): keep the app locked until it is unlocked Whether the app was locked depended on the lock timeout, and going to the background reset the time of the last use also while the app was locked. The app is now locked until LockedActivity is unlocked by authenticating: while it is locked, every screen that is resumed shows the lock screen again. The time of the last use isn't reset while the app is locked, so it is still locked after its process was killed. Assisted-by: Claude Code:claude-opus-5-5 Signed-off-by: Marcel Hibbe --- .../nextcloud/talk/activities/BaseActivity.kt | 30 +++++++++++++--- .../application/NextcloudTalkApplication.kt | 34 ++++++++++++++----- .../com/nextcloud/talk/lock/LockedActivity.kt | 12 ++++--- 3 files changed, 59 insertions(+), 17 deletions(-) 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 cd86e51f4bf..bad238f5c91 100644 --- a/app/src/main/java/com/nextcloud/talk/activities/BaseActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/activities/BaseActivity.kt @@ -207,14 +207,23 @@ open class BaseActivity : AppCompatActivity() { "elapsedSeconds=$elapsedSeconds" ) if (timeoutSeconds == 0 || elapsedSeconds >= timeoutSeconds) { - isCoveredByLockScreen = true - 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") @@ -250,9 +259,20 @@ open class BaseActivity : AppCompatActivity() { "activity=${this.javaClass.simpleName}" ) isCoveredByLockScreen = false - if (!skipLockCheckOnResume && NextcloudTalkApplication.pendingLockCheck) { - NextcloudTalkApplication.pendingLockCheck = false - lockScreenIfConditionsApply() + if (!skipLockCheckOnResume) { + // 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) or by Back. It is unlocked only by authenticating. + NextcloudTalkApplication.pendingLockCheck = false + showLockScreen() + } else if (NextcloudTalkApplication.pendingLockCheck) { + NextcloudTalkApplication.pendingLockCheck = false + lockScreenIfConditionsApply() + } } if (appPreferences.isKeyboardIncognito) { 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 53bd59bca74..011cdc8171d 100644 --- a/app/src/main/java/com/nextcloud/talk/application/NextcloudTalkApplication.kt +++ b/app/src/main/java/com/nextcloud/talk/application/NextcloudTalkApplication.kt @@ -223,14 +223,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) @@ -319,6 +312,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 @@ -332,6 +341,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/lock/LockedActivity.kt b/app/src/main/java/com/nextcloud/talk/lock/LockedActivity.kt index 85840f558b5..17b0d96a488 100644 --- a/app/src/main/java/com/nextcloud/talk/lock/LockedActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/lock/LockedActivity.kt @@ -99,8 +99,7 @@ class LockedActivity : AppCompatActivity() { override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { super.onAuthenticationSucceeded(result) Log.d(TAG, "Fingerprint recognised successfully") - appPreferences.setLockTimestamp(System.currentTimeMillis()) - finish() + unlock() } override fun onAuthenticationFailed() { @@ -133,13 +132,18 @@ class LockedActivity : AppCompatActivity() { private fun onConfirmDeviceCredentials(result: ActivityResult) { if (result.resultCode == Activity.RESULT_OK) { - appPreferences.setLockTimestamp(System.currentTimeMillis()) - finish() + unlock() } else { Log.d(TAG, "Authorization failed") } } + private fun unlock() { + appPreferences.setLockTimestamp(System.currentTimeMillis()) + NextcloudTalkApplication.isLocked = false + finish() + } + companion object { private val TAG = LockedActivity::class.java.simpleName } From f9e43b4ef519268ff474c7cb265d7087a6e5740e Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Mon, 2 Nov 2026 12:55:54 +0100 Subject: [PATCH 14/75] fix(share): don't copy the copies of shared files again ShareActivity copies each shared file into the cache and passes on the FileProvider uri of the copy. FileUtils.getFileFromUri, used by the attachment preview and UploadAndShareFilesWorker, copied every content uri into the cache again, and that second copy was never deleted. So a shared file needed twice its size in the cache, and a share could fail when free space ran short. getFileFromUri now returns the file behind an own FileProvider uri of the copies of shared files or the shared attachments, resolved like the check of shared uris, instead of copying it again. isSharedAttachmentProviderPath becomes sharedAttachmentProviderFile, which returns that file. Assisted-by: Claude Code:claude-opus-5-5 Signed-off-by: Marcel Hibbe --- .../com/nextcloud/talk/utils/FileUtils.kt | 24 +++-- .../talk/utils/FileUtilsGetFileFromUriTest.kt | 96 +++++++++++++++++++ .../com/nextcloud/talk/utils/FileUtilsTest.kt | 34 +++---- 3 files changed, 127 insertions(+), 27 deletions(-) create mode 100644 app/src/test/java/com/nextcloud/talk/utils/FileUtilsGetFileFromUriTest.kt 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 331147a0110..01e343da8de 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/FileUtils.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/FileUtils.kt @@ -69,17 +69,17 @@ object FileUtils { } /** - * Checks whether the [pathSegments] of an own FileProvider uri point to a file inside the - * dedicated shared attachments cache directory or the one of the copies of shared files. + * The file in [cacheDir] that the [pathSegments] of an own FileProvider uri point to, if it is inside the + * dedicated shared attachments cache directory or the one of the copies of shared files, else null. */ - fun isSharedAttachmentProviderPath(cacheDir: File, pathSegments: List): Boolean { + fun sharedAttachmentProviderFile(cacheDir: File, pathSegments: List): File? { val directory = pathSegments.firstOrNull()?.takeIf { it in SHARED_PROVIDER_DIRECTORIES } - if (pathSegments.size < 2 || directory == null) return false + if (pathSegments.size < 2 || directory == null) return null var resolvedFile: File? = File(cacheDir, directory) for (segment in pathSegments.drop(1)) { resolvedFile = resolvedFile?.let { resolveFileInDirectory(it, segment) } } - return resolvedFile != null + return resolvedFile } /** @@ -95,7 +95,7 @@ object FileUtils { /** * 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 - * [isSharedAttachmentProviderPath] allows. A user id prefix ("0@authority") is ignored, as the content resolver + * [sharedAttachmentProviderFile] allows. A user id prefix ("0@authority") is ignored, as the content resolver * resolves it to the own user. [packageOfAuthority] gives the package of the provider of an authority. */ fun isTrustedShareUri( @@ -104,11 +104,11 @@ object FileUtils { cacheDir: File, packageOfAuthority: (String) -> String? ): Boolean { - if (uri == null || ContentResolver.SCHEME_CONTENT != uri.scheme) return false - val authority = uri.authority?.substringAfterLast('@') ?: return false + val contentUri = uri?.takeIf { ContentResolver.SCHEME_CONTENT == it.scheme } + val authority = contentUri?.authority?.substringAfterLast('@') ?: return false val isOwnProvider = authority == packageName || packageOfAuthority(authority) == packageName val isSharedFileOfFileProvider = - authority == packageName && isSharedAttachmentProviderPath(cacheDir, uri.pathSegments) + authority == packageName && sharedAttachmentProviderFile(cacheDir, contentUri.pathSegments) != null return !isOwnProvider || isSharedFileOfFileProvider } @@ -199,7 +199,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 into its cache is used as it is, instead of copying it again + val ownSharedFile = sourceFileUri.takeIf { it.authority == context.packageName } + ?.let { sharedAttachmentProviderFile(context.cacheDir, 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") 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..d92c5fa7af1 --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/utils/FileUtilsGetFileFromUriTest.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.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 + + @Before + fun setUp() { + cacheDir.deleteRecursively() + cacheDir.mkdirs() + } + + @After + fun tearDown() { + cacheDir.deleteRecursively() + } + + private fun fileInCache(path: String) = + File(cacheDir, path).apply { + parentFile!!.mkdirs() + writeText("content") + } + + 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 = fileInCache("shared_file_copies/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 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) + } + + // 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") + fileInCache("shared_file_copies/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 another provider is copied`() { + fileInCache("shared_file_copies/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/FileUtilsTest.kt b/app/src/test/java/com/nextcloud/talk/utils/FileUtilsTest.kt index 2f48fb7118c..38077ce7739 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,17 @@ 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, listOf("shared_attachments", "image.jpg")) + ) + assertEquals( + File(sharedAttachments, "42/image.jpg"), + FileUtils.sharedAttachmentProviderFile(baseDir, listOf("shared_attachments", "42", "image.jpg")) ) } finally { baseDir.deleteRecursively() @@ -187,28 +191,24 @@ 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, listOf("logs", "log.txt"))) + assertNull(FileUtils.sharedAttachmentProviderFile(baseDir, listOf("photos", "photo.jpg"))) + assertNull(FileUtils.sharedAttachmentProviderFile(baseDir, listOf("shared_attachments"))) + assertNull(FileUtils.sharedAttachmentProviderFile(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")) - ) + assertNull(FileUtils.sharedAttachmentProviderFile(baseDir, listOf("shared_attachments", "..", "secret.db"))) + assertNull(FileUtils.sharedAttachmentProviderFile(baseDir, listOf("shared_attachments", "../secret.db"))) } finally { baseDir.deleteRecursively() } From 990f3fd3d92122fc3365ed4537cecdf707431484 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Mon, 2 Nov 2026 13:00:08 +0100 Subject: [PATCH 15/75] fix(account): show the list of the conversation's account after leaving it Leaving or deleting a conversation restarted the app with the launcher list, which shows the default account. A chat opened from a notification, the media notification or the audio player of another account doesn't make that account the default anymore, so afterwards the list of the default account showed. The same happened after a call of another account when no task of the app was left. Leaving or deleting a conversation now recreates the list in the task for the conversation's account (createAccountSwitchIntent with CLEAR_TOP), which also closes the screens above it. NEW_TASK isn't used there: with the empty task affinity it would start a second task when the list in the task was started from the launcher. A call without a task of the app left starts the list of its account in a new task. Assisted-by: Claude Code:claude-opus-5-5 Signed-off-by: Marcel Hibbe --- .../com/nextcloud/talk/activities/CallActivity.kt | 2 +- .../conversationinfo/ConversationInfoActivity.kt | 15 ++++++++++++--- .../java/com/nextcloud/talk/utils/AppTasks.kt | 8 ++++++++ 3 files changed, 21 insertions(+), 4 deletions(-) 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 c3818726d99..e1679222ea9 100644 --- a/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt @@ -2456,7 +2456,7 @@ class CallActivity : CallBaseActivity() { if (isShownInPrimaryTask) { removeOtherTasks(appTasks, primaryTask.taskInfoOrNull()?.taskIdCompat) } else { - startActivity(AppTasks.createRestartIntent(context)) + startActivity(AppTasks.createRestartIntent(context, conversationUser.id!!)) } } 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 ed7d8147179..7ee0abe4758 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationinfo/ConversationInfoActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationinfo/ConversationInfoActivity.kt @@ -40,7 +40,6 @@ import androidx.lifecycle.lifecycleScope import androidx.work.Data import androidx.work.ExistingWorkPolicy import androidx.work.OneTimeWorkRequest -import com.nextcloud.talk.utils.AppTasks import com.nextcloud.talk.utils.setExpeditedIfSupported import androidx.work.WorkInfo import androidx.work.WorkManager @@ -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,7 +606,7 @@ class ConversationInfoActivity : BaseActivity() { ) } - startActivity(AppTasks.createRestartIntent(context)) + showConversationList() } WorkInfo.State.FAILED -> { val errorType = workInfo.outputData.getString("error_type") @@ -672,10 +672,19 @@ class ConversationInfoActivity : BaseActivity() { ) } - startActivity(AppTasks.createRestartIntent(context)) + 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/utils/AppTasks.kt b/app/src/main/java/com/nextcloud/talk/utils/AppTasks.kt index 35489fb62ba..3ebda6effd0 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/AppTasks.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/AppTasks.kt @@ -78,6 +78,14 @@ object AppTasks { 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 From 435f9bae1e8d9519dd3921b9457838b3fe2f27c4 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Mon, 2 Nov 2026 13:22:02 +0100 Subject: [PATCH 16/75] fix(tasks): find the tasks of the app before Android 10 The tasks of the app were found by the alias in the base intent of their root. Before Android 10, the base intent of a task that an alias started names the target of the alias instead, so no task of the app was found: openChatInAppTask always took its fallback, which closed the screens open in the app task instead of opening the chat on top, and CallActivity could take a share task as the primary task and remove the real app task. The root is now taken from origActivity, which names the alias on every version, before the base intent. Assisted-by: Claude Code:claude-opus-5-5 Signed-off-by: Marcel Hibbe --- .../java/com/nextcloud/talk/utils/AppTasks.kt | 16 +++- .../talk/utils/AppTasksBeforeAndroid10Test.kt | 76 +++++++++++++++++++ 2 files changed, 89 insertions(+), 3 deletions(-) create mode 100644 app/src/test/java/com/nextcloud/talk/utils/AppTasksBeforeAndroid10Test.kt diff --git a/app/src/main/java/com/nextcloud/talk/utils/AppTasks.kt b/app/src/main/java/com/nextcloud/talk/utils/AppTasks.kt index 3ebda6effd0..58f0b03f76c 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/AppTasks.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/AppTasks.kt @@ -7,6 +7,7 @@ package com.nextcloud.talk.utils import android.app.ActivityManager +import android.content.ComponentName import android.content.Context import android.content.Intent import android.os.Build @@ -148,8 +149,9 @@ object AppTasks { /** * 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. + * [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. */ fun removeTasksLeftInRecents(context: Context) { findTasksLeftInRecents(context).forEach { it.finishAndRemoveTaskIfPresent() } @@ -172,8 +174,16 @@ object AppTasks { ): List = appTasks.filter { task -> val info = task.taskInfoOrNull() - info != null && info.baseIntent.component?.className in roots && predicate(info) + info != null && rootOf(info)?.className in roots && predicate(info) } + + /** + * The activity that the root of the task of [info] 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. + */ + private fun rootOf(info: ActivityManager.RecentTaskInfo): ComponentName? = + info.origActivity ?: info.baseIntent.component } // TaskInfo.taskId only exists since API 29; before that persistentId is the task id 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 }) + } +} From 556c3c260cd1d203858463c4f936240ab1b8dfbd Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Mon, 2 Nov 2026 13:22:03 +0100 Subject: [PATCH 17/75] fix(deeplink): don't pass on the link of a task relaunched from the recents Before Android 10, a task relaunched from the recents gets its intent with the target of the alias it was started through, so the list of a deep link that DeepLinkActivity passed on took it for a VIEW intent of another app. It passed the old link on to DeepLinkActivity again, and the forward dropped FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY, which bypassed the history guard of DeepLinkRouting: the link was routed again. A VIEW intent launched from the recents is no longer passed on, as its link was taken already. The list shows without routing it. Assisted-by: Claude Code:claude-opus-5-5 Signed-off-by: Marcel Hibbe --- .../talk/conversationlist/ConversationsListActivity.kt | 8 ++++++-- .../ConversationsListEntryPointTest.kt | 10 ++++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) 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 0167d0f84b7..ff9861669f1 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt @@ -1898,13 +1898,17 @@ class ConversationsListActivity : BaseActivity() { /** * 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]. + * 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 + intent.data != null && + intent.flags and Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY == 0 /** Whether [intent] shares content of another app, which is taken through [ShareActivity]. */ @VisibleForTesting diff --git a/app/src/test/java/com/nextcloud/talk/conversationlist/ConversationsListEntryPointTest.kt b/app/src/test/java/com/nextcloud/talk/conversationlist/ConversationsListEntryPointTest.kt index 0ab2968243c..779817a52a8 100644 --- a/app/src/test/java/com/nextcloud/talk/conversationlist/ConversationsListEntryPointTest.kt +++ b/app/src/test/java/com/nextcloud/talk/conversationlist/ConversationsListEntryPointTest.kt @@ -109,6 +109,16 @@ class ConversationsListEntryPointTest { 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 `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") From d01aa07daf58555f37c625d3a7c67e6c8f1287db Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Mon, 2 Nov 2026 13:30:14 +0100 Subject: [PATCH 18/75] fix(shortcuts): open the conversation of exactly the account of a shortcut The conversation shortcuts, also those of notifications, opened their conversation through a nextcloudtalk:// deep link, which names only the host and user name of the account. With two accounts of the same user on one host, e.g. on another path or port, a shortcut of one account opened or asked to join the conversation on the other. The update of the shortcuts of earlier versions turned exact notification shortcuts into such links. The system starts a shortcut as the app that published it, so the shortcuts now target the internal entry point of the conversation list with the account and room token as extras, like the direct share targets. The shortcuts of earlier versions, with extras or a deep link, are given this intent, with the account and conversation of their id. Only legacy home screen shortcuts, which the launcher starts as itself, keep the deep link. The list now takes the room token of a shortcut once: it opened the conversation again whenever the list was resumed, e.g. when going back from it. A token not opened yet is kept with the instance state. Assisted-by: Claude Code:claude-opus-5-5 Signed-off-by: Marcel Hibbe --- .../ConversationsListActivity.kt | 20 ++-- .../conversationlist/DirectShareHelper.kt | 14 +-- .../talk/utils/ShortcutManagerHelper.kt | 77 ++++++++++----- .../talk/utils/ShortcutManagerHelperTest.kt | 94 +++++++++++++++---- 4 files changed, 145 insertions(+), 60 deletions(-) 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 ff9861669f1..f515519b750 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt @@ -306,6 +306,10 @@ class ConversationsListActivity : BaseActivity() { forwardMessageState.value = intent.getBooleanExtra(KEY_FORWARD_MSG_FLAG, false) if (savedInstanceState != null) { 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 + pendingDirectShareToken = savedInstanceState.getString(KEY_PENDING_DIRECT_SHARE_TOKEN) + intent.removeExtra(KEY_ROOM_TOKEN) } onBackPressedDispatcher.addCallback(this, onBackPressedCallback) @@ -390,6 +394,7 @@ class ConversationsListActivity : BaseActivity() { override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) outState.putBoolean(KEY_ACCOUNT_DIALOG_VISIBLE, showAccountDialogState.value) + outState.putString(KEY_PENDING_DIRECT_SHARE_TOKEN, pendingDirectShareToken) if (::deepLinkRouting.isInitialized) deepLinkRouting.onSaveInstanceState(outState) } @@ -598,12 +603,14 @@ class ConversationsListActivity : BaseActivity() { showNotificationWarningState.value = shouldShowNotificationWarning() showShareToScreenState.value = isShare(intent) - // Home screen shortcut tap: shortcut uses ACTION_VIEW with KEY_ROOM_TOKEN to open the - // conversation directly without showing the share picker. + // 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. if (Intent.ACTION_VIEW == intent.action) { intent.getStringExtra(KEY_ROOM_TOKEN)?.takeIf(DeepLinkHandler::isValidToken)?.let { pendingDirectShareToken = it } + intent.removeExtra(KEY_ROOM_TOKEN) } // Share sheet shortcut tap: Android delivers ACTION_SEND with EXTRA_SHORTCUT_ID. @@ -632,9 +639,9 @@ class ConversationsListActivity : BaseActivity() { lifecycleScope.launch { val users = userManager.getUsers() hasMultipleAccountsState.value = users.size > 1 - if (!areShortcutsWithoutDeepLinkUpdated) { - areShortcutsWithoutDeepLinkUpdated = true - withContext(Dispatchers.IO) { ShortcutManagerHelper.updateShortcutsWithoutDeepLink(context, users) } + if (!areShortcutsOfEarlierVersionsUpdated) { + areShortcutsOfEarlierVersionsUpdated = true + withContext(Dispatchers.IO) { ShortcutManagerHelper.updateShortcutsOfEarlierVersions(context, users) } } } conversationsListViewModel.setHideRoomToken(intent.getStringExtra(KEY_FORWARD_HIDE_SOURCE_ROOM)) @@ -1815,7 +1822,7 @@ class ConversationsListActivity : BaseActivity() { private val TAG = ConversationsListActivity::class.java.simpleName // once per process: the shortcuts without a deep link don't change while the app runs - private var areShortcutsWithoutDeepLinkUpdated = false + private var areShortcutsOfEarlierVersionsUpdated = false /** * Creates an intent for this activity through its non-exported alias, so that it takes the account @@ -1933,5 +1940,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_DIRECT_SHARE_TOKEN = "pending_direct_share_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 7812f89198b..a6b37d8533b 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/DirectShareHelper.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/DirectShareHelper.kt @@ -9,7 +9,6 @@ package com.nextcloud.talk.conversationlist import android.content.Context -import android.content.Intent import android.content.res.Configuration import android.graphics.drawable.BitmapDrawable import android.util.Log @@ -29,7 +28,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 @@ -291,13 +290,10 @@ 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 = ConversationsListActivity.createIntent(context).setAction(Intent.ACTION_VIEW).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. + val intent = ShortcutManagerHelper.createConversationIntent(context, token, user) + ?: ConversationsListActivity.createIntent(context) val person = Person.Builder() .setName(displayName) 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 d196bbb8b9a..257e7f94e8c 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/ShortcutManagerHelper.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/ShortcutManagerHelper.kt @@ -10,11 +10,13 @@ import android.content.Context import android.content.Intent import android.net.Uri import android.util.Log +import androidx.annotation.VisibleForTesting 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.DeepLinkActivity +import com.nextcloud.talk.conversationlist.ConversationsListActivity import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.models.domain.ConversationModel import com.nextcloud.talk.utils.bundle.BundleKeys @@ -32,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 @@ -95,41 +97,43 @@ object ShortcutManagerHelper { } /** - * Gives the conversation shortcuts that target the launcher activity with only the room token and user id as - * extras the deep link of their conversation, like [createConversationShortcut]: the launcher activity takes no - * extras from intents of outside the app, so such a shortcut opens only the conversation list. Pinned ones, e.g. - * on the home screen or in a conversation widget, and cached ones keep their intent until their conversation has a - * notification or is among the dynamic shortcuts. + * Gives the conversation shortcuts of earlier versions the intent of [createConversationIntent], which opens the + * conversation of exactly their account: those that target the launcher activity, with the room token and user id + * as extras, which it takes from no intent of outside the app, so they open only the conversation list, and those + * with a nextcloudtalk:// deep link, which names only the host and user name of the account. The account and + * conversation are taken from the id of the shortcut. Pinned ones, e.g. on the home screen or in a conversation + * widget, and cached ones keep their intent until their conversation has a notification or is among the dynamic + * shortcuts. * * @param context Application context * @param users The accounts whose shortcuts can be updated */ - fun updateShortcutsWithoutDeepLink(context: Context, users: List) { + fun updateShortcutsOfEarlierVersions(context: Context, users: List) { try { - val updated = shortcutsWithDeepLinkFor(context, users) + val updated = shortcutsOfEarlierVersionsUpdated(context, users) if (updated.isNotEmpty()) { ShortcutManagerCompat.updateShortcuts(context, updated) } } catch (e: IllegalArgumentException) { // e.g. for a shortcut that can't be updated. Old shortcuts must not crash the conversation list. - Log.w(TAG, "Failed to update the shortcuts without a deep link", e) + Log.w(TAG, "Failed to update the shortcuts of earlier versions", e) } catch (e: IllegalStateException) { // Thrown while the user is locked - Log.w(TAG, "Failed to update the shortcuts without a deep link", e) + Log.w(TAG, "Failed to update the shortcuts of earlier versions", e) } } - /** The shortcuts of [updateShortcutsWithoutDeepLink] with the deep link of their conversation. */ - private fun shortcutsWithDeepLinkFor(context: Context, users: List): List { + /** 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.id.startsWith(CONVERSATION_SHORTCUT_PREFIX) && isWithoutDeepLink(it.intent) } + .filter { it.intent.component?.className != internalEntryPoint } .mapNotNull { shortcut -> - val user = usersById[shortcut.intent.getLongExtra(BundleKeys.KEY_INTERNAL_USER_ID, 0L)] - val roomToken = shortcut.intent.getStringExtra(BundleKeys.KEY_ROOM_TOKEN) - val intent = roomToken?.let { token -> user?.let { createConversationIntent(context, token, it) } } + val (userId, roomToken) = conversationOfShortcutId(shortcut.id) ?: return@mapNotNull null + val intent = usersById[userId]?.let { createConversationIntent(context, roomToken, it) } // the system keeps the other fields of a shortcut that is updated, e.g. its icon and person intent?.let { ShortcutInfoCompat.Builder(context, shortcut.id) @@ -140,8 +144,16 @@ object ShortcutManagerHelper { } } - private fun isWithoutDeepLink(intent: Intent): Boolean = - intent.component?.className == AppTasks.LAUNCHER_ACTIVITY && intent.data == null + /** The user id and room token of the conversation shortcut [shortcutId] of [getShortcutId], else null. */ + @VisibleForTesting + internal fun conversationOfShortcutId(shortcutId: String): Pair? { + val userIdAndToken = shortcutId.takeIf { it.startsWith(CONVERSATION_SHORTCUT_PREFIX) } + ?.removePrefix(CONVERSATION_SHORTCUT_PREFIX) + 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. @@ -170,9 +182,9 @@ object ShortcutManagerHelper { */ @Suppress("DEPRECATION") private fun createLegacyShortcut(context: Context, conversation: ConversationModel, user: User): Boolean { - val launchIntent = createConversationIntent(context, conversation.token, user) + val launchIntent = createConversationDeepLinkIntent(context, conversation.token, user) if (launchIntent == null) { - Log.w(TAG, "Cannot create legacy shortcut: user ID or base URL is missing") + Log.w(TAG, "Cannot create legacy shortcut: base URL is missing") return false } @@ -227,13 +239,28 @@ object ShortcutManagerHelper { } /** - * Creates the intent that opens the conversation [roomToken] of [user] through its nextcloudtalk:// deep link, or - * null if [user] has no id or no deep link can be built from its server url. The intent of all conversation - * shortcuts, also of those that notifications publish. + * 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? { + val userId = user.id ?: return null + return ConversationsListActivity.createIntent(context).apply { + action = Intent.ACTION_VIEW + putExtra(BundleKeys.KEY_ROOM_TOKEN, roomToken) + putExtra(BundleKeys.KEY_INTERNAL_USER_ID, userId) + } + } + + /** + * 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 - ?.takeIf { user.id != null } ?.let { DeepLinkHandler.createConversationUri(roomToken, it, user.username) } ?.takeIf { it != Uri.EMPTY } ?: return null diff --git a/app/src/test/java/com/nextcloud/talk/utils/ShortcutManagerHelperTest.kt b/app/src/test/java/com/nextcloud/talk/utils/ShortcutManagerHelperTest.kt index 99a9a63a65d..079f814e021 100644 --- a/app/src/test/java/com/nextcloud/talk/utils/ShortcutManagerHelperTest.kt +++ b/app/src/test/java/com/nextcloud/talk/utils/ShortcutManagerHelperTest.kt @@ -11,9 +11,11 @@ import android.content.Intent import androidx.core.content.pm.ShortcutInfoCompat import androidx.core.content.pm.ShortcutManagerCompat 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 @@ -21,8 +23,8 @@ import org.robolectric.RuntimeEnvironment import org.robolectric.annotation.Config /** - * The launcher activity takes no extras from outside the app, so the conversation shortcuts that notifications - * published with only extras are given the deep link of their conversation. + * The conversation shortcuts open the conversation of exactly their account through the internal entry point of the + * conversation list, and those of earlier versions are given that intent. */ @RunWith(RobolectricTestRunner::class) @Config(application = Application::class, sdk = [33]) @@ -30,20 +32,34 @@ class ShortcutManagerHelperTest { private val context = RuntimeEnvironment.getApplication() - private val alice = User(id = 1L, username = "alice", baseUrl = "https://cloud.example.com") + // 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 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) - ShortcutManagerCompat.pushDynamicShortcut( - context, - ShortcutInfoCompat.Builder(context, "conversation_${userId}_$roomToken") - .setShortLabel("Chat") - .setIntent(intent) - .build() - ) + 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 = @@ -51,25 +67,63 @@ class ShortcutManagerHelperTest { .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)) + } + @Test - fun `a conversation shortcut with only extras is given the deep link of its conversation`() { + 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.updateShortcutsWithoutDeepLink(context, listOf(alice)) + ShortcutManagerHelper.updateShortcutsOfEarlierVersions(context, listOf(alice, aliceOnOtherPort)) - val intent = intentOf("conversation_1_abcd1234") - assertEquals(DeepLinkActivity::class.java.name, intent.component?.className) - assertEquals(Intent.ACTION_VIEW, intent.action) - val deepLink = DeepLinkHandler.createConversationUri("abcd1234", alice.baseUrl!!, alice.username) - assertEquals(deepLink, intent.data) + 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") } @Test fun `a conversation shortcut of an account that is gone is kept as it is`() { - pushShortcutWithExtras(userId = 2L, roomToken = "abcd1234") + pushShortcutWithExtras(userId = 3L, roomToken = "abcd1234") - ShortcutManagerHelper.updateShortcutsWithoutDeepLink(context, listOf(alice)) + ShortcutManagerHelper.updateShortcutsOfEarlierVersions(context, listOf(alice, aliceOnOtherPort)) - assertEquals(AppTasks.LAUNCHER_ACTIVITY, intentOf("conversation_2_abcd1234").component?.className) + 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")) + assertNull(ShortcutManagerHelper.conversationOfShortcutId("direct_share_2_abcd1234")) + assertNull(ShortcutManagerHelper.conversationOfShortcutId("conversation_x_abcd1234")) + assertNull(ShortcutManagerHelper.conversationOfShortcutId("conversation_2")) + assertNull(ShortcutManagerHelper.conversationOfShortcutId("conversation_2_../abcd")) } } From 85e913cabb5e23c74e6ee0ed3df5986723b97a0d Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Mon, 2 Nov 2026 13:40:16 +0100 Subject: [PATCH 19/75] fix(share): stop copying the shared files when the share is left The shared files were copied in a blocking loop. Leaving ShareActivity cancelled the coroutine, but every file was still copied to the end and then left in the cache, as copyToCache also caught the cancellation. A recreation of ShareActivity, e.g. after the font scale or locale changed, started a second full copy into a new directory. The copy now stops between files and buffers when it is cancelled, and removes the copies of the share. ShareActivity handles all configuration changes itself, as it only shows a progress indicator. Assisted-by: Claude Code:claude-opus-5-5 Signed-off-by: Marcel Hibbe --- app/src/main/AndroidManifest.xml | 5 +- .../ShareListIntentFactory.kt | 49 ++++++++++++++----- .../ShareListIntentFactoryTest.kt | 34 +++++++++++++ 3 files changed, 75 insertions(+), 13 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 20d3534132c..71807609204 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -170,10 +170,11 @@ + to pick the conversation, in a task that is kept out of the recents. It handles all configuration + changes itself: it only shows a progress indicator, and being recreated would copy the files again. --> diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/ShareListIntentFactory.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/ShareListIntentFactory.kt index 469aa3700c6..df8dfcf4a64 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ShareListIntentFactory.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ShareListIntentFactory.kt @@ -20,8 +20,12 @@ 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], @@ -75,24 +79,34 @@ internal class ShareListIntentFactory( 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 that may be uploaded, see [FileUtils.isTrustedShareUri]. */ - private fun copySharedItems(sharedIntent: Intent): List { + /** + * The shared texts, and copies of the shared files that may be uploaded, see [FileUtils.isTrustedShareUri]. Stops + * copying when it is cancelled, e.g. when the share is left, and then removes the copies. + */ + private suspend fun copySharedItems(sharedIntent: Intent): List { // 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 directory by lazy { SharedFileCopies.createDirectory(context) } + val directoryLazy = lazy { SharedFileCopies.createDirectory(context) } 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 -> - when { - item.uri != null -> - copyToCache(item.uri) { directory?.let { File(it, index.toString()) } }?.let { ClipData.Item(it) } - item.text != null -> ClipData.Item(item.text) - else -> null + return try { + sharedItems.mapIndexedNotNull { index, item -> + currentCoroutineContext().ensureActive() + when { + item.uri != null -> + copyToCache(item.uri) { directoryLazy.value?.let { File(it, index.toString()) } } + ?.let { ClipData.Item(it) } + item.text != null -> ClipData.Item(item.text) + else -> null + } } + } catch (e: CancellationException) { + if (directoryLazy.isInitialized()) directoryLazy.value?.deleteRecursively() + throw e } } @@ -109,7 +123,7 @@ internal class ShareListIntentFactory( * it can't be used. */ @Suppress("Detekt.TooGenericExceptionCaught") - private fun copyToCache(uri: Uri, directoryOf: () -> File?): Uri? { + private suspend fun copyToCache(uri: Uri, directoryOf: () -> File?): Uri? { if (!FileUtils.isTrustedShareUri(uri, context)) { Log.w(TAG, "Rejecting untrusted uri from share intent: $uri") return null @@ -120,8 +134,10 @@ internal class ShareListIntentFactory( } val isCopied = file != null && context.contentResolver.openInputStream(uri) - ?.use { input -> file.outputStream().use { input.copyTo(it) } } != null + ?.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) @@ -129,6 +145,17 @@ internal class ShareListIntentFactory( } } + /** 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 private const val FILE_NAME = "file" diff --git a/app/src/test/java/com/nextcloud/talk/conversationlist/ShareListIntentFactoryTest.kt b/app/src/test/java/com/nextcloud/talk/conversationlist/ShareListIntentFactoryTest.kt index 53c95339b2e..853753d5ca8 100644 --- a/app/src/test/java/com/nextcloud/talk/conversationlist/ShareListIntentFactoryTest.kt +++ b/app/src/test/java/com/nextcloud/talk/conversationlist/ShareListIntentFactoryTest.kt @@ -14,9 +14,15 @@ 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 @@ -26,6 +32,7 @@ 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 @@ -197,7 +204,34 @@ class ShareListIntentFactoryTest { 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) } From c9515c1b6aebeb060f7260f083712737fdd8bba1 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Mon, 2 Nov 2026 14:56:38 +0100 Subject: [PATCH 20/75] fix(share): remove the copies of a share that is left or done The copies of shared files were only pruned when a later share with a file started. Prune the done shares also at app start, and remove the copies of a share right away when it is left in the conversation list or in the attachment preview, unless an upload of them is unfinished. Assisted-by: Claude Code:claude-opus-5-5 Signed-off-by: Marcel Hibbe --- .../application/NextcloudTalkApplication.kt | 3 + .../FileAttachmentPreviewFragment.kt | 18 ++++ .../ConversationsListActivity.kt | 17 ++++ .../nextcloud/talk/utils/SharedFileCopies.kt | 41 ++++++-- .../talk/utils/SharedFileCopiesTest.kt | 96 +++++++++++++++++++ 5 files changed, 168 insertions(+), 7 deletions(-) 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 011cdc8171d..790f528fb37 100644 --- a/app/src/main/java/com/nextcloud/talk/application/NextcloudTalkApplication.kt +++ b/app/src/main/java/com/nextcloud/talk/application/NextcloudTalkApplication.kt @@ -60,6 +60,7 @@ import com.nextcloud.talk.users.UserManager import com.nextcloud.talk.utils.ClosedInterfaceImpl import com.nextcloud.talk.utils.DeviceUtils import com.nextcloud.talk.utils.NotificationUtils +import com.nextcloud.talk.utils.SharedFileCopies import com.nextcloud.talk.utils.database.arbitrarystorage.ArbitraryStorageModule import com.nextcloud.talk.utils.database.user.UserModule import com.nextcloud.talk.utils.preferences.AppPreferences @@ -243,6 +244,8 @@ 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) } val accountRemovalWork = OneTimeWorkRequest.Builder(AccountRemovalWorker::class.java).build() val capabilitiesUpdateWork = OneTimeWorkRequest.Builder(CapabilitiesSyncWorker::class.java).build() 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..6dc2c19c5ca 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 @@ -26,7 +27,9 @@ 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 kotlinx.coroutines.launch import javax.inject.Inject @AutoInjector(NextcloudTalkApplication::class) @@ -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,17 @@ class FileAttachmentPreviewFragment : DialogFragment() { composeView = null } + override fun onDismiss(dialog: DialogInterface) { + super.onDismiss(dialog) + // Files shared by another app that are left here, unsent, remove their copies, see SharedFileCopies. Not when + // the dialog is only shown again after a configuration change. + if (!isSent && activity?.isChangingConfigurations != true) { + val application = NextcloudTalkApplication.sharedApplication!! + val files = filesList.toList() + application.appScope.launch { SharedFileCopies.removeSharesOf(application, files) } + } + } + companion object { private const val LIGHT_LUMINANCE_THRESHOLD = 0.5f 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 f515519b750..13ea5d477cc 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt @@ -112,6 +112,7 @@ 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 @@ -405,6 +406,9 @@ class ConversationsListActivity : BaseActivity() { // 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) removeExtrasOfApp(intent) @@ -1033,6 +1037,18 @@ 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 + if (isFinishing && isShare(intent) && !isSharePassedOn && !isRelaunching) { + val clip = intent.clipData + val sharedUris = clip?.let { (0 until it.itemCount).mapNotNull { index -> it.getItemAt(index).uri } } + if (!sharedUris.isNullOrEmpty()) { + val application = NextcloudTalkApplication.sharedApplication!! + application.appScope.launch { + SharedFileCopies.removeSharesOf(application, sharedUris.map { it.toString() }) + } + } + } } @Suppress("Detekt.ComplexMethod") @@ -1345,6 +1361,7 @@ class ConversationsListActivity : BaseActivity() { 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() } diff --git a/app/src/main/java/com/nextcloud/talk/utils/SharedFileCopies.kt b/app/src/main/java/com/nextcloud/talk/utils/SharedFileCopies.kt index 6eba4347e3c..71a8b84311c 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/SharedFileCopies.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/SharedFileCopies.kt @@ -39,29 +39,56 @@ object SharedFileCopies { 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. Blocks - * while it asks WorkManager for their uploads. + * 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? { - val sharesDirectory = File(context.cacheDir, FileUtils.SHARED_FILE_COPIES_DIRECTORY) + 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) - sharesDirectory.listFiles() + sharesDirectory(context).listFiles() ?.filter { it.lastModified() < expiry && !hasUnfinishedUpload(context, it.name) } ?.forEach { it.deleteRecursively() } - return File(sharesDirectory, UUID.randomUUID().toString()).takeIf { it.mkdirs() } + } + + /** + * 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) + fileUris.mapNotNull(::shareDirectoryNameOf) + .distinct() + .filterNot { hasUnfinishedUpload(context, it) } + .mapNotNull { FileUtils.resolveFileInDirectory(sharesDirectory, it) } + .forEach { it.deleteRecursively() } } /** 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? { + 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.authority == BuildConfig.APPLICATION_ID && segments.size > SHARE_DIRECTORY_SEGMENT + 1 && segments[0] == FileUtils.SHARED_FILE_COPIES_DIRECTORY - return if (isCopy) UPLOAD_TAG_PREFIX + segments[SHARE_DIRECTORY_SEGMENT] else null + return if (isCopy) segments[SHARE_DIRECTORY_SEGMENT] else null } + private fun sharesDirectory(context: Context) = File(context.cacheDir, FileUtils.SHARED_FILE_COPIES_DIRECTORY) + private fun hasUnfinishedUpload(context: Context, directoryName: String): Boolean = try { WorkManager.getInstance(context).getWorkInfosByTag(UPLOAD_TAG_PREFIX + directoryName).get() diff --git a/app/src/test/java/com/nextcloud/talk/utils/SharedFileCopiesTest.kt b/app/src/test/java/com/nextcloud/talk/utils/SharedFileCopiesTest.kt index d6fcece7ab8..aea494f27a2 100644 --- a/app/src/test/java/com/nextcloud/talk/utils/SharedFileCopiesTest.kt +++ b/app/src/test/java/com/nextcloud/talk/utils/SharedFileCopiesTest.kt @@ -7,19 +7,66 @@ 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.cacheDir, "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() { @@ -38,4 +85,53 @@ class SharedFileCopiesTest { assertNull(SharedFileCopies.uploadTagOf("content://other.app/shared_file_copies/share1/0/photo.jpg")) 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.cacheDir.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() + } } From 7e4aeeee055ed070aeb8dacec5d186bc76d88985 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Mon, 2 Nov 2026 15:18:25 +0100 Subject: [PATCH 21/75] fix(contacts): show an error when the chat with a contact can't be created After confirming to start a conversation with a contact, a failed createRoom, e.g. offline or rejected by the server, or a response without a conversation token, silently stayed in the conversation list. Let the failure reach the routing, which shows the error, as for any failed step. Assisted-by: Claude Code:claude-opus-5-5 Signed-off-by: Marcel Hibbe --- .../activities/DeepLinkRoutingViewModel.kt | 32 +++---- .../DeepLinkRoutingViewModelTest.kt | 83 ++++++++++++++++++- 2 files changed, 97 insertions(+), 18 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/activities/DeepLinkRoutingViewModel.kt b/app/src/main/java/com/nextcloud/talk/activities/DeepLinkRoutingViewModel.kt index b3952f94209..690c868daa9 100644 --- a/app/src/main/java/com/nextcloud/talk/activities/DeepLinkRoutingViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/activities/DeepLinkRoutingViewModel.kt @@ -309,14 +309,21 @@ class DeepLinkRoutingViewModel @Inject constructor( conversationsRepository.syncRooms(user, roomListTimeoutMillis = ROOM_SYNC_TIMEOUT_MS) != null && isStored() - /** Opens the one-to-one chat of [account] with the contact [contactUserId], or else stays in the list. */ + /** + * 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) - openOrLogin(roomToken?.let { Destination.Chat(account.id!!, it) } ?: Destination.ConversationList) + 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 on failure. */ - @Suppress("TooGenericExceptionCaught") + /** 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? { val apiVersion = ApiUtils.getConversationApiVersion(account, intArrayOf(ApiUtils.API_V4, 1)) val retrofitBucket = ApiUtils.getRetrofitBucketForCreateRoom( @@ -325,18 +332,11 @@ class DeepLinkRoutingViewModel @Inject constructor( roomType = "1", invite = userId ) - return try { - ncApiCoroutines.createRoom( - ApiUtils.getCredentials(account.username, account.token), - retrofitBucket.url, - retrofitBucket.queryMap - ).ocs?.data?.token - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - logger.e(TAG, "Error creating room", e) - null - } + return ncApiCoroutines.createRoom( + ApiUtils.getCredentials(account.username, account.token), + retrofitBucket.url, + retrofitBucket.queryMap + ).ocs?.data?.token?.takeIf { it.isNotBlank() } } companion object { diff --git a/app/src/test/java/com/nextcloud/talk/activities/DeepLinkRoutingViewModelTest.kt b/app/src/test/java/com/nextcloud/talk/activities/DeepLinkRoutingViewModelTest.kt index b7cbd406520..b7f15ddc9ea 100644 --- a/app/src/test/java/com/nextcloud/talk/activities/DeepLinkRoutingViewModelTest.kt +++ b/app/src/test/java/com/nextcloud/talk/activities/DeepLinkRoutingViewModelTest.kt @@ -16,6 +16,7 @@ import android.database.sqlite.SQLiteException import android.net.Uri import android.provider.ContactsContract import com.nextcloud.talk.R +import com.nextcloud.talk.api.NcApiCoroutines import com.nextcloud.talk.conversationlist.ConversationsListActivity import com.nextcloud.talk.activities.DeepLinkRoutingViewModel.Destination import com.nextcloud.talk.activities.DeepLinkRoutingViewModel.Request @@ -23,6 +24,11 @@ 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.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.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 @@ -53,6 +59,7 @@ 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 @@ -99,13 +106,14 @@ class DeepLinkRoutingViewModelTest { private fun createViewModel( repository: OfflineConversationsRepository, - userManager: UserManager = this.userManager + userManager: UserManager = this.userManager, + ncApiCoroutines: NcApiCoroutines = mock() ) = DeepLinkRoutingViewModel( context = RuntimeEnvironment.getApplication(), userManager = userManager, defaultAccountProvider = defaultAccountProvider, conversationsRepository = repository, - ncApiCoroutines = mock(), + ncApiCoroutines = ncApiCoroutines, logger = mock() ).also { it.ioDispatcher = dispatcher } @@ -348,6 +356,77 @@ class DeepLinkRoutingViewModelTest { 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(api: NcApiCoroutines): 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, api) + val contactData = MatrixCursor(arrayOf(ContactsContract.Data.DATA1)).apply { + addRow(arrayOf("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 api = mock { + onBlocking { createRoom(any(), any(), any()) } doReturn + RoomOverall(RoomOCS(meta = null, data = ConversationDto(token = TOKEN))) + } + + assertEquals( + RoutingState.Open(Destination.Chat(alice.id!!, TOKEN), isLongMessage = true), + confirmContactChat(api) + ) + } + + @Test + fun `a confirmed contact chat that can't be created shows an error`() { + val api = mock { + onBlocking { createRoom(any(), any(), any()) } doSuspendableAnswer { + throw java.io.IOException("offline") + } + } + + assertEquals(routingError, confirmContactChat(api)) + } + + @Test + fun `a confirmed contact chat without a conversation in the response shows an error`() { + val api = mock { + onBlocking { createRoom(any(), any(), any()) } doReturn RoomOverall(RoomOCS(meta = null)) + } + + assertEquals(routingError, confirmContactChat(api)) + } + + @Test + fun `a confirmed contact chat with a conversation without a token shows an error`() { + val api = mock { + onBlocking { createRoom(any(), any(), any()) } doReturn + RoomOverall(RoomOCS(meta = null, data = ConversationDto())) + } + + assertEquals(routingError, confirmContactChat(api)) + } + // alice is still the active account, but scheduled for deletion, so only bob is left private val userManagerWithAliceDeleted = mock { on { getUsersNotScheduledForDeletion() } doReturn listOf(bob) From 5e556cc3991671226a657f680d2fd63419824f8e Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Mon, 2 Nov 2026 15:28:44 +0100 Subject: [PATCH 22/75] fix(list): set up the push registration once per start of the app The list is no longer a single singleTask activity, so the setup meant for once per start of the app, the push registration and the key of the screen lock, ran for every new list, e.g. for a notification, a share or another account. Run it once per process. Assisted-by: Claude Code:claude-opus-5-5 Signed-off-by: Marcel Hibbe --- .../conversationlist/ConversationsListActivity.kt | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) 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 13ea5d477cc..aafd8850b85 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt @@ -285,9 +285,12 @@ class ConversationsListActivity : BaseActivity() { intent = intentOfAccount(this, intent, currentUser.id) if (savedInstanceState == null) { AppTasks.removeTasksLeftInRecents(this) + } + if (!isAppStartSetUpDone) { + isAppStartSetUpDone = true setUpPushRegistration() - // once per start of the app: a key that became invalid, e.g. after the lock screen of the device was - // reset, is created anew when it is checked + // 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) } @@ -1841,6 +1844,10 @@ class ConversationsListActivity : BaseActivity() { // once per process: the shortcuts without a deep link 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. From 98a90a0de11a2a899b5a5d5228a074b29405432c Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Mon, 2 Nov 2026 15:28:44 +0100 Subject: [PATCH 23/75] fix(contacts): only open the chat of a contact row of Talk The user id of a contact chat was read from any data row of the passed uri, e.g. the email address of a contact, which has the form of a user id at a server as well. Read it only from a row of Talk's contact chat type. Assisted-by: Claude Code:claude-opus-5-5 Signed-off-by: Marcel Hibbe --- .../activities/DeepLinkRoutingViewModel.kt | 8 +++-- .../DeepLinkRoutingViewModelTest.kt | 31 +++++++++++++------ 2 files changed, 27 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/activities/DeepLinkRoutingViewModel.kt b/app/src/main/java/com/nextcloud/talk/activities/DeepLinkRoutingViewModel.kt index 690c868daa9..39d3d4688fd 100644 --- a/app/src/main/java/com/nextcloud/talk/activities/DeepLinkRoutingViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/activities/DeepLinkRoutingViewModel.kt @@ -273,12 +273,16 @@ class DeepLinkRoutingViewModel @Inject constructor( // 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 or an unknown path + // 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()) { + 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() } diff --git a/app/src/test/java/com/nextcloud/talk/activities/DeepLinkRoutingViewModelTest.kt b/app/src/test/java/com/nextcloud/talk/activities/DeepLinkRoutingViewModelTest.kt index b7f15ddc9ea..e75ae1e9774 100644 --- a/app/src/test/java/com/nextcloud/talk/activities/DeepLinkRoutingViewModelTest.kt +++ b/app/src/test/java/com/nextcloud/talk/activities/DeepLinkRoutingViewModelTest.kt @@ -296,6 +296,22 @@ class DeepLinkRoutingViewModelTest { 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"))) @@ -310,7 +326,7 @@ class DeepLinkRoutingViewModelTest { @Test fun `a contact without a user id opens the start screen`() { - val contactData = MatrixCursor(arrayOf(ContactsContract.Data.DATA1)).apply { addRow(arrayOf(null)) } + val contactData = talkContactRow(cloudId = null) assertEquals(noAccountForContact, routeContactChatWith(cursor = contactData)) } @@ -344,9 +360,7 @@ class DeepLinkRoutingViewModelTest { on { syncRooms(any(), any(), anyOrNull()) } doReturn emptyList() } viewModel = createViewModel(repository, failingUserManager) - val contactData = MatrixCursor(arrayOf(ContactsContract.Data.DATA1)).apply { - addRow(arrayOf("carol@cloud.example.com")) - } + val contactData = talkContactRow("carol@cloud.example.com") val question = routeContactChatWith(contactData) assertEquals(RoutingState.AskToStartContactChat("carol@cloud.example.com"), question) @@ -374,9 +388,7 @@ class DeepLinkRoutingViewModelTest { on { syncRooms(any(), any(), anyOrNull()) } doReturn emptyList() } viewModel = createViewModel(repository, userManager, api) - val contactData = MatrixCursor(arrayOf(ContactsContract.Data.DATA1)).apply { - addRow(arrayOf("carol@cloud.example.com")) - } + val contactData = talkContactRow("carol@cloud.example.com") assertEquals(RoutingState.AskToStartContactChat("carol@cloud.example.com"), routeContactChatWith(contactData)) viewModel.confirm() @@ -453,15 +465,14 @@ class DeepLinkRoutingViewModelTest { @Test fun `a contact chat doesn't open with an account scheduled for deletion`() { viewModel = createViewModel(conversationsRepository, userManagerWithAliceDeleted) - val contactData = MatrixCursor(arrayOf(ContactsContract.Data.DATA1)).apply { - addRow(arrayOf("carol@cloud.example.com")) - } + 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 } } From fd42bfecefc32ea63d53564790a6d8f5350948f3 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Mon, 2 Nov 2026 15:42:55 +0100 Subject: [PATCH 24/75] fix(list): ignore a new intent of a list that wasn't set up A list that finishes in onCreate, e.g. for a launcher tap on a task of the app or an intent it passes on, has neither its account nor its deep link routing. The system doesn't pass new intents to a finishing activity; guard onNewIntent anyway instead of reading them uninitialized. Assisted-by: Claude Code:claude-opus-5-5 Signed-off-by: Marcel Hibbe --- .../talk/conversationlist/ConversationsListActivity.kt | 3 +++ 1 file changed, 3 insertions(+) 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 aafd8850b85..3b338498b94 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt @@ -420,6 +420,9 @@ class ConversationsListActivity : BaseActivity() { 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 From a2b5236b944d930917d536a541596d883254a078 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Mon, 2 Nov 2026 15:42:56 +0100 Subject: [PATCH 25/75] fix(share): keep the copies of shared files out of the cache The system clears the cache when the storage runs low, also while the uploads of the copies still wait for the network. Keep the copies in the files directory instead, which the app prunes itself, and exclude them from backups. Assisted-by: Claude Code:claude-opus-5-5 Signed-off-by: Marcel Hibbe --- .../ShareListIntentFactory.kt | 4 +-- .../com/nextcloud/talk/utils/FileUtils.kt | 30 +++++++++++-------- .../nextcloud/talk/utils/SharedFileCopies.kt | 10 ++++--- app/src/main/res/xml/backup_config.xml | 4 +++ app/src/main/res/xml/file_provider_paths.xml | 2 +- .../ShareListIntentFactoryTest.kt | 6 ++-- .../talk/utils/FileUtilsGetFileFromUriTest.kt | 24 +++++++++++---- .../talk/utils/FileUtilsShareUriTest.kt | 5 +++- .../com/nextcloud/talk/utils/FileUtilsTest.kt | 24 ++++++++++----- .../talk/utils/SharedFileCopiesTest.kt | 4 +-- 10 files changed, 75 insertions(+), 38 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/ShareListIntentFactory.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/ShareListIntentFactory.kt index df8dfcf4a64..9065fc91e03 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ShareListIntentFactory.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ShareListIntentFactory.kt @@ -98,7 +98,7 @@ internal class ShareListIntentFactory( currentCoroutineContext().ensureActive() when { item.uri != null -> - copyToCache(item.uri) { directoryLazy.value?.let { File(it, index.toString()) } } + copyToShareDirectory(item.uri) { directoryLazy.value?.let { File(it, index.toString()) } } ?.let { ClipData.Item(it) } item.text != null -> ClipData.Item(item.text) else -> null @@ -123,7 +123,7 @@ internal class ShareListIntentFactory( * it can't be used. */ @Suppress("Detekt.TooGenericExceptionCaught") - private suspend fun copyToCache(uri: Uri, directoryOf: () -> File?): Uri? { + 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 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 01e343da8de..954257bc24b 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/FileUtils.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/FileUtils.kt @@ -30,9 +30,9 @@ object FileUtils { 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 + // 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" - private val SHARED_PROVIDER_DIRECTORIES = setOf(SHARED_ATTACHMENTS_DIRECTORY, SHARED_FILE_COPIES_DIRECTORY) /** * Returns the dedicated cache directory used for externally shared attachments. @@ -69,13 +69,17 @@ object FileUtils { } /** - * The file in [cacheDir] that the [pathSegments] of an own FileProvider uri point to, if it is inside the - * dedicated shared attachments cache directory or the one of the copies of shared files, else null. + * 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 sharedAttachmentProviderFile(cacheDir: File, pathSegments: List): File? { - val directory = pathSegments.firstOrNull()?.takeIf { it in SHARED_PROVIDER_DIRECTORIES } - if (pathSegments.size < 2 || directory == null) return null - var resolvedFile: File? = File(cacheDir, 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) } } @@ -88,7 +92,7 @@ object FileUtils { * of shared files of its FileProvider. */ fun isTrustedShareUri(uri: Uri?, context: Context): Boolean = - isTrustedShareUri(uri, context.packageName, context.cacheDir) { authority -> + isTrustedShareUri(uri, context.packageName, context.cacheDir, context.filesDir) { authority -> context.packageManager.resolveContentProvider(authority, 0)?.packageName } @@ -102,13 +106,15 @@ object FileUtils { uri: Uri?, packageName: String, cacheDir: File, + filesDir: File, packageOfAuthority: (String) -> String? ): Boolean { val contentUri = uri?.takeIf { ContentResolver.SCHEME_CONTENT == it.scheme } val authority = contentUri?.authority?.substringAfterLast('@') ?: return false val isOwnProvider = authority == packageName || packageOfAuthority(authority) == packageName val isSharedFileOfFileProvider = - authority == packageName && sharedAttachmentProviderFile(cacheDir, contentUri.pathSegments) != null + authority == packageName && + sharedAttachmentProviderFile(cacheDir, filesDir, contentUri.pathSegments) != null return !isOwnProvider || isSharedFileOfFileProvider } @@ -199,9 +205,9 @@ object FileUtils { Log.d(TAG, "relative uri: " + sourceFileUri.path) throw IllegalArgumentException("relative paths are not supported") } else if (ContentResolver.SCHEME_CONTENT == scheme) { - // a shared file the app already copied into its cache is used as it is, instead of copying it again + // a shared file the app already copied is used as it is, instead of copying it again val ownSharedFile = sourceFileUri.takeIf { it.authority == context.packageName } - ?.let { sharedAttachmentProviderFile(context.cacheDir, it.pathSegments) } + ?.let { sharedAttachmentProviderFile(context.cacheDir, context.filesDir, it.pathSegments) } ?.takeIf { it.isFile } ownSharedFile ?: copyFileToCache(context, sourceFileUri, fileName) } else if (ContentResolver.SCHEME_FILE == scheme) { diff --git a/app/src/main/java/com/nextcloud/talk/utils/SharedFileCopies.kt b/app/src/main/java/com/nextcloud/talk/utils/SharedFileCopies.kt index 71a8b84311c..cb7f6482203 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/SharedFileCopies.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/SharedFileCopies.kt @@ -18,13 +18,15 @@ 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 cache directory - * of their own, which [FileUtils.isTrustedShareUri] trusts like the shared attachments directory. That one isn't used, + * 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. + * [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 { @@ -87,7 +89,7 @@ object SharedFileCopies { return if (isCopy) segments[SHARE_DIRECTORY_SEGMENT] else null } - private fun sharesDirectory(context: Context) = File(context.cacheDir, FileUtils.SHARED_FILE_COPIES_DIRECTORY) + private fun sharesDirectory(context: Context) = File(context.filesDir, FileUtils.SHARED_FILE_COPIES_DIRECTORY) private fun hasUnfinishedUpload(context: Context, directoryName: String): Boolean = try { 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/file_provider_paths.xml b/app/src/main/res/xml/file_provider_paths.xml index 16c2712ce00..22ed29e5515 100644 --- a/app/src/main/res/xml/file_provider_paths.xml +++ b/app/src/main/res/xml/file_provider_paths.xml @@ -8,7 +8,7 @@ - Date: Mon, 2 Nov 2026 15:51:50 +0100 Subject: [PATCH 26/75] refactor(notifications): use isStartedThrough for the conversation list intent Assisted-by: Claude Code:claude-opus-5-5 Signed-off-by: Marcel Hibbe --- .../main/java/com/nextcloud/talk/jobs/NotificationWorker.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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 457898111ea..b21b1ad4b92 100644 --- a/app/src/main/java/com/nextcloud/talk/jobs/NotificationWorker.kt +++ b/app/src/main/java/com/nextcloud/talk/jobs/NotificationWorker.kt @@ -1286,8 +1286,7 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor // See https://github.com/nextcloud/talk-android/issues/2111 val requestCode = System.currentTimeMillis().toInt() val intentFlag = pendingIntentFlags() - val isConversationList = - intent.component?.className == InternalEntryPoint.nameOf(ConversationsListActivity::class.java) + 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 From c56d87ed13808264557ed9fa30a203cb6c5a551d Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Mon, 2 Nov 2026 15:51:51 +0100 Subject: [PATCH 27/75] refactor(contacts): create the chat with a contact through ContactsRepository The routing of contact chats built the create room request itself, as ContactsRepository.createRoom already does. Assisted-by: Claude Code:claude-opus-5-5 Signed-off-by: Marcel Hibbe --- .../activities/DeepLinkRoutingViewModel.kt | 26 ++++++-------- .../DeepLinkRoutingViewModelTest.kt | 35 ++++++++++--------- 2 files changed, 28 insertions(+), 33 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/activities/DeepLinkRoutingViewModel.kt b/app/src/main/java/com/nextcloud/talk/activities/DeepLinkRoutingViewModel.kt index 39d3d4688fd..06b9d605354 100644 --- a/app/src/main/java/com/nextcloud/talk/activities/DeepLinkRoutingViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/activities/DeepLinkRoutingViewModel.kt @@ -17,7 +17,7 @@ import androidx.core.net.toUri import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.nextcloud.talk.R -import com.nextcloud.talk.api.NcApiCoroutines +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 @@ -26,7 +26,6 @@ import com.nextcloud.talk.logger.Logger 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.ApiUtils import com.nextcloud.talk.utils.DeepLinkHandler import com.nextcloud.talk.utils.InternalEntryPoint import com.nextcloud.talk.utils.ShortcutManagerHelper @@ -55,7 +54,7 @@ class DeepLinkRoutingViewModel @Inject constructor( private val userManager: UserManager, private val defaultAccountProvider: DefaultAccountProvider, private val conversationsRepository: OfflineConversationsRepository, - private val ncApiCoroutines: NcApiCoroutines, + private val contactsRepository: ContactsRepository, private val logger: Logger ) : ViewModel() { @@ -328,25 +327,20 @@ class DeepLinkRoutingViewModel @Inject constructor( } /** 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? { - val apiVersion = ApiUtils.getConversationApiVersion(account, intArrayOf(ApiUtils.API_V4, 1)) - val retrofitBucket = ApiUtils.getRetrofitBucketForCreateRoom( - version = apiVersion, - baseUrl = account.baseUrl!!, - roomType = "1", - invite = userId - ) - return ncApiCoroutines.createRoom( - ApiUtils.getCredentials(account.username, account.token), - retrofitBucket.url, - retrofitBucket.queryMap + 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" diff --git a/app/src/test/java/com/nextcloud/talk/activities/DeepLinkRoutingViewModelTest.kt b/app/src/test/java/com/nextcloud/talk/activities/DeepLinkRoutingViewModelTest.kt index e75ae1e9774..615f47c257e 100644 --- a/app/src/test/java/com/nextcloud/talk/activities/DeepLinkRoutingViewModelTest.kt +++ b/app/src/test/java/com/nextcloud/talk/activities/DeepLinkRoutingViewModelTest.kt @@ -16,7 +16,7 @@ import android.database.sqlite.SQLiteException import android.net.Uri import android.provider.ContactsContract import com.nextcloud.talk.R -import com.nextcloud.talk.api.NcApiCoroutines +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 @@ -107,13 +107,13 @@ class DeepLinkRoutingViewModelTest { private fun createViewModel( repository: OfflineConversationsRepository, userManager: UserManager = this.userManager, - ncApiCoroutines: NcApiCoroutines = mock() + contactsRepository: ContactsRepository = mock() ) = DeepLinkRoutingViewModel( context = RuntimeEnvironment.getApplication(), userManager = userManager, defaultAccountProvider = defaultAccountProvider, conversationsRepository = repository, - ncApiCoroutines = ncApiCoroutines, + contactsRepository = contactsRepository, logger = mock() ).also { it.ioDispatcher = dispatcher } @@ -378,7 +378,7 @@ class DeepLinkRoutingViewModelTest { } ) - private fun confirmContactChat(api: NcApiCoroutines): RoutingState { + private fun confirmContactChat(contacts: ContactsRepository): RoutingState { val userManager = mock { on { getUsersNotScheduledForDeletion() } doReturn listOf(aliceWithConversations) } @@ -387,7 +387,7 @@ class DeepLinkRoutingViewModelTest { on { roomListFlow(any()) } doReturn flowOf(emptyList()) on { syncRooms(any(), any(), anyOrNull()) } doReturn emptyList() } - viewModel = createViewModel(repository, userManager, api) + viewModel = createViewModel(repository, userManager, contacts) val contactData = talkContactRow("carol@cloud.example.com") assertEquals(RoutingState.AskToStartContactChat("carol@cloud.example.com"), routeContactChatWith(contactData)) @@ -398,45 +398,46 @@ class DeepLinkRoutingViewModelTest { @Test fun `a confirmed contact chat opens the created conversation`() { - val api = mock { - onBlocking { createRoom(any(), any(), any()) } doReturn + 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(api) + confirmContactChat(contacts) ) } @Test fun `a confirmed contact chat that can't be created shows an error`() { - val api = mock { - onBlocking { createRoom(any(), any(), any()) } doSuspendableAnswer { + val contacts = mock { + onBlocking { createRoom(any(), any(), anyOrNull(), any(), anyOrNull()) } doSuspendableAnswer { throw java.io.IOException("offline") } } - assertEquals(routingError, confirmContactChat(api)) + assertEquals(routingError, confirmContactChat(contacts)) } @Test fun `a confirmed contact chat without a conversation in the response shows an error`() { - val api = mock { - onBlocking { createRoom(any(), any(), any()) } doReturn RoomOverall(RoomOCS(meta = null)) + val contacts = mock { + onBlocking { createRoom(any(), any(), anyOrNull(), any(), anyOrNull()) } doReturn + RoomOverall(RoomOCS(meta = null)) } - assertEquals(routingError, confirmContactChat(api)) + assertEquals(routingError, confirmContactChat(contacts)) } @Test fun `a confirmed contact chat with a conversation without a token shows an error`() { - val api = mock { - onBlocking { createRoom(any(), any(), any()) } doReturn + val contacts = mock { + onBlocking { createRoom(any(), any(), anyOrNull(), any(), anyOrNull()) } doReturn RoomOverall(RoomOCS(meta = null, data = ConversationDto())) } - assertEquals(routingError, confirmContactChat(api)) + assertEquals(routingError, confirmContactChat(contacts)) } // alice is still the active account, but scheduled for deletion, so only bob is left From 8e7c218f48dc19b0e8389404a72916ac31a72174 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Mon, 2 Nov 2026 15:51:51 +0100 Subject: [PATCH 28/75] refactor(list): leave the login without an account to BaseActivity setUpBoundUserOrFinish already opens the login when there is no account, so the list doesn't read the accounts on the main thread for it beforehand. Assisted-by: Claude Code:claude-opus-5-5 Signed-off-by: Marcel Hibbe --- .../talk/conversationlist/ConversationsListActivity.kt | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) 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 3b338498b94..973c9bcb7ca 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt @@ -360,16 +360,14 @@ class ConversationsListActivity : BaseActivity() { AppTasks.isRunningAppTask(this, taskId) /** - * Passes the intent of another app on, or opens the login without an account, finishes this list and returns true - * if it is not shown for its intent. + * 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) - runBlocking { userManager.getUsersNotScheduledForDeletion() }.isEmpty() -> - ServerSelectionActivity.createLoginIntent(this) else -> null } ?: return false startActivity(otherIntent) From 08c027ffc389da09fe2d3d0fc800281dde777961 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Mon, 2 Nov 2026 15:51:51 +0100 Subject: [PATCH 29/75] refactor(list): move the setup once per process out of onCreate Assisted-by: Claude Code:claude-opus-5-5 Signed-off-by: Marcel Hibbe --- .../ConversationsListActivity.kt | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) 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 973c9bcb7ca..53a94cda608 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt @@ -286,15 +286,7 @@ class ConversationsListActivity : BaseActivity() { if (savedInstanceState == null) { AppTasks.removeTasksLeftInRecents(this) } - if (!isAppStartSetUpDone) { - 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) - } - } + setUpOncePerProcess() ecosystemManager = EcosystemManager(this@ConversationsListActivity) chatAudioPlayer = ChatAudioPlayer(this, chatAudioStore, lifecycleScope) lifecycle.addObserver(chatAudioPlayer) @@ -376,6 +368,17 @@ class ConversationsListActivity : BaseActivity() { 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) { From 955243bb9f6b32c61cf119bfb3810204a2020b26 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Mon, 2 Nov 2026 16:10:10 +0100 Subject: [PATCH 30/75] fix(chat): only open a chat with a valid room token The room token of the chat intent was checked with the token pattern where it enters the app, but not by the chat itself. A chat started without a valid token now finishes instead of crashing, and a room switch only goes to a valid token. Assisted-by: Claude Code:claude-opus-5-5 Signed-off-by: Marcel Hibbe --- app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) 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 b4b138ce3b1..ae6cb75479d 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 @@ -565,6 +566,11 @@ class ChatActivity : // Not loaded in a coroutine: initData() must have run before onResume() (activity and ChatViewModel), // and registerForActivityResult() must be called before the activity is started. initialUser = setUpBoundUserOrFinish() ?: return + if (intent.getStringExtra(KEY_ROOM_TOKEN)?.let(DeepLinkHandler::isValidToken) != true) { + Log.e(TAG, "Started without a valid room token") + finish() + return + } chatAudioPlayer = ChatAudioPlayer(this, chatAudioStore, lifecycleScope) lifecycle.addObserver(chatAudioPlayer) @@ -1278,7 +1284,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 { From b36f9833f868ecff6603d96266dbfb6446833ce4 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Mon, 2 Nov 2026 16:47:24 +0100 Subject: [PATCH 31/75] fix(shortcuts): open the conversation of a shortcut also when the app runs A conversation shortcut was started with FLAG_ACTIVITY_NEW_TASK only, so a task that an earlier shortcut started was just brought to the front, without the intent of the new shortcut, and only the conversation list was shown. Start it with CLEAR_TOP and SINGLE_TOP as well, so the list on top of that task gets the intent in onNewIntent. Assisted-by: Claude Code:claude-opus-5-5 Signed-off-by: Marcel Hibbe --- .../java/com/nextcloud/talk/utils/ShortcutManagerHelper.kt | 3 +++ .../com/nextcloud/talk/utils/ShortcutManagerHelperTest.kt | 4 ++++ 2 files changed, 7 insertions(+) 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 257e7f94e8c..fcd99ee5a62 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/ShortcutManagerHelper.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/ShortcutManagerHelper.kt @@ -250,6 +250,9 @@ object ShortcutManagerHelper { 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) } } diff --git a/app/src/test/java/com/nextcloud/talk/utils/ShortcutManagerHelperTest.kt b/app/src/test/java/com/nextcloud/talk/utils/ShortcutManagerHelperTest.kt index 079f814e021..9d88c5e8db5 100644 --- a/app/src/test/java/com/nextcloud/talk/utils/ShortcutManagerHelperTest.kt +++ b/app/src/test/java/com/nextcloud/talk/utils/ShortcutManagerHelperTest.kt @@ -72,6 +72,10 @@ class ShortcutManagerHelperTest { 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 From d7e35ff40c280f7f72c236f2528edbe54cae45bb Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Mon, 2 Nov 2026 19:26:28 +0100 Subject: [PATCH 32/75] fix(chat): don't crash when a chat without a room token is closed The token was checked after the user was set up, so onDestroy() read the missing room token for its log and created the view model of the chat. Assisted-by: Claude Code:claude-opus-5-5 Signed-off-by: Marcel Hibbe --- app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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 ae6cb75479d..693c4d60d6a 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt @@ -565,12 +565,13 @@ 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. - initialUser = setUpBoundUserOrFinish() ?: return + // 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) lifecycle.addObserver(chatAudioPlayer) @@ -2897,9 +2898,9 @@ class ChatActivity : override fun onDestroy() { super.onDestroy() - logConversationInfos("onDestroy") if (::initialUser.isInitialized) { + logConversationInfos("onDestroy") chatViewModel.leaveRoomViewState.removeObserver(leaveRoomObserver) } From c56208316c9da95fd680ca673b8f79d490c19d4d Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Mon, 2 Nov 2026 19:27:01 +0100 Subject: [PATCH 33/75] fix(lock): leave the app with Back on the lock screen Back closed the lock screen, and the screen below, still locked, opened it again right away, so the app couldn't be left with Back. Back now moves the task of the app to the back. In the task of a share, also one of another app, it leaves only the screens of the app, which leaves the share instead of hiding it where it can't be reached, as it is kept out of the recents. In the task of another app, the screens of the app are finished one by one: they have an empty task affinity, so finishAffinity() would also finish the screens of that app below that have one. The app keeps track of its activities for that. Assisted-by: Claude Code:claude-opus-5-5 Signed-off-by: Marcel Hibbe --- .../application/NextcloudTalkApplication.kt | 4 ++ .../com/nextcloud/talk/lock/LockedActivity.kt | 19 +++++++ .../java/com/nextcloud/talk/utils/AppTasks.kt | 22 ++++---- .../nextcloud/talk/utils/LiveActivities.kt | 47 ++++++++++++++++ .../talk/utils/LiveActivitiesTest.kt | 53 +++++++++++++++++++ 5 files changed, 136 insertions(+), 9 deletions(-) create mode 100644 app/src/main/java/com/nextcloud/talk/utils/LiveActivities.kt create mode 100644 app/src/test/java/com/nextcloud/talk/utils/LiveActivitiesTest.kt 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 790f528fb37..144c33aca43 100644 --- a/app/src/main/java/com/nextcloud/talk/application/NextcloudTalkApplication.kt +++ b/app/src/main/java/com/nextcloud/talk/application/NextcloudTalkApplication.kt @@ -59,6 +59,7 @@ 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.database.arbitrarystorage.ArbitraryStorageModule @@ -121,6 +122,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 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 17b0d96a488..26ed1a48b2d 100644 --- a/app/src/main/java/com/nextcloud/talk/lock/LockedActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/lock/LockedActivity.kt @@ -9,11 +9,13 @@ 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 @@ -24,7 +26,10 @@ 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 com.nextcloud.talk.utils.taskIdCompat +import com.nextcloud.talk.utils.taskInfoOrNull import java.util.concurrent.Executor import java.util.concurrent.Executors import javax.inject.Inject @@ -53,6 +58,20 @@ class LockedActivity : AppCompatActivity() { binding = ActivityLockedBinding.inflate(layoutInflater) setContentView(binding.root) + + // Back 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. + onBackPressedDispatcher.addCallback(this) { + 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() { diff --git a/app/src/main/java/com/nextcloud/talk/utils/AppTasks.kt b/app/src/main/java/com/nextcloud/talk/utils/AppTasks.kt index 58f0b03f76c..1ad6095f011 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/AppTasks.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/AppTasks.kt @@ -13,6 +13,7 @@ import android.content.Intent import android.os.Build import android.util.Log import com.nextcloud.talk.conversationlist.ConversationsListActivity +import com.nextcloud.talk.conversationlist.ShareActivity import com.nextcloud.talk.utils.bundle.BundleKeys // A task listed by ActivityManager.getAppTasks can be removed before it is used, e.g. when it is swiped from the @@ -174,18 +175,21 @@ object AppTasks { ): List = appTasks.filter { task -> val info = task.taskInfoOrNull() - info != null && rootOf(info)?.className in roots && predicate(info) + info != null && info.rootActivity?.className in roots && predicate(info) } - - /** - * The activity that the root of the task of [info] 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. - */ - private fun rootOf(info: ActivityManager.RecentTaskInfo): ComponentName? = - info.origActivity ?: info.baseIntent.component } +/** + * 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. + */ +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 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/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() + } +} From b85497761fbeccb7ce5675f672c6ef518d432e5e Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Mon, 2 Nov 2026 19:28:28 +0100 Subject: [PATCH 34/75] fix(shortcuts): open the conversation of a shortcut once it is in the list The token of a shortcut was dropped with the first room list, so the conversation of a shortcut that isn't stored yet, e.g. a new one, wasn't opened when the sync added it. It is now kept until the list is stopped, so also while it is only paused, e.g. by the request for the notification permission, and looked up in the room list the view model already shares. Assisted-by: Claude Code:claude-opus-5-5 Signed-off-by: Marcel Hibbe --- .../ConversationsListActivity.kt | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) 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 53a94cda608..9a221adc444 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt @@ -664,6 +664,15 @@ class ConversationsListActivity : BaseActivity() { conversationTagsViewModel.loadConversationTags() } + override fun onStop() { + super.onStop() + // 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) { + pendingDirectShareToken = null + } + } + override fun onPause() { super.onPause() conversationListLazyListState?.let { state -> @@ -723,14 +732,17 @@ class ConversationsListActivity : BaseActivity() { // Direct Share: the conversation of a share sheet or home screen shortcut opens only while the list is resumed // and not covered by the lock screen it started, as the chat would otherwise show above the lock screen. After - // unlocking, the list is resumed again and the room list emitted anew, so the conversation opens then. + // unlocking, the list is resumed again and the room list emitted anew, so the conversation opens then. A + // conversation that isn't stored yet, e.g. a new one, opens once the sync has added it to the list. lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.RESUMED) { - conversationsListViewModel.getRoomsFlow.collect { list -> + // the shared room list, which isn't queried again on every resume + conversationsListViewModel.getRoomsStateFlow.collect { list -> val token = pendingDirectShareToken - if (token != null && !isCoveredByLockScreen) { + val conversation = list.firstOrNull { it.token == token } + if (conversation != null && !isCoveredByLockScreen) { pendingDirectShareToken = null - list.firstOrNull { it.token == token }?.let { handleConversation(it) } + handleConversation(conversation) } } } From f0758039c485ebe675e80ce93e3791202a2158e0 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Mon, 2 Nov 2026 19:29:03 +0100 Subject: [PATCH 35/75] refactor(lock): remove the unused opt-out of the lock check No screen skips the lock check anymore since MainActivity was removed. The comment no longer names Back, which leaves the app on the lock screen now. Assisted-by: Claude Code:claude-opus-5-5 Signed-off-by: Marcel Hibbe --- .../nextcloud/talk/activities/BaseActivity.kt | 28 ++++++++----------- 1 file changed, 12 insertions(+), 16 deletions(-) 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 bad238f5c91..935a1776800 100644 --- a/app/src/main/java/com/nextcloud/talk/activities/BaseActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/activities/BaseActivity.kt @@ -247,8 +247,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() @@ -259,20 +257,18 @@ open class BaseActivity : AppCompatActivity() { "activity=${this.javaClass.simpleName}" ) isCoveredByLockScreen = false - if (!skipLockCheckOnResume) { - // 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) or by Back. It is unlocked only by authenticating. - NextcloudTalkApplication.pendingLockCheck = false - showLockScreen() - } else if (NextcloudTalkApplication.pendingLockCheck) { - NextcloudTalkApplication.pendingLockCheck = false - lockScreenIfConditionsApply() - } + // 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() } if (appPreferences.isKeyboardIncognito) { From e544e6c09f7dd49e249e22f5edb04009b5092a7e Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Mon, 2 Nov 2026 19:32:27 +0100 Subject: [PATCH 36/75] fix(share): leave a share that is left while its files are copied ShareActivity waited for being shown again, but its task is kept out of the recents, so a share left with Home could never be continued. It is now left then. The copies are also removed when ShareActivity is closed before it opened the list, or the copying is cancelled right after the last file. Assisted-by: Claude Code:claude-opus-5-5 Signed-off-by: Marcel Hibbe --- .../FileAttachmentPreviewFragment.kt | 5 +- .../ConversationsListActivity.kt | 9 +--- .../talk/conversationlist/ShareActivity.kt | 31 ++++++++---- .../ShareListIntentFactory.kt | 47 +++++++++++-------- .../nextcloud/talk/utils/SharedFileCopies.kt | 9 ++++ 5 files changed, 61 insertions(+), 40 deletions(-) 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 6dc2c19c5ca..f3cae875233 100644 --- a/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewFragment.kt +++ b/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewFragment.kt @@ -29,7 +29,6 @@ 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 kotlinx.coroutines.launch import javax.inject.Inject @AutoInjector(NextcloudTalkApplication::class) @@ -143,9 +142,7 @@ class FileAttachmentPreviewFragment : DialogFragment() { // Files shared by another app that are left here, unsent, remove their copies, see SharedFileCopies. Not when // the dialog is only shown again after a configuration change. if (!isSent && activity?.isChangingConfigurations != true) { - val application = NextcloudTalkApplication.sharedApplication!! - val files = filesList.toList() - application.appScope.launch { SharedFileCopies.removeSharesOf(application, files) } + SharedFileCopies.removeSharesLater(filesList.toList()) } } 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 9a221adc444..74b01aa5d24 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt @@ -1059,14 +1059,7 @@ class ConversationsListActivity : BaseActivity() { // 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 if (isFinishing && isShare(intent) && !isSharePassedOn && !isRelaunching) { - val clip = intent.clipData - val sharedUris = clip?.let { (0 until it.itemCount).mapNotNull { index -> it.getItemAt(index).uri } } - if (!sharedUris.isNullOrEmpty()) { - val application = NextcloudTalkApplication.sharedApplication!! - application.appScope.launch { - SharedFileCopies.removeSharesOf(application, sharedUris.map { it.toString() }) - } - } + SharedFileCopies.removeSharesLater(ShareListIntentFactory.sharedUrisOf(intent)) } } diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/ShareActivity.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/ShareActivity.kt index 9652cf2efb0..cbe50c289f8 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ShareActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ShareActivity.kt @@ -15,11 +15,13 @@ 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 cache of the app, so the chat and the upload don't depend on the read + * 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]. * @@ -45,15 +47,28 @@ class ShareActivity : AppCompatActivity() { ) lifecycleScope.launch { val shareListIntent = ShareListIntentFactory(this@ShareActivity).create(intent) - // left while the files were copied: the list opens when the share 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() + 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() } - 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 index 9065fc91e03..e001eed765b 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ShareListIntentFactory.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ShareListIntentFactory.kt @@ -71,8 +71,17 @@ internal class ShareListIntentFactory( 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 - val items = withContext(Dispatchers.IO) { copySharedItems(sharedIntent) } return if (items.isEmpty()) null else listIntent.apply { clipData = clipDataOf(sharedIntent.type, items) } } @@ -80,33 +89,25 @@ internal class ShareListIntentFactory( ClipData(null, arrayOf(type ?: "*/*"), items.first()).apply { items.drop(1).forEach { addItem(it) } } /** - * The shared texts, and copies of the shared files that may be uploaded, see [FileUtils.isTrustedShareUri]. Stops - * copying when it is cancelled, e.g. when the share is left, and then removes the copies. + * 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): List { - // 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) } + 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 try { - 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 - } + 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 } - } catch (e: CancellationException) { - if (directoryLazy.isInitialized()) directoryLazy.value?.deleteRecursively() - throw e } } @@ -158,6 +159,12 @@ internal class ShareListIntentFactory( 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/utils/SharedFileCopies.kt b/app/src/main/java/com/nextcloud/talk/utils/SharedFileCopies.kt index cb7f6482203..bdb777a899d 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/SharedFileCopies.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/SharedFileCopies.kt @@ -12,6 +12,8 @@ import android.util.Log import androidx.core.net.toUri import androidx.work.WorkManager 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 @@ -75,6 +77,13 @@ object SharedFileCopies { .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 } From ef9f4b07eeb953fe008804313bc1a477fec68503 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Mon, 2 Nov 2026 19:33:51 +0100 Subject: [PATCH 37/75] fix(shortcuts): give the shortcuts of earlier versions a working intent The direct share shortcuts of earlier versions target the conversation list with the room token and user id as extras, which it no longer takes from outside the app, so a pinned or cached one opened only the list. They are now updated like the conversation shortcuts. The update sets no categories, which the system keeps for an updated shortcut: with them, androidx.sharetarget would replace the share target it stores for the share sheet before Android 10 by one without the icon. The shortcuts are updated one by one, so one that can't be updated doesn't stop the others, and an error of an old shortcut doesn't crash the list. When the update fails, e.g. while the user is locked, or the list closes meanwhile, a later list tries again. Assisted-by: Claude Code:claude-opus-5-5 Signed-off-by: Marcel Hibbe --- .../ConversationsListActivity.kt | 14 ++++- .../conversationlist/DirectShareHelper.kt | 2 +- .../talk/utils/ShortcutManagerHelper.kt | 54 +++++++++++++------ .../talk/utils/ShortcutManagerHelperTest.kt | 47 +++++++++++++++- 4 files changed, 95 insertions(+), 22 deletions(-) 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 74b01aa5d24..0de01c50e29 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt @@ -651,8 +651,17 @@ class ConversationsListActivity : BaseActivity() { val users = userManager.getUsers() hasMultipleAccountsState.value = users.size > 1 if (!areShortcutsOfEarlierVersionsUpdated) { + // set before, so another list doesn't update them at the same time, and again after, so a list + // tries again when they couldn't be updated, e.g. while the user was locked, or this list was closed areShortcutsOfEarlierVersionsUpdated = true - withContext(Dispatchers.IO) { ShortcutManagerHelper.updateShortcutsOfEarlierVersions(context, users) } + var isUpdated = false + try { + isUpdated = withContext(Dispatchers.IO) { + ShortcutManagerHelper.updateShortcutsOfEarlierVersions(context, users) + } + } finally { + areShortcutsOfEarlierVersionsUpdated = isUpdated + } } } conversationsListViewModel.setHideRoomToken(intent.getStringExtra(KEY_FORWARD_HIDE_SOURCE_ROOM)) @@ -1850,7 +1859,8 @@ class ConversationsListActivity : BaseActivity() { companion object { private val TAG = ConversationsListActivity::class.java.simpleName - // once per process: the shortcuts without a deep link don't change while the app runs + // 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 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 a6b37d8533b..56c302206d0 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/DirectShareHelper.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/DirectShareHelper.kt @@ -36,7 +36,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_" + const val SHORTCUT_ID_PREFIX = "direct_share_" private const val SHORTCUT_ID_MIN_PARTS = 4 private const val AVATAR_SIZE_PX = 256 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 fcd99ee5a62..c7591913dbe 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/ShortcutManagerHelper.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/ShortcutManagerHelper.kt @@ -17,6 +17,7 @@ import androidx.core.graphics.drawable.IconCompat import com.nextcloud.talk.R 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 @@ -97,31 +98,40 @@ object ShortcutManagerHelper { } /** - * Gives the conversation shortcuts of earlier versions the intent of [createConversationIntent], which opens the - * conversation of exactly their account: those that target the launcher activity, with the room token and user id - * as extras, which it takes from no intent of outside the app, so they open only the conversation list, and those - * with a nextcloudtalk:// deep link, which names only the host and user name of the account. The account and - * conversation are taken from the id of the shortcut. Pinned ones, e.g. on the home screen or in a conversation + * Gives the conversation and direct share shortcuts of earlier versions the intent of [createConversationIntent], + * which opens the conversation of exactly their account: those that target the launcher activity or the + * conversation list, with the room token and user id as extras, which they take from no intent of outside the app, + * so they open only the conversation list, and those with a nextcloudtalk:// deep link, which names only the host + * and user name of the account. The account and conversation are taken from the id of the shortcut. Pinned ones, e.g. on the home screen or in a conversation * widget, and cached ones keep their intent until their conversation has a notification or is among the dynamic * shortcuts. * * @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 */ - fun updateShortcutsOfEarlierVersions(context: Context, users: List) { + @Suppress("TooGenericExceptionCaught") + fun updateShortcutsOfEarlierVersions(context: Context, users: List): Boolean = try { - val updated = shortcutsOfEarlierVersionsUpdated(context, users) - if (updated.isNotEmpty()) { - ShortcutManagerCompat.updateShortcuts(context, updated) + // 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) + } } - } catch (e: IllegalArgumentException) { - // e.g. for a shortcut that can't be updated. Old shortcuts must not crash the conversation list. - Log.w(TAG, "Failed to update the shortcuts of earlier versions", 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 { @@ -134,21 +144,31 @@ object ShortcutManagerHelper { .mapNotNull { shortcut -> val (userId, roomToken) = conversationOfShortcutId(shortcut.id) ?: return@mapNotNull null val intent = usersById[userId]?.let { createConversationIntent(context, roomToken, it) } - // the system keeps the other fields of a shortcut that is updated, e.g. its icon and person + // 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], else null. */ + /** + * The user id and room token of the conversation shortcut [shortcutId] of [getShortcutId], or of a direct share + * shortcut of [DirectShareHelper], else null. + */ @VisibleForTesting internal fun conversationOfShortcutId(shortcutId: String): Pair? { - val userIdAndToken = shortcutId.takeIf { it.startsWith(CONVERSATION_SHORTCUT_PREFIX) } - ?.removePrefix(CONVERSATION_SHORTCUT_PREFIX) + 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) diff --git a/app/src/test/java/com/nextcloud/talk/utils/ShortcutManagerHelperTest.kt b/app/src/test/java/com/nextcloud/talk/utils/ShortcutManagerHelperTest.kt index 9d88c5e8db5..161c8e1e227 100644 --- a/app/src/test/java/com/nextcloud/talk/utils/ShortcutManagerHelperTest.kt +++ b/app/src/test/java/com/nextcloud/talk/utils/ShortcutManagerHelperTest.kt @@ -6,10 +6,12 @@ */ 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 @@ -24,7 +26,7 @@ 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 of earlier versions are given that intent. + * 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]) @@ -38,6 +40,8 @@ class ShortcutManagerHelperTest { 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, @@ -103,6 +107,44 @@ class ShortcutManagerHelperTest { 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") @@ -125,7 +167,8 @@ class ShortcutManagerHelperTest { @Test fun `the account and conversation are read from the id of a conversation shortcut`() { assertEquals(2L to "abcd1234", ShortcutManagerHelper.conversationOfShortcutId("conversation_2_abcd1234")) - assertNull(ShortcutManagerHelper.conversationOfShortcutId("direct_share_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")) From 97e92c97cdd3f63c5c6571a27e7c1d2345634eee Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Mon, 2 Nov 2026 19:39:21 +0100 Subject: [PATCH 38/75] fix(shortcuts): open note to self through the list like the other shortcuts The note to self shortcut started the chat directly, in a task the app doesn't know as its own, so a later share, notification or tap on the launcher icon opened a second task. Its chat still opens with the focus in the message input, which is passed only with the conversation of the shortcut, not to one the user picks while it isn't listed yet. Assisted-by: Claude Code:claude-opus-5-5 Signed-off-by: Marcel Hibbe --- .../ConversationsListActivity.kt | 44 +++++++++++++------ 1 file changed, 30 insertions(+), 14 deletions(-) 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 0de01c50e29..e658d9534be 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt @@ -228,6 +228,9 @@ class ConversationsListActivity : BaseActivity() { private var textToPaste: String? = "" private var selectedMessageId: String? = null private var pendingDirectShareToken: String? = null + + // whether the chat of the pending shortcut opens with the focus in the message input, as the one of note to self + private var isPendingConversationInputFocused = false private var unauthorizedDialog: AlertDialog? = null private var unauthorizedHandling: Job? = null @@ -300,13 +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) - // 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 - pendingDirectShareToken = savedInstanceState.getString(KEY_PENDING_DIRECT_SHARE_TOKEN) - intent.removeExtra(KEY_ROOM_TOKEN) - } + savedInstanceState?.let(::restoreState) onBackPressedDispatcher.addCallback(this, onBackPressedCallback) val audioPlayerCallbacks = chatAudioPlayerCallbacks( @@ -396,10 +393,21 @@ class ConversationsListActivity : BaseActivity() { 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 + pendingDirectShareToken = savedInstanceState.getString(KEY_PENDING_DIRECT_SHARE_TOKEN) + isPendingConversationInputFocused = savedInstanceState.getBoolean(BundleKeys.KEY_FOCUS_INPUT) + intent.removeExtra(KEY_ROOM_TOKEN) + intent.removeExtra(BundleKeys.KEY_FOCUS_INPUT) + } + override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) outState.putBoolean(KEY_ACCOUNT_DIALOG_VISIBLE, showAccountDialogState.value) outState.putString(KEY_PENDING_DIRECT_SHARE_TOKEN, pendingDirectShareToken) + outState.putBoolean(BundleKeys.KEY_FOCUS_INPUT, isPendingConversationInputFocused) if (::deepLinkRouting.isInitialized) deepLinkRouting.onSaveInstanceState(outState) } @@ -620,8 +628,10 @@ class ConversationsListActivity : BaseActivity() { if (Intent.ACTION_VIEW == intent.action) { intent.getStringExtra(KEY_ROOM_TOKEN)?.takeIf(DeepLinkHandler::isValidToken)?.let { pendingDirectShareToken = it + isPendingConversationInputFocused = intent.getBooleanExtra(BundleKeys.KEY_FOCUS_INPUT, false) } intent.removeExtra(KEY_ROOM_TOKEN) + intent.removeExtra(BundleKeys.KEY_FOCUS_INPUT) } // Share sheet shortcut tap: Android delivers ACTION_SEND with EXTRA_SHORTCUT_ID. @@ -679,6 +689,7 @@ class ConversationsListActivity : BaseActivity() { // when it is only paused, e.g. below the request for the notification permission. if (!isCoveredByLockScreen && !isChangingConfigurations) { pendingDirectShareToken = null + isPendingConversationInputFocused = false } } @@ -751,7 +762,8 @@ class ConversationsListActivity : BaseActivity() { val conversation = list.firstOrNull { it.token == token } if (conversation != null && !isCoveredByLockScreen) { pendingDirectShareToken = null - handleConversation(conversation) + handleConversation(conversation, focusInput = isPendingConversationInputFocused) + isPendingConversationInputFocused = false } } } @@ -835,9 +847,10 @@ 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) + ?.putExtra(BundleKeys.KEY_FOCUS_INPUT, true) + ?: return val openNotesString = resources.getString(R.string.open_notes) val shortcut = ShortcutInfoCompat.Builder(context, NOTE_TO_SELF_SHORTCUT_ID) @@ -1073,7 +1086,7 @@ class ConversationsListActivity : BaseActivity() { } @Suppress("Detekt.ComplexMethod") - private fun handleConversation(conversation: ConversationModel?) { + private fun handleConversation(conversation: ConversationModel?, focusInput: Boolean = false) { selectedConversation = conversation if (selectedConversation != null) { val hasChatPermission = ParticipantPermissions( @@ -1100,7 +1113,7 @@ class ConversationsListActivity : BaseActivity() { } else if (conversation.checkIfVoiceRoom()) { showStartCallDialog() } else { - openConversation() + openConversation(focusInput = focusInput) } } } @@ -1345,7 +1358,7 @@ class ConversationsListActivity : BaseActivity() { } } - private fun openConversation(textToPaste: String? = "") { + private fun openConversation(textToPaste: String? = "", focusInput: Boolean = false) { if (CallActivity.active && !ApplicationWideCurrentRoomHolder.getInstance().isCurrentRoom(selectedConversation!!.token, currentUser.id) ) { @@ -1362,6 +1375,9 @@ class ConversationsListActivity : BaseActivity() { bundle.putString(BundleKeys.KEY_MESSAGE_ID, selectedMessageId) selectedMessageId = null } + if (focusInput) { + bundle.putBoolean(BundleKeys.KEY_FOCUS_INPUT, true) + } if (isShare(intent)) { openSharedConversationInAppTask(bundle) From 0ab595d3020e916b706977203c1e0f29ce821293 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Mon, 2 Nov 2026 19:42:49 +0100 Subject: [PATCH 39/75] perf(tasks): remove the tasks left in the recents off the main thread The removal asks ActivityManager for the info of every task of the app, which held up the creation of the conversation list and of the screen of a notification. Assisted-by: Claude Code:claude-opus-5-5 Signed-off-by: Marcel Hibbe --- app/src/main/java/com/nextcloud/talk/utils/AppTasks.kt | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/utils/AppTasks.kt b/app/src/main/java/com/nextcloud/talk/utils/AppTasks.kt index 1ad6095f011..e2968bf49dd 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/AppTasks.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/AppTasks.kt @@ -12,9 +12,11 @@ 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. @@ -152,10 +154,13 @@ object AppTasks { * 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. + * starts. In the scope of the app, as it asks ActivityManager for every task while the screen is created. */ fun removeTasksLeftInRecents(context: Context) { - findTasksLeftInRecents(context).forEach { it.finishAndRemoveTaskIfPresent() } + val applicationContext = context.applicationContext + NextcloudTalkApplication.sharedApplication!!.appScope.launch { + findTasksLeftInRecents(applicationContext).forEach { it.finishAndRemoveTaskIfPresent() } + } } private fun appTaskRoots(): Set = From a3f3803e66cab2540a332fd3cce5481f05fdd561 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Mon, 2 Nov 2026 19:45:46 +0100 Subject: [PATCH 40/75] refactor(lock): keep screens from opening above the lock screen in one place The deep link routing and the conversation of a shortcut each collected their state only while resumed and checked the lock screen themselves. collectWhileUnlocked does both, for every screen a stream opens. Assisted-by: Claude Code:claude-opus-5-5 Signed-off-by: Marcel Hibbe --- .../nextcloud/talk/activities/BaseActivity.kt | 35 ++++++++++++++----- .../talk/activities/DeepLinkRouting.kt | 13 ++----- .../ConversationsListActivity.kt | 27 ++++++-------- 3 files changed, 40 insertions(+), 35 deletions(-) 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 935a1776800..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,29 +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.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.SecurityUtils -import com.nextcloud.talk.ui.theme.ViewThemeUtils -import com.nextcloud.talk.ui.theme.ViewThemeUtilsFactory 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() { @@ -482,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/DeepLinkRouting.kt b/app/src/main/java/com/nextcloud/talk/activities/DeepLinkRouting.kt index 06ffcd3564f..b64c72c6c06 100644 --- a/app/src/main/java/com/nextcloud/talk/activities/DeepLinkRouting.kt +++ b/app/src/main/java/com/nextcloud/talk/activities/DeepLinkRouting.kt @@ -14,10 +14,7 @@ import androidx.annotation.StringRes import androidx.appcompat.app.AlertDialog import androidx.core.os.BundleCompat import androidx.lifecycle.DefaultLifecycleObserver -import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner -import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.repeatOnLifecycle import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.nextcloud.talk.R import com.nextcloud.talk.account.ServerSelectionActivity @@ -26,7 +23,6 @@ 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 kotlinx.coroutines.launch /** * Shows the routing of the deep links and contact chats that [DeepLinkActivity] passes on to the conversation list @@ -61,13 +57,8 @@ class DeepLinkRouting( SystemClock.elapsedRealtime() - createdAt < MAX_LAUNCH_SCREEN_MS init { - // shown only while resumed and not covered by the lock screen this activity started: the chat or question - // follows after unlocking, when the list is resumed again - activity.lifecycleScope.launch { - activity.repeatOnLifecycle(Lifecycle.State.RESUMED) { - viewModel.state.collect { if (!activity.isCoveredByLockScreen) render(it) } - } - } + // 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 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 e658d9534be..757e657f54b 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt @@ -55,6 +55,7 @@ import com.nextcloud.talk.activities.CallActivity 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 @@ -750,22 +751,16 @@ class ConversationsListActivity : BaseActivity() { }.collect() } - // Direct Share: the conversation of a share sheet or home screen shortcut opens only while the list is resumed - // and not covered by the lock screen it started, as the chat would otherwise show above the lock screen. After - // unlocking, the list is resumed again and the room list emitted anew, so the conversation opens then. A - // conversation that isn't stored yet, e.g. a new one, opens once the sync has added it to the list. - lifecycleScope.launch { - repeatOnLifecycle(Lifecycle.State.RESUMED) { - // the shared room list, which isn't queried again on every resume - conversationsListViewModel.getRoomsStateFlow.collect { list -> - val token = pendingDirectShareToken - val conversation = list.firstOrNull { it.token == token } - if (conversation != null && !isCoveredByLockScreen) { - pendingDirectShareToken = null - handleConversation(conversation, focusInput = isPendingConversationInputFocused) - isPendingConversationInputFocused = false - } - } + // Direct Share: the conversation of a share sheet or home screen shortcut, also after unlocking. A conversation + // that isn't stored yet, e.g. a new one, opens once the sync has added it to the list. The shared room list + // isn't queried again on every resume. + collectWhileUnlocked(conversationsListViewModel.getRoomsStateFlow) { list -> + val token = pendingDirectShareToken + val conversation = list.firstOrNull { it.token == token } + if (conversation != null) { + pendingDirectShareToken = null + handleConversation(conversation, focusInput = isPendingConversationInputFocused) + isPendingConversationInputFocused = false } } From 46078ac0744ad6e544d6ad5b08b5343a94467dbd Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Mon, 2 Nov 2026 20:07:45 +0100 Subject: [PATCH 41/75] fix(share): keep the copies of a preview the system recreates The attachment preview removed the copies of the shared files whenever it was dismissed without a configuration change, also when the system destroyed the chat in the background and restored the preview later, which then had no files. They are now removed only when the shown preview or its chat is closed. Assisted-by: Claude Code:claude-opus-5-5 Signed-off-by: Marcel Hibbe --- .../attachmentpreview/FileAttachmentPreviewFragment.kt | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) 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 f3cae875233..ce333877327 100644 --- a/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewFragment.kt +++ b/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewFragment.kt @@ -21,6 +21,7 @@ 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 @@ -139,9 +140,11 @@ class FileAttachmentPreviewFragment : DialogFragment() { override fun onDismiss(dialog: DialogInterface) { super.onDismiss(dialog) - // Files shared by another app that are left here, unsent, remove their copies, see SharedFileCopies. Not when - // the dialog is only shown again after a configuration change. - if (!isSent && activity?.isChangingConfigurations != true) { + // Files shared by another app that are left here, unsent, remove their copies, see SharedFileCopies: when the + // shown dialog is closed, or the chat below it. Not when the dialog is shown again after its chat is recreated, + // e.g. after a configuration change or when the system destroyed the chat in the background. + val isClosedWhileShown = lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED) + if (!isSent && (isClosedWhileShown || activity?.isFinishing == true)) { SharedFileCopies.removeSharesLater(filesList.toList()) } } From 46d45a4a06c5e9a51def5080b387e7fa85dab61a Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Mon, 2 Nov 2026 20:09:19 +0100 Subject: [PATCH 42/75] fix(share): pass on the files of a share that also has a text The list stopped reading the shared items at the first text, so the files after it were neither sent nor removed, as the share counted as passed on. It now takes all files and the first text. The text of an earlier share in the same list isn't taken for a share without one. Assisted-by: Claude Code:claude-opus-5-5 Signed-off-by: Marcel Hibbe --- .../talk/conversationlist/ConversationsListActivity.kt | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) 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 757e657f54b..031c68b1ec3 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt @@ -1133,7 +1133,7 @@ class ConversationsListActivity : BaseActivity() { openConversation(textToPaste) } else if (filesToShare != null && filesToShare!!.isNotEmpty()) { // the attachment preview of the chat shows the files and asks before uploading them; they are copies in - // the cache of the app (see ShareActivity), so reading them needs no permission + // the app (see SharedFileCopies), so reading them needs no permission openConversation() } else { logger.e(TAG, "no text or files to share") @@ -1148,6 +1148,7 @@ class ConversationsListActivity : BaseActivity() { @Suppress("Detekt.TooGenericExceptionCaught") private fun collectDataFromIntent() { filesToShare = ArrayList() + textToPaste = "" if (!isShare(intent)) return try { // ShareActivity only passes the text of a shared text on, without its files, see ShareListIntentFactory @@ -1172,13 +1173,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") } } From 0b1e00d8299684ee2630723166cfe7212ed7c8e4 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Mon, 2 Nov 2026 20:11:15 +0100 Subject: [PATCH 43/75] fix(list): drop the uri of a view intent when the list keeps its account The list rewrites its intent to the internal entry point to keep its account. A VIEW intent that the list shows, one flagged as relaunched from the recents, then kept its uri and was routed again, e.g. when the list was relaunched for another account. Its uri is now removed. Assisted-by: Claude Code:claude-opus-5-5 Signed-off-by: Marcel Hibbe --- .../conversationlist/ConversationsListActivity.kt | 15 +++++++++++---- .../ConversationsListEntryPointTest.kt | 13 +++++++++++++ 2 files changed, 24 insertions(+), 4 deletions(-) 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 031c68b1ec3..48aa50cd310 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt @@ -1947,13 +1947,20 @@ class ConversationsListActivity : BaseActivity() { /** * 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. + * [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 = - Intent(intent) + 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 { userId?.let { putExtra(KEY_INTERNAL_USER_ID, it) } } + .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 diff --git a/app/src/test/java/com/nextcloud/talk/conversationlist/ConversationsListEntryPointTest.kt b/app/src/test/java/com/nextcloud/talk/conversationlist/ConversationsListEntryPointTest.kt index 779817a52a8..e70771b7516 100644 --- a/app/src/test/java/com/nextcloud/talk/conversationlist/ConversationsListEntryPointTest.kt +++ b/app/src/test/java/com/nextcloud/talk/conversationlist/ConversationsListEntryPointTest.kt @@ -18,6 +18,7 @@ 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 @@ -119,6 +120,18 @@ class ConversationsListEntryPointTest { 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") From 02cdceb32e8ae63abbc2284306ce203096f56a5f Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Mon, 2 Nov 2026 20:14:41 +0100 Subject: [PATCH 44/75] docs: wrap the comments of the shortcut update and ShareActivity at 120 characters The comment of the shortcut update also says now that pinned and cached shortcuts are updated as well, instead of reading as if they were left out. Assisted-by: Claude Code:claude-opus-5-5 Signed-off-by: Marcel Hibbe --- .../com/nextcloud/talk/conversationlist/ShareActivity.kt | 6 +++--- .../java/com/nextcloud/talk/utils/ShortcutManagerHelper.kt | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/ShareActivity.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/ShareActivity.kt index cbe50c289f8..f6cf0fc651c 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ShareActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ShareActivity.kt @@ -21,9 +21,9 @@ 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]. + * 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). 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 c7591913dbe..f8332aeb560 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/ShortcutManagerHelper.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/ShortcutManagerHelper.kt @@ -102,9 +102,9 @@ object ShortcutManagerHelper { * which opens the conversation of exactly their account: those that target the launcher activity or the * conversation list, with the room token and user id as extras, which they take from no intent of outside the app, * so they open only the conversation list, and those with a nextcloudtalk:// deep link, which names only the host - * and user name of the account. The account and conversation are taken from the id of the shortcut. Pinned ones, e.g. on the home screen or in a conversation - * widget, and cached ones keep their intent until their conversation has a notification or is among the dynamic - * shortcuts. + * and user name of the account. The account and conversation are taken from the id of the shortcut. Also the + * pinned ones, e.g. on the home screen or in a conversation widget, and the cached ones, which would otherwise keep + * their intent until their conversation has a notification or is among the dynamic shortcuts. * * @param context Application context * @param users The accounts whose shortcuts can be updated From cfe1f4517e35d0478ebfd78dad2a253352cf8da6 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Mon, 2 Nov 2026 20:15:28 +0100 Subject: [PATCH 45/75] refactor(list): name the account switch of the shown list apart from the other createSwitchToAccountIntent and createAccountSwitchIntent read the same but one switches the shown list in place and the other replaces the root list. The first is now createInPlaceAccountSwitchIntent, which the call and the chat use too instead of building the same intent by hand. Assisted-by: Claude Code:claude-opus-5-5 Signed-off-by: Marcel Hibbe --- .../talk/account/SwitchAccountActivity.kt | 2 +- .../com/nextcloud/talk/activities/CallActivity.kt | 5 +---- .../java/com/nextcloud/talk/chat/ChatActivity.kt | 14 +++++--------- .../chooseaccount/ChooseAccountDialogCompose.kt | 2 +- .../conversationlist/ConversationsListActivity.kt | 4 ++-- 5 files changed, 10 insertions(+), 17 deletions(-) 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 c1c3e62a51a..3980a570d2d 100644 --- a/app/src/main/java/com/nextcloud/talk/account/SwitchAccountActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/account/SwitchAccountActivity.kt @@ -89,7 +89,7 @@ class SwitchAccountActivity : BaseActivity() { DirectShareHelper.removeAllShareTargetShortcuts(this@SwitchAccountActivity) cookieManager.cookieStore.removeAll() val context = this@SwitchAccountActivity - startActivity(ConversationsListActivity.createSwitchToAccountIntent(context, user.id!!)) + startActivity(ConversationsListActivity.createInPlaceAccountSwitchIntent(context, user.id!!)) finish() } } 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 e1679222ea9..ebb079215e8 100644 --- a/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt @@ -2446,10 +2446,7 @@ class CallActivity : CallBaseActivity() { baseActivityName != CallNotificationActivity::class.java.name } - val listIntent = ConversationsListActivity.createIntent(context).apply { - putExtra(KEY_INTERNAL_USER_ID, conversationUser.id) - addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP) - } + val listIntent = ConversationsListActivity.createInPlaceAccountSwitchIntent(context, conversationUser.id!!) val isShownInPrimaryTask = primaryTask != null && primaryTask.startActivityIfPresent(context, listIntent) && primaryTask.moveToFrontIfPresent() 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 693c4d60d6a..6e7d37175a3 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt @@ -456,10 +456,9 @@ class ChatActivity : isEnabled = false onBackPressedDispatcher.onBackPressed() } else { - val intent = ConversationsListActivity.createIntent(this@ChatActivity) - 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() } } @@ -3502,11 +3501,8 @@ class ChatActivity : bundle.putString(BundleKeys.KEY_FORWARD_MSG_TEXT, message?.message) bundle.putString(BundleKeys.KEY_FORWARD_HIDE_SOURCE_ROOM, roomToken) - val intent = ConversationsListActivity.createIntent(this) - 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() } 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 e289fc179d2..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.createSwitchToAccountIntent(activity, userItem.user.id!!) + ConversationsListActivity.createInPlaceAccountSwitchIntent(activity, userItem.user.id!!) ) onSelected() } 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 48aa50cd310..0f1ac10b6c8 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt @@ -566,7 +566,7 @@ class ConversationsListActivity : BaseActivity() { user.username == trimmedAccountName && baseUrl == user.baseUrl?.toUri()?.host } if (user != null && userManager.setUserAsActive(user)) { - startActivity(createSwitchToAccountIntent(context, user.id!!)) + startActivity(createInPlaceAccountSwitchIntent(context, user.id!!)) } else { showSnackbar(getString(R.string.nc_no_account_found)) } @@ -1903,7 +1903,7 @@ class ConversationsListActivity : BaseActivity() { * 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 createSwitchToAccountIntent(context: Context, userId: Long): Intent = + fun createInPlaceAccountSwitchIntent(context: Context, userId: Long): Intent = createAccountSwitchIntent(context, userId).addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP) /** From 53c35158060b8cd799c704a460aa9246d352b641 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Mon, 2 Nov 2026 20:32:23 +0100 Subject: [PATCH 46/75] fix(lock): close the lock screen when it isn't needed anymore When the screen lock of the device was removed while the lock screen was shown, it couldn't authenticate, and Back only moves the app to the back, so the app couldn't be used anymore. And when the app was unlocked on the lock screen of another task, e.g. of a share, the one left in the task of the app asked to authenticate again when it was shown. Both now close. Assisted-by: Claude Code:claude-opus-5-5 Signed-off-by: Marcel Hibbe --- .../main/java/com/nextcloud/talk/lock/LockedActivity.kt | 9 +++++++++ 1 file changed, 9 insertions(+) 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 26ed1a48b2d..1345b090a85 100644 --- a/app/src/main/java/com/nextcloud/talk/lock/LockedActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/lock/LockedActivity.kt @@ -76,6 +76,11 @@ class LockedActivity : AppCompatActivity() { 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 { @@ -97,6 +102,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() } } From cc388d36b90cb1f456a46e59250c9cf11bd9619d Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Mon, 2 Nov 2026 20:33:27 +0100 Subject: [PATCH 47/75] fix(deeplink): open a link without a user name with the current account Any account on the server of the link was taken before the current one, so a link without a user name opened with the first account on that server, also when the current account was on it. Assisted-by: Claude Code:claude-opus-5-5 Signed-off-by: Marcel Hibbe --- .../activities/DeepLinkRoutingViewModel.kt | 6 +-- .../DeepLinkRoutingViewModelTargetUserTest.kt | 53 +++++++++++++++++++ 2 files changed, 56 insertions(+), 3 deletions(-) create mode 100644 app/src/test/java/com/nextcloud/talk/activities/DeepLinkRoutingViewModelTargetUserTest.kt diff --git a/app/src/main/java/com/nextcloud/talk/activities/DeepLinkRoutingViewModel.kt b/app/src/main/java/com/nextcloud/talk/activities/DeepLinkRoutingViewModel.kt index 06b9d605354..d25bee33d00 100644 --- a/app/src/main/java/com/nextcloud/talk/activities/DeepLinkRoutingViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/activities/DeepLinkRoutingViewModel.kt @@ -349,8 +349,8 @@ class DeepLinkRoutingViewModel @Inject constructor( * * 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) + * 2. Current active user, if it is on the server + * 3. Another user on the server */ @VisibleForTesting internal fun resolveTargetUser( @@ -369,7 +369,7 @@ class DeepLinkRoutingViewModel @Inject constructor( } val serverMatch = users.find { user -> hostOf(user) == deepLinkHost } val currentUserMatch = defaultUser?.takeIf { hostOf(it) == deepLinkHost } - return exactMatch ?: serverMatch ?: currentUserMatch + return exactMatch ?: currentUserMatch ?: serverMatch } /** 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)) + } +} From 3644be12b412693605ad3fef7d6ef73805aaf39b Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Mon, 2 Nov 2026 20:35:00 +0100 Subject: [PATCH 48/75] fix(share): preselect the conversation of a direct share target reliably The id of the direct share target stayed in the intent and was read on every resume of the list, so a conversation that couldn't take the share showed its error again each time. It is now read once, and also before Android 10, where ChooserTargetServiceCompat passes it as well. The system keeps the direct share targets of an account shown before cached, but the list looked up their conversation among those of the current account, so the chosen one was ignored. The list now passes such a share on to its account. The account and token are read with the parser of the shortcut ids, which also checks the token. Assisted-by: Claude Code:claude-opus-5-5 Signed-off-by: Marcel Hibbe --- .../ConversationsListActivity.kt | 24 ++++++++++++++----- .../conversationlist/DirectShareHelper.kt | 15 ------------ .../talk/utils/ShortcutManagerHelper.kt | 4 +--- 3 files changed, 19 insertions(+), 24 deletions(-) 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 0f1ac10b6c8..37ffad7b595 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt @@ -402,6 +402,7 @@ class ConversationsListActivity : BaseActivity() { isPendingConversationInputFocused = savedInstanceState.getBoolean(BundleKeys.KEY_FOCUS_INPUT) intent.removeExtra(KEY_ROOM_TOKEN) intent.removeExtra(BundleKeys.KEY_FOCUS_INPUT) + intent.removeExtra(ShortcutManagerCompat.EXTRA_SHORTCUT_ID) } override fun onSaveInstanceState(outState: Bundle) { @@ -635,13 +636,24 @@ class ConversationsListActivity : BaseActivity() { intent.removeExtra(BundleKeys.KEY_FOCUS_INPUT) } - // 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 && isShare(intent)) { - val shortcutId = intent.getStringExtra(Intent.EXTRA_SHORTCUT_ID) - if (shortcutId != null) { - pendingDirectShareToken = DirectShareHelper.extractTokenFromShortcutId(shortcutId) + // 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) { + pendingDirectShareToken = target.second } + // preselected once, like the conversation of a shortcut above + intent.removeExtra(ShortcutManagerCompat.EXTRA_SHORTCUT_ID) } if (!eventBus.isRegistered(this)) { 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 56c302206d0..12b20923961 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/DirectShareHelper.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/DirectShareHelper.kt @@ -37,7 +37,6 @@ object DirectShareHelper { private val TAG = DirectShareHelper::class.java.simpleName private const val SHARE_TARGET_CATEGORY = "com.nextcloud.talk.sharing.SHARE_TARGET_CATEGORY" const val SHORTCUT_ID_PREFIX = "direct_share_" - private const val SHORTCUT_ID_MIN_PARTS = 4 private const val AVATAR_SIZE_PX = 256 private val publishMutex = Mutex() @@ -167,20 +166,6 @@ 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. - */ - 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("_") - } - } - /** * 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. 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 f8332aeb560..43f711203c3 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/ShortcutManagerHelper.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/ShortcutManagerHelper.kt @@ -10,7 +10,6 @@ import android.content.Context import android.content.Intent import android.net.Uri import android.util.Log -import androidx.annotation.VisibleForTesting import androidx.core.content.pm.ShortcutInfoCompat import androidx.core.content.pm.ShortcutManagerCompat import androidx.core.graphics.drawable.IconCompat @@ -164,8 +163,7 @@ object ShortcutManagerHelper { * The user id and room token of the conversation shortcut [shortcutId] of [getShortcutId], or of a direct share * shortcut of [DirectShareHelper], else null. */ - @VisibleForTesting - internal fun conversationOfShortcutId(shortcutId: String): Pair? { + 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) } From 4a8609d12477aae27d82a4d66751a4a82fe6274d Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Mon, 2 Nov 2026 20:36:47 +0100 Subject: [PATCH 49/75] fix(chat): keep the shared files of a chat recreated before its room loaded The shared files were removed from the intent of the chat when it was created, but shown only once its room was loaded, so a chat recreated in between, e.g. by a rotation, dropped them and left their copies behind. Whether the shared files and text were taken is now kept in the saved state, the files once the attachment preview has them. Removing them from the intent only changed the chat's own copy of it: a chat recreated after its process was killed gets the intent it was started with, so it offered the files again, and the shared text was put into the draft again on every recreation, which replaced the edits of the draft. Assisted-by: Claude Code:claude-opus-5-5 Signed-off-by: Marcel Hibbe --- .../com/nextcloud/talk/chat/ChatActivity.kt | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) 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 6e7d37175a3..814214e2882 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt @@ -434,6 +434,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() @@ -628,10 +633,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 { @@ -1303,7 +1313,6 @@ class ChatActivity : sharedFilePaths = extras?.getStringArrayList(BundleKeys.KEY_SHARED_FILE_PATHS) ?.filter { FileUtils.isTrustedShareUri(it.toUri(), this) } ?: emptyList() - intent.removeExtra(BundleKeys.KEY_SHARED_FILE_PATHS) Log.d(TAG, " roomToken = $roomToken") if (roomToken.isEmpty()) { @@ -1341,6 +1350,8 @@ class ChatActivity : if (isChangingConfigurations) { chatViewModel.handleOrientationChange() } + outState.putBoolean(KEY_SHARED_TEXT_TAKEN, isSharedTextTaken) + outState.putBoolean(KEY_SHARED_FILES_TAKEN, areSharedFilesTaken) super.onSaveInstanceState(outState) } @@ -1998,6 +2009,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 } } @@ -4150,6 +4163,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 From fd85ea67558f9e300d49709c65f6378fae36360e Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Mon, 2 Nov 2026 20:40:44 +0100 Subject: [PATCH 50/75] fix(call): keep a share list when a call ends Without a running task of the app, the conversation list of an ended call was opened in any other task of the app, also in the task of a share, whose list then replaced the shared content. And the other tasks were removed with the share in them. Shares are now left out of both. Assisted-by: Claude Code:claude-opus-5-5 Signed-off-by: Marcel Hibbe --- .../nextcloud/talk/activities/CallActivity.kt | 25 +++++++++++-------- 1 file changed, 15 insertions(+), 10 deletions(-) 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 ebb079215e8..b9cca8a092b 100644 --- a/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt @@ -2439,11 +2439,7 @@ class CallActivity : CallBaseActivity() { val primaryTask = AppTasks.findRunningAppTask(this, appTasks) { it.taskIdCompat != taskId } ?: appTasks.firstOrNull { appTask -> val taskInfo = appTask.taskInfoOrNull() - val baseActivityName = taskInfo?.baseActivity?.className - taskInfo != null && - taskInfo.taskIdCompat != taskId && - baseActivityName != CallActivity::class.java.name && - baseActivityName != CallNotificationActivity::class.java.name + taskInfo != null && taskInfo.taskIdCompat != taskId && !isCallOrShareTask(taskInfo) } val listIntent = ConversationsListActivity.createInPlaceAccountSwitchIntent(context, conversationUser.id!!) @@ -2457,22 +2453,31 @@ class CallActivity : CallBaseActivity() { } } - /** Removes the [appTasks] other than the one of [primaryTaskId], this one and those of calls. */ + /** 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() - val baseActivityName = taskInfo?.baseActivity?.className - val isCallTask = baseActivityName == CallActivity::class.java.name || - baseActivityName == CallNotificationActivity::class.java.name taskInfo != null && - !isCallTask && + !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.baseIntent.action == Intent.ACTION_SEND || + taskInfo.baseIntent.action == Intent.ACTION_SEND_MULTIPLE + } + private fun startVideoCapture(isPortrait: Boolean) { val (width, height) = if (isPortrait) { WIDTH_4_TO_3_RATIO to HEIGHT_4_TO_3_RATIO From 676922a017c830a869b20c2e712fa9f4b05401c9 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Mon, 2 Nov 2026 20:41:16 +0100 Subject: [PATCH 51/75] fix(share): give the fallback intent of a direct share shortcut an action A shortcut must have an intent with an action, so the fallback for an account without an id would have thrown while publishing the share targets instead of opening the list. Assisted-by: Claude Code:claude-opus-5-5 Signed-off-by: Marcel Hibbe --- .../com/nextcloud/talk/conversationlist/DirectShareHelper.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 12b20923961..11baa306cec 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/DirectShareHelper.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/DirectShareHelper.kt @@ -9,6 +9,7 @@ package com.nextcloud.talk.conversationlist import android.content.Context +import android.content.Intent import android.content.res.Configuration import android.graphics.drawable.BitmapDrawable import android.util.Log @@ -277,8 +278,9 @@ object DirectShareHelper { ): ShortcutInfoCompat.Builder { // 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) + ?: ConversationsListActivity.createIntent(context).setAction(Intent.ACTION_VIEW) val person = Person.Builder() .setName(displayName) From c7a03da44af46a4b758f9e99727d2b49bba559c9 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Mon, 2 Nov 2026 21:01:58 +0100 Subject: [PATCH 52/75] refactor(list): take the conversation of a shortcut in a function of its own onResume had become too long for detekt with the direct share of another account. The comment of the direct share is wrapped at 120 characters. Assisted-by: Claude Code:claude-opus-5-5 Signed-off-by: Marcel Hibbe --- .../ConversationsListActivity.kt | 68 ++++++++++--------- 1 file changed, 37 insertions(+), 31 deletions(-) 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 37ffad7b595..f2f5231575a 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt @@ -624,37 +624,7 @@ class ConversationsListActivity : BaseActivity() { showNotificationWarningState.value = shouldShowNotificationWarning() showShareToScreenState.value = isShare(intent) - // 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. - if (Intent.ACTION_VIEW == intent.action) { - intent.getStringExtra(KEY_ROOM_TOKEN)?.takeIf(DeepLinkHandler::isValidToken)?.let { - pendingDirectShareToken = it - isPendingConversationInputFocused = intent.getBooleanExtra(BundleKeys.KEY_FOCUS_INPUT, false) - } - intent.removeExtra(KEY_ROOM_TOKEN) - intent.removeExtra(BundleKeys.KEY_FOCUS_INPUT) - } - - // 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) { - pendingDirectShareToken = target.second - } - // preselected once, like the conversation of a shortcut above - intent.removeExtra(ShortcutManagerCompat.EXTRA_SHORTCUT_ID) - } + takeConversationOfShortcut() if (!eventBus.isRegistered(this)) { eventBus.register(this) @@ -696,6 +666,42 @@ class ConversationsListActivity : BaseActivity() { conversationTagsViewModel.loadConversationTags() } + /** Takes the conversation of the shortcut that started the list from its intent, see [pendingDirectShareToken]. */ + 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. + if (Intent.ACTION_VIEW == intent.action) { + intent.getStringExtra(KEY_ROOM_TOKEN)?.takeIf(DeepLinkHandler::isValidToken)?.let { + pendingDirectShareToken = it + isPendingConversationInputFocused = intent.getBooleanExtra(BundleKeys.KEY_FOCUS_INPUT, false) + } + intent.removeExtra(KEY_ROOM_TOKEN) + intent.removeExtra(BundleKeys.KEY_FOCUS_INPUT) + } + + // 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) { + pendingDirectShareToken = target.second + } + // preselected once, like the conversation of a shortcut above + intent.removeExtra(ShortcutManagerCompat.EXTRA_SHORTCUT_ID) + } + } + override fun onStop() { super.onStop() // The conversation of a shortcut that isn't in the list yet isn't opened anymore once the list is left. Not From ab8cc6197b91679684ea4624190d19896a060943 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Mon, 2 Nov 2026 21:03:23 +0100 Subject: [PATCH 53/75] refactor(list): leave onCreate in one place when the list isn't shown A launcher tap on a task of the app and an intent the list passes on each returned on their own, which detekt counts as too many returns. Assisted-by: Claude Code:claude-opus-5-5 Signed-off-by: Marcel Hibbe --- .../talk/conversationlist/ConversationsListActivity.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 f2f5231575a..732d51b524c 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt @@ -276,11 +276,11 @@ class ConversationsListActivity : BaseActivity() { removeExtrasOfApp(intent) super.onCreate(savedInstanceState) NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) - if (isLauncherTapOnAppTask()) { - finish() + if (isLauncherTapOnAppTask() || finishIfNotShown()) { + // finishIfNotShown() finished the list already + if (!isFinishing) finish() return } - if (finishIfNotShown()) return currentUser = setUpBoundUserOrFinish() ?: return // A recreation after a configuration change reuses this intent. Through the launcher alias, // removeExtrasOfApp would remove the account written into it again, and the list would switch to the default From 0fafa5b97194af1aaa0a2abd46431f7cba261fac Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Mon, 2 Nov 2026 21:05:13 +0100 Subject: [PATCH 54/75] fix(share): read the authority of a shared file's uri that names the user getFileFromUri didn't recognise a uri with the user id prefix of the own user ("0@authority") as one of the app's copies and copied it into the cache by its name, where a file of the same name would have been taken instead, and its upload got no tag of its share, so its copy could be removed while the upload still waited. They now drop a prefix of the own user. A uri of another user, e.g. of the app in another profile, isn't taken for one of the app's copies. Assisted-by: Claude Code:claude-opus-5-5 Signed-off-by: Marcel Hibbe --- .../com/nextcloud/talk/utils/FileUtils.kt | 29 ++++++++++++++++--- .../nextcloud/talk/utils/SharedFileCopies.kt | 2 +- .../talk/utils/FileUtilsGetFileFromUriTest.kt | 18 ++++++++++++ .../talk/utils/SharedFileCopiesTest.kt | 9 ++++++ 4 files changed, 53 insertions(+), 5 deletions(-) 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 954257bc24b..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 @@ -99,8 +100,9 @@ object FileUtils { /** * 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 ("0@authority") is ignored, as the content resolver - * resolves it to the own user. [packageOfAuthority] gives the package of the provider of an authority. + * [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?, @@ -110,7 +112,7 @@ object FileUtils { packageOfAuthority: (String) -> String? ): Boolean { val contentUri = uri?.takeIf { ContentResolver.SCHEME_CONTENT == it.scheme } - val authority = contentUri?.authority?.substringAfterLast('@') ?: return false + val authority = contentUri?.authorityWithoutUser ?: return false val isOwnProvider = authority == packageName || packageOfAuthority(authority) == packageName val isSharedFileOfFileProvider = authority == packageName && @@ -206,7 +208,7 @@ object FileUtils { throw IllegalArgumentException("relative paths are not supported") } else if (ContentResolver.SCHEME_CONTENT == scheme) { // a shared file the app already copied is used as it is, instead of copying it again - val ownSharedFile = sourceFileUri.takeIf { it.authority == context.packageName } + val ownSharedFile = sourceFileUri.takeIf { it.authorityOfOwnUser == context.packageName } ?.let { sharedAttachmentProviderFile(context.cacheDir, context.filesDir, it.pathSegments) } ?.takeIf { it.isFile } ownSharedFile ?: copyFileToCache(context, sourceFileUri, fileName) @@ -329,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/SharedFileCopies.kt b/app/src/main/java/com/nextcloud/talk/utils/SharedFileCopies.kt index bdb777a899d..08cd1807ae2 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/SharedFileCopies.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/SharedFileCopies.kt @@ -92,7 +92,7 @@ object SharedFileCopies { val uri = fileUri.toUri() val segments = uri.pathSegments val isCopy = uri.scheme == ContentResolver.SCHEME_CONTENT && - uri.authority == BuildConfig.APPLICATION_ID && + 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 diff --git a/app/src/test/java/com/nextcloud/talk/utils/FileUtilsGetFileFromUriTest.kt b/app/src/test/java/com/nextcloud/talk/utils/FileUtilsGetFileFromUriTest.kt index aa95fa89d8a..182ff6f1c49 100644 --- a/app/src/test/java/com/nextcloud/talk/utils/FileUtilsGetFileFromUriTest.kt +++ b/app/src/test/java/com/nextcloud/talk/utils/FileUtilsGetFileFromUriTest.kt @@ -64,6 +64,15 @@ class FileUtilsGetFileFromUriTest { 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") @@ -95,6 +104,15 @@ class FileUtilsGetFileFromUriTest { 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") diff --git a/app/src/test/java/com/nextcloud/talk/utils/SharedFileCopiesTest.kt b/app/src/test/java/com/nextcloud/talk/utils/SharedFileCopiesTest.kt index f597d4160bf..8d059e12838 100644 --- a/app/src/test/java/com/nextcloud/talk/utils/SharedFileCopiesTest.kt +++ b/app/src/test/java/com/nextcloud/talk/utils/SharedFileCopiesTest.kt @@ -76,6 +76,13 @@ class SharedFileCopiesTest { ) } + @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")) @@ -83,6 +90,8 @@ class SharedFileCopiesTest { 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")) } From 5f2496a6d08530a383b05b86c0a13e7e2535051d Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Mon, 2 Nov 2026 21:06:14 +0100 Subject: [PATCH 55/75] perf(share): ask WorkManager once for the uploads of the shares to remove The removal of the copies asked WorkManager for the uploads of each share on its own, one after another, when the app starts and when a share starts. It now asks once for all of them. Assisted-by: Claude Code:claude-opus-5-5 Signed-off-by: Marcel Hibbe --- .../nextcloud/talk/utils/SharedFileCopies.kt | 39 ++++++++++++------- 1 file changed, 26 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/utils/SharedFileCopies.kt b/app/src/main/java/com/nextcloud/talk/utils/SharedFileCopies.kt index 08cd1807ae2..ee090c42e57 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/SharedFileCopies.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/SharedFileCopies.kt @@ -10,7 +10,9 @@ 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 @@ -58,9 +60,9 @@ object SharedFileCopies { */ fun removeDoneShares(context: Context) { val expiry = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(KEPT_DAYS) - sharesDirectory(context).listFiles() - ?.filter { it.lastModified() < expiry && !hasUnfinishedUpload(context, it.name) } - ?.forEach { it.deleteRecursively() } + 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() } } /** @@ -70,9 +72,9 @@ object SharedFileCopies { */ fun removeSharesOf(context: Context, fileUris: Collection) { val sharesDirectory = sharesDirectory(context) - fileUris.mapNotNull(::shareDirectoryNameOf) - .distinct() - .filterNot { hasUnfinishedUpload(context, it) } + val names = fileUris.mapNotNull(::shareDirectoryNameOf).distinct() + val uploading = sharesWithUnfinishedUpload(context, names) + names.filterNot { it in uploading } .mapNotNull { FileUtils.resolveFileInDirectory(sharesDirectory, it) } .forEach { it.deleteRecursively() } } @@ -100,16 +102,27 @@ object SharedFileCopies { private fun sharesDirectory(context: Context) = File(context.filesDir, FileUtils.SHARED_FILE_COPIES_DIRECTORY) - private fun hasUnfinishedUpload(context: Context, directoryName: String): Boolean = - try { - WorkManager.getInstance(context).getWorkInfosByTag(UPLOAD_TAG_PREFIX + directoryName).get() - .any { !it.state.isFinished } + /** + * 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) { - // keep the copies when in doubt Log.w(TAG, "Failed to get the uploads of shared files", e) - true + directoryNames.toSet() } catch (e: InterruptedException) { Thread.currentThread().interrupt() - true + directoryNames.toSet() } + } } From a634809450d2a367a521b54836014ea363bc5481 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Mon, 2 Nov 2026 21:08:06 +0100 Subject: [PATCH 56/75] docs: shorten the comments of the account intent, the shortcut update and shared files AGENTS.md asks for brief comments matching the surrounding code. Assisted-by: Claude Code:claude-opus-5-5 Signed-off-by: Marcel Hibbe --- .../attachmentpreview/FileAttachmentPreviewFragment.kt | 4 +--- .../talk/conversationlist/ConversationsListActivity.kt | 8 ++------ .../com/nextcloud/talk/utils/ShortcutManagerHelper.kt | 10 +++------- 3 files changed, 6 insertions(+), 16 deletions(-) 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 ce333877327..c67d4bc739b 100644 --- a/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewFragment.kt +++ b/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewFragment.kt @@ -140,9 +140,7 @@ class FileAttachmentPreviewFragment : DialogFragment() { override fun onDismiss(dialog: DialogInterface) { super.onDismiss(dialog) - // Files shared by another app that are left here, unsent, remove their copies, see SharedFileCopies: when the - // shown dialog is closed, or the chat below it. Not when the dialog is shown again after its chat is recreated, - // e.g. after a configuration change or when the system destroyed the chat in the background. + // 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()) 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 732d51b524c..d12a4489575 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt @@ -282,10 +282,7 @@ class ConversationsListActivity : BaseActivity() { return } currentUser = setUpBoundUserOrFinish() ?: return - // A recreation after a configuration change reuses this intent. Through the launcher alias, - // removeExtrasOfApp would remove the account written into it again, and the list would switch to the default - // account of that time while its view models stay on this one. The intents of other apps that the list - // doesn't show were passed on above, and their extras of the app removed. + // 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) @@ -644,8 +641,7 @@ class ConversationsListActivity : BaseActivity() { val users = userManager.getUsers() hasMultipleAccountsState.value = users.size > 1 if (!areShortcutsOfEarlierVersionsUpdated) { - // set before, so another list doesn't update them at the same time, and again after, so a list - // tries again when they couldn't be updated, e.g. while the user was locked, or this list was closed + // reset if they couldn't be updated, so a later list tries again areShortcutsOfEarlierVersionsUpdated = true var isUpdated = false try { 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 43f711203c3..7b583c591d2 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/ShortcutManagerHelper.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/ShortcutManagerHelper.kt @@ -97,13 +97,9 @@ object ShortcutManagerHelper { } /** - * Gives the conversation and direct share shortcuts of earlier versions the intent of [createConversationIntent], - * which opens the conversation of exactly their account: those that target the launcher activity or the - * conversation list, with the room token and user id as extras, which they take from no intent of outside the app, - * so they open only the conversation list, and those with a nextcloudtalk:// deep link, which names only the host - * and user name of the account. The account and conversation are taken from the id of the shortcut. Also the - * pinned ones, e.g. on the home screen or in a conversation widget, and the cached ones, which would otherwise keep - * their intent until their conversation has a notification or is among the dynamic shortcuts. + * 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 From aa5f1246340093c37637913076e2eb2bc82fcd50 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Mon, 2 Nov 2026 21:27:20 +0100 Subject: [PATCH 57/75] fix(shortcuts): don't open the conversation of a shortcut again from the recents A task a shortcut started keeps its intent, so when its list was gone and the task was relaunched from the recents, the conversation of the shortcut was opened again. Assisted-by: Claude Code:claude-opus-5-5 Signed-off-by: Marcel Hibbe --- .../talk/conversationlist/ConversationsListActivity.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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 d12a4489575..fa6f112c3bd 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt @@ -666,8 +666,9 @@ class ConversationsListActivity : BaseActivity() { 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. - if (Intent.ACTION_VIEW == intent.action) { + // again when the list is shown again, e.g. by going back from the conversation or from the recents. + val isRelaunchedFromRecents = intent.flags and Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY != 0 + if (Intent.ACTION_VIEW == intent.action && !isRelaunchedFromRecents) { intent.getStringExtra(KEY_ROOM_TOKEN)?.takeIf(DeepLinkHandler::isValidToken)?.let { pendingDirectShareToken = it isPendingConversationInputFocused = intent.getBooleanExtra(BundleKeys.KEY_FOCUS_INPUT, false) From eb67fc74387f9281f6070fac749d579313b8af0d Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Mon, 2 Nov 2026 21:29:45 +0100 Subject: [PATCH 58/75] fix(list): don't drop a new intent for a switch to the default account When the default account changed while the list was in the background, the list switched to it in onRestart, before onNewIntent() delivered e.g. a shortcut or deep link, which was then lost with the old list. The check is now armed when the list is stopped and done on resume, and not for a new intent, which chooses its account itself. A list that switches doesn't load its data anymore. It waits while the lock screen covers the list: the list of the default account would start above it and open a second lock screen. Assisted-by: Claude Code:claude-opus-5-5 Signed-off-by: Marcel Hibbe --- .../ConversationsListActivity.kt | 27 ++++++++++++++----- 1 file changed, 21 insertions(+), 6 deletions(-) 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 fa6f112c3bd..cdaeef8c717 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt @@ -230,6 +230,9 @@ class ConversationsListActivity : BaseActivity() { private var selectedMessageId: String? = null private var pendingDirectShareToken: String? = null + // set when the list is shown again, see switchToDefaultAccountIfChanged + private var isDefaultAccountCheckDue = false + // whether the chat of the pending shortcut opens with the focus in the message input, as the one of note to self private var isPendingConversationInputFocused = false private var unauthorizedDialog: AlertDialog? = null @@ -422,6 +425,7 @@ class ConversationsListActivity : BaseActivity() { override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) + isDefaultAccountCheckDue = false removeExtrasOfApp(intent) val newUserId = intent.getLongExtra(KEY_INTERNAL_USER_ID, 0L) when { @@ -601,12 +605,13 @@ 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 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. + /** + * 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 || isShare(intent) @@ -617,6 +622,14 @@ class ConversationsListActivity : BaseActivity() { 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 = isShare(intent) @@ -701,6 +714,8 @@ class ConversationsListActivity : BaseActivity() { 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) { From 152573abf4ef2280264c336e750a3b3a2afba4fb Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Mon, 2 Nov 2026 21:47:12 +0100 Subject: [PATCH 59/75] fix(lock): don't lock again right after unlocking Leaving the app while the lock screen is shown, e.g. for the screen lock of the device, set the lock check for its return, which the screen below then ran after unlocking: with no timeout it locked again right away. Assisted-by: Claude Code:claude-opus-5-5 Signed-off-by: Marcel Hibbe --- app/src/main/java/com/nextcloud/talk/lock/LockedActivity.kt | 2 ++ 1 file changed, 2 insertions(+) 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 1345b090a85..ae6018a2dd5 100644 --- a/app/src/main/java/com/nextcloud/talk/lock/LockedActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/lock/LockedActivity.kt @@ -169,6 +169,8 @@ class LockedActivity : AppCompatActivity() { 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() } From de4c67f0cb3822f193e7e4af2e2ff10da5a3c186 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Mon, 2 Nov 2026 21:49:09 +0100 Subject: [PATCH 60/75] fix(share): remove copies only for a share of ShareActivity A list that passed a share on to ShareActivity still removed the copies its uris named when it finished, which could be those of another share in progress. Assisted-by: Claude Code:claude-opus-5-5 Signed-off-by: Marcel Hibbe --- .../talk/conversationlist/ConversationsListActivity.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 cdaeef8c717..7a830f9ac69 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt @@ -1105,7 +1105,9 @@ class ConversationsListActivity : BaseActivity() { 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 - if (isFinishing && isShare(intent) && !isSharePassedOn && !isRelaunching) { + // 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)) } } From 43f4a7480a53dc6f7cc008e75000eea839b2e810 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Mon, 2 Nov 2026 21:52:39 +0100 Subject: [PATCH 61/75] refactor: tell a task relaunched from the recents in one place The routing of deep links and the list each read the flag of a relaunch from the recents themselves, which all have to agree on. Assisted-by: Claude Code:claude-opus-5-5 Signed-off-by: Marcel Hibbe --- .../java/com/nextcloud/talk/activities/DeepLinkRouting.kt | 6 +++--- .../talk/conversationlist/ConversationsListActivity.kt | 5 ++--- app/src/main/java/com/nextcloud/talk/utils/AppTasks.kt | 4 ++++ 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/activities/DeepLinkRouting.kt b/app/src/main/java/com/nextcloud/talk/activities/DeepLinkRouting.kt index b64c72c6c06..19e01e1c2cc 100644 --- a/app/src/main/java/com/nextcloud/talk/activities/DeepLinkRouting.kt +++ b/app/src/main/java/com/nextcloud/talk/activities/DeepLinkRouting.kt @@ -23,6 +23,7 @@ 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 /** * Shows the routing of the deep links and contact chats that [DeepLinkActivity] passes on to the conversation list @@ -80,9 +81,8 @@ class DeepLinkRouting( val routedIntent = savedInstanceState?.let { BundleCompat.getParcelable(it, KEY_ROUTED_INTENT, Intent::class.java) } - // a task relaunched from recents gets the intent it was started with again, which was routed already - val isLaunchedFromHistory = intent.flags and Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY != 0 - val routed = routedIntent ?: intent.takeUnless { isLaunchedFromHistory } ?: return + // the intent of a task relaunched from the recents was routed already + val routed = routedIntent ?: intent.takeUnless(AppTasks::isRelaunchedFromRecents) ?: return route(routed) } 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 7a830f9ac69..7b563d4ccfe 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt @@ -680,8 +680,7 @@ class ConversationsListActivity : BaseActivity() { // 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. - val isRelaunchedFromRecents = intent.flags and Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY != 0 - if (Intent.ACTION_VIEW == intent.action && !isRelaunchedFromRecents) { + if (Intent.ACTION_VIEW == intent.action && !AppTasks.isRelaunchedFromRecents(intent)) { intent.getStringExtra(KEY_ROOM_TOKEN)?.takeIf(DeepLinkHandler::isValidToken)?.let { pendingDirectShareToken = it isPendingConversationInputFocused = intent.getBooleanExtra(BundleKeys.KEY_FOCUS_INPUT, false) @@ -2006,7 +2005,7 @@ class ConversationsListActivity : BaseActivity() { !InternalEntryPoint.isStartedThrough(intent, ConversationsListActivity::class.java) && intent.action == Intent.ACTION_VIEW && intent.data != null && - intent.flags and Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY == 0 + !AppTasks.isRelaunchedFromRecents(intent) /** Whether [intent] shares content of another app, which is taken through [ShareActivity]. */ @VisibleForTesting diff --git a/app/src/main/java/com/nextcloud/talk/utils/AppTasks.kt b/app/src/main/java/com/nextcloud/talk/utils/AppTasks.kt index e2968bf49dd..3481b0b0da5 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/AppTasks.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/AppTasks.kt @@ -163,6 +163,10 @@ object AppTasks { } } + /** 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)) From 50b1e6662682d8a48d3a083799af80992417fa17 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Mon, 2 Nov 2026 22:10:09 +0100 Subject: [PATCH 62/75] fix(share): keep the copies of shared files out of backups also from Android 12 on From Android 12 on, cloud backups and device transfers of an app targeting it ignore the full backup rules and only follow the data extraction rules, so the copies were backed up. Assisted-by: Claude Code:claude-opus-5-5 Signed-off-by: Marcel Hibbe --- app/src/main/AndroidManifest.xml | 1 + .../main/res/xml/data_extraction_rules.xml | 21 +++++++++++++++++++ 2 files changed, 22 insertions(+) create mode 100644 app/src/main/res/xml/data_extraction_rules.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 71807609204..360160604f9 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -86,6 +86,7 @@ + + + + + + + + + + From f75f699d682507946b831bfa122247580e992571 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Mon, 2 Nov 2026 22:12:44 +0100 Subject: [PATCH 63/75] fix(shortcuts): open the conversation of a shortcut once the sync ends The conversation of a shortcut that wasn't listed yet opened whenever a sync added it while the list stayed shown, also minutes later. It now opens at the latest when the sync of the list ends, reading the stored conversations, or a snackbar tells that it wasn't found, also on a slow network. A search started meanwhile drops it, so it doesn't open over what the user does. The list keeps it in its saved state only when it is recreated, not when it was left: before Android 9, the state is saved before the list is stopped, which restored a conversation the user had left. Assisted-by: Claude Code:claude-opus-5-5 Signed-off-by: Marcel Hibbe --- .../ConversationsListActivity.kt | 53 ++++++++++++++++--- .../viewmodels/ConversationsListViewModel.kt | 4 ++ app/src/main/res/values/strings.xml | 1 + 3 files changed, 51 insertions(+), 7 deletions(-) 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 7b563d4ccfe..2dd864ac1de 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt @@ -39,6 +39,7 @@ 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 @@ -408,8 +409,12 @@ class ConversationsListActivity : BaseActivity() { override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) outState.putBoolean(KEY_ACCOUNT_DIALOG_VISIBLE, showAccountDialogState.value) - outState.putString(KEY_PENDING_DIRECT_SHARE_TOKEN, pendingDirectShareToken) - outState.putBoolean(BundleKeys.KEY_FOCUS_INPUT, isPendingConversationInputFocused) + // 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_DIRECT_SHARE_TOKEN, pendingDirectShareToken) + outState.putBoolean(BundleKeys.KEY_FOCUS_INPUT, isPendingConversationInputFocused) + } if (::deepLinkRouting.isInitialized) deepLinkRouting.onSaveInstanceState(outState) } @@ -667,7 +672,7 @@ class ConversationsListActivity : BaseActivity() { } } conversationsListViewModel.setHideRoomToken(intent.getStringExtra(KEY_FORWARD_HIDE_SOURCE_ROOM)) - fetchRooms() + openConversationOfShortcutAfter(fetchRooms()) fetchPendingInvitations() conversationsListViewModel.checkIfThreadsExist() @@ -780,9 +785,9 @@ class ConversationsListActivity : BaseActivity() { }.collect() } - // Direct Share: the conversation of a share sheet or home screen shortcut, also after unlocking. A conversation - // that isn't stored yet, e.g. a new one, opens once the sync has added it to the list. The shared room list - // isn't queried again on every resume. + // 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 = pendingDirectShareToken val conversation = list.firstOrNull { it.token == token } @@ -793,6 +798,16 @@ class ConversationsListActivity : BaseActivity() { } } + // the conversation of a shortcut doesn't open over a search the user started meanwhile + lifecycleScope.launch { + conversationsListViewModel.isSearchActiveFlow.collect { isSearchActive -> + if (isSearchActive) { + pendingDirectShareToken = null + isPendingConversationInputFocused = false + } + } + } + lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { contactsViewModel.roomViewState.collect { state -> @@ -986,8 +1001,32 @@ 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 = pendingDirectShareToken ?: return + lifecycleScope.launch { + sync.join() + // also one the list doesn't show yet + val conversation = conversationsListViewModel.storedRooms().firstOrNull { it.token == token } + withResumed { + if (pendingDirectShareToken == token && !isCoveredByLockScreen) { + pendingDirectShareToken = null + if (conversation != null) { + handleConversation(conversation, focusInput = isPendingConversationInputFocused) + } else { + showSnackbar(getString(R.string.nc_shortcut_conversation_not_found)) + } + isPendingConversationInputFocused = false + } + } + } } private fun fetchPendingInvitations() { 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/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9105068d48f..848e2948f07 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1103,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 From 66f4722c3b0a69abf5ea8b8727bd85e35c662a4e Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Mon, 2 Nov 2026 22:15:27 +0100 Subject: [PATCH 64/75] fix(chat): replace the chat by the chat of an audio message of another room Opening the chat of an audio message from the player of a chat put the new chat on top of the shown one, so chats piled up, each joined to its room. The chat of another room now replaces the shown one. A thread and the chat of its room stay, as Back leads from one to the other. Assisted-by: Claude Code:claude-opus-5-5 Signed-off-by: Marcel Hibbe --- app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt | 8 +++++--- .../com/nextcloud/talk/chat/audio/ChatAudioIntents.kt | 2 +- .../talk/chat/data/io/VoiceMessageMediaService.kt | 4 ++-- .../com/nextcloud/talk/chat/audio/ChatAudioIntentsTest.kt | 2 +- 4 files changed, 9 insertions(+), 7 deletions(-) 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 814214e2882..830b28d6a4a 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt @@ -1107,14 +1107,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() } } 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 e0c77a056a8..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 @@ -22,7 +22,7 @@ fun ChatAudioKey.openMessageIntent(context: Context, threadId: Long?): Intent = * 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.openMessageFromOutsideIntents(context: Context, threadId: Long?): Array = +fun ChatAudioKey.openMessageBackStack(context: Context, threadId: Long?): Array = ConversationsListActivity.createChatBackStack(context, internalUserId, roomToken, openMessageExtras(threadId)) private fun ChatAudioKey.openMessageExtras(threadId: Long?): Bundle = 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 4cb51f7e4c9..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 @@ -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.openMessageFromOutsideIntents +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 @@ -194,7 +194,7 @@ class VoiceMessageMediaService : MediaSessionService() { private fun sessionActivityFor(key: ChatAudioKey, threadId: Long?): PendingIntent { val backStack = TaskStackBuilder.create(this) - key.openMessageFromOutsideIntents(this, threadId).forEach { backStack.addNextIntent(it) } + key.openMessageBackStack(this, threadId).forEach { backStack.addNextIntent(it) } return checkNotNull( backStack.getPendingIntent( SESSION_ACTIVITY_REQUEST_CODE, 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 index 19b7ad1259d..862f9d87120 100644 --- a/app/src/test/java/com/nextcloud/talk/chat/audio/ChatAudioIntentsTest.kt +++ b/app/src/test/java/com/nextcloud/talk/chat/audio/ChatAudioIntentsTest.kt @@ -45,7 +45,7 @@ class ChatAudioIntentsTest { @Test fun `the media notification opens the chat of the message above the conversation list of its account`() { - val (list, chat) = key.openMessageFromOutsideIntents(context, threadId = 7L) + 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)) From 961b68be6604972554f3624c364a047b2d28ac14 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Mon, 2 Nov 2026 22:38:11 +0100 Subject: [PATCH 65/75] refactor(call): tell a share task as AppTasks does The call recognised the task of a share by the action of its intent, beside AppTasks, which tells it by its root. Assisted-by: Claude Code:claude-opus-5-5 Signed-off-by: Marcel Hibbe --- .../main/java/com/nextcloud/talk/activities/CallActivity.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 b9cca8a092b..21cac2f2242 100644 --- a/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt @@ -154,6 +154,7 @@ 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 @@ -2474,8 +2475,7 @@ class CallActivity : CallBaseActivity() { val baseActivityName = taskInfo.baseActivity?.className return baseActivityName == CallActivity::class.java.name || baseActivityName == CallNotificationActivity::class.java.name || - taskInfo.baseIntent.action == Intent.ACTION_SEND || - taskInfo.baseIntent.action == Intent.ACTION_SEND_MULTIPLE + taskInfo.isShareTask } private fun startVideoCapture(isPortrait: Boolean) { From c43b624c78c2777a9bd8ff45f84bee2710dcbf01 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Tue, 3 Nov 2026 09:08:36 +0100 Subject: [PATCH 66/75] refactor(shortcuts): don't focus the message input for the note to self shortcut The note to self shortcut opened its chat with the focus in the message input, which isn't needed. The extra, and the state the conversation list kept for it while the conversation of a shortcut was pending, are removed. Assisted-by: Claude Code:claude-opus-5-5 Signed-off-by: Marcel Hibbe --- .../com/nextcloud/talk/chat/ChatActivity.kt | 11 ----- .../ConversationsListActivity.kt | 40 +++++-------------- .../nextcloud/talk/utils/bundle/BundleKeys.kt | 1 - 3 files changed, 9 insertions(+), 43 deletions(-) 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 830b28d6a4a..ad029e4f65b 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt @@ -407,7 +407,6 @@ class ChatActivity : var startCallFromRoomSwitch: Boolean = false var voiceOnly: Boolean = true - var focusInput: Boolean = false private lateinit var path: String var myFirstMessage: CharSequence? = null @@ -1333,8 +1332,6 @@ class ChatActivity : 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 } @@ -1502,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) { @@ -2000,9 +1992,6 @@ class ChatActivity : private fun returnToChat(conversation: ConversationModel) { sendSharedFiles() - if (focusInput && messageInputFragment.isAdded) { - messageInputFragment.binding.fragmentMessageInputView.requestFocus() - } refreshConversationExtras(conversation) updateRoomTimerHandler(MILLIS_250) } 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 2dd864ac1de..8b34c438578 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt @@ -234,8 +234,6 @@ class ConversationsListActivity : BaseActivity() { // set when the list is shown again, see switchToDefaultAccountIfChanged private var isDefaultAccountCheckDue = false - // whether the chat of the pending shortcut opens with the focus in the message input, as the one of note to self - private var isPendingConversationInputFocused = false private var unauthorizedDialog: AlertDialog? = null private var unauthorizedHandling: Job? = null @@ -400,9 +398,7 @@ class ConversationsListActivity : BaseActivity() { // 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 pendingDirectShareToken = savedInstanceState.getString(KEY_PENDING_DIRECT_SHARE_TOKEN) - isPendingConversationInputFocused = savedInstanceState.getBoolean(BundleKeys.KEY_FOCUS_INPUT) intent.removeExtra(KEY_ROOM_TOKEN) - intent.removeExtra(BundleKeys.KEY_FOCUS_INPUT) intent.removeExtra(ShortcutManagerCompat.EXTRA_SHORTCUT_ID) } @@ -411,10 +407,7 @@ class ConversationsListActivity : BaseActivity() { 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_DIRECT_SHARE_TOKEN, pendingDirectShareToken) - outState.putBoolean(BundleKeys.KEY_FOCUS_INPUT, isPendingConversationInputFocused) - } + if (isChangingConfigurations) outState.putString(KEY_PENDING_DIRECT_SHARE_TOKEN, pendingDirectShareToken) if (::deepLinkRouting.isInitialized) deepLinkRouting.onSaveInstanceState(outState) } @@ -688,10 +681,8 @@ class ConversationsListActivity : BaseActivity() { if (Intent.ACTION_VIEW == intent.action && !AppTasks.isRelaunchedFromRecents(intent)) { intent.getStringExtra(KEY_ROOM_TOKEN)?.takeIf(DeepLinkHandler::isValidToken)?.let { pendingDirectShareToken = it - isPendingConversationInputFocused = intent.getBooleanExtra(BundleKeys.KEY_FOCUS_INPUT, false) } intent.removeExtra(KEY_ROOM_TOKEN) - intent.removeExtra(BundleKeys.KEY_FOCUS_INPUT) } // Share sheet shortcut tap: Android delivers ACTION_SEND with EXTRA_SHORTCUT_ID, before Android 10 through @@ -722,10 +713,7 @@ class ConversationsListActivity : BaseActivity() { 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) { - pendingDirectShareToken = null - isPendingConversationInputFocused = false - } + if (!isCoveredByLockScreen && !isChangingConfigurations) pendingDirectShareToken = null } override fun onPause() { @@ -793,18 +781,14 @@ class ConversationsListActivity : BaseActivity() { val conversation = list.firstOrNull { it.token == token } if (conversation != null) { pendingDirectShareToken = null - handleConversation(conversation, focusInput = isPendingConversationInputFocused) - isPendingConversationInputFocused = false + 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) { - pendingDirectShareToken = null - isPendingConversationInputFocused = false - } + if (isSearchActive) pendingDirectShareToken = null } } @@ -887,9 +871,7 @@ class ConversationsListActivity : BaseActivity() { private fun handleNoteToSelfShortcut(noteToSelfAvailable: Boolean, noteToSelfToken: String) { if (noteToSelfAvailable) { // 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) - ?.putExtra(BundleKeys.KEY_FOCUS_INPUT, true) - ?: return + 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) @@ -1019,11 +1001,10 @@ class ConversationsListActivity : BaseActivity() { if (pendingDirectShareToken == token && !isCoveredByLockScreen) { pendingDirectShareToken = null if (conversation != null) { - handleConversation(conversation, focusInput = isPendingConversationInputFocused) + handleConversation(conversation) } else { showSnackbar(getString(R.string.nc_shortcut_conversation_not_found)) } - isPendingConversationInputFocused = false } } } @@ -1151,7 +1132,7 @@ class ConversationsListActivity : BaseActivity() { } @Suppress("Detekt.ComplexMethod") - private fun handleConversation(conversation: ConversationModel?, focusInput: Boolean = false) { + private fun handleConversation(conversation: ConversationModel?) { selectedConversation = conversation if (selectedConversation != null) { val hasChatPermission = ParticipantPermissions( @@ -1178,7 +1159,7 @@ class ConversationsListActivity : BaseActivity() { } else if (conversation.checkIfVoiceRoom()) { showStartCallDialog() } else { - openConversation(focusInput = focusInput) + openConversation() } } } @@ -1421,7 +1402,7 @@ class ConversationsListActivity : BaseActivity() { } } - private fun openConversation(textToPaste: String? = "", focusInput: Boolean = false) { + private fun openConversation(textToPaste: String? = "") { if (CallActivity.active && !ApplicationWideCurrentRoomHolder.getInstance().isCurrentRoom(selectedConversation!!.token, currentUser.id) ) { @@ -1438,9 +1419,6 @@ class ConversationsListActivity : BaseActivity() { bundle.putString(BundleKeys.KEY_MESSAGE_ID, selectedMessageId) selectedMessageId = null } - if (focusInput) { - bundle.putBoolean(BundleKeys.KEY_FOCUS_INPUT, true) - } if (isShare(intent)) { openSharedConversationInAppTask(bundle) 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 4a2c495ba4b..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 @@ -83,7 +83,6 @@ object BundleKeys { 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" From 9eb9693ea4bc0de2ee45f01a47fc081c7011d69c Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Tue, 3 Nov 2026 09:13:03 +0100 Subject: [PATCH 67/75] refactor(list): rename pendingDirectShareToken to pendingShortcutToken It holds the conversation of any shortcut that started the list, the conversation shortcuts as well as the direct share targets. Assisted-by: Claude Code:claude-opus-5-5 Signed-off-by: Marcel Hibbe --- .../ConversationsListActivity.kt | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) 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 8b34c438578..a2de0ee8a22 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt @@ -229,7 +229,7 @@ class ConversationsListActivity : BaseActivity() { private var selectedConversation: ConversationModel? = null private var textToPaste: String? = "" private var selectedMessageId: String? = null - private var pendingDirectShareToken: String? = null + private var pendingShortcutToken: String? = null // set when the list is shown again, see switchToDefaultAccountIfChanged private var isDefaultAccountCheckDue = false @@ -397,7 +397,7 @@ class ConversationsListActivity : BaseActivity() { 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 - pendingDirectShareToken = savedInstanceState.getString(KEY_PENDING_DIRECT_SHARE_TOKEN) + pendingShortcutToken = savedInstanceState.getString(KEY_PENDING_SHORTCUT_TOKEN) intent.removeExtra(KEY_ROOM_TOKEN) intent.removeExtra(ShortcutManagerCompat.EXTRA_SHORTCUT_ID) } @@ -407,7 +407,7 @@ class ConversationsListActivity : BaseActivity() { 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_DIRECT_SHARE_TOKEN, pendingDirectShareToken) + if (isChangingConfigurations) outState.putString(KEY_PENDING_SHORTCUT_TOKEN, pendingShortcutToken) if (::deepLinkRouting.isInitialized) deepLinkRouting.onSaveInstanceState(outState) } @@ -673,14 +673,14 @@ class ConversationsListActivity : BaseActivity() { conversationTagsViewModel.loadConversationTags() } - /** Takes the conversation of the shortcut that started the list from its intent, see [pendingDirectShareToken]. */ + /** 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 { - pendingDirectShareToken = it + pendingShortcutToken = it } intent.removeExtra(KEY_ROOM_TOKEN) } @@ -700,7 +700,7 @@ class ConversationsListActivity : BaseActivity() { } } } else if (target != null) { - pendingDirectShareToken = target.second + pendingShortcutToken = target.second } // preselected once, like the conversation of a shortcut above intent.removeExtra(ShortcutManagerCompat.EXTRA_SHORTCUT_ID) @@ -713,7 +713,7 @@ class ConversationsListActivity : BaseActivity() { 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) pendingDirectShareToken = null + if (!isCoveredByLockScreen && !isChangingConfigurations) pendingShortcutToken = null } override fun onPause() { @@ -777,10 +777,10 @@ class ConversationsListActivity : BaseActivity() { // 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 = pendingDirectShareToken + val token = pendingShortcutToken val conversation = list.firstOrNull { it.token == token } if (conversation != null) { - pendingDirectShareToken = null + pendingShortcutToken = null handleConversation(conversation) } } @@ -788,7 +788,7 @@ class ConversationsListActivity : BaseActivity() { // the conversation of a shortcut doesn't open over a search the user started meanwhile lifecycleScope.launch { conversationsListViewModel.isSearchActiveFlow.collect { isSearchActive -> - if (isSearchActive) pendingDirectShareToken = null + if (isSearchActive) pendingShortcutToken = null } } @@ -992,14 +992,14 @@ class ConversationsListActivity : BaseActivity() { * unlocking, which waits for a sync of its own. */ private fun openConversationOfShortcutAfter(sync: Job) { - val token = pendingDirectShareToken ?: return + 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 (pendingDirectShareToken == token && !isCoveredByLockScreen) { - pendingDirectShareToken = null + if (pendingShortcutToken == token && !isCoveredByLockScreen) { + pendingShortcutToken = null if (conversation != null) { handleConversation(conversation) } else { @@ -2047,6 +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_DIRECT_SHARE_TOKEN = "pending_direct_share_token" + private const val KEY_PENDING_SHORTCUT_TOKEN = "pending_shortcut_token" } } From 95ca9f1b469022ba26d304029ea1f639dcd9f3a4 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Tue, 3 Nov 2026 10:17:52 +0100 Subject: [PATCH 68/75] fix(upload): keep the access to picked files until their upload is done The photo picker and the file picker grant the read access to a picked file only to the chat that picked it, until it is closed or the process is killed. An upload that waited for the network, e.g. while the app was in the background, then couldn't read its file and failed with a SecurityException. The access is now persisted when the file is sent, and released when its upload ends or is cancelled, unless another upload of the file still needs it, and for leftovers when the app starts. Assisted-by: Claude Code:claude-opus-5-5 Signed-off-by: Marcel Hibbe --- .../application/NextcloudTalkApplication.kt | 3 + .../talk/chat/viewmodels/ChatViewModel.kt | 5 +- .../talk/jobs/UploadAndShareFilesWorker.kt | 13 +- .../talk/utils/UploadSourceGrants.kt | 113 +++++++++++++++++ .../jobs/UploadAndShareFilesWorkerTest.kt | 18 ++- .../talk/utils/UploadSourceGrantsTest.kt | 117 ++++++++++++++++++ 6 files changed, 264 insertions(+), 5 deletions(-) create mode 100644 app/src/main/java/com/nextcloud/talk/utils/UploadSourceGrants.kt create mode 100644 app/src/test/java/com/nextcloud/talk/utils/UploadSourceGrantsTest.kt 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 144c33aca43..64011b5c6b6 100644 --- a/app/src/main/java/com/nextcloud/talk/application/NextcloudTalkApplication.kt +++ b/app/src/main/java/com/nextcloud/talk/application/NextcloudTalkApplication.kt @@ -62,6 +62,7 @@ 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 @@ -250,6 +251,8 @@ class NextcloudTalkApplication : 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() 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/jobs/UploadAndShareFilesWorker.kt b/app/src/main/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorker.kt index 12fed6f15e5..bbe6c820dd3 100644 --- a/app/src/main/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorker.kt +++ b/app/src/main/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorker.kt @@ -50,6 +50,7 @@ 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 @@ -130,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 !is Result.Retry && sourceFile != null) UploadSourceGrants.release(context, sourceFile, id) + } } finally { referenceId?.let { cancelledReferenceIds.remove(it) } } @@ -579,6 +584,8 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa ) // 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 @@ -602,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/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/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/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() + } +} From e7736b87801315f1e185b505283745e324e90afd Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Tue, 3 Nov 2026 10:30:18 +0100 Subject: [PATCH 69/75] fix(deeplink): show the splash screen of a link with the app icon Since Android 13, the system shows the splash screen of a start that isn't one of the launcher as a plain color, without the icon of the app. The conversation list keeps it while it routes a link that started the app, so a link showed a blue screen without the logo. DeepLinkActivity now asks for the splash screen with the icon. Assisted-by: Claude Code:claude-opus-5-5 Signed-off-by: Marcel Hibbe --- .../talk/activities/DeepLinkActivity.kt | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/nextcloud/talk/activities/DeepLinkActivity.kt b/app/src/main/java/com/nextcloud/talk/activities/DeepLinkActivity.kt index 849c3615c3f..a07ce20bdcb 100644 --- a/app/src/main/java/com/nextcloud/talk/activities/DeepLinkActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/activities/DeepLinkActivity.kt @@ -7,9 +7,12 @@ 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 @@ -26,11 +29,22 @@ class DeepLinkActivity : Activity() { // 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) + 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 From 581f64f238da87391701099b9f8233a42782068c Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Tue, 3 Nov 2026 10:44:25 +0100 Subject: [PATCH 70/75] fix(lock): leave the app when the screen lock of the device is left without unlocking Without a fingerprint, or after the biometric prompt failed, the lock screen shows the screen lock of the device. Leaving it with Back returned to the lock screen, which asked again right away, so the screen lock came back and the app couldn't be left. It now leaves the app like Back on the lock screen. Closing the biometric prompt with Back keeps the lock screen instead of asking for the screen lock of the device, and its callbacks run on the main thread. Assisted-by: Claude Code:claude-opus-5-5 Signed-off-by: Marcel Hibbe --- .../com/nextcloud/talk/lock/LockedActivity.kt | 53 ++++++++++++------- 1 file changed, 35 insertions(+), 18 deletions(-) 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 ae6018a2dd5..2b664b9041b 100644 --- a/app/src/main/java/com/nextcloud/talk/lock/LockedActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/lock/LockedActivity.kt @@ -20,6 +20,7 @@ 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 @@ -30,8 +31,6 @@ import com.nextcloud.talk.utils.isShareTask import com.nextcloud.talk.utils.preferences.AppPreferences import com.nextcloud.talk.utils.taskIdCompat import com.nextcloud.talk.utils.taskInfoOrNull -import java.util.concurrent.Executor -import java.util.concurrent.Executors import javax.inject.Inject @AutoInjector(NextcloudTalkApplication::class) @@ -52,6 +51,9 @@ 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) @@ -59,18 +61,22 @@ class LockedActivity : AppCompatActivity() { binding = ActivityLockedBinding.inflate(layoutInflater) setContentView(binding.root) - // Back 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. - onBackPressedDispatcher.addCallback(this) { - 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) - } + 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) } } @@ -86,7 +92,12 @@ class LockedActivity : AppCompatActivity() { binding.unlockContainer.setOnClickListener { checkIfWeAreSecure() } - checkIfWeAreSecure() + if (!isLeaving) checkIfWeAreSecure() + } + + override fun onStop() { + super.onStop() + isLeaving = false } private fun hideLogoForBrandedClients() { @@ -119,10 +130,9 @@ 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) @@ -137,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() } } ) @@ -162,7 +176,10 @@ class LockedActivity : AppCompatActivity() { if (result.resultCode == Activity.RESULT_OK) { 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() } } From 3e661aa2f2bb53b03df4e9f7f77b137e4b23ac6d Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Tue, 3 Nov 2026 10:53:30 +0100 Subject: [PATCH 71/75] fix(deeplink): ask before joining the call of a voice room opened by a link A nextcloudtalk:// link to a known conversation opened its chat right away, and the chat of a voice room joins its call as soon as it has joined the room. Such a link now opens the voice room through the conversation list, like a conversation shortcut, which asks whether to join the call, as when it is opened from the list. Assisted-by: Claude Code:claude-opus-5-5 Signed-off-by: Marcel Hibbe --- .../talk/activities/DeepLinkRouting.kt | 5 +++ .../activities/DeepLinkRoutingViewModel.kt | 31 +++++++++++++++---- .../talk/utils/ShortcutManagerHelper.kt | 10 +++--- .../DeepLinkRoutingViewModelTest.kt | 19 ++++++++++++ 4 files changed, 55 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/activities/DeepLinkRouting.kt b/app/src/main/java/com/nextcloud/talk/activities/DeepLinkRouting.kt index 19e01e1c2cc..9f5f7fe21ef 100644 --- a/app/src/main/java/com/nextcloud/talk/activities/DeepLinkRouting.kt +++ b/app/src/main/java/com/nextcloud/talk/activities/DeepLinkRouting.kt @@ -24,6 +24,7 @@ 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 @@ -148,6 +149,10 @@ class DeepLinkRouting( 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) + ) } } diff --git a/app/src/main/java/com/nextcloud/talk/activities/DeepLinkRoutingViewModel.kt b/app/src/main/java/com/nextcloud/talk/activities/DeepLinkRoutingViewModel.kt index d25bee33d00..d67c8f4a965 100644 --- a/app/src/main/java/com/nextcloud/talk/activities/DeepLinkRoutingViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/activities/DeepLinkRoutingViewModel.kt @@ -23,9 +23,11 @@ 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 @@ -97,6 +99,12 @@ class DeepLinkRoutingViewModel @Inject constructor( /** 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 { @@ -225,12 +233,18 @@ class DeepLinkRoutingViewModel @Inject constructor( 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 { - user == null -> open(Destination.ConversationList, R.string.nc_no_account_for_server, isLongMessage = true) - isKnownConversation(user, link.roomToken) -> openDeepLinkConversation(user, link.roomToken) - else -> ask(RoutingState.AskToJoin(link.serverUrl.toUri().host ?: link.serverUrl)) { + 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) } } @@ -238,11 +252,16 @@ class DeepLinkRoutingViewModel @Inject constructor( private suspend fun defaultUserAmong(users: List): User? = defaultAccountProvider.getDefaultUser()?.takeIf { default -> users.any { it.id == default.id } } - /** Whether [roomToken] is a conversation of [user], see [isStoredOrSynced]. */ - private suspend fun isKnownConversation(user: User, roomToken: String): Boolean = + /** 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) { - conversationsRepository.observeConversation(user.id!!, roomToken).first() is ConversationResult.Found + 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 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 7b583c591d2..bbd271072b9 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/ShortcutManagerHelper.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/ShortcutManagerHelper.kt @@ -258,9 +258,12 @@ object ShortcutManagerHelper { * 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? { - val userId = user.id ?: return null - return ConversationsListActivity.createIntent(context).apply { + 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) @@ -268,7 +271,6 @@ object ShortcutManagerHelper { // 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 diff --git a/app/src/test/java/com/nextcloud/talk/activities/DeepLinkRoutingViewModelTest.kt b/app/src/test/java/com/nextcloud/talk/activities/DeepLinkRoutingViewModelTest.kt index 615f47c257e..c3c64f4d35f 100644 --- a/app/src/test/java/com/nextcloud/talk/activities/DeepLinkRoutingViewModelTest.kt +++ b/app/src/test/java/com/nextcloud/talk/activities/DeepLinkRoutingViewModelTest.kt @@ -24,9 +24,11 @@ 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 @@ -213,6 +215,23 @@ class DeepLinkRoutingViewModelTest { 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) { From 3a8a74eb9dddb834c0f10f07f78f03588d2ef2f8 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Tue, 3 Nov 2026 14:08:49 +0100 Subject: [PATCH 72/75] fix(test): read the default account of LoginIT from the conversation list LoginIT started the app through the launcher intent into an ActivityScenario, which has no defaultAccountProvider, so the android tests didn't compile. The launcher alias opens the conversation list, so the scenario is typed with ConversationsListActivity. Assisted-by: Claude Code:claude-opus-5-5 Signed-off-by: Marcel Hibbe --- app/src/androidTest/java/com/nextcloud/talk/ui/LoginIT.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 e98e5ae90a0..e2c5fa415a1 100644 --- a/app/src/androidTest/java/com/nextcloud/talk/ui/LoginIT.java +++ b/app/src/androidTest/java/com/nextcloud/talk/ui/LoginIT.java @@ -8,10 +8,10 @@ */ package com.nextcloud.talk.ui; -import android.app.Activity; import android.os.Bundle; import com.nextcloud.talk.R; +import com.nextcloud.talk.conversationlist.ConversationsListActivity; import com.nextcloud.talk.utils.AppTasks; import org.junit.Ignore; @@ -47,7 +47,7 @@ public class LoginIT { public void login() throws InterruptedException { // started like by the launcher, without an account - ActivityScenario activityScenario = ActivityScenario.launch( + ActivityScenario activityScenario = ActivityScenario.launch( AppTasks.INSTANCE.createLauncherIntent(ApplicationProvider.getApplicationContext())); Bundle arguments = androidx.test.platform.app.InstrumentationRegistry.getArguments(); From a3554c8fa3192f5d6f1d9194ac796c0196cf93f3 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Tue, 3 Nov 2026 14:08:52 +0100 Subject: [PATCH 73/75] fix(build): keep the test libraries out of the app room-testing-android and junit-ktx were dependencies of the app, so it shipped junit and hamcrest-core 1.3. The android test APK leaves out what the app already has and is held to its versions, so it lacked org.hamcrest.Matchers and Espresso's withId() failed with a NoClassDefFoundError. Both are only used by the android tests and are now dependencies of those. The changed dependencies need the checksum of core-common-2.1.0.pom. Assisted-by: Claude Code:claude-opus-5-5 Signed-off-by: Marcel Hibbe --- app/build.gradle.kts | 4 ++-- gradle/verification-metadata.xml | 5 +++++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index cd29a46d905..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"))) 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 @@ + + + + + From b399c7195990ac82840e90f65c64ea508959d721 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Tue, 3 Nov 2026 14:21:07 +0100 Subject: [PATCH 74/75] fix(lint): mark origActivity of a recent task as available before API 29 Lint takes RecentTaskInfo.origActivity for the field of TaskInfo, which was added in API 29, and reports it as a new API. RecentTaskInfo declares the field itself since API 1 and only inherits it since API 29, and the bytecode reads it from RecentTaskInfo, so it exists on every supported version. The check is suppressed for the getter that reads it. Assisted-by: Claude Code:claude-opus-5-5 Signed-off-by: Marcel Hibbe --- app/src/main/java/com/nextcloud/talk/utils/AppTasks.kt | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/src/main/java/com/nextcloud/talk/utils/AppTasks.kt b/app/src/main/java/com/nextcloud/talk/utils/AppTasks.kt index 3481b0b0da5..1d502c2c73f 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/AppTasks.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/AppTasks.kt @@ -6,6 +6,7 @@ */ package com.nextcloud.talk.utils +import android.annotation.SuppressLint import android.app.ActivityManager import android.content.ComponentName import android.content.Context @@ -191,7 +192,11 @@ object AppTasks { /** * 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 From 5135a16bb2400a29b9a40614fab51ed126eed120 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Tue, 3 Nov 2026 14:25:00 +0100 Subject: [PATCH 75/75] fix(lint): check for a retry of an upload through Result.retry() Result.Retry is restricted to the library group of WorkManager, so lint reported its use in UploadAndShareFilesWorker. The result is now compared with Result.retry(), which equals every retry, so the access to the picked file is still kept for a retry and released otherwise. Assisted-by: Claude Code:claude-opus-5-5 Signed-off-by: Marcel Hibbe --- .../java/com/nextcloud/talk/jobs/UploadAndShareFilesWorker.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 bbe6c820dd3..f20e6d263b5 100644 --- a/app/src/main/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorker.kt +++ b/app/src/main/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorker.kt @@ -134,7 +134,7 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa return doUpload().also { result -> // not for a retry, which reads the file again val sourceFile = inputData.getString(DEVICE_SOURCE_FILE) - if (result !is Result.Retry && sourceFile != null) UploadSourceGrants.release(context, sourceFile, id) + if (result != Result.retry() && sourceFile != null) UploadSourceGrants.release(context, sourceFile, id) } } finally { referenceId?.let { cancelledReferenceIds.remove(it) }