From 9e4132a05c5be9eb1ecc9551c791f8113d2370af Mon Sep 17 00:00:00 2001 From: Ashish Yadav <48384865+criticalAY@users.noreply.github.com> Date: Mon, 2 Nov 2026 23:29:32 +0530 Subject: [PATCH] fix(backup): count a share to the work profile as a backup On Android 16 the share sheet hides which app was picked when it is in the work profile, so sharing an exported collection there didn't postpone the backup reminder. A hidden pick now counts as well. Part of 22893 Assisted-by: Claude Opus 5.5 --- .../anki/receiver/ExportSharedReceiver.kt | 13 +- .../ExportSharedReceiverRegressionTest.kt | 123 ++++++++++++++++++ 2 files changed, 134 insertions(+), 2 deletions(-) create mode 100644 AnkiDroid/src/test/java/com/ichi2/anki/receiver/ExportSharedReceiverRegressionTest.kt diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/receiver/ExportSharedReceiver.kt b/AnkiDroid/src/main/java/com/ichi2/anki/receiver/ExportSharedReceiver.kt index f679ca6d1b89..98804279d3ae 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/receiver/ExportSharedReceiver.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/receiver/ExportSharedReceiver.kt @@ -8,6 +8,8 @@ import android.content.ComponentName import android.content.Context import android.content.Intent import android.content.IntentSender +import android.os.Build +import android.service.chooser.ChooserResult import androidx.core.app.PendingIntentCompat import androidx.core.content.IntentCompat import com.ichi2.anki.AnkiDroidApp @@ -21,8 +23,9 @@ class ExportSharedReceiver : BroadcastReceiver() { intent: Intent, ) { val target = IntentCompat.getParcelableExtra(intent, Intent.EXTRA_CHOSEN_COMPONENT, ComponentName::class.java) - Timber.i("Export shared with %s", target?.packageName) - if (target == null || target.packageName == context.packageName) return + val crossProfile = intent.isCrossProfileShare() + Timber.i("Export shared with %s, cross-profile: %b", target?.packageName, crossProfile) + if (!crossProfile && (target == null || target.packageName == context.packageName)) return val path = intent.getStringExtra(EXTRA_EXPORT_PATH) ?: return BackupPromptDialog.onFileExported(AnkiDroidApp.wrapForActiveProfile(context), File(path)) } @@ -44,3 +47,9 @@ class ExportSharedReceiver : BroadcastReceiver() { )?.intentSender } } + +private fun Intent.isCrossProfileShare(): Boolean { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.VANILLA_ICE_CREAM) return false + val result = IntentCompat.getParcelableExtra(this, Intent.EXTRA_CHOOSER_RESULT, ChooserResult::class.java) + return result?.type == ChooserResult.CHOOSER_RESULT_UNKNOWN +} diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/receiver/ExportSharedReceiverRegressionTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/receiver/ExportSharedReceiverRegressionTest.kt new file mode 100644 index 000000000000..9590979a74d6 --- /dev/null +++ b/AnkiDroid/src/test/java/com/ichi2/anki/receiver/ExportSharedReceiverRegressionTest.kt @@ -0,0 +1,123 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki.receiver + +import android.app.Activity +import android.content.ComponentName +import android.content.Intent +import android.content.IntentSender +import android.os.Build +import android.os.Parcel +import android.service.chooser.ChooserResult +import androidx.core.content.IntentCompat +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.SdkSuppress +import com.ichi2.anki.RobolectricTest +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config +import java.io.File +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +@RunWith(AndroidJUnit4::class) +@Config(sdk = [36]) +@SdkSuppress(minSdkVersion = Build.VERSION_CODES.VANILLA_ICE_CREAM) +class ExportSharedReceiverRegressionTest : RobolectricTest(loadBackend = false) { + @Test + fun `Android 16 same profile chooser result control postpones the reminder`() { + val sender = assertNotNull(ExportSharedReceiver.getIntentSender(targetContext, File(tempFolder.root, "collection.colpkg"))) + val chooser = Intent.createChooser(Intent(Intent.ACTION_SEND), "Share", sender) + val callback = + assertNotNull(IntentCompat.getParcelableExtra(chooser, Intent.EXTRA_CHOOSER_RESULT_INTENT_SENDER, IntentSender::class.java)) + val parcel = Parcel.obtain() + val result = + try { + parcel.writeInt(0) // CHOOSER_RESULT_SELECTED_COMPONENT + ComponentName.writeToParcel(ComponentName("com.example.drive", "UploadActivity"), parcel) + parcel.writeBoolean(false) + parcel.setDataPosition(0) + ChooserResult.CREATOR.createFromParcel(parcel) + } finally { + parcel.recycle() + } + + callback.sendIntent( + targetContext, + Activity.RESULT_OK, + Intent() + .putExtra( + Intent.EXTRA_CHOOSER_RESULT, + result, + ).putExtra(Intent.EXTRA_CHOSEN_COMPONENT, ComponentName("com.example.drive", "UploadActivity")), + null, + null, + ) + advanceRobolectricLooper() + + assertTrue( + getPreferences().getLong("timeToShowBackupDialog", 0) > 0, + "Real Android 16 same profile callback must postpone the reminder", + ) + } + + @Test + fun `Android 16 work profile share postpones the reminder`() { + val sender = assertNotNull(ExportSharedReceiver.getIntentSender(targetContext, File(tempFolder.root, "collection.colpkg"))) + val chooser = Intent.createChooser(Intent(Intent.ACTION_SEND), "Share", sender) + val callback = + assertNotNull(IntentCompat.getParcelableExtra(chooser, Intent.EXTRA_CHOOSER_RESULT_INTENT_SENDER, IntentSender::class.java)) + val parcel = Parcel.obtain() + val result = + try { + parcel.writeInt(-1) // CHOOSER_RESULT_UNKNOWN: Android redacts cross profile targets. + ComponentName.writeToParcel(null, parcel) + parcel.writeBoolean(false) + parcel.setDataPosition(0) + ChooserResult.CREATOR.createFromParcel(parcel) + } finally { + parcel.recycle() + } + + callback.sendIntent(targetContext, Activity.RESULT_OK, Intent().putExtra(Intent.EXTRA_CHOOSER_RESULT, result), null, null) + advanceRobolectricLooper() + + assertTrue(getPreferences().getLong("timeToShowBackupDialog", 0) > 0, "Completed share to work profile must postpone the reminder") + } + + @Test + fun `Android 16 copy action keeps the reminder`() { + val sender = assertNotNull(ExportSharedReceiver.getIntentSender(targetContext, File(tempFolder.root, "collection.colpkg"))) + val chooser = Intent.createChooser(Intent(Intent.ACTION_SEND), "Share", sender) + val callback = + assertNotNull(IntentCompat.getParcelableExtra(chooser, Intent.EXTRA_CHOOSER_RESULT_INTENT_SENDER, IntentSender::class.java)) + val parcel = Parcel.obtain() + val result = + try { + parcel.writeInt(ChooserResult.CHOOSER_RESULT_COPY) + ComponentName.writeToParcel(null, parcel) + parcel.writeBoolean(false) + parcel.setDataPosition(0) + ChooserResult.CREATOR.createFromParcel(parcel) + } finally { + parcel.recycle() + } + + callback.sendIntent(targetContext, Activity.RESULT_OK, Intent().putExtra(Intent.EXTRA_CHOOSER_RESULT, result), null, null) + advanceRobolectricLooper() + + assertEquals(0L, getPreferences().getLong("timeToShowBackupDialog", 0)) + } + + @Test + fun `legacy callback control postpones the reminder`() { + val sender = assertNotNull(ExportSharedReceiver.getIntentSender(targetContext, File(tempFolder.root, "collection.colpkg"))) + val result = Intent().putExtra(Intent.EXTRA_CHOSEN_COMPONENT, ComponentName("com.example.drive", "UploadActivity")) + + sender.sendIntent(targetContext, Activity.RESULT_OK, result, null, null) + advanceRobolectricLooper() + + assertTrue(getPreferences().getLong("timeToShowBackupDialog", 0) > 0) + } +}